Merge develop branch and remove notify.py.save

This commit is contained in:
copilot-swe-agent[bot] 2026-05-31 06:25:16 +00:00 committed by GitHub
commit 628e2933f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 3033 additions and 1683 deletions

View File

@ -1,6 +1,11 @@
GRPC_PORT=5500
BACK_PORT=5700
# 服务绑定地址,默认 ::IPv6 通配,双栈系统同时支持 IPv4/IPv6
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
# BIND_HOST=0.0.0.0
# BIND_HOST_GRPC=0.0.0.0
LOG_LEVEL='info'
JWT_SECRET=

View File

@ -11,6 +11,9 @@ on:
- "v*"
workflow_dispatch:
permissions:
contents: read
jobs:
code_gitlab:
runs-on: ubuntu-latest
@ -33,26 +36,51 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
with:
src: github/whyour
dst: gitee/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "qinglong"
force_update: true
- name: Setup SSH and push to Gitee
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee push --all succeeded"
else
echo "::warning::Push --all failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee push --all succeeded after repo creation"
else
echo "::warning::Gitee push --all failed after retry"
fi
fi
if git push --force --tags gitee 2>&1; then
echo "::notice::Gitee push --tags succeeded"
else
echo "::warning::Gitee push --tags failed"
fi
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: build front and back
run: |
@ -64,9 +92,6 @@ jobs:
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
REPO_GITEE: git@gitee.com:whyour/qinglong-static.git
REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git
PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: |
mkdir -p tmp
cd ./tmp
@ -97,57 +122,53 @@ jobs:
needs: build-static
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
with:
src: github/whyour
dst: gitee/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "qinglong-static"
force_update: true
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan github.com gitee.com >> ~/.ssh/known_hosts
- name: Mirror qinglong-static to Gitee
run: |
git clone --mirror https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote set-url origin git@gitee.com:whyour/qinglong-static.git
git push --force --mirror
build:
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: Asia/Shanghai
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -155,7 +176,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
@ -164,109 +185,256 @@ jobs:
latest=false
tags: |
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=ref,event=branch,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}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
- name: Build and push (Alpine)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# linux/s390x npm 暂不可用
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
context: .
file: ./docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
- name: Image digest
run: |
echo ${{ steps.docker_build.outputs.digest }}
build310:
if: ${{ github.ref_name == 'master' }}
build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: Asia/Shanghai
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build and push python3.10
id: docker_build_310
uses: docker/build-push-action@v6
- name: Build and push (Debian)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# linux/s390x npm 暂不可用
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
context: .
file: ./docker/310.Dockerfile
file: ./docker/Dockerfile.310
push: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-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-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
- name: Image digest
build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
echo ${{ steps.docker_build_310.outputs.digest }}
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
publish:
if: ${{ github.ref_name == 'master' }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: publish npm package
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
npm publish

2
.gitignore vendored
View File

@ -28,3 +28,5 @@ __pycache__
/shell/preload/notify.*
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
.deepseek/

22
.npmignore Normal file
View File

@ -0,0 +1,22 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env

View File

@ -48,6 +48,17 @@ docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
When running the `debian` image as a non-root user, specify `--user qinglong`:
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.

View File

@ -50,6 +50,17 @@ docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`

View File

@ -14,7 +14,7 @@ export default (app: Router) => {
app.use('/configs', route);
route.get(
'/sample',
'/samples',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({

View File

@ -18,6 +18,10 @@ const storage = multer.diskStorage({
},
});
const upload = multer({ storage: storage });
const labelSchema = Joi.array()
.items(Joi.string().trim().required())
.min(1)
.required();
export default (app: Router) => {
app.use('/envs', route);
@ -44,6 +48,7 @@ export default (app: Router) => {
.required()
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
remarks: Joi.string().optional().allow(''),
labels: Joi.array().items(Joi.string().trim()).optional(),
}),
),
}),
@ -70,6 +75,7 @@ export default (app: Router) => {
name: Joi.string().required(),
remarks: Joi.string().optional().allow('').allow(null),
id: Joi.number().required(),
labels: Joi.array().items(Joi.string().trim()).optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@ -230,6 +236,44 @@ export default (app: Router) => {
},
);
route.post(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.addLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.removeLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/upload',
upload.single('env'),
@ -248,6 +292,7 @@ export default (app: Router) => {
name: x.name,
value: x.value,
remarks: x.remarks,
labels: x.labels,
})),
);
return res.send({ code: 200, data: result });

View File

@ -140,12 +140,12 @@ export default (app: Router) => {
);
route.put(
'/two-factor/deactive',
'/two-factor/deactivate',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.deactiveTwoFactor();
const data = await userService.deactivateTwoFactor();
res.send({ code: 200, data });
} catch (e) {
return next(e);

View File

@ -49,3 +49,38 @@ export const NotificationModeStringMap = {
19: 'ntfy',
20: 'wxPusherBot',
} as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
'Debian' | 'Ubuntu' | 'Alpine',
{
install: string;
uninstall: string;
info: string;
check(info: string): boolean;
}
> = {
Debian: {
install: 'sudo apt-get install -y',
uninstall: 'sudo apt-get remove -y',
info: 'sudo dpkg-query -s',
check(info: string) {
return info.includes('install ok installed');
},
},
Ubuntu: {
install: 'sudo apt-get install -y',
uninstall: 'sudo apt-get remove -y',
info: 'sudo dpkg-query -s',
check(info: string) {
return info.includes('install ok installed');
},
},
Alpine: {
install: 'apk add --no-check-certificate',
uninstall: 'apk del',
info: 'apk info -es',
check(info: string) {
return info.includes('installed');
},
},
};

View File

@ -9,6 +9,8 @@ dotenv.config({
interface Config {
port: number;
grpcPort: number;
bindHost: string;
bindHostGrpc: string;
nodeEnv: string;
isDevelopment: boolean;
isProduction: boolean;
@ -31,6 +33,8 @@ interface Config {
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
bindHost: process.env.BIND_HOST || '::',
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',

View File

@ -1,6 +1,6 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree';
import { promisify } from 'util';
import { load } from 'js-yaml';
@ -10,9 +10,38 @@ import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
import os from 'os';
export * from './share';
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
function getOsTypeSync(): 'Debian' | 'Ubuntu' | 'Alpine' | undefined {
// 1. 环境变量覆盖
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') return 'Alpine';
if (envOs === 'debian') return 'Debian';
if (envOs === 'ubuntu') return 'Ubuntu';
// 2. 模块缓存(由 detectOS 设置)
if (osType) return osType;
// 3. 能力检测:检查包管理器二进制
try {
execSync('which apt-get', { stdio: 'ignore' });
return 'Debian';
} catch {
try {
execSync('which apk', { stdio: 'ignore' });
return 'Alpine';
} catch {
// macOS / 未知系统
}
}
return undefined;
}
export async function getFileContentByName(fileName: string) {
const _exsit = await fileExist(fileName);
if (_exsit) {
@ -550,7 +579,9 @@ except:
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: `apk info -es ${name}`,
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? `apk info -es ${name}`
: `sudo dpkg-query -s ${name}`,
};
return baseCommands[type];
@ -561,7 +592,9 @@ export function getInstallCommand(type: DependenceTypes, name: string): string {
[DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.python3]:
'pip3 install --disable-pip-version-check --root-user-action=ignore',
[DependenceTypes.linux]: 'apk add --no-check-certificate',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: 'sudo apt-get install -y',
};
let command = baseCommands[type];
@ -581,7 +614,9 @@ export function getUninstallCommand(
[DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: 'apk del',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: 'sudo apt-get remove -y',
};
return `${baseCommands[type]} ${name.trim()}`;
@ -590,3 +625,160 @@ export function getUninstallCommand(
export function isDemoEnv() {
return process.env.DeployEnv === 'demo';
}
async function getOSReleaseInfo(): Promise<string> {
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
return osRelease;
}
function isDebian(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Debian');
}
function isUbuntu(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Ubuntu');
}
function isCentOS(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
}
function isAlpine(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Alpine');
}
export async function detectOS(): Promise<
'Debian' | 'Ubuntu' | 'Alpine' | undefined
> {
if (osType) return osType;
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') {
osType = 'Alpine';
return osType;
}
if (envOs === 'debian') {
osType = 'Debian';
return osType;
}
if (envOs === 'ubuntu') {
osType = 'Ubuntu';
return osType;
}
const platform = os.platform();
if (platform === 'linux') {
const osReleaseInfo = await getOSReleaseInfo();
if (isDebian(osReleaseInfo)) {
osType = 'Debian';
} else if (isUbuntu(osReleaseInfo)) {
osType = 'Ubuntu';
} else if (isAlpine(osReleaseInfo)) {
osType = 'Alpine';
} else {
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
}
} else if (platform === 'darwin') {
osType = undefined;
} else {
Logger.error(`Unsupported platform: ${platform}`);
console.error(`Unsupported platform: ${platform}`);
}
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
): Promise<string> {
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
await replaceDomainInFile(
filePath,
currentDomainWithScheme,
mirrorDomainWithScheme || 'http://deb.debian.org',
);
return 'sudo apt-get update';
} else {
throw Error(`Current mirror domain not found.`);
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
await replaceDomainInFile(
filePath,
currentDomainWithScheme,
mirrorDomainWithScheme || 'http://archive.ubuntu.com',
);
return 'sudo apt-get update';
} else {
throw Error(`Current mirror domain not found.`);
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
await replaceDomainInFile(
filePath,
currentDomainWithScheme,
mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org',
);
return 'apk update';
} else {
throw Error(`Current mirror domain not found.`);
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
const detectedOS = await detectOS();
if (!detectedOS) {
throw Error(`Unknown Linux Distribution`);
}
return await _updateLinuxMirror(detectedOS, mirror);
}

View File

@ -10,6 +10,7 @@ export class Env {
name?: string;
remarks?: string;
isPinned?: 1 | 0;
labels?: string[];
constructor(options: Env) {
this.value = options.value;
@ -23,6 +24,7 @@ export class Env {
this.name = options.name;
this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
this.labels = options.labels || [];
}
}
@ -45,4 +47,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
labels: DataTypes.JSON,
});

View File

@ -20,6 +20,7 @@ export enum NotificationMode {
'chronocat' = 'Chronocat',
'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot',
'openiLink' = 'openiLink',
}
abstract class NotificationBaseInfo {
@ -161,6 +162,12 @@ export class WxPusherBotNotification extends NotificationBaseInfo {
public wxPusherBotUids = '';
}
export class OpeniLinkNotification extends NotificationBaseInfo {
public openiLinkAppToken = '';
public openiLinkHubUrl = '';
public openiLinkContextToken = '';
}
export interface NotificationInfo
extends GoCqHttpBotNotification,
GotifyNotification,
@ -182,4 +189,5 @@ export interface NotificationInfo
ChronocatNotification,
LarkNotification,
NtfyNotification,
WxPusherBotNotification {}
WxPusherBotNotification,
OpeniLinkNotification {}

View File

@ -40,6 +40,7 @@ export default async () => {
type: 'NUMBER',
},
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
{ table: 'Envs', column: 'labels', type: 'JSON' },
];
for (const migration of migrations) {

View File

@ -13,9 +13,29 @@ import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
@ -36,7 +56,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^\/(?!api\/).*/],
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}),
);
@ -51,19 +71,20 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req: Request, res, next) => {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (req.path.startsWith('/open/')) {
if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) &&
@ -98,7 +119,15 @@ export default ({ app }: { app: Application }) => {
});
app.use(async (req, res, next) => {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(req.path)
) {
return next();
}
const authInfo =

View File

@ -13,7 +13,7 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system';
import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, safeJSONParse } from '../config/util';
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
@ -50,7 +50,7 @@ export default async () => {
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
});
if (!authConfig?.info) {
if (!authConfig?.info || isDemoEnv()) {
let authInfo = {
username: 'admin',
password: 'admin',

View File

@ -20,6 +20,7 @@ const uploadPath = path.join(dataPath, 'upload/');
const bakPath = path.join(dataPath, 'bak/');
const samplePath = path.join(rootPath, 'sample/');
const tmpPath = path.join(logPath, '.tmp/');
const rootTmpPath = path.join(rootPath, '.tmp/');
const confFile = path.join(configPath, 'config.sh');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh');
@ -44,6 +45,7 @@ const directories = [
preloadPath,
logPath,
tmpPath,
rootTmpPath,
uploadPath,
sshPath,
bakPath,

View File

@ -250,7 +250,7 @@ const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
return {
...data,
sub_id: data.sub_id ?? undefined,
extra_schedules: data.extra_schedules ?? undefined,
extra_schedules: data.extra_schedules ?? [],
pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined,

View File

@ -10,7 +10,7 @@ import config from '../config';
class Client {
private client = new CronClient(
`0.0.0.0:${config.grpcPort}`,
`localhost:${config.grpcPort}`,
credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 },
);

View File

@ -10,7 +10,7 @@ const check = async (
switch (call.request.service) {
case 'cron':
const res = await promiseExec(
`curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`,
`curl -s --noproxy '*' http://localhost:${config.port}/api/system`,
);
if (res.includes('200')) {

View File

@ -39,6 +39,25 @@ export default class CronService {
return false;
}
private get schedulerMode(): 'system' | 'node' {
const env = process.env.QL_SCHEDULER;
if (env === 'system') return 'system';
if (env === 'node') return 'node';
try {
execSync('which crond', { stdio: 'ignore' });
return 'system';
} catch {
return 'node';
}
}
private shouldUseCronClient(cron: Crontab): boolean {
if (this.schedulerMode === 'node') {
return !this.isSpecialSchedule(cron.schedule);
}
return this.isNodeCron(cron) && !this.isSpecialSchedule(cron.schedule);
}
private isOnceSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.ONCE);
}
@ -80,7 +99,7 @@ export default class CronService {
return doc;
}
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
if (this.shouldUseCronClient(doc)) {
await cronClient.addCron([
{
name: doc.name || '',
@ -111,11 +130,9 @@ export default class CronService {
return newDoc;
}
if (this.isNodeCron(doc)) {
await cronClient.delCron([String(doc.id)]);
}
await cronClient.delCron([String(newDoc.id)]);
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) {
if (this.shouldUseCronClient(newDoc)) {
await cronClient.addCron([
{
name: doc.name || '',
@ -577,8 +594,8 @@ export default class CronService {
public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const sixCron = docs
.filter((x) => this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule))
const crons = docs
.filter((x) => this.shouldUseCronClient(x))
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
@ -590,7 +607,8 @@ export default class CronService {
if (isDemoEnv()) {
return;
}
await cronClient.addCron(sixCron);
await cronClient.addCron(crons);
await this.setCrontab();
}
@ -690,11 +708,13 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string);
try {
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
if (this.schedulerMode === 'system') {
try {
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: {} });
@ -745,8 +765,7 @@ export default class CronService {
.filter(
(x) =>
x.isDisabled !== 1 &&
this.isNodeCron(x) &&
!this.isSpecialSchedule(x.schedule),
this.shouldUseCronClient(x),
)
.map((doc) => ({
name: doc.name || '',

View File

@ -22,6 +22,8 @@ import {
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
@Service()
export default class DependenceService {
@ -159,8 +161,19 @@ export default class DependenceService {
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
const depInstallCommand = getInstallCommand(doc.type, doc.name);
const depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
let depInstallCommand = getInstallCommand(doc.type, doc.name);
let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const isLinuxDependence = doc.type === DependenceTypes.linux;
if (isLinuxDependence) {
const osType = await detectOS();
if (!osType) {
continue;
}
const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`;
depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`;
}
const pids = await Promise.all([
getPid(depInstallCommand),
getPid(depUnInstallCommand),
@ -217,23 +230,54 @@ export default class DependenceService {
if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null);
}
taskLimit.removeQueuedDependency(dependency);
const depIds = [dependency.id!];
let depName = dependency.name.trim();
const actionText = isInstall ? '安装' : '删除';
const socketMessageType = isInstall
? 'installDependence'
: 'uninstallDependence';
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence = dependency.type === DependenceTypes.python3;
const osType = await detectOS();
let linuxCommand = {} as typeof LINUX_DEPENDENCE_COMMAND.Alpine;
taskLimit.removeQueuedDependency(dependency);
if (isLinuxDependence) {
if (!osType) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: depIds } },
);
const startTime = dayjs();
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
'YYYY-MM-DD HH:mm:ss',
)}\n\n当前系统不支持\n\n依赖${actionText} ${startTime.format(
'YYYY-MM-DD HH:mm:ss',
)} ${startTime.diff(startTime, 'second')} `;
this.sockService.sendMessage({
type: socketMessageType,
message,
references: depIds,
});
this.updateLog(depIds, message);
return resolve(null);
}
linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
}
const status = isInstall
? DependenceStatus.installing
: DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } });
const socketMessageType = isInstall
? 'installDependence'
: 'uninstallDependence';
let depName = dependency.name.trim();
const command = isInstall
let command = isInstall
? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName);
const actionText = isInstall ? '安装' : '删除';
if (isLinuxDependence) {
command = isInstall
? `${linuxCommand.install} ${depName.trim()}`
: `${linuxCommand.uninstall} ${depName.trim()}`;
}
const startTime = dayjs();
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
@ -248,8 +292,12 @@ export default class DependenceService {
// 判断是否已经安装过依赖
if (isInstall && !force) {
const getCommand = getGetCommand(dependency.type, depName);
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
getCommand = `${linuxCommand.info} ${depName}`;
}
let depVersion = '';
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
@ -261,10 +309,6 @@ export default class DependenceService {
depVersion = _depVersion;
}
}
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence =
dependency.type === DependenceTypes.python3;
const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
@ -273,7 +317,7 @@ export default class DependenceService {
depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
(isLinuxDependence &&
depInfo.toLocaleLowerCase().includes('installed')) ||
linuxCommand.check(depInfo.toLocaleLowerCase())) ||
isPythonDependence) &&
(!depVersion || depInfo.includes(depVersion))
) {

View File

@ -27,7 +27,7 @@ export default class EnvService {
envs.length > 0 &&
typeof envs[envs.length - 1].position === 'number'
) {
position = envs[envs.length - 1].position!;
position = this.getPrecisionPosition(envs[envs.length - 1].position!);
}
const tabs = payloads.map((x) => {
position = position - stepPosition;
@ -100,7 +100,7 @@ export default class EnvService {
}
private async checkPosition(position: number, edge: number = 0) {
const precisionPosition = parseFloat(position.toPrecision(16));
const precisionPosition = this.getPrecisionPosition(position);
if (
precisionPosition < minPosition ||
precisionPosition > maxPosition ||
@ -116,7 +116,7 @@ export default class EnvService {
}
private getPrecisionPosition(position: number): number {
return parseFloat(position.toPrecision(16));
return Math.trunc(parseFloat(position.toPrecision(16)));
}
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
@ -199,6 +199,44 @@ export default class EnvService {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async addLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{ labels: Array.from(new Set([...(env.labels || []), ...labels])) },
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async removeLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{
labels: (env.labels || []).filter(
(label: string) => !labels.includes(label),
),
},
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async set_envs() {
const envs = await this.envs('', {
name: { [Op.not]: null },

View File

@ -16,6 +16,13 @@ import { Service } from 'typedi';
export class GrpcServerService {
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
private formatGrpcAddress(host: string, port: number): string {
if (host === '::') {
return `[::]:${port}`;
}
return `${host}:${port}`;
}
async initialize() {
try {
this.server.addService(HealthService, { check });
@ -23,18 +30,32 @@ export class GrpcServerService {
this.server.addService(ApiService, Api);
const grpcPort = config.grpcPort;
const hostsToTry = [
config.bindHostGrpc,
...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
await bindAsync(
`0.0.0.0:${grpcPort}`,
ServerCredentials.createInsecure(),
);
Logger.debug(`✌️ gRPC service started successfully`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
});
let lastError: Error | null = null;
return grpcPort;
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, ServerCredentials.createInsecure());
Logger.debug(`✌️ gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return grpcPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
}
}
Logger.error('Failed to start gRPC service on all hosts');
throw lastError || new Error('Failed to start gRPC service');
} catch (err) {
Logger.error('Failed to start gRPC service:', err);
throw err;

View File

@ -3,31 +3,51 @@ import Logger from '../loaders/logger';
import { metricsService } from './metrics';
import { Service } from 'typedi';
import { Server } from 'http';
import config from '../config';
@Service()
export class HttpServerService {
private server?: Server = undefined;
async initialize(expressApp: express.Application, port: number) {
try {
return new Promise((resolve, reject) => {
this.server = expressApp.listen(port, '0.0.0.0', () => {
Logger.debug(`✌️ HTTP service started successfully`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
});
resolve(this.server);
});
const hostsToTry = [
config.bindHost,
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
this.server?.on('error', (err: Error) => {
Logger.error('Failed to start HTTP service:', err);
reject(err);
let lastError: Error | null = null;
for (const host of hostsToTry) {
try {
const server = await this.tryListen(expressApp, port, host);
Logger.debug(`✌️ HTTP service started successfully on ${host}:${port}`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
host
});
});
} catch (err) {
Logger.error('Failed to start HTTP service:', err);
throw err;
this.server = server;
return server;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
}
}
Logger.error('Failed to start HTTP service on all hosts');
throw lastError || new Error('Failed to start HTTP service');
}
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
return new Promise((resolve, reject) => {
const server = expressApp.listen(port, host, () => {
resolve(server);
});
server.on('error', (err: Error) => {
server.close();
reject(err);
});
});
}
async shutdown() {

View File

@ -34,6 +34,7 @@ export default class NotificationService {
['chronocat', this.chronocat],
['ntfy', this.ntfy],
['wxPusherBot', this.wxPusherBot],
['openiLink', this.openiLink],
]);
private title = '';
@ -90,6 +91,14 @@ export default class NotificationService {
return true;
}
private parseMailRecipients(value?: string) {
const recipients = (value || '')
.split(/[;]/)
.map((item) => item.trim())
.filter(Boolean);
return recipients.length > 0 ? recipients : undefined;
}
private async gotify() {
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try {
@ -591,6 +600,7 @@ export default class NotificationService {
private async email() {
const { emailPass, emailService, emailUser, emailTo } = this.params;
const recipients = this.parseMailRecipients(emailTo) || emailUser;
try {
const transporter = nodemailer.createTransport({
@ -603,7 +613,7 @@ export default class NotificationService {
const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`,
to: emailTo ? emailTo.split(';') : emailUser,
to: recipients,
subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`,
});
@ -858,4 +868,35 @@ export default class NotificationService {
}
return {};
}
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
content: `${this.title}\n\n${this.content}`,
};
if (openiLinkContextToken) {
body.context_token = openiLinkContextToken;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: body,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${openiLinkAppToken}`,
},
});
if (res.ok) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
}

View File

@ -37,6 +37,7 @@ import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock';
import os from 'os';
import dayjs from 'dayjs';
import { updateLinuxMirrorFile } from '../config/util';
@Service()
export default class SystemService {
@ -214,33 +215,11 @@ export default class SystemService {
onEnd?: () => void,
) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let defaultDomain = 'https://dl-cdn.alpinelinux.org';
let targetDomain = 'https://dl-cdn.alpinelinux.org';
if (os.platform() !== 'linux') {
return;
}
const content = await fs.promises.readFile('/etc/apk/repositories', {
encoding: 'utf-8',
});
const domainMatch = content.match(/(http.*)\/alpine\/.*/);
if (domainMatch) {
defaultDomain = domainMatch[1];
}
if (info.linuxMirror) {
targetDomain = info.linuxMirror;
}
const command = `sed -i 's/${defaultDomain.replace(
/\//g,
'\\/',
)}/${targetDomain.replace(
/\//g,
'\\/',
)}/g' /etc/apk/repositories && apk update -f`;
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
let hasError = false;
this.scheduleService.runTask(
command,
{
@ -254,8 +233,15 @@ export default class SystemService {
message: 'update linux mirror end',
});
onEnd?.();
if (!hasError) {
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
}
},
onError: async (message: string) => {
hasError = true;
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
},
onLog: async (message: string) => {

View File

@ -330,7 +330,7 @@ export default class UserService {
}
}
public async deactiveTwoFactor() {
public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, {
twoFactorActivated: false,

View File

@ -37,7 +37,7 @@ class TaskLimit {
concurrency: Math.max(os.cpus().length, 4),
});
private client = new ApiClient(
`0.0.0.0:${config.grpcPort}`,
`localhost:${config.grpcPort}`,
credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 },
);

View File

@ -22,7 +22,7 @@ ENV QL_DIR=/ql \
PS1="\u@\h:\w \$ "
VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
@ -69,10 +69,11 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
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 \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@ -80,9 +81,13 @@ RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]

View File

@ -22,7 +22,7 @@ ENV QL_DIR=/ql \
PS1="\u@\h:\w \$ "
VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
@ -69,10 +69,11 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
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 \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@ -80,9 +81,13 @@ RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]

119
docker/Dockerfile.debian Normal file
View File

@ -0,0 +1,119 @@
FROM node:22-slim AS nodebuilder
FROM python:3.11-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.11-slim-bookworm
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.11
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
ARG QL_UID=5432
ARG QL_GID=5432
RUN groupadd -g ${QL_GID} qinglong && \
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
chmod 700 /home/qinglong/.ssh && \
chown -R ${QL_UID}:${QL_GID} /home/qinglong && \
mkdir -p /etc/sudoers.d && \
echo 'qinglong ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qinglong
ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
apt-get update && \
apt-get upgrade -y && \
apt-get install --no-install-recommends -y git \
curl \
wget \
tzdata \
perl \
openssl \
openssh-client \
jq \
procps \
netcat-openbsd \
sudo \
unzip \
libatomic1 && \
apt-get clean && \
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" >/etc/timezone && \
git config --global user.email "qinglong@users.noreply.github.com" && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
rm -rf /etc/apt/apt.conf.d/docker-clean && \
ulimit -c 0
RUN mkdir -p ${QL_DIR} && \
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
USER qinglong
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \
cd ${QL_DIR} && \
cp -f .env.example .env && \
chmod 777 ${QL_DIR}/shell/*.sh && \
chmod 777 ${QL_DIR}/docker/*.sh && \
git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \
mkdir -p ${QL_DIR}/static && \
cp -rf /tmp/static/* ${QL_DIR}/static && \
rm -rf /tmp/static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/home/qinglong
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
USER root
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
VOLUME /ql/data
EXPOSE 5700

119
docker/Dockerfile.debian310 Normal file
View File

@ -0,0 +1,119 @@
FROM node:22-slim AS nodebuilder
FROM python:3.10-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.10-slim-bookworm
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.10
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
ARG QL_UID=5432
ARG QL_GID=5432
RUN groupadd -g ${QL_GID} qinglong && \
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
chmod 700 /home/qinglong/.ssh && \
chown -R ${QL_UID}:${QL_GID} /home/qinglong && \
mkdir -p /etc/sudoers.d && \
echo 'qinglong ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qinglong
ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get upgrade -y && \
apt-get install --no-install-recommends -y git \
curl \
wget \
tzdata \
perl \
openssl \
openssh-client \
jq \
procps \
netcat-openbsd \
sudo \
unzip \
libatomic1 && \
apt-get clean && \
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" >/etc/timezone && \
git config --global user.email "qinglong@users.noreply.github.com" && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
rm -rf /etc/apt/apt.conf.d/docker-clean && \
ulimit -c 0
RUN mkdir -p ${QL_DIR} && \
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
USER qinglong
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \
cd ${QL_DIR} && \
cp -f .env.example .env && \
chmod 777 ${QL_DIR}/shell/*.sh && \
chmod 777 ${QL_DIR}/docker/*.sh && \
git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \
mkdir -p ${QL_DIR}/static && \
cp -rf /tmp/static/* ${QL_DIR}/static && \
rm -rf /tmp/static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/home/qinglong
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
USER root
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
VOLUME /ql/data
EXPOSE 5700

View File

@ -1,7 +1,5 @@
#!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell
. $dir_shell/share.sh
@ -17,9 +15,68 @@ log_with_style() {
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
# ============================================
# 确保当前用户对 /ql 和 /ql/data 目录有写入权限
# /ql/data 是 Docker Volume 挂载点,权限可能与 /ql 不同,需单独检测
# ============================================
ensure_ql_permissions() {
local current_uid
local current_gid
current_uid=$(id -u)
current_gid=$(id -g)
if [ "$current_uid" -eq 0 ]; then
return 0
fi
# ---- 检查 /ql 目录 ----
if ! mkdir -p "$QL_DIR/.tmp" 2>/dev/null; then
if chown -R "$current_uid:$current_gid" "$QL_DIR" 2>/dev/null; then
log_with_style "INFO" "已修正 /ql 目录权限: UID=$current_uid GID=$current_gid"
else
local ql_owner
ql_owner=$(stat -c '%u' "$QL_DIR" 2>/dev/null || stat -f '%u' "$QL_DIR" 2>/dev/null)
log_with_style "ERROR" "============================================="
log_with_style "ERROR" " 权限错误:无法写入 /ql 目录"
log_with_style "ERROR" " 当前用户 UID: $current_uid"
log_with_style "ERROR" " /ql 目录所有者 UID: ${ql_owner:-未知}"
log_with_style "ERROR" ""
log_with_style "ERROR" " 解决方案:"
log_with_style "ERROR" " 1. 使用镜像内置用户: docker run --user ${ql_owner:-5432}:${ql_owner:-5432} ..."
log_with_style "ERROR" " 2. 使用 root 运行: 移除 --user 参数"
log_with_style "ERROR" " 3. 修正宿主机数据目录: chown -R $current_uid:$current_gid /path/to/ql/data"
log_with_style "ERROR" "============================================="
exit 1
fi
fi
rmdir "$QL_DIR/.tmp" 2>/dev/null || true
# ---- 检查 /ql/data 目录Volume 挂载点,不在用户数据卷内创建临时文件) ----
if [ ! -w "$QL_DIR/data" ] || [ ! -x "$QL_DIR/data" ]; then
if chown "$current_uid:$current_gid" "$QL_DIR/data" 2>/dev/null; then
log_with_style "INFO" "已修正 /ql/data 目录权限: UID=$current_uid GID=$current_gid"
if [ ! -w "$QL_DIR/data" ] || [ ! -x "$QL_DIR/data" ]; then
log_with_style "ERROR" "修正后仍无法写入 /ql/data请检查挂载的数据卷权限"
log_with_style "ERROR" "确保宿主机目录: chown -R $current_uid:$current_gid /your/data"
exit 1
fi
else
local data_owner
data_owner=$(stat -c '%u' "$QL_DIR/data" 2>/dev/null || stat -f '%u' "$QL_DIR/data" 2>/dev/null)
log_with_style "ERROR" "============================================="
log_with_style "ERROR" " 权限错误:无法写入 /ql/data (Volume 挂载点)"
log_with_style "ERROR" " 当前用户 UID: $current_uid"
log_with_style "ERROR" " /ql/data 所有者 UID: ${data_owner:-未知}"
log_with_style "ERROR" ""
log_with_style "ERROR" " 请修正宿主机数据目录权限:"
log_with_style "ERROR" " chown -R $current_uid:$current_gid /your/ql/data"
log_with_style "ERROR" "============================================="
exit 1
fi
fi
}
# Fix DNS resolution issues in Alpine Linux
# Alpine uses musl libc which has known DNS resolver issues with certain domains
# Adding ndots:0 prevents unnecessary search domain appending
if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf
@ -27,6 +84,26 @@ if [ -f /etc/alpine-release ]; then
fi
fi
# 确保 /etc/hosts 包含 localhost 解析(应对精简镜像或仅 IPv4/IPv6 环境)
if ! grep -qE '^127\.0\.0\.1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then
echo "127.0.0.1 localhost" >> /etc/hosts
log_with_style "INFO" "🔧 0. 已添加 IPv4 localhost 解析"
fi
if ! grep -qE '^::1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then
echo "::1 localhost ip6-localhost ip6-loopback" >> /etc/hosts
log_with_style "INFO" "🔧 0. 已添加 IPv6 localhost 解析"
fi
# 自定义用户(非 qinglong/root可能 HOME 为空或不可写
# 修正 HOME 确保 npm/pip/pm2 等工具有可用的缓存目录
if [ ! -w "$HOME" ]; then
mkdir -p "$QL_DIR/.tmp"
export HOME="$QL_DIR/.tmp"
fi
# 在一切操作之前检查目录权限
ensure_ql_permissions
log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs
export_ql_envs
@ -52,6 +129,19 @@ fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null
# 自动检测调度模式:有 crond 二进制 → system 模式,否则 node 模式
if [ -z "$QL_SCHEDULER" ]; then
if command -v crond &>/dev/null; then
export QL_SCHEDULER="system"
else
export QL_SCHEDULER="node"
fi
fi
if [ "$QL_SCHEDULER" = "system" ]; then
crond -f > /dev/null
else
tail -f /dev/null
fi
exec "$@"

View File

@ -0,0 +1,550 @@
# Project Architecture Guide
This document is written for AI coding agents and maintainers who need to understand and modify this project safely. It focuses on where behavior lives, how the application starts, and which files are usually involved for common changes.
## Project Summary
Qinglong is a timed task management platform. It provides a web admin panel for managing cron jobs, scripts, environment variables, subscriptions, dependencies, logs, configuration files, and system settings.
The repository is organized as a full-stack TypeScript application:
- `src/`: frontend admin panel, built with Umi Max, React, Ant Design, and Ant Design Pro Layout.
- `back/`: backend application, built with Express, TypeScript, typedi, Sequelize, SQLite, gRPC, and worker processes.
- `shell/`: runtime shell scripts used to execute tasks and preload task environments.
- `data/`: local runtime data, including scripts, logs, configs, SQLite database, uploaded files, and cloned repositories.
- `static/`: built frontend and backend artifacts.
- `docker/`: Docker images, compose file, and entrypoint.
- `sample/`: sample scripts and default config templates.
## High-Level Runtime Flow
```text
Browser
-> src/pages/*
-> src/utils/http.tsx
-> /api/*
-> back/api/*
-> back/services/*
-> back/data/* Sequelize models
-> data/db/database.sqlite
Cron/task execution
-> back/services/cron.ts
-> shell/task.sh or shell/otask.sh
-> data/scripts/*
-> data/log/*
Frontend assets in production
-> static/dist/*
-> served by back/loaders/express.ts
```
## Main Startup Path
Development starts from `package.json`:
```bash
pnpm start
```
This runs:
- `start:back`: `nodemon ./back/app.ts`
- `start:front`: `max dev`
Backend startup begins in `back/app.ts`.
Important details:
- The backend uses Node `cluster`.
- The primary process initializes the database first.
- A gRPC worker starts before the HTTP worker.
- The HTTP worker starts Express and serves API routes plus frontend static files.
- If the gRPC worker restarts, the HTTP worker is asked to re-register cron jobs.
Production-style backend output is generated by:
```bash
pnpm run build:back
```
The compiled backend is placed under `static/build`.
Frontend output is generated by:
```bash
pnpm run build:front
```
The compiled frontend is placed under `static/dist`.
## Backend Architecture
### Entry Point
- `back/app.ts`
Responsibilities:
- Creates the Express application.
- Starts primary/worker process logic.
- Initializes database in the primary process.
- Starts gRPC and HTTP workers.
- Handles graceful shutdown.
- Re-registers cron jobs after gRPC worker recovery.
### Loaders
- `back/loaders/app.ts`
- `back/loaders/express.ts`
- `back/loaders/db.ts`
- `back/loaders/depInjector.ts`
- `back/loaders/initData.ts`
- `back/loaders/initFile.ts`
- `back/loaders/initTask.ts`
- `back/loaders/server.ts`
- `back/loaders/sock.ts`
Loader responsibilities:
- Register dependency injection bindings.
- Sync Sequelize models.
- Initialize files and default data.
- Initialize scheduled tasks.
- Configure Express middleware.
- Register routes.
- Attach socket/server behavior.
`back/loaders/express.ts` is the main HTTP middleware and routing setup. It handles:
- CORS.
- Helmet.
- body parser.
- static frontend serving.
- JWT validation.
- token validation against shared auth state.
- `/open/*` rewrite to `/api/*`.
- route mounting through `back/api/index.ts`.
- frontend fallback to `static/dist/index.html`.
- API error handling.
### API Routes
- `back/api/index.ts`
This file registers all API modules:
- `user.ts`: login, initialization, authentication-related user endpoints.
- `env.ts`: environment variable endpoints.
- `config.ts`: config file endpoints.
- `log.ts`: log endpoints.
- `cron.ts`: cron/task endpoints.
- `script.ts`: script file endpoints.
- `open.ts`: open API/app token endpoints.
- `dependence.ts`: dependency management endpoints.
- `system.ts`: system information/settings endpoints.
- `subscription.ts`: subscription endpoints.
- `update.ts`: update/check endpoints.
- `health.ts`: health check endpoints.
Route files should stay thin. They should validate input, get a service from `typedi`'s `Container`, call the service, and return `{ code, data, message }` style responses.
### Services
- `back/services/*`
Services contain most business logic. Common examples:
- `cron.ts`: create/update/delete/run cron jobs, generate crontab data, manage logs, call scheduler client.
- `env.ts`: manage environment variables.
- `config.ts`: read/write config files.
- `script.ts`: manage script files.
- `subscription.ts`: manage script subscriptions and repository pulls.
- `dependence.ts`: install/manage runtime dependencies.
- `system.ts`: system info and settings.
- `notify.ts`: notification behavior.
- `sock.ts`: socket/log stream behavior.
- `grpc.ts`: gRPC server lifecycle.
- `http.ts`: HTTP server lifecycle.
When changing backend behavior, first find the API route, then follow it into the matching service. In most cases, the service is the right place for behavioral changes.
### Data Models
- `back/data/index.ts`
- `back/data/*.ts`
The backend uses Sequelize with SQLite. Database storage is configured in `back/data/index.ts`:
```text
data/db/database.sqlite
```
Common model files:
- `cron.ts`: cron job model.
- `cronView.ts`: saved cron table views.
- `env.ts`: environment variable model.
- `dependence.ts`: dependency model.
- `open.ts`: open API app/token model.
- `subscription.ts`: subscription model.
- `system.ts`: system settings model.
- `notify.ts`: notification-related data.
Model sync and simple column migrations are currently handled in `back/loaders/db.ts`.
### Configuration
- `back/config/index.ts`
This is the central runtime config file. It reads `.env`, establishes `QL_DIR`, and defines important paths:
- `dataPath`: runtime data root.
- `configPath`: config files.
- `scriptPath`: user scripts.
- `repoPath`: subscription repositories.
- `logPath`: task logs.
- `dbPath`: SQLite database location.
- `uploadPath`: uploaded files.
- `shellPath`: shell runtime scripts.
- `preloadPath`: JS/Python/Shell preload files.
Before hardcoding paths, check `back/config/index.ts`.
### Scheduling And gRPC
- `back/schedule/*`
- `back/protos/*`
- `back/services/grpc.ts`
The project has two scheduling paths:
- Standard crontab-style tasks are persisted and written through backend cron logic.
- Node/gRPC scheduler logic handles cases such as second-level cron expressions or additional schedules.
`back/services/cron.ts` decides whether a task needs the Node scheduler using schedule shape and `extra_schedules`.
### Shared Backend Utilities
- `back/shared/*`
- `back/config/util.ts`
- `back/config/share.ts`
- `back/config/http.ts`
Use these before adding new global helpers. Existing shared code includes:
- auth helpers.
- shared store.
- log stream manager.
- task runner helpers.
- concurrency limits.
- file locking utilities.
- HTTP/proxy helpers.
## Frontend Architecture
### Umi Config
- `.umirc.ts`
Important behavior:
- Dev server proxies API requests to `http://127.0.0.1:5700/`.
- Frontend build output is `static/dist`.
- Runtime env script is loaded from `./api/env.js`.
- `QlBaseUrl` affects frontend public path and routing base.
### App Initialization
- `src/app.ts`
Responsibilities:
- Load Chinese and English locale JSON.
- Determine locale from URL/cookie/localStorage.
- Set Umi locale.
- Apply `QlBaseUrl` as public path and router basename.
### Layout And Routes
- `src/layouts/defaultProps.tsx`
- `src/layouts/index.tsx`
`defaultProps.tsx` defines the main route/menu list. If adding a new page visible in the sidebar, update this file.
Current major pages:
- `src/pages/crontab`: timed task management.
- `src/pages/subscription`: subscription management.
- `src/pages/env`: environment variables.
- `src/pages/config`: config files.
- `src/pages/script`: script management.
- `src/pages/dependence`: dependency management.
- `src/pages/log`: log management.
- `src/pages/diff`: diff tool.
- `src/pages/setting`: system settings.
- `src/pages/login`: login.
- `src/pages/initialization`: first-run initialization.
- `src/pages/error`: error page.
### Frontend Utilities
- `src/utils/http.tsx`: API request helper.
- `src/utils/websocket.ts`: socket connection behavior.
- `src/utils/config.ts`: frontend config helpers.
- `src/utils/const.ts`: constants.
- `src/utils/date.ts`: date formatting helpers.
- `src/utils/init.ts`: initialization helpers.
- `src/utils/codemirror/*`: CodeMirror integration.
- `src/utils/monaco/*`: Monaco integration.
When changing a page's API behavior, inspect both the page file and `src/utils/http.tsx`.
### Components And Styling
- `src/components/*`: reusable UI components.
- `src/pages/**/index.less`: page-level styles.
- `src/pages/script/index.module.less` and `src/pages/log/index.module.less`: CSS module styles.
- `src/assets/fonts/*`: bundled fonts.
- `src/locales/*.json`: i18n text.
Follow the existing Ant Design and Ant Design Pro patterns when modifying UI.
## Shell Runtime
- `shell/task.sh`: task execution path.
- `shell/otask.sh`: alternate/manual task execution path.
- `shell/api.sh`: shell-side API helpers.
- `shell/env.sh`: environment setup.
- `shell/check.sh`: runtime check helpers.
- `shell/update.sh`: update helpers.
- `shell/rmlog.sh`: log cleanup.
- `shell/share.sh`: shared shell helpers.
- `shell/preload/*`: preload files injected into JS/Python/Shell task environments.
The backend often coordinates task execution, but the actual user script process environment is shaped by files in `shell/`.
## Runtime Data Directory
- `data/`
This directory is runtime state, not just source code. Be careful when modifying or deleting files here.
Important subdirectories:
- `data/db`: SQLite database.
- `data/config`: generated and user-edited config files.
- `data/scripts`: user scripts.
- `data/repo`: cloned subscription repositories.
- `data/log`: task logs.
- `data/upload`: uploaded files.
- `data/syslog`: system logs.
- `data/ssh.d`: SSH-related runtime data.
- `data/dep_cache`: dependency cache, when present.
Many bugs that appear as "backend logic" may involve state stored under `data/`.
## Docker And Release Files
- `docker/Dockerfile`
- `docker/310.Dockerfile`
- `docker/docker-compose.yml`
- `docker/docker-entrypoint.sh`
- `ecosystem.config.js`
- `version.yaml`
Use these when changing deployment, container startup, PM2 behavior, or release metadata.
## Common Modification Map
### Add Or Modify A Backend API
Typical files:
1. Add or update route in `back/api/<module>.ts`.
2. Add or update service logic in `back/services/<module>.ts`.
3. Add or update model in `back/data/<module>.ts` if persistence changes.
4. Add validation with `celebrate`/`Joi` near the route.
5. Update frontend caller in `src/pages/**` or `src/utils/**`.
### Add A New Frontend Page
Typical files:
1. Create `src/pages/<page>/index.tsx`.
2. Add styles in `src/pages/<page>/index.less` if needed.
3. Register route/menu in `src/layouts/defaultProps.tsx`.
4. Add locale strings in `src/locales/zh-CN.json` and `src/locales/en-US.json`.
5. Add API calls through the existing request helper.
### Change Cron/Task Behavior
Start with:
- `back/api/cron.ts`
- `back/services/cron.ts`
- `back/schedule/*`
- `shell/task.sh`
- `shell/otask.sh`
- `shell/preload/*`
Also inspect:
- `back/data/cron.ts`
- `back/validation/schedule.ts`
- `data/config/crontab.list`
- `data/log/*`
### Change Environment Variable Behavior
Start with:
- `back/api/env.ts`
- `back/services/env.ts`
- `back/data/env.ts`
- `src/pages/env/index.tsx`
Also inspect:
- `shell/preload/env.sh`
- `shell/preload/env.js`
- `shell/preload/env.py`
### Change Script Management
Start with:
- `back/api/script.ts`
- `back/services/script.ts`
- `src/pages/script/index.tsx`
- `data/scripts/*`
### Change Login/Auth/Security
Start with:
- `back/api/user.ts`
- `back/services/user.ts`
- `back/shared/auth.ts`
- `back/shared/store.ts`
- `back/loaders/express.ts`
- `back/token.ts`
- `src/pages/login/index.tsx`
- `src/pages/initialization/index.tsx`
Be careful with:
- JWT behavior.
- open API token behavior.
- first-run initialization.
- platform-specific session limits.
### Change Subscription Behavior
Start with:
- `back/api/subscription.ts`
- `back/services/subscription.ts`
- `back/data/subscription.ts`
- `src/pages/subscription/index.tsx`
- `data/repo/*`
### Change Dependency Management
Start with:
- `back/api/dependence.ts`
- `back/services/dependence.ts`
- `back/data/dependence.ts`
- `src/pages/dependence/index.tsx`
- `data/deps`
- `data/dep_cache`
### Change Logs Or Live Log Streaming
Start with:
- `back/api/log.ts`
- `back/services/log.ts`
- `back/services/sock.ts`
- `back/shared/logStreamManager.ts`
- `src/pages/log/index.tsx`
- `src/components/terminal.tsx`
- `data/log/*`
## Coding Conventions
Backend:
- Prefer adding business logic to services, not route files.
- Use `typedi` services consistently.
- Use existing config paths from `back/config/index.ts`.
- Return API responses in the existing `{ code, data, message }` shape.
- Use existing utilities before adding new helpers.
- Preserve current SQLite/Sequelize style unless doing a larger data-layer refactor.
Frontend:
- Follow existing Umi/React/Ant Design patterns.
- Keep route/menu changes in `src/layouts/defaultProps.tsx`.
- Use existing request/WebSocket helpers.
- Add or update locale strings for visible UI text.
- Keep page-specific styles near the page.
Shell/runtime:
- Treat `shell/` as part of production behavior.
- Test task execution changes with realistic scripts when possible.
- Be careful with path quoting and environment variable propagation.
Data:
- Treat `data/` as mutable runtime state.
- Do not delete runtime state unless explicitly requested.
- Schema changes should account for existing SQLite databases.
## Suggested First Steps For AI Agents
When asked to modify behavior:
1. Identify whether the change is frontend, backend, shell runtime, data model, or deployment.
2. Search by feature name in `src/pages`, `back/api`, and `back/services`.
3. Read the route file and matching service before editing.
4. If persistence is involved, read the matching `back/data` model and `back/loaders/db.ts`.
5. If task execution is involved, inspect `shell/` and `back/services/cron.ts`.
6. Make the smallest scoped change that matches existing patterns.
7. Run the most relevant check:
- `pnpm run build:back` for backend TypeScript changes.
- `pnpm run build:front` for frontend build changes.
- targeted manual task/API checks for shell and scheduler changes.
## Quick Directory Reference
```text
.
├── back/ Backend TypeScript application
│ ├── api/ Express route modules
│ ├── config/ Runtime config, paths, constants, helpers
│ ├── data/ Sequelize models and SQLite connection
│ ├── loaders/ Startup initialization and Express setup
│ ├── middlewares/ Express middlewares
│ ├── protos/ gRPC proto files and generated TS
│ ├── schedule/ Scheduler/gRPC client helpers
│ ├── services/ Business logic services
│ ├── shared/ Shared backend utilities
│ └── validation/ Joi validation schemas
├── src/ Frontend Umi/React application
│ ├── assets/ Fonts and static frontend assets
│ ├── components/ Shared UI components
│ ├── hooks/ Frontend hooks
│ ├── layouts/ Main layout and menu route config
│ ├── locales/ i18n JSON
│ ├── pages/ Feature pages
│ └── utils/ HTTP, WebSocket, editor, date, and config utilities
├── shell/ Task runtime shell scripts and preload files
├── data/ Runtime state: db, logs, scripts, repos, configs
├── docker/ Docker build and compose files
├── sample/ Sample scripts and default config templates
├── static/ Built frontend/backend artifacts
└── docs/ Project documentation
```

View File

@ -1,6 +1,17 @@
{
"private": true,
"name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.20.2-3",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
"url": "https://github.com/whyour/qinglong.git"
},
"author": "whyour",
"license": "Apache License 2.0",
"bugs": {
"url": "https://github.com/whyour/qinglong/issues"
},
"scripts": {
"start": "concurrently -n w: npm:start:*",
"start:back": "nodemon ./back/app.ts",
@ -25,6 +36,11 @@
"prettier --parser=typescript --write"
]
},
"bin": {
"ql": "shell/update.sh",
"task": "shell/task.sh",
"qinglong": "shell/start.sh"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
@ -51,7 +67,9 @@
}
},
"overrides": {
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3"
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16"
}
},
"dependencies": {
@ -77,9 +95,9 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"multer": "1.4.5-lts.1",
"multer": "2.1.1",
"node-schedule": "^2.1.0",
"nodemailer": "^6.9.16",
"nodemailer": "^8.0.1",
"p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.10.0",
"ps-tree": "^1.2.0",
@ -104,8 +122,8 @@
"moment": "2.30.1",
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-layout": "6.38.22",
"@codemirror/view": "^6.34.1",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "6.39.16",
"@codemirror/state": "6.5.4",
"@monaco-editor/react": "4.2.1",
"@react-hook/resize-observer": "^2.0.2",
"react-router-dom": "6.26.1",

File diff suppressed because it is too large Load Diff

View File

@ -195,12 +195,14 @@ export SMTP_SERVER=""
## SMTP 发送邮件服务器是否使用 SSL填写 true 或 false
export SMTP_SSL=""
## smtp_email 填写 SMTP 发件邮箱,通知将会由自己发给自己
## smtp_email 填写 SMTP 发件邮箱
export SMTP_EMAIL=""
## smtp_password 填写 SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
export SMTP_PASSWORD=""
## smtp_name 填写 SMTP 收发件人姓名,可随意填写
export SMTP_NAME=""
## smtp_email_to 填写 SMTP 收件邮箱,多个用英文;分隔,不填默认发给发件邮箱
export SMTP_EMAIL_TO=""
## 17. PushMe
## 官方说明文档https://push.i-i.me/
@ -259,4 +261,13 @@ export WEBHOOK_METHOD=""
## 支持 text/plain、application/json、multipart/form-data、application/x-www-form-urlencoded
export WEBHOOK_CONTENT_TYPE=""
## 23. OpeniLink
## 官方文档: https://openilink.com/docs/hub/apps
## 在 OpeniLink Hub 后台安装 App 后获取 app_token
export OPENILINK_APP_TOKEN=""
## OpeniLink Hub 地址,默认为 https://hub.openilink.com自建 Hub 时填写自己的地址
export OPENILINK_HUB_URL=""
## OpeniLink 的 context_token用于标识消息会话上下文可从消息事件中获取
export OPENILINK_CONTEXT_TOKEN=""
## 其他需要的变量,脚本中需要的变量使用 export 变量名= 声明即可

View File

@ -108,8 +108,8 @@ const push_config = {
QYWX_KEY: '', // 企业微信机器人的 webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770)例如693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa
TG_BOT_TOKEN: '', // tg 机器人的 TG_BOT_TOKEN1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ
TG_USER_ID: '', // tg 机器人的 TG_USER_ID1434078534
TG_BOT_TOKEN: '', // tg 机器人的 TG_BOT_TOKEN1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
TG_USER_ID: '', // tg 机器人的 TG_USER_ID1234567890
TG_API_HOST: 'https://api.telegram.org', // tg 代理 api
TG_PROXY_AUTH: '', // tg 代理认证参数
TG_PROXY_HOST: '', // tg 机器人的 TG_PROXY_HOST
@ -121,7 +121,8 @@ const push_config = {
SMTP_SERVICE: '', // 邮箱服务名称,比如 126、163、Gmail、QQ 等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json
SMTP_EMAIL: '', // SMTP 发件邮箱
SMTP_TO: '', // SMTP 收件邮箱,默认通知将会发给发件邮箱
SMTP_TO: '', // SMTP 收件邮箱,兼容旧参数名,默认通知将会发给发件邮箱
SMTP_EMAIL_TO: '', // SMTP 收件邮箱,多个分号分隔,默认发给发件邮箱
SMTP_PASSWORD: '', // SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
SMTP_NAME: '', // SMTP 收发件人姓名,可随意填写
@ -151,6 +152,11 @@ const push_config = {
WXPUSHER_APP_TOKEN: '', // wxpusher 的 appToken
WXPUSHER_TOPIC_IDS: '', // wxpusher 的 主题ID多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
WXPUSHER_UIDS: '', // wxpusher 的 用户ID多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
// 官方文档: https://openilink.com/docs/hub/apps
OPENILINK_APP_TOKEN: '', // OpeniLink 的 app_token在 OpeniLink Hub 后台安装 App 后获取
OPENILINK_HUB_URL: '', // OpeniLink Hub 地址,默认为 https://hub.openilink.com自建 Hub 时填写自己的地址
OPENILINK_CONTEXT_TOKEN: '', // OpeniLink 的 context_token用于标识消息会话上下文可从消息事件中获取
};
for (const key in push_config) {
@ -1046,8 +1052,14 @@ function fsBotNotify(text, desp) {
}
async function smtpNotify(text, desp) {
const { SMTP_EMAIL, SMTP_TO, SMTP_PASSWORD, SMTP_SERVICE, SMTP_NAME } =
push_config;
const {
SMTP_EMAIL,
SMTP_TO,
SMTP_EMAIL_TO,
SMTP_PASSWORD,
SMTP_SERVICE,
SMTP_NAME,
} = push_config;
if (![SMTP_EMAIL, SMTP_PASSWORD].every(Boolean) || !SMTP_SERVICE) {
return;
}
@ -1063,9 +1075,20 @@ async function smtpNotify(text, desp) {
});
const addr = SMTP_NAME ? `"${SMTP_NAME}" <${SMTP_EMAIL}>` : SMTP_EMAIL;
const recipients = [SMTP_EMAIL_TO, SMTP_TO].reduce((list, value) => {
if (!value) {
return list;
}
return list.concat(
value
.split(/[;]/)
.map((item) => item.trim())
.filter(Boolean),
);
}, []);
const info = await transporter.sendMail({
from: addr,
to: SMTP_TO ? SMTP_TO.split(';') : addr,
to: recipients.length ? recipients : SMTP_EMAIL,
subject: text,
html: `${desp.replace(/\n/g, '<br/>')}`,
});
@ -1408,6 +1431,54 @@ function wxPusherNotify(text, desp) {
});
}
function openiLinkNotify(text, desp) {
return new Promise((resolve) => {
const { OPENILINK_APP_TOKEN, OPENILINK_HUB_URL, OPENILINK_CONTEXT_TOKEN } =
push_config;
if (OPENILINK_APP_TOKEN) {
const baseUrl = OPENILINK_HUB_URL
? OPENILINK_HUB_URL.replace(/\/$/, '')
: 'https://hub.openilink.com';
const body = {
type: 'text',
content: `${text}\n\n${desp}`,
};
if (OPENILINK_CONTEXT_TOKEN) {
body.context_token = OPENILINK_CONTEXT_TOKEN;
}
const options = {
url: `${baseUrl}/bot/v1/message/send`,
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENILINK_APP_TOKEN}`,
},
timeout,
};
$.post(options, (err, resp, data) => {
try {
if (err) {
console.log('OpeniLink 发送通知消息失败!\n', err);
} else {
if (data.ok) {
console.log('OpeniLink 发送通知消息成功!');
} else {
console.log(`OpeniLink 发送通知消息异常:${data.error}`);
}
}
} catch (e) {
$.logErr(e, resp);
} finally {
resolve(data);
}
});
} else {
resolve();
}
});
}
function parseString(input, valueFormatFn) {
const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g;
const matches = {};
@ -1527,7 +1598,7 @@ async function sendNotify(text, desp, params = {}) {
iGotNotify(text, desp, params), // iGot
gobotNotify(text, desp), // go-cqhttp
gotifyNotify(text, desp), // gotify
chatNotify(text, desp), // synolog chat
chatNotify(text, desp), // synology chat
pushDeerNotify(text, desp), // PushDeer
aibotkNotify(text, desp), // 智能微秘书
fsBotNotify(text, desp), // 飞书机器人
@ -1538,6 +1609,7 @@ async function sendNotify(text, desp, params = {}) {
qmsgNotify(text, desp), // 自定义通知
ntfyNotify(text, desp), // Ntfy
wxPusherNotify(text, desp), // wxpusher
openiLinkNotify(text, desp), // OpeniLink
]);
}

View File

@ -94,8 +94,8 @@ push_config = {
'QYWX_KEY': '', # 企业微信机器人
'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ
'TG_USER_ID': '', # tg 机器人的 TG_USER_ID1434078534
'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
'TG_USER_ID': '', # tg 机器人的 TG_USER_ID1234567890
'TG_API_HOST': '', # tg 代理 api
'TG_PROXY_AUTH': '', # tg 代理认证参数
'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST
@ -107,7 +107,8 @@ push_config = {
'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465
'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL填写 true 或 false
'SMTP_EMAIL': '', # SMTP 收发件邮箱,通知将会由自己发给自己
'SMTP_EMAIL': '', # SMTP 发件邮箱
'SMTP_EMAIL_TO': '', # SMTP 收件邮箱,多个分号分隔,默认发给发件邮箱
'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写
@ -135,6 +136,10 @@ push_config = {
'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/
'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
'WXPUSHER_UIDS': '', # wxpusher 的 用户ID多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
'OPENILINK_APP_TOKEN': '', # OpeniLink 的 app_token在 OpeniLink Hub 后台安装 App 后获取 官方文档: https://openilink.com/docs/hub/apps
'OPENILINK_HUB_URL': '', # OpeniLink Hub 地址,默认为 https://hub.openilink.com自建 Hub 时填写自己的地址
'OPENILINK_CONTEXT_TOKEN': '', # OpeniLink 的 context_token用于标识消息会话上下文可从消息事件中获取
}
# fmt: on
@ -690,6 +695,10 @@ def smtp(title: str, content: str) -> None:
return
print("SMTP 邮件 服务启动")
email_to = push_config.get("SMTP_EMAIL_TO") or push_config.get("SMTP_EMAIL")
email_to_list = [
item.strip() for item in re.split(r"[;]", email_to) if item.strip()
]
message = MIMEText(content, "plain", "utf-8")
message["From"] = formataddr(
(
@ -697,12 +706,7 @@ def smtp(title: str, content: str) -> None:
push_config.get("SMTP_EMAIL"),
)
)
message["To"] = formataddr(
(
Header(push_config.get("SMTP_NAME"), "utf-8").encode(),
push_config.get("SMTP_EMAIL"),
)
)
message["To"] = ",".join(email_to_list)
message["Subject"] = Header(title, "utf-8")
try:
@ -716,7 +720,7 @@ def smtp(title: str, content: str) -> None:
)
smtp_server.sendmail(
push_config.get("SMTP_EMAIL"),
push_config.get("SMTP_EMAIL"),
email_to_list,
message.as_bytes(),
)
smtp_server.close()
@ -898,6 +902,43 @@ def wxpusher_bot(title: str, content: str) -> None:
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
def openilink(title: str, content: str) -> None:
"""
通过 OpeniLink 推送消息
支持的环境变量:
- OPENILINK_APP_TOKEN: OpeniLink Hub 后台安装 App 后获取的 app_token
- OPENILINK_HUB_URL: OpeniLink Hub 地址默认为 https://hub.openilink.com
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token可从消息事件中获取
"""
if not push_config.get("OPENILINK_APP_TOKEN"):
return
print("OpeniLink 服务启动")
base_url = (
push_config.get("OPENILINK_HUB_URL", "").rstrip("/")
or "https://hub.openilink.com"
)
url = f"{base_url}/bot/v1/message/send"
headers = {
"Content-Type": "application/json",
"Authorization": f'Bearer {push_config.get("OPENILINK_APP_TOKEN")}',
}
data = {
"type": "text",
"content": f"{title}\n\n{content}",
}
if push_config.get("OPENILINK_CONTEXT_TOKEN"):
data["context_token"] = push_config.get("OPENILINK_CONTEXT_TOKEN")
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("ok"):
print("OpeniLink 推送成功!")
else:
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
def parse_headers(headers):
if not headers:
return {}
@ -1063,6 +1104,8 @@ def add_notify_function():
push_config.get("WXPUSHER_TOPIC_IDS") or push_config.get("WXPUSHER_UIDS")
):
notify_function.append(wxpusher_bot)
if push_config.get("OPENILINK_APP_TOKEN"):
notify_function.append(openilink)
if not notify_function:
print(f"无推送渠道,请检查通知变量是否正确")
return notify_function

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,29 @@ else
fi
echo -e "\n1、安装bot依赖...\n"
apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev
os_name="${QL_OS_TYPE:-}"
if [ -z "$os_name" ]; then
os_name=$(source /etc/os-release && echo "$ID")
fi
# 非 root 用户使用 sudo
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
case "$os_name" in
alpine)
$SUDO apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev
;;
debian|ubuntu)
$SUDO apt-get install -y gcc python3-dev musl-dev zlib1g-dev libjpeg-dev libfreetype-dev
;;
*)
echo -e "暂不支持此系统 $os_name"
exit 1
;;
esac
echo -e "\nbot依赖安装成功...\n"
echo -e "2、下载bot所需文件...\n"

View File

@ -83,6 +83,35 @@ clear_non_sh_env() {
fi
}
append_node_dependency_path() {
export PREV_NODE_PATH="${NODE_PATH:=}"
local pnpm_global_path=$(pnpm root -g 2>/dev/null)
if [[ -n "$pnpm_global_path" ]]; then
export QL_NODE_GLOBAL_PATH="$pnpm_global_path"
export NODE_PATH="${NODE_PATH:+${NODE_PATH}:}${pnpm_global_path}"
fi
}
enter_script_workdir() {
local use_dot_prefix="$1"
cd $dir_scripts
if [[ ${file_param} =~ "/" ]]; then
local script_dir="${file_param%/*}"
local script_name="${file_param##*/}"
if [[ -d ${script_dir} ]]; then
cd ${script_dir}
if [[ "${use_dot_prefix}" == "true" ]]; then
file_param="./${script_name}"
else
file_param="${script_name}"
fi
fi
fi
}
## 正常运行单个脚本,$1传入参数
run_normal() {
local file_param=$1
@ -90,12 +119,7 @@ run_normal() {
random_delay "$file_param"
fi
cd $dir_scripts
local relative_path="${file_param%/*}"
if [[ ${file_param} != /* ]] && [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then
cd ${relative_path}
file_param=${file_param/$relative_path\//}
fi
enter_script_workdir
if [[ $isJsOrPythonFile == 'false' ]]; then
clear_non_sh_env
@ -128,12 +152,7 @@ run_concurrent() {
time=$(date "+$mtime_format")
single_log_time=$(format_log_time "$mtime_format" "$time")
cd $dir_scripts
local relative_path="${file_param%/*}"
if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then
cd ${relative_path}
file_param=${file_param/$relative_path\//}
fi
enter_script_workdir
local j=0
for i in ${array_run[@]}; do
@ -182,12 +201,7 @@ run_designated() {
clear_non_sh_env
fi
cd $dir_scripts
local relative_path="${file_param%/*}"
if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then
cd ${relative_path}
file_param=${file_param/$relative_path\//}
fi
enter_script_workdir
envParam="${env_param}" numParam="${num_param}" $timeoutCmd $which_program $file_param "${script_params[@]}"
}
@ -196,12 +210,7 @@ run_designated() {
run_else() {
local file_param="$1"
cd $dir_scripts
local relative_path="${file_param%/*}"
if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then
cd ${relative_path}
file_param=${file_param/$relative_path\//.\/}
fi
enter_script_workdir true
shift
@ -242,7 +251,7 @@ check_nounset() {
}
main() {
if [[ $1 == *.js ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.sh ]] || [[ $1 == *.ts ]]; then
if [[ $1 == *.js ]] || [[ $1 == *.mjs ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.sh ]] || [[ $1 == *.ts ]]; then
if [[ $1 == *.sh ]]; then
timeoutCmd=""
fi
@ -278,6 +287,7 @@ main() {
handle_task_start "${task_shell_params[@]}"
check_file "${task_shell_params[@]}"
append_node_dependency_path
if [[ $isJsOrPythonFile == 'false' ]]; then
run_task_before "${task_shell_params[@]}"
fi
@ -287,6 +297,8 @@ main "${task_shell_params[@]}"
if [[ "$set_u_on" == 'true' ]]; then
set -u
fi
export NODE_PATH="${PREV_NODE_PATH}"
unset QL_NODE_GLOBAL_PATH
if [[ $isJsOrPythonFile == 'true' ]]; then
export NODE_OPTIONS="${PREV_NODE_OPTIONS}"
export PYTHONPATH="${PREV_PYTHONPATH}"

View File

@ -1,7 +1,36 @@
const { execSync } = require('child_process');
const Module = require('module');
const path = require('path');
const client = require('./client.js');
require(`./env.js`);
function preferGlobalNodeModules() {
const { QL_NODE_GLOBAL_PATH } = process.env;
if (!QL_NODE_GLOBAL_PATH || Module._qlGlobalPathPatched) {
return;
}
const originalResolveFilename = Module._resolveFilename;
Module._resolveFilename = function (request, parent, isMain, options) {
if (
!Module.builtinModules.includes(request) &&
!request.startsWith('node:') &&
!request.startsWith('.') &&
!path.isAbsolute(request)
) {
try {
return originalResolveFilename.call(this, request, parent, isMain, {
...options,
paths: [QL_NODE_GLOBAL_PATH],
});
} catch (error) {}
}
return originalResolveFilename.call(this, request, parent, isMain, options);
};
Module._qlGlobalPathPatched = true;
}
function expandRange(rangeStr, max) {
const tempRangeStr = rangeStr
.trim()
@ -113,6 +142,8 @@ try {
return;
}
preferGlobalNodeModules();
process.on('SIGTERM', (code) => {
process.exit(15);
});

View File

@ -1,26 +1,26 @@
#!/usr/bin/env bash
echo -e "开始发布"
echo -e "切换master分支"
git branch -D master
git checkout -b master
git push --set-upstream origin master -f
echo -e "切换 debian 分支"
git branch -D debian
git checkout -b debian
git push --set-upstream origin debian -f
echo -e "更新cdn文件"
ts-node-transpile-only sample/tool.ts
string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*")
version="v$string"
echo -e "当前版本$version"
echo -e "当前版本$version-debian"
echo -e "删除已经存在的本地tag"
git tag -d "$version" &>/dev/null
git tag -d "$version-debian" &>/dev/null
echo -e "删除已经存在的远程tag"
git push origin :refs/tags/$version &>/dev/null
git push origin :refs/tags/$version-debian &>/dev/null
echo -e "创建新tag"
git tag -a "$version" -m "release $version"
git tag -a "$version-debian" -m "release $version-debian"
echo -e "提交tag"
git push --tags

View File

@ -65,17 +65,7 @@ link_name=(
)
init_env() {
local pnpm_global_path=$(pnpm root -g 2>/dev/null)
export NODE_PATH="/usr/local/bin:/usr/local/lib/node_modules${pnpm_global_path:+:${pnpm_global_path}}"
# 如果存在 pnpm 全局路径,创建软链接
if [[ -n "$pnpm_global_path" ]]; then
# 确保目标目录存在
mkdir -p "${dir_root}/node_modules"
# 链接全局模块到项目的 node_modules
ln -sf "${pnpm_global_path}/"* "${dir_root}/node_modules/" 2>/dev/null || true
fi
export NODE_PATH="/usr/local/bin:/usr/local/lib/node_modules"
export PYTHONUNBUFFERED=1
}

138
shell/start.sh Normal file
View File

@ -0,0 +1,138 @@
#!/usr/bin/env bash
# 前置依赖 nodejs、npm、python3
set -e
set -x
if [[ ! $QL_DIR ]]; then
npm_dir=$(npm root -g)
pnpm_dir=$(pnpm root -g)
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then
QL_DIR="$npm_dir/@whyour/qinglong"
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then
QL_DIR="$pnpm_dir/@whyour/qinglong"
else
echo -e "未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装"
fi
if [[ $QL_DIR ]]; then
echo -e "请先手动设置 export QL_DIR=$QL_DIR,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务"
fi
exit 1
fi
if [[ ! $QL_DATA_DIR ]]; then
echo -e "请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量"
exit 1
fi
if [[ $QL_DATA_DIR != */data ]]; then
echo -e "QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data如果有历史数据请新建 data 目录,把历史数据放到 data 目录中"
exit 1
fi
command="$1"
if [[ $command != "reload" ]]; then
# 安装依赖
os_name="${QL_OS_TYPE:-}"
if [ -z "$os_name" ]; then
os_name=$(source /etc/os-release && echo "$ID")
fi
# 非 root 用户使用 sudo
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
case "$os_name" in
alpine)
$SUDO apk update
$SUDO apk add -f bash \
coreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
jq \
nginx \
openssh \
procps \
netcat-openbsd
;;
debian|ubuntu)
$SUDO apt-get update
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
;;
*)
echo -e "暂不支持此系统部署 $os_name"
exit 1
;;
esac
npm install -g pnpm@8.3.1 pm2 ts-node
fi
export PYTHON_SHORT_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
export PNPM_HOME=${QL_DIR}/data/dep_cache/node
export PYTHON_HOME=${QL_DIR}/data/dep_cache/python3
export PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin
export NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules
export PIP_CACHE_DIR=${PYTHON_HOME}/pip
export PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
if [[ $command != "reload" ]]; then
pip3 install --prefix ${PYTHON_HOME} requests
fi
cd ${QL_DIR}
cp -f .env.example .env
chmod 777 ${QL_DIR}/shell/*.sh
. ${QL_DIR}/shell/share.sh
. ${QL_DIR}/shell/env.sh
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
fix_config
pm2 l &>/dev/null
log_with_style "INFO" "🔄 2. 启动 nginx..."
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
reload_pm2
if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
pm2 startup
pm2 save
fi
log_with_style "SUCCESS" "🎉 启动成功!"

View File

@ -66,9 +66,7 @@ const EditableTagGroup = ({
}, [inputVisible]);
useEffect(() => {
if (value) {
setTags(value);
}
setTags(value || []);
}, [value]);
return (

View File

@ -29,10 +29,10 @@ const Terminal = ({
const lastLineRef = useRef<null | HTMLElement>(null);
// An effect that handles scrolling into view the last line of terminal input or output
const performScrolldown = useRef(false);
const performScrollDown = useRef(false);
useEffect(() => {
if (performScrolldown.current) {
// skip scrolldown when the component first loads
if (performScrollDown.current) {
// skip scrollDown when the component first loads
setTimeout(
() =>
lastLineRef?.current?.scrollIntoView({
@ -42,7 +42,7 @@ const Terminal = ({
500,
);
}
performScrolldown.current = true;
performScrollDown.current = true;
}, [lineData.length]);
const renderedLineData = lineData.map((ld, i) => {

View File

@ -357,8 +357,8 @@
"BARK推送图标自定义推送图标 (需iOS15或以上才能显示)": "BARK push icon, custom push icon (requires iOS 15 or above to display)",
"BARK推送铃声铃声列表去APP查看复制填写": "BARK push ringtone, check and copy from the APP's ringtone list",
"BARK推送消息的分组默认为qinglong": "BARK push message grouping, default is qinglong",
"BARK推送消息的时效性默认为active": "BARK push message redirecting URL",
"BARK推送消息的跳转URL": "BARK push message grouping, default is qinglong",
"BARK推送消息的时效性默认为active": "BARK push message timeliness, default is active",
"BARK推送消息的跳转URL": "BARK push message redirecting URL",
"BARK是否保存推送消息": "Does BARK save push messages",
"telegram机器人的token例如1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw": "Telegram Bot token, e.g., 1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw",
"telegram用户的id例如129xxx206": "Telegram user ID, e.g., 129xxx206",
@ -437,6 +437,9 @@
"Cron表达式格式有误": "Incorrect Cron Expression Format",
"添加Labels成功": "Labels added successfully",
"删除Labels成功": "Labels deleted successfully",
"添加标签成功": "Tags added successfully",
"删除标签成功": "Tags deleted successfully",
"请至少输入一个标签": "Please enter at least one tag",
"编辑视图": "Edit View",
"排序方式": "Sort Order",
"开始时间": "Start Time",

View File

@ -437,6 +437,9 @@
"Cron表达式格式有误": "Cron表达式格式有误",
"添加Labels成功": "添加Labels成功",
"删除Labels成功": "删除Labels成功",
"添加标签成功": "添加标签成功",
"删除标签成功": "删除标签成功",
"请至少输入一个标签": "请至少输入一个标签",
"编辑视图": "编辑视图",
"排序方式": "排序方式",
"开始时间": "开始时间",

View File

@ -64,7 +64,7 @@ const Diff = () => {
const getFiles = () => {
setLoading(true);
request
.get(`${config.apiPrefix}configs/sample`)
.get(`${config.apiPrefix}configs/samples`)
.then(({ code, data }) => {
if (code === 200) {
setFiles(data);

View File

@ -36,7 +36,7 @@ import { useVT } from 'virtualizedtableforantd4';
import Copy from '../../components/copy';
import EditNameModal from './editNameModal';
import './index.less';
import EnvModal from './modal';
import EnvModal, { EnvLabelModal } from './modal';
const { Paragraph } = Typography;
const { Search } = Input;
@ -121,6 +121,25 @@ const Env = () => {
);
},
},
{
title: intl.get('标签'),
dataIndex: 'labels',
key: 'labels',
render: (labels: string[] | null) => {
const envLabels = Array.isArray(labels) ? labels : [];
return (
<Space size={[0, 4]} wrap>
{envLabels
.filter((label) => label)
.map((label) => (
<Tag key={label} color="blue">
{label}
</Tag>
))}
</Space>
);
},
},
{
title: intl.get('更新时间'),
dataIndex: 'timestamp',
@ -238,6 +257,7 @@ const Env = () => {
const [loading, setLoading] = useState(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [isEditNameModalVisible, setIsEditNameModalVisible] = useState(false);
const [isLabelModalVisible, setIsLabelModalVisible] = useState(false);
const [editedEnv, setEditedEnv] = useState();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
@ -408,6 +428,13 @@ const Env = () => {
getEnvs();
};
const handleLabelCancel = (needUpdate?: boolean) => {
setIsLabelModalVisible(false);
if (needUpdate) {
getEnvs();
}
};
const [vt, setVT] = useVT(
() => ({ scroll: { y: tableScrollHeight } }),
[tableScrollHeight],
@ -542,7 +569,12 @@ const Env = () => {
const exportEnvs = () => {
const envs = value
.filter((x) => selectedRowIds.includes(x.id))
.map((x) => ({ value: x.value, name: x.name, remarks: x.remarks }));
.map((x) => ({
value: x.value,
name: x.name,
remarks: x.remarks,
labels: x.labels,
}));
exportJson('env.json', JSON.stringify(envs));
};
@ -550,6 +582,10 @@ const Env = () => {
setIsEditNameModalVisible(true);
};
const modifyLabels = () => {
setIsLabelModalVisible(true);
};
const onSearch = (value: string) => {
setSearchText(value.trim());
};
@ -622,6 +658,13 @@ const Env = () => {
>
{intl.get('批量修改变量名称')}
</Button>
<Button
type="primary"
style={{ marginBottom: 5, marginLeft: 8 }}
onClick={modifyLabels}
>
{intl.get('批量修改标签')}
</Button>
<Button
type="primary"
style={{ marginBottom: 5, marginLeft: 8 }}
@ -700,6 +743,12 @@ const Env = () => {
ids={selectedRowIds}
/>
)}
{isLabelModalVisible && (
<EnvLabelModal
handleCancel={handleLabelCancel}
ids={selectedRowIds}
/>
)}
</PageContainer>
);
};

View File

@ -1,8 +1,9 @@
import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form, Radio } from 'antd';
import { Modal, message, Input, Form, Radio, Button } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import EditableTagGroup from '@/components/tag';
const EnvModal = ({
env,
@ -16,7 +17,7 @@ const EnvModal = ({
const handleOk = async (values: any) => {
setLoading(true);
const { value, split, name, remarks } = values;
const { value, split, name, remarks, labels } = values;
const method = env ? 'put' : 'post';
let payload;
if (!env) {
@ -27,10 +28,11 @@ const EnvModal = ({
name: name,
value: x,
remarks: remarks,
labels: labels || [],
};
});
} else {
payload = [{ value, name, remarks }];
payload = [{ value, name, remarks, labels: labels || [] }];
}
} else {
payload = { ...values, id: env.id };
@ -123,9 +125,100 @@ const EnvModal = ({
<Form.Item name="remarks" label={intl.get('备注')}>
<Input placeholder={intl.get('请输入备注')} />
</Form.Item>
<Form.Item name="labels" label={intl.get('标签')}>
<EditableTagGroup />
</Form.Item>
</Form>
</Modal>
);
};
export default EnvModal;
export { EnvModal as default };
export const EnvLabelModal = ({
ids,
handleCancel,
}: {
ids: string[];
handleCancel: (needUpdate?: boolean) => void;
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const update = async (action: 'add' | 'delete') => {
try {
const values = await form.validateFields();
const payload = { ids, labels: values.labels };
setLoading(true);
const { code } =
action === 'add'
? await request.post(`${config.apiPrefix}envs/labels`, payload)
: await request.delete(`${config.apiPrefix}envs/labels`, {
data: payload,
});
if (code === 200) {
message.success(
action === 'add'
? intl.get('添加标签成功')
: intl.get('删除标签成功'),
);
handleCancel(true);
}
} catch (error: any) {
if (error?.errorFields) {
return;
}
} finally {
setLoading(false);
}
};
return (
<Modal
title={intl.get('批量修改标签')}
open={true}
footer={[
<Button key="cancel" onClick={() => handleCancel(false)}>
{intl.get('取消')}
</Button>,
<Button
key="delete"
type="primary"
danger
loading={loading}
onClick={() => update('delete')}
>
{intl.get('删除')}
</Button>,
<Button
key="add"
type="primary"
loading={loading}
onClick={() => update('add')}
>
{intl.get('添加')}
</Button>,
]}
centered
maskClosable={false}
forceRender
onCancel={() => handleCancel(false)}
>
<Form form={form} layout="vertical" name="env_label_modal">
<Form.Item
name="labels"
label={intl.get('标签')}
rules={[
{
required: true,
message: intl.get('请至少输入一个标签'),
},
]}
>
<EditableTagGroup />
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -50,7 +50,7 @@ const Login = () => {
});
};
const completeTowFactor = (values: any) => {
const completeTwoFactor = (values: any) => {
setVerifying(true);
request
.put(`${config.apiPrefix}user/two-factor/login`, {
@ -129,7 +129,7 @@ const Login = () => {
const { value } = e.target as any;
const regx = /^[0-9]{6}$/;
if (regx.test(value)) {
completeTowFactor({ code: value });
completeTwoFactor({ code: value });
}
};
@ -156,7 +156,7 @@ const Login = () => {
</div>
<div className={styles.main}>
{twoFactor ? (
<Form layout="vertical" onFinish={completeTowFactor}>
<Form layout="vertical" onFinish={completeTwoFactor}>
<FormItem
name="code"
label={intl.get('验证码')}

View File

@ -215,12 +215,12 @@ const Dependence = () => {
<Form.Item
label={intl.get('Linux 软件包镜像源')}
name="linux"
tooltip={intl.get('alpine linux 镜像源')}
tooltip={intl.get('debian linux 镜像源')}
>
<Input.Group compact>
<Input
style={{ width: 250 }}
placeholder={'https://mirrors.aliyun.com'}
placeholder={'http://mirrors.aliyun.com'}
value={systemConfig?.linuxMirror}
onChange={(e) => {
setSystemConfig({

View File

@ -37,18 +37,18 @@ const SecuritySettings = ({ user, userChange }: any) => {
});
};
const activeOrDeactiveTwoFactor = () => {
const activeOrDeactivateTwoFactor = () => {
if (twoFactorActivated) {
deactiveTowFactor();
deactivateTwoFactor();
} else {
getTwoFactorInfo();
setTwoFactoring(true);
}
};
const deactiveTowFactor = () => {
const deactivateTwoFactor = () => {
request
.put(`${config.apiPrefix}user/two-factor/deactive`)
.put(`${config.apiPrefix}user/two-factor/deactivate`)
.then(({ code, data }) => {
if (code === 200 && data) {
setTwoFactorActivated(false);
@ -60,7 +60,7 @@ const SecuritySettings = ({ user, userChange }: any) => {
});
};
const completeTowFactor = () => {
const completeTwoFactor = () => {
setLoading(true);
request
.put(`${config.apiPrefix}user/two-factor/active`, { code })
@ -162,7 +162,7 @@ const SecuritySettings = ({ user, userChange }: any) => {
onChange={(e) => setCode(e.target.value)}
placeholder="123456"
/>
<Button type="primary" loading={loading} onClick={completeTowFactor}>
<Button type="primary" loading={loading} onClick={completeTwoFactor}>
{intl.get('完成设置')}
</Button>
</div>
@ -230,7 +230,7 @@ const SecuritySettings = ({ user, userChange }: any) => {
<Button
type="primary"
danger={twoFactorActivated}
onClick={activeOrDeactiveTwoFactor}
onClick={activeOrDeactivateTwoFactor}
>
{twoFactorActivated ? intl.get('禁用') : intl.get('启用')}
</Button>

View File

@ -15,29 +15,6 @@ export default {
exclude: [/(\/(en|zh))*\/login/],
},
],
/* I18n configuration, `languages` and `defaultLanguage` are required currently. */
i18n: {
/* Countrys flags: https://www.flaticon.com/packs/countrys-flags */
languages: [
{
key: 'pt-br',
title: 'Português',
flag: '/portugal.svg',
},
{
key: 'en',
title: 'English',
flag: '/america.svg',
},
{
key: 'zh',
title: intl.get('中文'),
flag: '/china.svg',
},
],
defaultLanguage: 'en',
},
scopes: [
{
name: intl.get('定时任务'),
@ -98,6 +75,7 @@ export default {
{ value: 'pushPlus', label: 'PushPlus' },
{ value: 'wePlusBot', label: intl.get('微加机器人') },
{ value: 'wxPusherBot', label: 'wxPusher' },
{ value: 'openiLink', label: 'OpeniLink' },
{ value: 'chat', label: intl.get('群晖chat') },
{ value: 'email', label: intl.get('邮箱') },
{ value: 'lark', label: intl.get('飞书机器人') },
@ -387,6 +365,27 @@ export default {
required: false,
},
],
openiLink: [
{
label: 'openiLinkAppToken',
tip: intl.get(
'OpeniLink的app_token在OpeniLink Hub后台安装App后获取参考 https://openilink.com/docs/hub/apps',
),
required: true,
},
{
label: 'openiLinkHubUrl',
tip: intl.get(
'OpeniLink Hub地址默认为 https://hub.openilink.com自建Hub时填写自己的地址',
),
},
{
label: 'openiLinkContextToken',
tip: intl.get(
'OpeniLink的context_token用于标识消息会话上下文可从消息事件中获取',
),
},
],
lark: [
{
label: 'larkKey',

View File

@ -179,7 +179,7 @@ export default function browserType() {
/**
*
* @param {*} extraHeight ( Number类型,74)
* @param {*} id table时需要定table的id
* @param {*} id table时需要定table的id
*/
export function getTableScroll({
extraHeight,
@ -208,7 +208,7 @@ export function getTableScroll({
}
// 自动触发点击事件
function automaticClick(elment: HTMLElement) {
function automaticClick(element: HTMLElement) {
const ev = document.createEvent('MouseEvents');
ev.initMouseEvent(
'click',
@ -227,7 +227,7 @@ function automaticClick(elment: HTMLElement) {
0,
null,
);
elment.dispatchEvent(ev);
element.dispatchEvent(ev);
}
// 导出文件

View File

@ -1,11 +1,6 @@
version: 2.20.1
changeLogLink: https://t.me/jiao_long/433
publishTime: 2025-12-26 22:00
version: 2.20.2
changeLogLink: https://t.me/jiao_long/434
publishTime: 2026-03-01 1800
changeLog: |
1. 修复获取依赖管理列表
2. notify.js 修复 TG_PROXY_AUTH 参数拼接
3. QLAPI.notify larkSecret 参数
4. 修复 cron parser 定时规则校验
5. 修复设置 baseUrl 后无法访问
6. 修复环境变量排序
7. 修复定时任务无法停止
1. 修复 path 安全漏洞(重要)