mirror of
https://github.com/whyour/qinglong.git
synced 2026-06-28 02:45:08 +08:00
Merge develop branch and remove notify.py.save
This commit is contained in:
commit
628e2933f9
|
|
@ -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=
|
||||
|
|
|
|||
310
.github/workflows/build-docker-image.yml
vendored
310
.github/workflows/build-docker-image.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -28,3 +28,5 @@ __pycache__
|
|||
/shell/preload/notify.*
|
||||
/shell/preload/*-notify.json
|
||||
/shell/preload/__ql_notify__.*
|
||||
|
||||
.deepseek/
|
||||
|
|
|
|||
22
.npmignore
Normal file
22
.npmignore
Normal 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
|
||||
11
README-en.md
11
README-en.md
|
|
@ -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.
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
119
docker/Dockerfile.debian
Normal 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
119
docker/Dockerfile.debian310
Normal 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
|
||||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
550
docs/PROJECT_ARCHITECTURE.md
Normal file
550
docs/PROJECT_ARCHITECTURE.md
Normal 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
|
||||
```
|
||||
30
package.json
30
package.json
|
|
@ -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",
|
||||
|
|
|
|||
986
pnpm-lock.yaml
986
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -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 变量名= 声明即可
|
||||
|
|
|
|||
|
|
@ -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_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ
|
||||
TG_USER_ID: '', // tg 机器人的 TG_USER_ID,例:1434078534
|
||||
TG_BOT_TOKEN: '', // tg 机器人的 TG_BOT_TOKEN,例:1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
TG_USER_ID: '', // tg 机器人的 TG_USER_ID,例:1234567890
|
||||
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
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,8 +94,8 @@ push_config = {
|
|||
|
||||
'QYWX_KEY': '', # 企业微信机器人
|
||||
|
||||
'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ
|
||||
'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1434078534
|
||||
'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1234567890
|
||||
'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
24
shell/bot.sh
24
shell/bot.sh
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
16
shell/pub.sh
16
shell/pub.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
138
shell/start.sh
Normal 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" "🎉 启动成功!"
|
||||
|
|
@ -66,9 +66,7 @@ const EditableTagGroup = ({
|
|||
}, [inputVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setTags(value);
|
||||
}
|
||||
setTags(value || []);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -437,6 +437,9 @@
|
|||
"Cron表达式格式有误": "Cron表达式格式有误",
|
||||
"添加Labels成功": "添加Labels成功",
|
||||
"删除Labels成功": "删除Labels成功",
|
||||
"添加标签成功": "添加标签成功",
|
||||
"删除标签成功": "删除标签成功",
|
||||
"请至少输入一个标签": "请至少输入一个标签",
|
||||
"编辑视图": "编辑视图",
|
||||
"排序方式": "排序方式",
|
||||
"开始时间": "开始时间",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
53
src/pages/env/index.tsx
vendored
53
src/pages/env/index.tsx
vendored
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
101
src/pages/env/modal.tsx
vendored
101
src/pages/env/modal.tsx
vendored
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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('验证码')}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// 导出文件
|
||||
|
|
|
|||
15
version.yaml
15
version.yaml
|
|
@ -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 安全漏洞(重要)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user