mirror of
https://github.com/whyour/qinglong.git
synced 2026-08-05 16:25:04 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 924dfd6a6f | |||
| 07f43538df | |||
| 4fa5fa2014 | |||
| f9b9543a6d | |||
| af97543918 | |||
| a508522872 | |||
| f1ca2134b7 | |||
| a9755655b2 | |||
| 6a76e82f26 | |||
| 6775f5d123 | |||
| ad6e08525c | |||
| 51ef4e7476 | |||
| e5b35273f9 | |||
| 647ed3b66c |
+4
-127
@@ -56,138 +56,15 @@ npm i @whyour/qinglong
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker (Recommended)
|
||||
[View Documentation](https://qinglong.online/guide/getting-started/installation-guide)
|
||||
|
||||
```bash
|
||||
# curl -sSL get.docker.com | sh
|
||||
docker run -dit \
|
||||
-v $PWD/ql/data:/ql/data \
|
||||
# The 5700 after the colon is the default port, if QlPort is set, it needs to be the same as QlPort.
|
||||
-p 5700:5700 \
|
||||
# Deployment paths are not required, e.g. /test.
|
||||
-e QlBaseUrl="/" \
|
||||
# Deployment port is not required, when using host mode, you can set the port after service startup, default 5700
|
||||
-e QlPort="5700" \
|
||||
--name qinglong \
|
||||
--hostname qinglong \
|
||||
--restart unless-stopped \
|
||||
whyour/qinglong:latest
|
||||
```
|
||||
## Built-in API
|
||||
|
||||
### BaoTa Panel one-click deployment (Recommended)
|
||||
|
||||
1. To install Pagoda Panel, go to the official website of [BaoTa Panel](https://www.bt.cn/u/EcDAFU), select the official version of the script to download and install.
|
||||
|
||||
2. After installation, login to Pagoda Panel, click `Docker` in the menu bar, the first time you enter, you will be prompted to install `Docker` service, click Install Now, follow the prompts to complete the installation.
|
||||
|
||||
3. After the installation is complete, find `Qinglong Panel` in the app shop, click Install, configure the domain name and other basic information to complete the installation.
|
||||
|
||||
### Docker-compose (Recommended)
|
||||
|
||||
```bash
|
||||
# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
|
||||
mkdir qinglong
|
||||
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
|
||||
|
||||
# start
|
||||
docker-compose up -d
|
||||
# stop
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Podman (Recommended)
|
||||
|
||||
```bash
|
||||
# https://podman.io/getting-started/installation
|
||||
podman run -dit \
|
||||
--network bridge \
|
||||
-v $PWD/ql/data:/ql/data \
|
||||
# The 5700 after the colon is the default port, if QlPort is set, it needs to be the same as QlPort.
|
||||
-p 5700:5700 \
|
||||
# Deployment paths are not required, e.g. /test.
|
||||
-e QlBaseUrl="/" \
|
||||
# Deployment port is not required, when using host mode, you can set the port after service startup, default 5700
|
||||
-e QlPort="5700" \
|
||||
--name qinglong \
|
||||
--hostname qinglong \
|
||||
docker.io/whyour/qinglong:latest
|
||||
```
|
||||
|
||||
### Npm (Local)
|
||||
|
||||
It is recommended to use a pure system installation to avoid losing the original system data, you need to install node/npm/python3/pip3/pnpm yourself
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
```
|
||||
|
||||
```bash
|
||||
npm install -g node-pre-gyp pnpm@8.3.1
|
||||
npm install -g @whyour/qinglong
|
||||
qinglong
|
||||
# Add the environment variables QL_DIR and QL_DATA_DIR when prompted, QL_DATA_DIR must end with /data.
|
||||
export QL_DIR=""
|
||||
export QL_DATA_DIR=""
|
||||
# Run again
|
||||
qinglong
|
||||
```
|
||||
[View Documentation](https://qinglong.online/guide/user-guide/built-in-api)
|
||||
|
||||
## Built-in commands
|
||||
|
||||
- task
|
||||
|
||||
```bash
|
||||
# Execute in sequence, if a random delay is set, it will be randomly delayed by a certain number of seconds
|
||||
task <file_path>
|
||||
# Execute in sequence, regardless of whether a random delay is set, all run immediately,
|
||||
# and the foreground will output the day, while recorded in the log file
|
||||
task <file_path> now
|
||||
# Concurrent execution, regardless of whether a random delay is set, are run immediately,
|
||||
# the foreground does not generate the day, directly recorded in the log file, and can be specified account execution
|
||||
task <file_path> conc <env_name> <account_number>(Optional)
|
||||
# Specify the account to execute and run immediately regardless of whether a random delay is set
|
||||
task <file_path> desi <env_name> <account_number>
|
||||
# Set task timeout
|
||||
task -m <max_time> <file_path>
|
||||
# Use -- to split, -- followed by a parameter that is passed to the script, as in the following example, the script receives the parameter -u whyour -p password
|
||||
task <file_path> -- -u whyour -p password
|
||||
```
|
||||
|
||||
- ql
|
||||
|
||||
```bash
|
||||
# Update and restart Green Dragon
|
||||
ql update
|
||||
# Run custom scripts extra.sh
|
||||
ql extra
|
||||
# Adding a single script file
|
||||
ql raw <file_url>
|
||||
# Add a specific script for a single repository
|
||||
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch>
|
||||
# Delete old logs
|
||||
ql rmlog <days>
|
||||
# Start bot
|
||||
ql bot
|
||||
# Detecting the Green Dragon environment and repairing it
|
||||
ql check
|
||||
# Reset the number of login errors
|
||||
ql resetlet
|
||||
# Disable two-step login
|
||||
ql resettfa
|
||||
```
|
||||
|
||||
| **Parameter** | **Description** |
|
||||
|---|---|
|
||||
| file_url | Script address |
|
||||
| repo_url | Repository address |
|
||||
| whitelist | The whitelist when pulling the repository, i.e., the string contained in the path of the script to be pulled |
|
||||
| blacklist | Blacklisting when pulling repositories, i.e. strings that are not included in the path of the script to be pulled |
|
||||
| dependence | Pulling the dependencies needed for the repository will be copied directly from the repository to the repository directory under scripts, regardless of the blacklist |
|
||||
| extensions | Pull the branch of the repository |
|
||||
| branch | Number of days of logs to be kept |
|
||||
| days | File path for task execution |
|
||||
| file_path | The name of the environment variable that needs to be concurrent or specified at the time of task execution |
|
||||
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -58,136 +58,15 @@ npm i @whyour/qinglong
|
||||
|
||||
## 部署
|
||||
|
||||
### docker (推荐)
|
||||
[查看文档](https://qinglong.online/guide/getting-started/installation-guide)
|
||||
|
||||
```bash
|
||||
# curl -sSL get.docker.com | sh
|
||||
docker run -dit \
|
||||
-v $PWD/ql/data:/ql/data \
|
||||
# 冒号后面的 5700 为默认端口,如果设置了 QlPort, 需要跟 QlPort 保持一致
|
||||
-p 5700:5700 \
|
||||
# 部署路径非必须,比如 /test
|
||||
-e QlBaseUrl="/" \
|
||||
# 部署端口非必须,当使用 host 模式时,可以设置服务启动后的端口,默认 5700
|
||||
-e QlPort="5700" \
|
||||
--name qinglong \
|
||||
--hostname qinglong \
|
||||
--restart unless-stopped \
|
||||
whyour/qinglong:latest
|
||||
```
|
||||
## 内置 API
|
||||
|
||||
### 宝塔面板一键部署(推荐)
|
||||
|
||||
1. 安装宝塔面板,前往 [宝塔面板](https://www.bt.cn/u/EcDAFU) 官网,选择正式版的脚本下载安装
|
||||
|
||||
2. 安装后登录宝塔面板,在菜单栏中点击 `Docker`,首次进入会提示安装`Docker`服务,点击立即安装,按提示完成安装
|
||||
|
||||
3. 安装完成后在应用商店中找到`青龙面板`,点击安装,配置域名等基本信息即可完成安装
|
||||
|
||||
### docker-compose (推荐)
|
||||
|
||||
```bash
|
||||
# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
|
||||
mkdir qinglong && cd $_
|
||||
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
|
||||
|
||||
# 启动
|
||||
docker-compose up -d
|
||||
# 停止
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### podman (推荐)
|
||||
|
||||
```bash
|
||||
# https://podman.io/getting-started/installation
|
||||
podman run -dit \
|
||||
--network bridge \
|
||||
-v $PWD/ql/data:/ql/data \
|
||||
# 冒号后面的 5700 为默认端口,如果设置了 QlPort, 需要跟 QlPort 保持一致
|
||||
-p 5700:5700 \
|
||||
# 部署路径非必须,比如 /test
|
||||
-e QlBaseUrl="/" \
|
||||
# 部署端口非必须,当使用 host 模式时,可以设置服务启动后的端口,默认 5700
|
||||
-e QlPort="5700" \
|
||||
--name qinglong \
|
||||
--hostname qinglong \
|
||||
docker.io/whyour/qinglong:latest
|
||||
```
|
||||
|
||||
### npm (本地)
|
||||
|
||||
建议使用纯净系统安装,避免系统原有数据丢失,需要自己安装 node/npm/python3/pip3/pnpm
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
```
|
||||
|
||||
```bash
|
||||
npm install -g node-pre-gyp pnpm@8.3.1
|
||||
npm install -g @whyour/qinglong
|
||||
qinglong
|
||||
# 根据提示增加环境变量 QL_DIR 和 QL_DATA_DIR,QL_DATA_DIR 必须以 /data 结尾
|
||||
export QL_DIR=""
|
||||
export QL_DATA_DIR=""
|
||||
# 再次执行
|
||||
qinglong
|
||||
```
|
||||
[查看文档](https://qinglong.online/guide/user-guide/built-in-api)
|
||||
|
||||
## 内置命令
|
||||
|
||||
- task
|
||||
|
||||
```bash
|
||||
# 依次执行,如果设置了随机延迟,将随机延迟一定秒数
|
||||
task <file_path>
|
||||
# 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日,同时记录在日志文件中
|
||||
task <file_path> now
|
||||
# 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日,直接记录在日志文件中,且可指定账号执行
|
||||
task <file_path> conc <env_name> <account_number>(可选的)
|
||||
# 指定账号执行,无论是否设置了随机延迟,均立即运行
|
||||
task <file_path> desi <env_name> <account_number>
|
||||
# 设置任务超时时间
|
||||
task -m <max_time> <file_path>
|
||||
# 使用 -- 分割,-- 后面的参数会传给脚本,下面的例子,脚本就可接收到参数 -u whyour -p password
|
||||
task <file_path> -- -u whyour -p password
|
||||
```
|
||||
|
||||
- ql
|
||||
|
||||
```bash
|
||||
# 更新并重启青龙
|
||||
ql update
|
||||
# 运行自定义脚本extra.sh
|
||||
ql extra
|
||||
# 添加单个脚本文件
|
||||
ql raw <file_url>
|
||||
# 添加单个仓库的指定脚本
|
||||
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> <extensions>
|
||||
# 删除旧日志
|
||||
ql rmlog <days>
|
||||
# 启动tg-bot
|
||||
ql bot
|
||||
# 检测青龙环境并修复
|
||||
ql check
|
||||
# 重置登录错误次数
|
||||
ql resetlet
|
||||
# 禁用两步登录
|
||||
ql resettfa
|
||||
```
|
||||
|
||||
| **参数** | **说明** |
|
||||
|------------|---------------------------------------------------------------------------------------------|
|
||||
| file_url | 脚本地址 |
|
||||
| repo_url | 仓库地址 |
|
||||
| whitelist | 拉取仓库时的白名单,即就是需要拉取的脚本的路径包含的字符串,多个竖线分割 |
|
||||
| blacklist | 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串,多个竖线分割 |
|
||||
| dependence | 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响,多个竖线分割 |
|
||||
| extensions | 拉取仓库的文件后缀,多个竖线分割 |
|
||||
| branch | 拉取仓库的分支 |
|
||||
| days | 需要保留的日志的天数 |
|
||||
| file_path | 任务执行时的文件路径 |
|
||||
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, please open a private vulnerability report at <https://github.com/whyour/qinglong/security>.
|
||||
|
||||
While the discovery of new vulnerabilities is rare, we also recommend always using the latest versions of Qinglong to ensure your application remains as secure as possible.
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'express';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
platform: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { getFileContentByName, readDirs, rmPath } from '../config/util';
|
||||
import { getFileContentByName, readDirs, removeAnsi, rmPath } from '../config/util';
|
||||
import { join, resolve } from 'path';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
const route = Router();
|
||||
@@ -42,7 +42,7 @@ export default (app: Router) => {
|
||||
return res.send({ code: 403, message: '暂无权限' });
|
||||
}
|
||||
const content = await getFileContentByName(finalPath);
|
||||
res.send({ code: 200, data: content });
|
||||
res.send({ code: 200, data: removeAnsi(content) });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import './loaders/sentry'
|
||||
import 'reflect-metadata'; // We need this in order to use @Decorators
|
||||
import config from './config';
|
||||
import express from 'express';
|
||||
|
||||
@@ -51,7 +51,6 @@ const extraFile = path.join(configPath, 'extra.sh');
|
||||
const confBakDir = path.join(dataPath, 'config/bak/');
|
||||
const sampleFile = path.join(samplePath, 'config.sample.sh');
|
||||
const sqliteFile = path.join(samplePath, 'database.sqlite');
|
||||
const systemNotifyFile = path.join(preloadPath, 'system-notify.json');
|
||||
|
||||
const authError = '错误的用户名密码,请重试';
|
||||
const loginFaild = '请先登录!';
|
||||
@@ -135,5 +134,4 @@ export default {
|
||||
sqliteFile,
|
||||
sshdPath,
|
||||
systemLogPath,
|
||||
systemNotifyFile,
|
||||
};
|
||||
|
||||
+14
-5
@@ -22,6 +22,10 @@ export async function getFileContentByName(fileName: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function removeAnsi(text: string) {
|
||||
return text.replace(/\x1b\[\d+m/g, '');
|
||||
}
|
||||
|
||||
export async function getLastModifyFilePath(dir: string) {
|
||||
let filePath = '';
|
||||
|
||||
@@ -153,7 +157,12 @@ export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
|
||||
let platform = 'desktop';
|
||||
if (system === 'windows' || system === 'macos' || system === 'linux') {
|
||||
platform = 'desktop';
|
||||
} else if (system === 'android' || system === 'ios' || system === 'openharmony' || testUa(/mobile/g)) {
|
||||
} else if (
|
||||
system === 'android' ||
|
||||
system === 'ios' ||
|
||||
system === 'openharmony' ||
|
||||
testUa(/mobile/g)
|
||||
) {
|
||||
platform = 'mobile';
|
||||
}
|
||||
|
||||
@@ -233,14 +242,14 @@ interface IFile {
|
||||
key: string;
|
||||
type: 'directory' | 'file';
|
||||
parent: string;
|
||||
mtime: number;
|
||||
createTime: number;
|
||||
size?: number;
|
||||
children?: IFile[];
|
||||
}
|
||||
|
||||
export function dirSort(a: IFile, b: IFile): number {
|
||||
if (a.type === 'file' && b.type === 'file') {
|
||||
return b.mtime - a.mtime;
|
||||
return b.createTime - a.createTime;
|
||||
} else if (a.type === 'directory' && b.type === 'directory') {
|
||||
return a.title.localeCompare(b.title);
|
||||
} else {
|
||||
@@ -274,7 +283,7 @@ export async function readDirs(
|
||||
key,
|
||||
type: 'directory',
|
||||
parent: relativePath,
|
||||
mtime: stats.mtime.getTime(),
|
||||
createTime: stats.birthtime.getTime(),
|
||||
children: children.sort(sort),
|
||||
});
|
||||
} else {
|
||||
@@ -284,7 +293,7 @@ export async function readDirs(
|
||||
key,
|
||||
parent: relativePath,
|
||||
size: stats.size,
|
||||
mtime: stats.mtime.getTime(),
|
||||
createTime: stats.birthtime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export class Env {
|
||||
this.timestamp = new Date().toString();
|
||||
this.position = options.position;
|
||||
this.name = options.name;
|
||||
this.remarks = options.remarks;
|
||||
this.remarks = options.remarks || '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-81
@@ -1,15 +1,11 @@
|
||||
import Logger from './logger';
|
||||
import path from 'path';
|
||||
import DataStore from 'nedb';
|
||||
import { EnvModel } from '../data/env';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { DependenceModel } from '../data/dependence';
|
||||
import { AppModel } from '../data/open';
|
||||
import { SystemModel } from '../data/system';
|
||||
import { fileExist } from '../config/util';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
import { CrontabViewModel } from '../data/cronView';
|
||||
import config from '../config';
|
||||
import { sequelize } from '../data';
|
||||
|
||||
export default async () => {
|
||||
@@ -48,88 +44,18 @@ export default async () => {
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
|
||||
} catch (error) { }
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query('alter table Crontabs add column extra_schedules JSON');
|
||||
} catch (error) { }
|
||||
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) { }
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query('alter table Crontabs add column task_after TEXT');
|
||||
} catch (error) { }
|
||||
|
||||
// 2.10-2.11 升级
|
||||
const cronDbFile = path.join(config.rootPath, 'db/crontab.db');
|
||||
const envDbFile = path.join(config.rootPath, 'db/env.db');
|
||||
const appDbFile = path.join(config.rootPath, 'db/app.db');
|
||||
const authDbFile = path.join(config.rootPath, 'db/auth.db');
|
||||
const dependenceDbFile = path.join(config.rootPath, 'db/dependence.db');
|
||||
const crondbExist = await fileExist(cronDbFile);
|
||||
const dependenceDbExist = await fileExist(dependenceDbFile);
|
||||
const envDbExist = await fileExist(envDbFile);
|
||||
const appDbExist = await fileExist(appDbFile);
|
||||
const authDbExist = await fileExist(authDbFile);
|
||||
|
||||
const cronCount = await CrontabModel.count();
|
||||
const dependenceCount = await DependenceModel.count();
|
||||
const envCount = await EnvModel.count();
|
||||
const appCount = await AppModel.count();
|
||||
const authCount = await SystemModel.count();
|
||||
if (crondbExist && cronCount === 0) {
|
||||
const cronDb = new DataStore({
|
||||
filename: cronDbFile,
|
||||
autoload: true,
|
||||
});
|
||||
cronDb.persistence.compactDatafile();
|
||||
cronDb.find({}).exec(async (err, docs) => {
|
||||
await CrontabModel.bulkCreate(docs, { ignoreDuplicates: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (dependenceDbExist && dependenceCount === 0) {
|
||||
const dependenceDb = new DataStore({
|
||||
filename: dependenceDbFile,
|
||||
autoload: true,
|
||||
});
|
||||
dependenceDb.persistence.compactDatafile();
|
||||
dependenceDb.find({}).exec(async (err, docs) => {
|
||||
await DependenceModel.bulkCreate(docs, { ignoreDuplicates: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (envDbExist && envCount === 0) {
|
||||
const envDb = new DataStore({
|
||||
filename: envDbFile,
|
||||
autoload: true,
|
||||
});
|
||||
envDb.persistence.compactDatafile();
|
||||
envDb.find({}).exec(async (err, docs) => {
|
||||
await EnvModel.bulkCreate(docs, { ignoreDuplicates: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (appDbExist && appCount === 0) {
|
||||
const appDb = new DataStore({
|
||||
filename: appDbFile,
|
||||
autoload: true,
|
||||
});
|
||||
appDb.persistence.compactDatafile();
|
||||
appDb.find({}).exec(async (err, docs) => {
|
||||
await AppModel.bulkCreate(docs, { ignoreDuplicates: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (authDbExist && authCount === 0) {
|
||||
const authDb = new DataStore({
|
||||
filename: authDbFile,
|
||||
autoload: true,
|
||||
});
|
||||
authDb.persistence.compactDatafile();
|
||||
authDb.find({}).exec(async (err, docs) => {
|
||||
await SystemModel.bulkCreate(docs, { ignoreDuplicates: true });
|
||||
});
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
console.log('✌️ DB loaded');
|
||||
Logger.info('✌️ DB loaded');
|
||||
|
||||
@@ -6,7 +6,6 @@ import config from '../config';
|
||||
import { UnauthorizedError, expressjwt } from 'express-jwt';
|
||||
import { getPlatform, getToken } from '../config/util';
|
||||
import rewrite from 'express-urlrewrite';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { errors } from 'celebrate';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { serveEnv } from '../config/serverEnv';
|
||||
@@ -131,8 +130,6 @@ export default ({ app }: { app: Application }) => {
|
||||
|
||||
app.use(errors());
|
||||
|
||||
Sentry.setupExpressErrorHandler(app);
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: Error & { status: number },
|
||||
@@ -162,8 +159,8 @@ export default ({ app }: { app: Application }) => {
|
||||
.status(500)
|
||||
.send({
|
||||
code: 400,
|
||||
message: `${err.name} ${err.message}`,
|
||||
validation: err.errors,
|
||||
message: `${err.message}`,
|
||||
errors: err.errors,
|
||||
})
|
||||
.end();
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ import { AuthDataType, SystemModel } from '../data/system';
|
||||
import SystemService from '../services/system';
|
||||
import UserService from '../services/user';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { safeJSONParse } from '../config/util';
|
||||
import { createRandomString, safeJSONParse } from '../config/util';
|
||||
import OpenService from '../services/open';
|
||||
import { shareStore } from '../shared/store';
|
||||
import Logger from './logger';
|
||||
import { AppModel } from '../data/open';
|
||||
|
||||
export default async () => {
|
||||
const cronService = Container.get(CronService);
|
||||
@@ -27,10 +28,23 @@ export default async () => {
|
||||
const openService = Container.get(OpenService);
|
||||
|
||||
// 初始化增加系统配置
|
||||
let systemApp = (
|
||||
await AppModel.findOne({
|
||||
where: { name: 'system' },
|
||||
})
|
||||
)?.get({ plain: true });
|
||||
if (!systemApp) {
|
||||
systemApp = await AppModel.create({
|
||||
name: 'system',
|
||||
scopes: ['crons', 'system'],
|
||||
client_id: createRandomString(12, 12),
|
||||
client_secret: createRandomString(24, 24),
|
||||
});
|
||||
}
|
||||
const [systemConfig] = await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.systemConfig },
|
||||
});
|
||||
const [notifyConfig] = await SystemModel.findOrCreate({
|
||||
await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.notification },
|
||||
});
|
||||
const [authConfig] = await SystemModel.findOrCreate({
|
||||
@@ -54,11 +68,6 @@ export default async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化通知配置
|
||||
if (notifyConfig.info) {
|
||||
await writeFile(config.systemNotifyFile, JSON.stringify(notifyConfig.info));
|
||||
}
|
||||
|
||||
const installDependencies = () => {
|
||||
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖
|
||||
DependenceModel.findAll({
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import Logger from './logger';
|
||||
import fs from 'fs';
|
||||
import config from '../config';
|
||||
import { parseContentVersion } from '../config/util';
|
||||
|
||||
let version = '1.0.0';
|
||||
try {
|
||||
const content = fs.readFileSync(config.versionFile, 'utf-8');
|
||||
({ version } = parseContentVersion(content));
|
||||
} catch (error) {}
|
||||
|
||||
Sentry.init({
|
||||
ignoreErrors: [
|
||||
/SequelizeUniqueConstraintError/i,
|
||||
/Validation error/i,
|
||||
/UnauthorizedError/i,
|
||||
/celebrate request validation failed/i,
|
||||
],
|
||||
dsn: 'https://8b5c84cfef3e22541bc84de0ed00497b@o1098464.ingest.sentry.io/6122819',
|
||||
tracesSampleRate: 0.5,
|
||||
release: version,
|
||||
});
|
||||
|
||||
Logger.info('✌️ Sentry loaded');
|
||||
console.log('✌️ Sentry loaded');
|
||||
@@ -0,0 +1,72 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package com.ql.api;
|
||||
|
||||
message EnvItem {
|
||||
optional int32 id = 1;
|
||||
optional string name = 2;
|
||||
optional string value = 3;
|
||||
optional string remarks = 4;
|
||||
optional int32 status = 5;
|
||||
optional int32 position = 6;
|
||||
}
|
||||
|
||||
message GetEnvsRequest { string searchValue = 1; }
|
||||
|
||||
message CreateEnvRequest { repeated EnvItem envs = 1; }
|
||||
|
||||
message UpdateEnvRequest { EnvItem env = 1; }
|
||||
|
||||
message DeleteEnvsRequest { repeated int32 ids = 1; }
|
||||
|
||||
message MoveEnvRequest {
|
||||
int32 id = 1;
|
||||
int32 fromIndex = 2;
|
||||
int32 toIndex = 3;
|
||||
}
|
||||
|
||||
message DisableEnvsRequest { repeated int32 ids = 1; }
|
||||
|
||||
message EnableEnvsRequest { repeated int32 ids = 1; }
|
||||
|
||||
message UpdateEnvNamesRequest {
|
||||
repeated int32 ids = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message GetEnvByIdRequest { int32 id = 1; }
|
||||
|
||||
message EnvsResponse {
|
||||
int32 code = 1;
|
||||
repeated EnvItem data = 2;
|
||||
optional string message = 3;
|
||||
}
|
||||
|
||||
message EnvResponse {
|
||||
int32 code = 1;
|
||||
EnvItem data = 2;
|
||||
optional string message = 3;
|
||||
}
|
||||
|
||||
message Response {
|
||||
int32 code = 1;
|
||||
optional string message = 2;
|
||||
}
|
||||
|
||||
message SystemNotifyRequest {
|
||||
string title = 1;
|
||||
string content = 2;
|
||||
}
|
||||
|
||||
service Api {
|
||||
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
||||
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
||||
rpc UpdateEnv(UpdateEnvRequest) returns (EnvResponse) {}
|
||||
rpc DeleteEnvs(DeleteEnvsRequest) returns (Response) {}
|
||||
rpc MoveEnv(MoveEnvRequest) returns (EnvResponse) {}
|
||||
rpc DisableEnvs(DisableEnvsRequest) returns (Response) {}
|
||||
rpc EnableEnvs(EnableEnvsRequest) returns (Response) {}
|
||||
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
|
||||
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
|
||||
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
|
||||
}
|
||||
+1451
File diff suppressed because it is too large
Load Diff
+50
-44
@@ -1,15 +1,21 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v1.181.2
|
||||
// protoc v3.17.3
|
||||
// source: back/protos/cron.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import {
|
||||
CallOptions,
|
||||
type CallOptions,
|
||||
ChannelCredentials,
|
||||
Client,
|
||||
ClientOptions,
|
||||
ClientUnaryCall,
|
||||
handleUnaryCall,
|
||||
type ClientOptions,
|
||||
type ClientUnaryCall,
|
||||
type handleUnaryCall,
|
||||
makeGenericClientConstructor,
|
||||
Metadata,
|
||||
ServiceError,
|
||||
UntypedServiceImplementation,
|
||||
type ServiceError,
|
||||
type UntypedServiceImplementation,
|
||||
} from "@grpc/grpc-js";
|
||||
import _m0 from "protobufjs/minimal";
|
||||
|
||||
@@ -77,19 +83,20 @@ export const ISchedule = {
|
||||
},
|
||||
|
||||
fromJSON(object: any): ISchedule {
|
||||
return { schedule: isSet(object.schedule) ? String(object.schedule) : "" };
|
||||
return { schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "" };
|
||||
},
|
||||
|
||||
toJSON(message: ISchedule): unknown {
|
||||
const obj: any = {};
|
||||
message.schedule !== undefined && (obj.schedule = message.schedule);
|
||||
if (message.schedule !== "") {
|
||||
obj.schedule = message.schedule;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ISchedule>, I>>(base?: I): ISchedule {
|
||||
return ISchedule.fromPartial(base ?? {});
|
||||
return ISchedule.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<ISchedule>, I>>(object: I): ISchedule {
|
||||
const message = createBaseISchedule();
|
||||
message.schedule = object.schedule ?? "";
|
||||
@@ -174,34 +181,39 @@ export const ICron = {
|
||||
|
||||
fromJSON(object: any): ICron {
|
||||
return {
|
||||
id: isSet(object.id) ? String(object.id) : "",
|
||||
schedule: isSet(object.schedule) ? String(object.schedule) : "",
|
||||
command: isSet(object.command) ? String(object.command) : "",
|
||||
extraSchedules: Array.isArray(object?.extraSchedules)
|
||||
id: isSet(object.id) ? globalThis.String(object.id) : "",
|
||||
schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "",
|
||||
command: isSet(object.command) ? globalThis.String(object.command) : "",
|
||||
extraSchedules: globalThis.Array.isArray(object?.extraSchedules)
|
||||
? object.extraSchedules.map((e: any) => ISchedule.fromJSON(e))
|
||||
: [],
|
||||
name: isSet(object.name) ? String(object.name) : "",
|
||||
name: isSet(object.name) ? globalThis.String(object.name) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: ICron): unknown {
|
||||
const obj: any = {};
|
||||
message.id !== undefined && (obj.id = message.id);
|
||||
message.schedule !== undefined && (obj.schedule = message.schedule);
|
||||
message.command !== undefined && (obj.command = message.command);
|
||||
if (message.extraSchedules) {
|
||||
obj.extraSchedules = message.extraSchedules.map((e) => e ? ISchedule.toJSON(e) : undefined);
|
||||
} else {
|
||||
obj.extraSchedules = [];
|
||||
if (message.id !== "") {
|
||||
obj.id = message.id;
|
||||
}
|
||||
if (message.schedule !== "") {
|
||||
obj.schedule = message.schedule;
|
||||
}
|
||||
if (message.command !== "") {
|
||||
obj.command = message.command;
|
||||
}
|
||||
if (message.extraSchedules?.length) {
|
||||
obj.extraSchedules = message.extraSchedules.map((e) => ISchedule.toJSON(e));
|
||||
}
|
||||
if (message.name !== "") {
|
||||
obj.name = message.name;
|
||||
}
|
||||
message.name !== undefined && (obj.name = message.name);
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ICron>, I>>(base?: I): ICron {
|
||||
return ICron.fromPartial(base ?? {});
|
||||
return ICron.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<ICron>, I>>(object: I): ICron {
|
||||
const message = createBaseICron();
|
||||
message.id = object.id ?? "";
|
||||
@@ -249,23 +261,20 @@ export const AddCronRequest = {
|
||||
},
|
||||
|
||||
fromJSON(object: any): AddCronRequest {
|
||||
return { crons: Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
|
||||
return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
|
||||
},
|
||||
|
||||
toJSON(message: AddCronRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.crons) {
|
||||
obj.crons = message.crons.map((e) => e ? ICron.toJSON(e) : undefined);
|
||||
} else {
|
||||
obj.crons = [];
|
||||
if (message.crons?.length) {
|
||||
obj.crons = message.crons.map((e) => ICron.toJSON(e));
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<AddCronRequest>, I>>(base?: I): AddCronRequest {
|
||||
return AddCronRequest.fromPartial(base ?? {});
|
||||
return AddCronRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
|
||||
const message = createBaseAddCronRequest();
|
||||
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
|
||||
@@ -308,9 +317,8 @@ export const AddCronResponse = {
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<AddCronResponse>, I>>(base?: I): AddCronResponse {
|
||||
return AddCronResponse.fromPartial(base ?? {});
|
||||
return AddCronResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<AddCronResponse>, I>>(_: I): AddCronResponse {
|
||||
const message = createBaseAddCronResponse();
|
||||
return message;
|
||||
@@ -353,23 +361,20 @@ export const DeleteCronRequest = {
|
||||
},
|
||||
|
||||
fromJSON(object: any): DeleteCronRequest {
|
||||
return { ids: Array.isArray(object?.ids) ? object.ids.map((e: any) => String(e)) : [] };
|
||||
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.String(e)) : [] };
|
||||
},
|
||||
|
||||
toJSON(message: DeleteCronRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.ids) {
|
||||
obj.ids = message.ids.map((e) => e);
|
||||
} else {
|
||||
obj.ids = [];
|
||||
if (message.ids?.length) {
|
||||
obj.ids = message.ids;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(base?: I): DeleteCronRequest {
|
||||
return DeleteCronRequest.fromPartial(base ?? {});
|
||||
return DeleteCronRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(object: I): DeleteCronRequest {
|
||||
const message = createBaseDeleteCronRequest();
|
||||
message.ids = object.ids?.map((e) => e) || [];
|
||||
@@ -412,9 +417,8 @@ export const DeleteCronResponse = {
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(base?: I): DeleteCronResponse {
|
||||
return DeleteCronResponse.fromPartial(base ?? {});
|
||||
return DeleteCronResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(_: I): DeleteCronResponse {
|
||||
const message = createBaseDeleteCronResponse();
|
||||
return message;
|
||||
@@ -484,12 +488,14 @@ export interface CronClient extends Client {
|
||||
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
|
||||
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): CronClient;
|
||||
service: typeof CronService;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
export type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends Array<infer U> ? Array<DeepPartial<U>> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
|
||||
+24
-14
@@ -1,17 +1,23 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v1.181.2
|
||||
// protoc v3.17.3
|
||||
// source: back/protos/health.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import {
|
||||
CallOptions,
|
||||
type CallOptions,
|
||||
ChannelCredentials,
|
||||
Client,
|
||||
ClientOptions,
|
||||
type ClientOptions,
|
||||
ClientReadableStream,
|
||||
ClientUnaryCall,
|
||||
type ClientUnaryCall,
|
||||
handleServerStreamingCall,
|
||||
handleUnaryCall,
|
||||
type handleUnaryCall,
|
||||
makeGenericClientConstructor,
|
||||
Metadata,
|
||||
ServiceError,
|
||||
UntypedServiceImplementation,
|
||||
type ServiceError,
|
||||
type UntypedServiceImplementation,
|
||||
} from "@grpc/grpc-js";
|
||||
import _m0 from "protobufjs/minimal";
|
||||
|
||||
@@ -106,19 +112,20 @@ export const HealthCheckRequest = {
|
||||
},
|
||||
|
||||
fromJSON(object: any): HealthCheckRequest {
|
||||
return { service: isSet(object.service) ? String(object.service) : "" };
|
||||
return { service: isSet(object.service) ? globalThis.String(object.service) : "" };
|
||||
},
|
||||
|
||||
toJSON(message: HealthCheckRequest): unknown {
|
||||
const obj: any = {};
|
||||
message.service !== undefined && (obj.service = message.service);
|
||||
if (message.service !== "") {
|
||||
obj.service = message.service;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(base?: I): HealthCheckRequest {
|
||||
return HealthCheckRequest.fromPartial(base ?? {});
|
||||
return HealthCheckRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(object: I): HealthCheckRequest {
|
||||
const message = createBaseHealthCheckRequest();
|
||||
message.service = object.service ?? "";
|
||||
@@ -167,14 +174,15 @@ export const HealthCheckResponse = {
|
||||
|
||||
toJSON(message: HealthCheckResponse): unknown {
|
||||
const obj: any = {};
|
||||
message.status !== undefined && (obj.status = healthCheckResponse_ServingStatusToJSON(message.status));
|
||||
if (message.status !== 0) {
|
||||
obj.status = healthCheckResponse_ServingStatusToJSON(message.status);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(base?: I): HealthCheckResponse {
|
||||
return HealthCheckResponse.fromPartial(base ?? {});
|
||||
return HealthCheckResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(object: I): HealthCheckResponse {
|
||||
const message = createBaseHealthCheckResponse();
|
||||
message.status = object.status ?? 0;
|
||||
@@ -236,12 +244,14 @@ export interface HealthClient extends Client {
|
||||
export const HealthClient = makeGenericClientConstructor(HealthService, "com.ql.health.Health") as unknown as {
|
||||
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): HealthClient;
|
||||
service: typeof HealthService;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
export type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends Array<infer U> ? Array<DeepPartial<U>> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'reflect-metadata';
|
||||
import { Container } from 'typedi';
|
||||
import EnvService from '../services/env';
|
||||
import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js';
|
||||
import {
|
||||
CreateEnvRequest,
|
||||
DeleteEnvsRequest,
|
||||
DisableEnvsRequest,
|
||||
EnableEnvsRequest,
|
||||
EnvItem,
|
||||
EnvResponse,
|
||||
EnvsResponse,
|
||||
GetEnvByIdRequest,
|
||||
GetEnvsRequest,
|
||||
MoveEnvRequest,
|
||||
Response,
|
||||
SystemNotifyRequest,
|
||||
UpdateEnvNamesRequest,
|
||||
UpdateEnvRequest,
|
||||
} from '../protos/api';
|
||||
import LoggerInstance from '../loaders/logger';
|
||||
import pick from 'lodash/pick';
|
||||
import SystemService from '../services/system';
|
||||
|
||||
Container.set('logger', LoggerInstance);
|
||||
|
||||
export const getEnvs = async (
|
||||
call: ServerUnaryCall<GetEnvsRequest, EnvsResponse>,
|
||||
callback: sendUnaryData<EnvsResponse>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.envs(call.request.searchValue);
|
||||
callback(null, {
|
||||
code: 200,
|
||||
data: data.map((x) => ({ ...x, remarks: x.remarks || '' })),
|
||||
});
|
||||
} catch (e: any) {
|
||||
callback(null, {
|
||||
code: 500,
|
||||
data: [],
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createEnv = async (
|
||||
call: ServerUnaryCall<CreateEnvRequest, EnvsResponse>,
|
||||
callback: sendUnaryData<EnvsResponse>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.create(call.request.envs);
|
||||
callback(null, { code: 200, data });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateEnv = async (
|
||||
call: ServerUnaryCall<UpdateEnvRequest, EnvResponse>,
|
||||
callback: sendUnaryData<EnvResponse>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.update(
|
||||
pick(call.request.env, ['id', 'name', 'value', 'remark']) as EnvItem,
|
||||
);
|
||||
callback(null, { code: 200, data });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEnvs = async (
|
||||
call: ServerUnaryCall<DeleteEnvsRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
await envService.remove(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const moveEnv = async (
|
||||
call: ServerUnaryCall<MoveEnvRequest, EnvResponse>,
|
||||
callback: sendUnaryData<EnvResponse>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.move(call.request.id, {
|
||||
fromIndex: call.request.fromIndex,
|
||||
toIndex: call.request.toIndex,
|
||||
});
|
||||
callback(null, { code: 200, data });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const disableEnvs = async (
|
||||
call: ServerUnaryCall<DisableEnvsRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
await envService.disabled(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const enableEnvs = async (
|
||||
call: ServerUnaryCall<EnableEnvsRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
await envService.enabled(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateEnvNames = async (
|
||||
call: ServerUnaryCall<UpdateEnvNamesRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
await envService.updateNames({
|
||||
ids: call.request.ids,
|
||||
name: call.request.name,
|
||||
});
|
||||
callback(null, { code: 200 });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getEnvById = async (
|
||||
call: ServerUnaryCall<GetEnvByIdRequest, EnvResponse>,
|
||||
callback: sendUnaryData<EnvResponse>,
|
||||
) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.getDb({ id: call.request.id });
|
||||
callback(null, {
|
||||
code: 200,
|
||||
data: { ...data, remarks: data.remarks || '' },
|
||||
});
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const systemNotify = async (
|
||||
call: ServerUnaryCall<SystemNotifyRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const data = await systemService.notify(call.request);
|
||||
callback(null, data);
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
@@ -6,10 +6,13 @@ import { HealthService } from '../protos/health';
|
||||
import { check } from './health';
|
||||
import config from '../config';
|
||||
import Logger from '../loaders/logger';
|
||||
import { ApiService } from '../protos/api';
|
||||
import * as Api from './api';
|
||||
|
||||
const server = new Server({ 'grpc.enable_http_proxy': 0 });
|
||||
server.addService(HealthService, { check });
|
||||
server.addService(CronService, { addCron, delCron });
|
||||
server.addService(ApiService, Api);
|
||||
server.bindAsync(
|
||||
`0.0.0.0:${config.cronPort}`,
|
||||
ServerCredentials.createInsecure(),
|
||||
|
||||
@@ -41,7 +41,7 @@ export default class EnvService {
|
||||
}
|
||||
|
||||
public async insert(payloads: Env[]): Promise<Env[]> {
|
||||
const result = [];
|
||||
const result: Env[] = [];
|
||||
for (const env of payloads) {
|
||||
const doc = await EnvModel.create(env, { returning: true });
|
||||
result.push(doc);
|
||||
@@ -62,7 +62,7 @@ export default class EnvService {
|
||||
return await this.getDb({ id: payload.id });
|
||||
}
|
||||
|
||||
public async remove(ids: string[]) {
|
||||
public async remove(ids: number[]) {
|
||||
await EnvModel.destroy({ where: { id: ids } });
|
||||
await this.set_envs();
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export default class EnvService {
|
||||
['position', 'DESC'],
|
||||
['createdAt', 'ASC'],
|
||||
]);
|
||||
return result as any;
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export default class EnvService {
|
||||
where: { ...query },
|
||||
order: [...sort],
|
||||
});
|
||||
return docs;
|
||||
return docs.map((x) => x.get({ plain: true }));
|
||||
}
|
||||
|
||||
public async getDb(query: FindOptions<Env>['where']): Promise<Env> {
|
||||
@@ -172,7 +172,7 @@ export default class EnvService {
|
||||
return doc.get({ plain: true });
|
||||
}
|
||||
|
||||
public async disabled(ids: string[]) {
|
||||
public async disabled(ids: number[]) {
|
||||
await EnvModel.update(
|
||||
{ status: EnvStatus.disabled },
|
||||
{ where: { id: ids } },
|
||||
@@ -180,12 +180,12 @@ export default class EnvService {
|
||||
await this.set_envs();
|
||||
}
|
||||
|
||||
public async enabled(ids: string[]) {
|
||||
public async enabled(ids: number[]) {
|
||||
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
|
||||
await this.set_envs();
|
||||
}
|
||||
|
||||
public async updateNames({ ids, name }: { ids: string[]; name: string }) {
|
||||
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
|
||||
await EnvModel.update({ name }, { where: { id: ids } });
|
||||
await this.set_envs();
|
||||
}
|
||||
|
||||
+54
-61
@@ -3,12 +3,9 @@ import got from 'got';
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import { parseBody, parseHeaders, safeJSONParse } from '../config/util';
|
||||
import { parseBody, parseHeaders } from '../config/util';
|
||||
import { NotificationInfo } from '../data/notify';
|
||||
import UserService from './user';
|
||||
import { readFile } from 'fs/promises';
|
||||
import config from '../config';
|
||||
|
||||
@Service()
|
||||
export default class NotificationService {
|
||||
@@ -49,27 +46,6 @@ export default class NotificationService {
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async externalNotify(
|
||||
title: string,
|
||||
content: string,
|
||||
): Promise<boolean | undefined> {
|
||||
const { type, ...rest } = safeJSONParse(
|
||||
await readFile(config.systemNotifyFile, 'utf-8'),
|
||||
);
|
||||
if (type) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.params = rest;
|
||||
const notificationModeAction = this.modeMap.get(type);
|
||||
try {
|
||||
return await notificationModeAction?.call(this);
|
||||
} catch (error: any) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async notify(
|
||||
title: string,
|
||||
content: string,
|
||||
@@ -154,15 +130,18 @@ export default class NotificationService {
|
||||
private async serverChan() {
|
||||
const { serverChanKey } = this.params;
|
||||
const matchResult = serverChanKey.match(/^sctp(\d+)t/i);
|
||||
const url = matchResult && matchResult[1]
|
||||
? `https://${matchResult[1]}.push.ft07.com/send/${serverChanKey}.send`
|
||||
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
|
||||
const url =
|
||||
matchResult && matchResult[1]
|
||||
? `https://${matchResult[1]}.push.ft07.com/send/${serverChanKey}.send`
|
||||
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
|
||||
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
...this.gotOption,
|
||||
body: `title=${encodeURIComponent(this.title)}&desp=${encodeURIComponent(this.content)}`,
|
||||
body: `title=${encodeURIComponent(
|
||||
this.title,
|
||||
)}&desp=${encodeURIComponent(this.content)}`,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
.json();
|
||||
@@ -520,10 +499,18 @@ export default class NotificationService {
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pushPlus() {
|
||||
const { pushPlusToken, pushPlusUser, pushplusWebhook, pushPlusTemplate, pushplusChannel, pushplusCallbackUrl, pushplusTo} = this.params;
|
||||
const {
|
||||
pushPlusToken,
|
||||
pushPlusUser,
|
||||
pushplusWebhook,
|
||||
pushPlusTemplate,
|
||||
pushplusChannel,
|
||||
pushplusCallbackUrl,
|
||||
pushplusTo,
|
||||
} = this.params;
|
||||
const url = `https://www.pushplus.plus/send`;
|
||||
try {
|
||||
let body = {
|
||||
@@ -537,13 +524,11 @@ export default class NotificationService {
|
||||
channel: `${pushplusChannel || 'wechat'}`,
|
||||
webhook: `${pushplusWebhook || ''}`,
|
||||
callbackUrl: `${pushplusCallbackUrl || ''}`,
|
||||
to: `${pushplusTo || ''}`
|
||||
to: `${pushplusTo || ''}`,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const res: any = await got
|
||||
.post(url, body)
|
||||
.json();
|
||||
const res: any = await got.post(url, body).json();
|
||||
|
||||
if (res.code === 200) {
|
||||
return true;
|
||||
@@ -678,37 +663,46 @@ export default class NotificationService {
|
||||
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
|
||||
const encodedText = Buffer.from(text).toString('base64');
|
||||
return `=?${charset}?B?${encodedText}?=`;
|
||||
};
|
||||
};
|
||||
try {
|
||||
const encodedTitle = encodeRfc2047(this.title);
|
||||
const res: any = await got
|
||||
.post(`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`, {
|
||||
...this.gotOption,
|
||||
body: `${this.content}`,
|
||||
headers: { 'Title': encodedTitle, 'Priority': `${ntfyPriority || '3'}` },
|
||||
});
|
||||
if (res.statusCode === 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(JSON.stringify(res));
|
||||
}
|
||||
const encodedTitle = encodeRfc2047(this.title);
|
||||
const res: any = await got.post(
|
||||
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
|
||||
{
|
||||
...this.gotOption,
|
||||
body: `${this.content}`,
|
||||
headers: { Title: encodedTitle, Priority: `${ntfyPriority || '3'}` },
|
||||
},
|
||||
);
|
||||
if (res.statusCode === 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(JSON.stringify(res));
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
}
|
||||
}
|
||||
|
||||
private async wxPusherBot() {
|
||||
const { wxPusherBotAppToken, wxPusherBotTopicIds, wxPusherBotUids } = this.params;
|
||||
const { wxPusherBotAppToken, wxPusherBotTopicIds, wxPusherBotUids } =
|
||||
this.params;
|
||||
// 处理 topicIds,将分号分隔的字符串转为数组
|
||||
const topicIds = wxPusherBotTopicIds ? wxPusherBotTopicIds.split(';')
|
||||
.map(id => id.trim())
|
||||
.filter(id => id)
|
||||
.map(id => parseInt(id)) : [];
|
||||
const topicIds = wxPusherBotTopicIds
|
||||
? wxPusherBotTopicIds
|
||||
.split(';')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id)
|
||||
.map((id) => parseInt(id))
|
||||
: [];
|
||||
|
||||
// 处理 uids,将分号分隔的字符串转为数组
|
||||
const uids = wxPusherBotUids ? wxPusherBotUids.split(';')
|
||||
.map(uid => uid.trim())
|
||||
.filter(uid => uid) : [];
|
||||
// 处理 uids,将分号分隔的字符串转为数组
|
||||
const uids = wxPusherBotUids
|
||||
? wxPusherBotUids
|
||||
.split(';')
|
||||
.map((uid) => uid.trim())
|
||||
.filter((uid) => uid)
|
||||
: [];
|
||||
|
||||
// topic_ids 和 uids 至少要有一个
|
||||
if (!topicIds.length && !uids.length) {
|
||||
@@ -727,7 +721,7 @@ export default class NotificationService {
|
||||
contentType: 2,
|
||||
topicIds: topicIds,
|
||||
uids: uids,
|
||||
verifyPayType: 0
|
||||
verifyPayType: 0,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
@@ -742,7 +736,6 @@ export default class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async chronocat() {
|
||||
const { chronocatURL, chronocatQQ, chronocatToken } = this.params;
|
||||
try {
|
||||
|
||||
@@ -159,16 +159,15 @@ export default class OpenService {
|
||||
value: string;
|
||||
expiration: number;
|
||||
}> {
|
||||
let systemApp = (
|
||||
await AppModel.findOne({
|
||||
where: { name: 'system' },
|
||||
})
|
||||
)?.get({ plain: true });
|
||||
const apps = await shareStore.getApps();
|
||||
const systemApp = apps?.find((x) => x.name === 'system');
|
||||
if (!systemApp) {
|
||||
systemApp = await this.create({
|
||||
name: 'system',
|
||||
scopes: ['crons', 'system'],
|
||||
} as App);
|
||||
throw new Error('system app not found');
|
||||
}
|
||||
const now = Math.round(Date.now() / 1000);
|
||||
const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
|
||||
if (currentToken) {
|
||||
return currentToken;
|
||||
}
|
||||
const { data } = await this.authToken({
|
||||
client_id: systemApp.client_id,
|
||||
|
||||
@@ -440,7 +440,7 @@ export default class SystemService {
|
||||
const logs = result
|
||||
.reverse()
|
||||
.filter((x) => x.title.endsWith('.log'))
|
||||
.filter((x) => x.mtime >= startTime && x.mtime <= endTime);
|
||||
.filter((x) => x.createTime >= startTime && x.createTime <= endTime);
|
||||
|
||||
res.set({
|
||||
'Content-Length': sum(logs.map((x) => x.size)),
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import {
|
||||
createFile,
|
||||
createRandomString,
|
||||
fileExist,
|
||||
getNetIp,
|
||||
getPlatform,
|
||||
safeJSONParse,
|
||||
} from '../config/util';
|
||||
import { createRandomString, getNetIp } from '../config/util';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticator } from '@otplib/preset-default';
|
||||
import {
|
||||
|
||||
+20
-3
@@ -11,6 +11,9 @@ import {
|
||||
IScheduleFn,
|
||||
TCron,
|
||||
} from './interface';
|
||||
import config from '../config';
|
||||
import { credentials } from '@grpc/grpc-js';
|
||||
import { ApiClient } from '../protos/api';
|
||||
|
||||
class TaskLimit {
|
||||
private dependenyLimit = new PQueue({ concurrency: 1 });
|
||||
@@ -33,6 +36,11 @@ class TaskLimit {
|
||||
private systemLimit = new PQueue({
|
||||
concurrency: Math.max(os.cpus().length, 4),
|
||||
});
|
||||
private client = new ApiClient(
|
||||
`0.0.0.0:${config.cronPort}`,
|
||||
credentials.createInsecure(),
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
|
||||
get cronLimitActiveCount() {
|
||||
return this.cronLimit.pending;
|
||||
@@ -126,9 +134,18 @@ class TaskLimit {
|
||||
if (result?.length > 5) {
|
||||
if (repeatTimes < 3) {
|
||||
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
|
||||
this.notificationService.externalNotify(
|
||||
'任务重复运行',
|
||||
`任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`,
|
||||
this.client.systemNotify(
|
||||
{
|
||||
title: '任务重复运行',
|
||||
content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`,
|
||||
},
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
Logger.error(
|
||||
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
|
||||
|
||||
+21
-6
@@ -1,27 +1,42 @@
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { writeFile, open } from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { writeFile, open, chmod } from 'fs/promises';
|
||||
import { fileExist } from '../config/util';
|
||||
|
||||
function getUniqueLockPath(filePath: string) {
|
||||
const sanitizedPath = filePath
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.replace(/^_/, '');
|
||||
return path.join(os.tmpdir(), `${sanitizedPath}.ql_lock`);
|
||||
}
|
||||
|
||||
export async function writeFileWithLock(
|
||||
path: string,
|
||||
filePath: string,
|
||||
content: string | Buffer,
|
||||
options: Parameters<typeof writeFile>[2] = {},
|
||||
) {
|
||||
if (typeof options === 'string') {
|
||||
options = { encoding: options };
|
||||
}
|
||||
if (!(await fileExist(path))) {
|
||||
const fileHandle = await open(path, 'w');
|
||||
if (!(await fileExist(filePath))) {
|
||||
const fileHandle = await open(filePath, 'w');
|
||||
fileHandle.close();
|
||||
}
|
||||
const release = await lock(path, {
|
||||
const lockfilePath = getUniqueLockPath(filePath);
|
||||
|
||||
const release = await lock(filePath, {
|
||||
retries: {
|
||||
retries: 10,
|
||||
factor: 2,
|
||||
minTimeout: 100,
|
||||
maxTimeout: 3000,
|
||||
},
|
||||
lockfilePath,
|
||||
});
|
||||
await writeFile(path, content, { encoding: 'utf8', ...options });
|
||||
await writeFile(filePath, content, { encoding: 'utf8', ...options });
|
||||
if (options?.mode) {
|
||||
await chmod(filePath, options.mode);
|
||||
}
|
||||
await release();
|
||||
}
|
||||
|
||||
+5
-7
@@ -14,6 +14,7 @@
|
||||
"public": "npm run build:back && node static/build/public.js",
|
||||
"update": "npm run build:back && node static/build/update.js",
|
||||
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true",
|
||||
"gen:api": "python3 -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. ./back/protos/api.proto",
|
||||
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
|
||||
"postinstall": "max setup 2>/dev/null || true",
|
||||
"test": "umi-test",
|
||||
@@ -61,8 +62,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.12.3",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@sentry/node": "^8.42.0",
|
||||
"body-parser": "^1.20.3",
|
||||
"celebrate": "^15.0.3",
|
||||
"chokidar": "^4.0.1",
|
||||
@@ -71,7 +72,7 @@
|
||||
"cross-spawn": "^7.0.6",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.6",
|
||||
"express": "^4.21.1",
|
||||
"express": "^4.21.2",
|
||||
"express-jwt": "^8.4.1",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"express-urlrewrite": "^2.0.3",
|
||||
@@ -84,7 +85,6 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"nedb": "^1.8.0",
|
||||
"node-schedule": "^2.1.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"p-queue-cjs": "7.3.4",
|
||||
@@ -115,7 +115,6 @@
|
||||
"@monaco-editor/react": "4.2.1",
|
||||
"@react-hook/resize-observer": "^2.0.2",
|
||||
"react-router-dom": "6.26.1",
|
||||
"@sentry/react": "^8.42.0",
|
||||
"@types/body-parser": "^1.19.2",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/cross-spawn": "^6.0.2",
|
||||
@@ -126,7 +125,6 @@
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/lodash": "^4.14.185",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/nedb": "^1.8.12",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/node-schedule": "^1.3.2",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
@@ -142,11 +140,11 @@
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@uiw/codemirror-extensions-langs": "^4.21.9",
|
||||
"@uiw/react-codemirror": "^4.21.9",
|
||||
"@umijs/max": "^4.3.36",
|
||||
"@umijs/max": "^4.4.4",
|
||||
"@umijs/ssr-darkreader": "^4.9.45",
|
||||
"ahooks": "^3.7.8",
|
||||
"ansi-to-react": "^6.1.6",
|
||||
"antd": "^4.24.8",
|
||||
"antd": "^4.24.16",
|
||||
"antd-img-crop": "^4.23.0",
|
||||
"axios": "^1.4.0",
|
||||
"compression-webpack-plugin": "9.2.0",
|
||||
|
||||
Generated
+138
-917
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -428,7 +428,7 @@ function tgBotNotify(text, desp) {
|
||||
TG_PROXY_AUTH,
|
||||
} = push_config;
|
||||
if (TG_BOT_TOKEN && TG_USER_ID) {
|
||||
const options = {
|
||||
let options = {
|
||||
url: `${TG_API_HOST}/bot${TG_BOT_TOKEN}/sendMessage`,
|
||||
json: {
|
||||
chat_id: `${TG_USER_ID}`,
|
||||
@@ -442,20 +442,20 @@ function tgBotNotify(text, desp) {
|
||||
};
|
||||
if (TG_PROXY_HOST && TG_PROXY_PORT) {
|
||||
const { HttpProxyAgent, HttpsProxyAgent } = require('hpagent');
|
||||
const options = {
|
||||
const _options = {
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 1000,
|
||||
maxSockets: 256,
|
||||
maxFreeSockets: 256,
|
||||
proxy: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`,
|
||||
};
|
||||
const httpAgent = new HttpProxyAgent(options);
|
||||
const httpsAgent = new HttpsProxyAgent(options);
|
||||
const httpAgent = new HttpProxyAgent(_options);
|
||||
const httpsAgent = new HttpsProxyAgent(_options);
|
||||
const agent = {
|
||||
http: httpAgent,
|
||||
https: httpsAgent,
|
||||
};
|
||||
Object.assign(options, { agent });
|
||||
options.agent = agent;
|
||||
}
|
||||
$.post(options, (err, resp, data) => {
|
||||
try {
|
||||
|
||||
@@ -6,4 +6,10 @@
|
||||
*/
|
||||
console.log('test scripts');
|
||||
QLAPI.notify('test scripts', 'test desc');
|
||||
QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
|
||||
console.log('getEnvs', x);
|
||||
});
|
||||
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
|
||||
console.log('systemNotify', x);
|
||||
});
|
||||
console.log('test desc');
|
||||
|
||||
+6
-1
@@ -4,6 +4,11 @@ name: script name
|
||||
定时规则
|
||||
cron: 1 9 * * *
|
||||
"""
|
||||
|
||||
print("test script")
|
||||
QLAPI.notify('test script', 'test desc')
|
||||
print(QLAPI.notify("test script", "test desc"))
|
||||
print("test systemNotify")
|
||||
print(QLAPI.systemNotify({"title": "test script", "content": "dddd"}))
|
||||
print("test getEnvs")
|
||||
print(QLAPI.getEnvs({"searchValue": "1"}))
|
||||
print("test desc")
|
||||
|
||||
+4
-1
@@ -178,7 +178,10 @@ update_cron() {
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code != 200 ]]; then
|
||||
echo -e "\n## 更新任务状态失败(${message})\n"
|
||||
if [[ ! $message ]]; then
|
||||
message="$api"
|
||||
fi
|
||||
echo -e "${message}"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const grpc = require('@grpc/grpc-js');
|
||||
const protoLoader = require('@grpc/proto-loader');
|
||||
|
||||
const PROTO_PATH = `${process.env.QL_DIR}/back/protos/api.proto`;
|
||||
const options = {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
};
|
||||
|
||||
const packageDefinition = protoLoader.loadSync(PROTO_PATH, options);
|
||||
const apiProto = grpc.loadPackageDefinition(packageDefinition).com.ql.api;
|
||||
|
||||
const client = new apiProto.Api(
|
||||
`0.0.0.0:5500`,
|
||||
grpc.credentials.createInsecure(),
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
|
||||
const promisify = (fn) => {
|
||||
return (...args) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn.call(client, ...args, (err, response) => {
|
||||
if (err) return reject(err);
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const api = {
|
||||
getEnvs: promisify(client.GetEnvs),
|
||||
createEnv: promisify(client.CreateEnv),
|
||||
updateEnv: promisify(client.UpdateEnv),
|
||||
deleteEnvs: promisify(client.DeleteEnvs),
|
||||
moveEnv: promisify(client.MoveEnv),
|
||||
disableEnvs: promisify(client.DisableEnvs),
|
||||
enableEnvs: promisify(client.EnableEnvs),
|
||||
updateEnvNames: promisify(client.UpdateEnvNames),
|
||||
getEnvById: promisify(client.GetEnvById),
|
||||
systemNotify: promisify(client.SystemNotify),
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,113 @@
|
||||
import subprocess
|
||||
import json
|
||||
import tempfile
|
||||
import os
|
||||
from typing import Dict, List
|
||||
from functools import wraps
|
||||
|
||||
|
||||
def error_handler(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(f"parse json error: {str(e)}")
|
||||
except subprocess.SubprocessError as e:
|
||||
raise Exception(f"node process error: {str(e)}")
|
||||
except Exception as e:
|
||||
raise Exception(f"unknown error: {str(e)}")
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.temp_dir = tempfile.mkdtemp(prefix="node_client_")
|
||||
self.temp_script = os.path.join(self.temp_dir, "temp_script.js")
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
if os.path.exists(self.temp_script):
|
||||
os.remove(self.temp_script)
|
||||
os.rmdir(self.temp_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@error_handler
|
||||
def _execute_node(self, method: str, params: Dict = None) -> Dict:
|
||||
node_code = f"""
|
||||
const api = require('{os.getenv("QL_DIR")}/shell/preload/client.js');
|
||||
|
||||
(async () => {{
|
||||
try {{
|
||||
const result = await api.{method}({json.dumps(params) if params else ''});
|
||||
console.log(JSON.stringify(result));
|
||||
}} catch (error) {{
|
||||
console.error(JSON.stringify({{
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
}}));
|
||||
process.exit(1);
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
|
||||
with open(self.temp_script, "w", encoding="utf-8") as f:
|
||||
f.write(node_code)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["node", self.temp_script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_data = json.loads(result.stderr)
|
||||
raise Exception(f"{error_data.get('stack')}")
|
||||
|
||||
return json.loads(result.stdout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Exception("node process timeout")
|
||||
|
||||
@error_handler
|
||||
def getEnvs(self, params: Dict = None) -> Dict:
|
||||
return self._execute_node("getEnvs", params)
|
||||
|
||||
@error_handler
|
||||
def createEnv(self, data: Dict) -> Dict:
|
||||
return self._execute_node("createEnv", data)
|
||||
|
||||
@error_handler
|
||||
def updateEnv(self, data: Dict) -> Dict:
|
||||
return self._execute_node("updateEnv", data)
|
||||
|
||||
@error_handler
|
||||
def deleteEnvs(self, data: Dict) -> Dict:
|
||||
return self._execute_node("deleteEnvs", data)
|
||||
|
||||
@error_handler
|
||||
def moveEnv(self, data: Dict) -> Dict:
|
||||
return self._execute_node("moveEnv", data)
|
||||
|
||||
@error_handler
|
||||
def disableEnvs(self, data: Dict) -> Dict:
|
||||
return self._execute_node("disableEnvs", data)
|
||||
|
||||
@error_handler
|
||||
def enableEnvs(self, data: Dict) -> Dict:
|
||||
return self._execute_node("enableEnvs", data)
|
||||
|
||||
@error_handler
|
||||
def updateEnvNames(self, data: Dict) -> Dict:
|
||||
return self._execute_node("updateEnvNames", data)
|
||||
|
||||
@error_handler
|
||||
def getEnvById(self, data: Dict) -> Dict:
|
||||
return self._execute_node("getEnvById", data)
|
||||
|
||||
@error_handler
|
||||
def systemNotify(self, data: Dict) -> Dict:
|
||||
return self._execute_node("systemNotify", data)
|
||||
@@ -1,4 +1,5 @@
|
||||
const { execSync } = require('child_process');
|
||||
const client = require('./client.js');
|
||||
require(`./env.js`);
|
||||
|
||||
function expandRange(rangeStr, max) {
|
||||
@@ -100,6 +101,7 @@ try {
|
||||
const { sendNotify } = require('./notify.js');
|
||||
global.QLAPI = {
|
||||
notify: sendNotify,
|
||||
...client,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(`run builtin code error: `, error, '\n');
|
||||
|
||||
@@ -6,6 +6,7 @@ import builtins
|
||||
import sys
|
||||
import env
|
||||
import signal
|
||||
from client import Client
|
||||
|
||||
|
||||
def try_parse_int(value):
|
||||
@@ -108,7 +109,7 @@ try:
|
||||
|
||||
from notify import send
|
||||
|
||||
class BaseApi:
|
||||
class BaseApi(Client):
|
||||
def notify(self, *args, **kwargs):
|
||||
return send(*args, **kwargs)
|
||||
|
||||
|
||||
+16
-5
@@ -438,8 +438,14 @@ clear_env() {
|
||||
}
|
||||
|
||||
handle_task_start() {
|
||||
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
|
||||
echo -e "## 开始执行... $begin_time\n"
|
||||
local error_message=""
|
||||
if [[ $ID ]]; then
|
||||
local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp")
|
||||
if [[ $error ]]; then
|
||||
error_message=", 任务状态更新失败(${error})"
|
||||
fi
|
||||
fi
|
||||
echo -e "## 开始执行... ${begin_time}${error_message}\n"
|
||||
}
|
||||
|
||||
run_task_before() {
|
||||
@@ -468,12 +474,17 @@ handle_task_end() {
|
||||
local end_timestamp=$(format_timestamp "$time_format" "$etime")
|
||||
local diff_time=$(($end_timestamp - $begin_timestamp))
|
||||
local suffix=""
|
||||
[[ "$MANUAL" == "true" ]] && suffix="(手动停止)"
|
||||
[[ "${MANUAL:=}" == "true" ]] && suffix="(手动停止)"
|
||||
|
||||
[[ "$diff_time" == 0 ]] && diff_time=1
|
||||
|
||||
echo -e "\n## 执行结束$suffix... $end_time 耗时 $diff_time 秒 "
|
||||
[[ $ID ]] && update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time"
|
||||
if [[ $ID ]]; then
|
||||
local error=$(update_cron "\"$ID\"" "1" "" "$log_path" "$begin_timestamp" "$diff_time")
|
||||
if [[ $error ]]; then
|
||||
error_message=", 任务状态更新失败(${error})"
|
||||
fi
|
||||
fi
|
||||
echo -e "\n## 执行结束$suffix... $end_time 耗时 $diff_time 秒${error_message:=} "
|
||||
}
|
||||
|
||||
init_env
|
||||
|
||||
+2
-1
@@ -489,7 +489,6 @@ main() {
|
||||
local time_format="%Y-%m-%d %H:%M:%S"
|
||||
local time=$(date "+$time_format")
|
||||
local begin_timestamp=$(format_timestamp "$time_format" "$time")
|
||||
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
|
||||
|
||||
local begin_time=$(format_time "$time_format" "$time")
|
||||
|
||||
@@ -497,6 +496,8 @@ main() {
|
||||
eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd
|
||||
fi
|
||||
|
||||
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
|
||||
|
||||
case $p1 in
|
||||
update)
|
||||
fix_config
|
||||
|
||||
+25
-39
@@ -1,35 +1,21 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ProLayout, { PageLoading } from '@ant-design/pro-layout';
|
||||
import * as DarkReader from '@umijs/ssr-darkreader';
|
||||
import defaultProps from './defaultProps';
|
||||
import { Link, history, Outlet, useLocation } from '@umijs/max';
|
||||
import config from '@/utils/config';
|
||||
import { useCtx, useTheme } from '@/utils/hooks';
|
||||
import { request } from '@/utils/http';
|
||||
import {
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import config from '@/utils/config';
|
||||
import { request } from '@/utils/http';
|
||||
import './index.less';
|
||||
import ProLayout, { PageLoading } from '@ant-design/pro-layout';
|
||||
import { history, Link, Outlet, useLocation } from '@umijs/max';
|
||||
import * as DarkReader from '@umijs/ssr-darkreader';
|
||||
import { Avatar, Badge, Dropdown, Image, MenuProps, Tooltip } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import vhCheck from 'vh-check';
|
||||
import { useCtx, useTheme } from '@/utils/hooks';
|
||||
import {
|
||||
message,
|
||||
Badge,
|
||||
Modal,
|
||||
Avatar,
|
||||
Dropdown,
|
||||
Menu,
|
||||
Image,
|
||||
Popover,
|
||||
Descriptions,
|
||||
Tooltip,
|
||||
MenuProps,
|
||||
} from 'antd';
|
||||
// @ts-ignore
|
||||
import * as Sentry from '@sentry/react';
|
||||
import defaultProps from './defaultProps';
|
||||
import './index.less';
|
||||
import { init } from '../utils/init';
|
||||
import WebSocketManager from '../utils/websocket';
|
||||
|
||||
@@ -178,9 +164,9 @@ export default function () {
|
||||
useEffect(() => {
|
||||
if (!user || !user.username) return;
|
||||
const ws = WebSocketManager.getInstance(
|
||||
`${window.location.origin}${config.apiPrefix}ws?token=${localStorage.getItem(
|
||||
config.authKey,
|
||||
)}`,
|
||||
`${window.location.origin}${
|
||||
config.apiPrefix
|
||||
}ws?token=${localStorage.getItem(config.authKey)}`,
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -201,9 +187,6 @@ export default function () {
|
||||
console.log(
|
||||
`从开始至load总耗时: ${timing.loadEventEnd - timing.navigationStart}`,
|
||||
);
|
||||
Sentry.captureMessage(
|
||||
`白屏时间 ${timing.responseStart - timing.navigationStart}`,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -254,18 +237,15 @@ export default function () {
|
||||
<ProLayout
|
||||
selectedKeys={[location.pathname]}
|
||||
loading={loading}
|
||||
ErrorBoundary={Sentry.ErrorBoundary}
|
||||
logo={
|
||||
<>
|
||||
<Image preview={false} src="https://qn.whyour.cn/logo.png" />
|
||||
<div className="title">
|
||||
<span className="title">{intl.get('青龙')}</span>
|
||||
<a
|
||||
href={systemInfo?.changeLogLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(systemInfo?.changeLogLink, '_blank');
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
@@ -289,7 +269,7 @@ export default function () {
|
||||
</span>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@@ -320,7 +300,9 @@ export default function () {
|
||||
shape="square"
|
||||
size="small"
|
||||
icon={<UserOutlined />}
|
||||
src={user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''}
|
||||
src={
|
||||
user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''
|
||||
}
|
||||
/>
|
||||
<span style={{ marginLeft: 5 }}>{user.username}</span>
|
||||
</span>
|
||||
@@ -342,7 +324,11 @@ export default function () {
|
||||
shape="square"
|
||||
size="small"
|
||||
icon={<UserOutlined />}
|
||||
src={user.avatar ? `${config.apiPrefix}static/${user.avatar}` : ''}
|
||||
src={
|
||||
user.avatar
|
||||
? `${config.apiPrefix}static/${user.avatar}`
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<span style={{ marginLeft: 5 }}>{user.username}</span>
|
||||
</span>
|
||||
|
||||
@@ -393,7 +393,7 @@
|
||||
"SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions",
|
||||
"PushMe的Key,https://push.i-i.me/": "PushMe key, https://push.i-i.me/",
|
||||
"自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "The self built PushMeServer message interface address, for example: http://127.0.0.1:3010 If left blank, use the official message interface",
|
||||
"ntfy的url地址,例如 https://ntfy.sh'": "The URL address of ntfy, for example, https://ntfy.sh.",
|
||||
"ntfy的url地址,例如 https://ntfy.sh": "The URL address of ntfy, for example, https://ntfy.sh.",
|
||||
"ntfy的消息应用topic": "The topic for ntfy's messaging application.",
|
||||
"wxPusherBot的appToken": "wxPusherBot's appToken, obtain according to docs https://wxpusher.zjiecode.com/docs/",
|
||||
"wxPusherBot的topicIds": "wxPusherBot's topicIds, at least one of topicIds or uids must be configured",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import { message } from 'antd';
|
||||
import { message, notification } from 'antd';
|
||||
import config from './config';
|
||||
import { history } from '@umijs/max';
|
||||
import axios, {
|
||||
@@ -14,7 +14,7 @@ export interface IResponseData {
|
||||
code?: number;
|
||||
data?: any;
|
||||
message?: string;
|
||||
error?: any;
|
||||
errors?: any[];
|
||||
}
|
||||
|
||||
export type Override<
|
||||
@@ -41,7 +41,7 @@ const errorHandler = function (
|
||||
) {
|
||||
if (error.response) {
|
||||
const msg = error.response.data
|
||||
? error.response.data.message || error.message || error.response.data
|
||||
? error.response.data.message || error.message
|
||||
: error.response.statusText;
|
||||
const responseStatus = error.response.status;
|
||||
if ([502, 504].includes(responseStatus)) {
|
||||
@@ -57,9 +57,17 @@ const errorHandler = function (
|
||||
return error.config?.onError(error.response);
|
||||
}
|
||||
|
||||
message.error({
|
||||
content: msg,
|
||||
style: { maxWidth: 500, margin: '0 auto' },
|
||||
notification.error({
|
||||
message: msg,
|
||||
description: (
|
||||
<>
|
||||
{error.response?.data?.errors?.map((item: any) => (
|
||||
<div>
|
||||
{item.message} ({item.value})
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -107,9 +115,15 @@ _request.interceptors.response.use(async (response) => {
|
||||
if (res.code !== 200) {
|
||||
const msg = res.message || res.data;
|
||||
msg &&
|
||||
message.error({
|
||||
content: msg,
|
||||
style: { maxWidth: 500, margin: '0 auto' },
|
||||
notification.error({
|
||||
message: msg,
|
||||
description: (
|
||||
<>
|
||||
{res?.errors.map((item: any) => (
|
||||
<div>{item.message}</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
return res;
|
||||
@@ -1,42 +1,7 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { loader } from '@monaco-editor/react';
|
||||
import config from './config';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from 'react-router-dom';
|
||||
|
||||
export function init(version: string) {
|
||||
// sentry监控 init
|
||||
Sentry.init({
|
||||
dsn: 'https://49b9ad1a6201bfe027db296ab7c6d672@o1098464.ingest.sentry.io/6122818',
|
||||
integrations: [
|
||||
Sentry.reactRouterV6BrowserTracingIntegration({
|
||||
useEffect,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
}),
|
||||
Sentry.replayIntegration(),
|
||||
],
|
||||
beforeBreadcrumb(breadcrumb) {
|
||||
if (breadcrumb.data && breadcrumb.data.url) {
|
||||
const url = breadcrumb.data.url.replace(/token=.*/, '');
|
||||
breadcrumb.data.url = url;
|
||||
}
|
||||
return breadcrumb;
|
||||
},
|
||||
tracesSampleRate: 0.1,
|
||||
tracePropagationTargets: [/^(?!\/api\/(ws|static)).*$/],
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 0.1,
|
||||
release: version,
|
||||
});
|
||||
|
||||
// monaco 编辑器配置cdn和locale
|
||||
loader.config({
|
||||
paths: {
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
"noEmit": false,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["./back/**/*"],
|
||||
"include": ["./back/**/*", "./back.d.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
"files": ["./back/index.d.ts"]
|
||||
}
|
||||
|
||||
+6
-7
@@ -1,8 +1,7 @@
|
||||
version: 2.18.0
|
||||
changeLogLink: https://t.me/jiao_long/424
|
||||
publishTime: 2025-01-05 13:00
|
||||
version: 2.18.1
|
||||
changeLogLink: https://t.me/jiao_long/426
|
||||
publishTime: 2025-01-15 08:00
|
||||
changeLog: |
|
||||
1. 由于安全问题,修改认证信息存储方式,不再使用 auth.json 存储
|
||||
2. 修复初始化 SystemConfig 数据
|
||||
3. 修改通知文件未设置时提示
|
||||
4. 修复配置文件更新可能异常
|
||||
1. 内置 QLAPI 增加环境变量和系统通知 api
|
||||
2. 移除 nedb 和 sentry,不再支持 2.10.x 版本自动迁移
|
||||
3. 修复多语言翻译
|
||||
|
||||
Reference in New Issue
Block a user