mirror of
https://github.com/whyour/qinglong.git
synced 2026-08-11 10:40:52 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bb41ebe96 | ||
|
|
d67f33df39 | ||
|
|
24d539fc05 | ||
|
|
214e23a63d | ||
|
|
ddb3ab7ac3 | ||
|
|
da6dac8bf1 | ||
|
|
9b17a41987 | ||
|
|
cdfa6c3c3c | ||
|
|
1953022b99 | ||
|
|
38d84f9f31 | ||
|
|
e92ddb0aed | ||
|
|
a0d57c78c5 | ||
|
|
a942a5686f | ||
|
|
a1ccd9947d | ||
|
|
327c3a317b | ||
|
|
d3016431ce | ||
|
|
5fbff0e1c8 | ||
|
|
3044f63f03 | ||
|
|
369dd13212 | ||
|
|
b5a5fb3be6 | ||
|
|
0ee0b83207 | ||
|
|
5f2d5bb24a | ||
|
|
949d956aef | ||
|
|
96b4c90398 | ||
|
|
7d8feadc78 | ||
|
|
d1dfde3ca9 | ||
|
|
6796068523 | ||
|
|
05f8fd3805 | ||
|
|
946731ac8d | ||
|
|
617cf7e5b4 | ||
|
|
662adc0f9c | ||
|
|
23b41893a3 | ||
|
|
34d4526413 | ||
|
|
ad92e0c2c0 | ||
|
|
c14390cf41 | ||
|
|
fd6b4e4cde | ||
|
|
865e3035b9 | ||
|
|
d8d30c3619 | ||
|
|
4e803df51e | ||
|
|
e8ac195c96 | ||
|
|
c0b7527148 | ||
|
|
ca347c5854 | ||
|
|
4bee3fbdf4 | ||
|
|
b6537e27ef | ||
|
|
0e5b8bef4b | ||
|
|
e06c3571a8 | ||
|
|
c2eac8b8fd | ||
|
|
3aab1233bb | ||
|
|
2fe9470ff0 | ||
|
|
abad29cbf9 | ||
|
|
84d730d510 | ||
|
|
57d58c871e | ||
|
|
bb6d436c19 | ||
|
|
8bc0906949 | ||
|
|
7a8917f8e4 | ||
|
|
66f9457be8 | ||
|
|
0c5e3b5d04 | ||
|
|
400e4770de | ||
|
|
40d4de9017 | ||
|
|
3464c4da61 | ||
|
|
1315578878 | ||
|
|
a1ae08da58 | ||
|
|
66700ebe1a | ||
|
|
07bf0c705b | ||
|
|
fd516977e3 | ||
|
|
c39f4ef846 | ||
|
|
275d8af4e2 | ||
|
|
544c432f49 | ||
|
|
6bec52dca1 | ||
|
|
ce599d306f | ||
|
|
d53437d169 | ||
|
|
d526602d19 | ||
|
|
91b44914f6 | ||
|
|
4f6c93cc1c | ||
|
|
e326d89571 | ||
|
|
5f0dafa010 | ||
|
|
dc0b3f2eb2 | ||
|
|
3db716763d | ||
|
|
fae226745e | ||
|
|
9330650163 | ||
|
|
073de76a4a | ||
|
|
c61d1aa828 | ||
|
|
33fa3aca99 | ||
|
|
c772fc9527 | ||
|
|
c5d2aa3aba | ||
|
|
02a05f06bd | ||
|
|
3b0f55caf4 | ||
|
|
6a3dd4f83c | ||
|
|
177cd3de81 | ||
|
|
d473c3ae88 | ||
|
|
ee2fbe5335 | ||
|
|
48abf44ceb | ||
|
|
03c7031a3c | ||
|
|
0e5de4a824 | ||
|
|
af96bd98ac | ||
|
|
08ef509e27 | ||
|
|
fbeb4f4a6c | ||
|
|
eb09a417a1 | ||
|
|
e84ddb6cfc | ||
|
|
d01ec3b310 | ||
|
|
45fac29804 | ||
|
|
72d4c8bfd4 | ||
|
|
8f23c61fee | ||
|
|
e41eed81ae | ||
|
|
58f5fe763f | ||
|
|
214241797d | ||
|
|
aedd48c9c4 | ||
|
|
90ddf0fb57 | ||
|
|
c9fc9b4b45 | ||
|
|
8fdc69421c | ||
|
|
1deb264913 | ||
|
|
1f2fd8ac02 | ||
|
|
06aa07329f | ||
|
|
4cb9f57479 | ||
|
|
c369514741 | ||
|
|
0e28e1b6c4 | ||
|
|
73f8f3c5fa | ||
|
|
494ccb3c08 | ||
|
|
399728b433 | ||
|
|
52a1de5063 | ||
|
|
18f27a9a69 | ||
|
|
07951964a1 | ||
|
|
a1f888af59 | ||
|
|
f7472b6e74 | ||
|
|
a7baeba755 | ||
|
|
e4f733320d | ||
|
|
55c92dc320 | ||
|
|
50769c43dd | ||
|
|
0587644a6b | ||
|
|
87b934aafe | ||
|
|
7a92e7c6ab | ||
|
|
1d8403c0ec | ||
|
|
ef9e38f167 | ||
|
|
c9bd053fbd | ||
|
|
57939391b9 | ||
|
|
394e96bbf8 | ||
|
|
47c194c1f4 | ||
|
|
7d65d96ebd | ||
|
|
224000b63b | ||
|
|
1c18668bad | ||
|
|
f94582b68d | ||
|
|
eb1c00984c | ||
|
|
1a185f5682 | ||
|
|
b6ea8565ec | ||
|
|
2e94d58758 | ||
|
|
472a3088df | ||
|
|
ec3d61a713 | ||
|
|
95459c33ed | ||
|
|
5459b72f63 | ||
|
|
05db2b1df8 | ||
|
|
c3072e7712 | ||
|
|
3fafe4d24d | ||
|
|
32bccb3f3e | ||
|
|
ac04478d1d | ||
|
|
8a18baa921 | ||
|
|
9a399f8de8 | ||
|
|
da639a1f8f | ||
|
|
425e49675a | ||
|
|
8174762c18 | ||
|
|
710a107e73 | ||
|
|
d871585eee | ||
|
|
729b405b0f | ||
|
|
71a7c1b9d3 | ||
|
|
40a831f3a2 | ||
|
|
1befa1bb8c | ||
|
|
8009634b44 | ||
|
|
03a23f6f31 | ||
|
|
124e01e93a | ||
|
|
2370923a75 | ||
|
|
256e4e7a83 | ||
|
|
c0ec063333 | ||
|
|
576408de01 | ||
|
|
cf94ecfb11 |
+13
-11
@@ -1,14 +1,16 @@
|
||||
UPDATE_PORT=5300
|
||||
PUBLIC_PORT=5400
|
||||
CRON_PORT=5500
|
||||
BACK_PORT=5600
|
||||
PORT=5700
|
||||
GRPC_PORT=5500
|
||||
BACK_PORT=5700
|
||||
|
||||
LOG_LEVEL='debug'
|
||||
# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6)
|
||||
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
|
||||
# BIND_HOST=0.0.0.0
|
||||
# BIND_HOST_GRPC=0.0.0.0
|
||||
|
||||
SECRET='whyour'
|
||||
LOG_LEVEL='info'
|
||||
|
||||
QINIU_AK=''
|
||||
QINIU_SK=''
|
||||
QINIU_SCOPE=''
|
||||
TEMP=''
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRES_IN=
|
||||
|
||||
QINIU_AK=
|
||||
QINIU_SK=
|
||||
QINIU_SCOPE=
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
name: Bug Fixer
|
||||
description: Fix this issue following our error handling pattern.
|
||||
---
|
||||
@@ -0,0 +1,457 @@
|
||||
name: Build And Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
branches:
|
||||
- "master"
|
||||
- "develop"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
code_gitlab:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Push to GitLab
|
||||
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 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
git remote add gitlab git@gitlab.com:whyour/qinglong.git 2>/dev/null
|
||||
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
|
||||
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
|
||||
|
||||
code_gitee:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 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 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
|
||||
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
|
||||
fi
|
||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
|
||||
|
||||
build-static:
|
||||
runs-on: ubuntu-latest
|
||||
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: build front and back
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
|
||||
- name: copy to static repo
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
mkdir -p tmp
|
||||
cd ./tmp
|
||||
cp -rf ../static/* ./
|
||||
git init -b ${GITHUB_BRANCH} && git add .
|
||||
git config --local user.name 'github-actions[bot]'
|
||||
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
|
||||
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
||||
|
||||
static_gitlab:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Push qinglong-static to GitLab
|
||||
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 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
||||
cd static-mirror
|
||||
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
|
||||
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
|
||||
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
|
||||
|
||||
static_gitee:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Push qinglong-static 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 clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
|
||||
cd static-mirror
|
||||
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
|
||||
|
||||
if git push --force --all gitee 2>&1; then
|
||||
echo "::notice::Gitee static push succeeded"
|
||||
else
|
||||
echo "::warning::Push 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-static","private":"false"}' \
|
||||
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
||||
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
|
||||
fi
|
||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
|
||||
|
||||
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@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: 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=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- 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
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,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-alpine
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
|
||||
|
||||
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@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: 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@v4
|
||||
with:
|
||||
cache-image: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- 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
|
||||
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
||||
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
|
||||
with:
|
||||
cache-image: false
|
||||
|
||||
- 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
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
context: .
|
||||
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-alpine-python3.10
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
|
||||
|
||||
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: |
|
||||
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
|
||||
with:
|
||||
cache-image: false
|
||||
|
||||
- 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
|
||||
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
|
||||
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
|
||||
@@ -1,239 +0,0 @@
|
||||
name: Build And Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
branches:
|
||||
- "master"
|
||||
- "develop"
|
||||
tags:
|
||||
- "v*"
|
||||
schedule:
|
||||
- cron: "00 20 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
to_gitlab:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: wearerequired/git-mirror-action@v1
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||
with:
|
||||
source-repo: https://github.com/whyour/qinglong.git
|
||||
destination-repo: git@gitlab.com:whyour/qinglong.git
|
||||
|
||||
to_gitee:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: wearerequired/git-mirror-action@v1
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||
with:
|
||||
source-repo: https://github.com/whyour/qinglong.git
|
||||
destination-repo: git@gitee.com:whyour/qinglong.git
|
||||
|
||||
build-static:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: "8.3.1"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: build front and back
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
|
||||
- name: copy to static repo
|
||||
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
|
||||
cp -rf ../static/* ./
|
||||
git init -b ${GITHUB_BRANCH} && git add .
|
||||
git config --local user.name 'github-actions[bot]'
|
||||
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
|
||||
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
echo "${PRIVATE_KEY}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
|
||||
export GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -l git"
|
||||
git remote add gitee "${REPO_GITEE}"
|
||||
git remote add gitlab "${REPO_GITLAB}"
|
||||
git gc
|
||||
git push --force --quiet gitee ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
||||
git push --force --quiet gitlab ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
||||
|
||||
build:
|
||||
needs: build-static
|
||||
|
||||
# 由于 ubuntu-latest 中使用 linux6.x 内核 linux/s390x npm 无法使用
|
||||
runs-on: ubuntu-20.04
|
||||
# runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup timezone
|
||||
uses: szenius/set-timezone@v2.0
|
||||
with:
|
||||
timezoneLinux: Asia/Shanghai
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
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@v5
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# nightly, master, pr-2, 1.2.3, 1.2, 1
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=schedule,pattern=nightly
|
||||
type=edge
|
||||
type=ref,event=pr
|
||||
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
# platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x,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
|
||||
|
||||
- name: Image digest
|
||||
run: |
|
||||
echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
build310:
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
needs: build-static
|
||||
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup timezone
|
||||
uses: szenius/set-timezone@v2.0
|
||||
with:
|
||||
timezoneLinux: Asia/Shanghai
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push python3.10
|
||||
id: docker_build_310
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
context: .
|
||||
file: ./docker/310.Dockerfile
|
||||
push: true
|
||||
tags: whyour/qinglong:python3.10
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: |
|
||||
echo ${{ steps.docker_build_310.outputs.digest }}
|
||||
@@ -28,3 +28,10 @@ __pycache__
|
||||
/shell/preload/notify.*
|
||||
/shell/preload/*-notify.json
|
||||
/shell/preload/__ql_notify__.*
|
||||
/shell/preload/lang_env.sh
|
||||
|
||||
.deepseek/
|
||||
.claude/
|
||||
|
||||
# local Kubernetes overlays
|
||||
/deploy/kubernetes/overlays/local/
|
||||
|
||||
+22
@@ -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
|
||||
@@ -16,18 +16,8 @@ export default defineConfig({
|
||||
favicons: [`https://qn.whyour.cn/favicon.svg`],
|
||||
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
|
||||
proxy: {
|
||||
[`${baseUrl}api/update`]: {
|
||||
target: 'http://127.0.0.1:5300/',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { [`^${baseUrl}api/update`]: '/api' },
|
||||
},
|
||||
[`${baseUrl}api/public`]: {
|
||||
target: 'http://127.0.0.1:5400/',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { [`^${baseUrl}api/public`]: '/api' },
|
||||
},
|
||||
[`${baseUrl}api`]: {
|
||||
target: 'http://127.0.0.1:5600/',
|
||||
target: 'http://127.0.0.1:5700/',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
pathRewrite: { [`^${baseUrl}api`]: '/api' },
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **qinglong** (2778 symbols, 6698 relationships, 233 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "develop"})`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/qinglong/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/qinglong/clusters` | All functional areas |
|
||||
| `gitnexus://repo/qinglong/processes` | All execution flows |
|
||||
| `gitnexus://repo/qinglong/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
+15
-2
@@ -18,9 +18,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
||||
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
|
||||
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
|
||||
|
||||
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
|
||||
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
|
||||
|
||||
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
|
||||
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
|
||||
</div>
|
||||
|
||||

|
||||
@@ -41,11 +41,24 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
||||
|
||||
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
|
||||
|
||||
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
|
||||
|
||||
```bash
|
||||
docker pull whyour/qinglong:latest
|
||||
docker pull whyour/qinglong:debian
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -20,9 +20,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
||||
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
|
||||
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
|
||||
|
||||
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
|
||||
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
|
||||
|
||||
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
|
||||
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
|
||||
</div>
|
||||
|
||||

|
||||
@@ -43,11 +43,24 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
||||
|
||||
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
|
||||
|
||||
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
|
||||
|
||||
```bash
|
||||
docker pull whyour/qinglong:latest
|
||||
docker pull whyour/qinglong:debian
|
||||
```
|
||||
|
||||
使用 `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`
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import 'express';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
platform: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -4,8 +4,9 @@ import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { join } from 'path';
|
||||
import { join, basename } from 'path';
|
||||
import { SAMPLE_FILES } from '../config/const';
|
||||
import { t } from '../shared/i18n';
|
||||
import ConfigService from '../services/config';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
const route = Router();
|
||||
@@ -14,7 +15,7 @@ export default (app: Router) => {
|
||||
app.use('/configs', route);
|
||||
|
||||
route.get(
|
||||
'/sample',
|
||||
'/samples',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send({
|
||||
@@ -71,15 +72,24 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const { name, content } = req.body;
|
||||
if (config.blackFileList.includes(name)) {
|
||||
res.send({ code: 403, message: '文件无法访问' });
|
||||
}
|
||||
let path = join(config.configPath, name);
|
||||
// Resolve path first to prevent traversal attacks
|
||||
let basePath = config.configPath;
|
||||
if (name.startsWith('data/scripts/')) {
|
||||
path = join(config.rootPath, name);
|
||||
basePath = join(config.rootPath, 'data/scripts');
|
||||
}
|
||||
await writeFileWithLock(path, content);
|
||||
res.send({ code: 200, message: '保存成功' });
|
||||
const cleanName = name.replace(/^data\/scripts\//, '');
|
||||
const resolvedPath = join(basePath, cleanName);
|
||||
const normalized = join(resolvedPath);
|
||||
// Verify the resolved path stays within allowed directory
|
||||
if (!normalized.startsWith(basePath)) {
|
||||
return res.send({ code: 403, message: t('文件路径无效') });
|
||||
}
|
||||
// Check blacklist on actual filename (not user input)
|
||||
if (config.blackFileList.includes(basename(normalized))) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
await writeFileWithLock(normalized, content);
|
||||
res.send({ code: 200, message: t('保存成功') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
+51
-3
@@ -5,6 +5,11 @@ import CronService from '../services/cron';
|
||||
import CronViewService from '../services/cronView';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { commonCronSchema } from '../validation/schedule';
|
||||
import {
|
||||
RunningInstanceModel,
|
||||
InstanceStatus,
|
||||
} from '../data/runningInstance';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
const route = Router();
|
||||
|
||||
@@ -60,7 +65,7 @@ export default (app: Router) => {
|
||||
try {
|
||||
const cronViewService = Container.get(CronViewService);
|
||||
if (req.body.type === 1) {
|
||||
return res.send({ code: 400, message: '参数错误' });
|
||||
return res.send({ code: 400, message: t('参数错误') });
|
||||
} else {
|
||||
const data = await cronViewService.update(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
@@ -307,8 +312,8 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const cronService = Container.get(CronService);
|
||||
const data = await cronService.log(req.params.id);
|
||||
return res.send({ code: 200, data });
|
||||
const result = await cronService.log(req.params.id);
|
||||
return res.send({ code: 200, data: result.content, logStatus: result.status });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -429,6 +434,7 @@ export default (app: Router) => {
|
||||
log_path: Joi.string().optional().allow(null),
|
||||
last_running_time: Joi.number().optional().allow(null),
|
||||
last_execution_time: Joi.number().optional().allow(null),
|
||||
exit_code: Joi.number().optional().allow(null),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -446,6 +452,48 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/:id/instances',
|
||||
celebrate({
|
||||
params: Joi.object({
|
||||
id: Joi.number().required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const instances = await RunningInstanceModel.findAll({
|
||||
where: {
|
||||
cron_id: req.params.id,
|
||||
},
|
||||
order: [['started_at', 'DESC']],
|
||||
raw: true,
|
||||
});
|
||||
return res.send({ code: 200, data: instances });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/:id/instances/:instanceId/stop',
|
||||
celebrate({
|
||||
params: Joi.object({
|
||||
id: Joi.number().required(),
|
||||
instanceId: Joi.number().required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const cronService = Container.get(CronService);
|
||||
const data = await cronService.stopInstance(req.params.instanceId);
|
||||
return res.send(data);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/:id/logs',
|
||||
celebrate({
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { fn, col, where, Op } from 'sequelize';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import {
|
||||
RunningInstanceModel,
|
||||
InstanceStatus,
|
||||
} from '../data/runningInstance';
|
||||
import dayjs from 'dayjs';
|
||||
import os from 'os';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/dashboard', route);
|
||||
|
||||
route.post('/record', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { ref_id, code, elapsed } = req.body;
|
||||
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const isSuccess = code === 0 ? 1 : 0;
|
||||
const isFail = code !== 0 ? 1 : 0;
|
||||
const elapsedMs = (Number(elapsed) || 0) * 1000;
|
||||
|
||||
const existing = await CrontabStatModel.findOne({
|
||||
where: { ref_id: Number(ref_id), date: today },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await CrontabStatModel.update(
|
||||
{
|
||||
run_count: (existing.run_count || 0) + 1,
|
||||
success_count: (existing.success_count || 0) + isSuccess,
|
||||
fail_count: (existing.fail_count || 0) + isFail,
|
||||
total_time: (existing.total_time || 0) + elapsedMs,
|
||||
max_time: Math.max(existing.max_time || 0, elapsedMs),
|
||||
},
|
||||
{ where: { id: existing.id } },
|
||||
);
|
||||
} else {
|
||||
await CrontabStatModel.create({
|
||||
ref_id: Number(ref_id),
|
||||
date: today,
|
||||
run_count: 1,
|
||||
success_count: isSuccess,
|
||||
fail_count: isFail,
|
||||
total_time: elapsedMs,
|
||||
max_time: elapsedMs,
|
||||
});
|
||||
}
|
||||
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
res.send({ code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
route.get(
|
||||
'/overview',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [total, enabled, disabled, stats] = await Promise.all([
|
||||
CrontabModel.count(),
|
||||
CrontabModel.count({ where: { isDisabled: 0 } }),
|
||||
CrontabModel.count({ where: { isDisabled: 1 } }),
|
||||
CrontabStatModel.findOne({
|
||||
attributes: [
|
||||
[fn('SUM', col('run_count')), 'total_runs'],
|
||||
[fn('SUM', col('success_count')), 'total_success'],
|
||||
[fn('SUM', col('fail_count')), 'total_fail'],
|
||||
[fn('SUM', col('total_time')), 'total_time'],
|
||||
],
|
||||
where: { date: today },
|
||||
raw: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const row = stats as any;
|
||||
const totalRuns = Number(row?.total_runs) || 0;
|
||||
const totalSuccess = Number(row?.total_success) || 0;
|
||||
const totalFail = Number(row?.total_fail) || 0;
|
||||
const totalTime = Number(row?.total_time) || 0;
|
||||
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
total,
|
||||
enabled,
|
||||
disabled,
|
||||
todayRuns: totalRuns,
|
||||
todaySuccess: totalSuccess,
|
||||
todayFail: totalFail,
|
||||
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
|
||||
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/trend',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const days = parseInt(req.query.days as string) || 7;
|
||||
const dates: string[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
|
||||
}
|
||||
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: [
|
||||
'date',
|
||||
[fn('SUM', col('run_count')), 'total_runs'],
|
||||
[fn('SUM', col('success_count')), 'total_success'],
|
||||
[fn('SUM', col('fail_count')), 'total_fail'],
|
||||
],
|
||||
where: {
|
||||
date: { [Op.in]: dates },
|
||||
},
|
||||
group: ['date'],
|
||||
order: [['date', 'ASC']],
|
||||
raw: true,
|
||||
})) as any[];
|
||||
|
||||
const dataMap: Record<string, any> = {};
|
||||
rows.forEach((r: any) => {
|
||||
dataMap[r.date] = {
|
||||
total: Number(r.total_runs) || 0,
|
||||
success: Number(r.total_success) || 0,
|
||||
fail: Number(r.total_fail) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
const data = dates.map((d) => ({
|
||||
date: dayjs(d).format('MM-DD'),
|
||||
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
|
||||
}));
|
||||
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/top-time',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: [
|
||||
'ref_id',
|
||||
[fn('SUM', col('total_time')), 'total_time'],
|
||||
[fn('SUM', col('run_count')), 'run_count'],
|
||||
[fn('MAX', col('max_time')), 'max_time'],
|
||||
],
|
||||
where: { date: today, run_count: { [Op.gt]: 0 } },
|
||||
group: ['ref_id'],
|
||||
order: [[fn('SUM', col('total_time')), 'DESC']],
|
||||
limit: 5,
|
||||
raw: true,
|
||||
})) as any[];
|
||||
|
||||
const ids = rows.map((r) => Number(r.ref_id));
|
||||
const crons = await CrontabModel.findAll({
|
||||
where: { id: { [Op.in]: ids } },
|
||||
raw: true,
|
||||
});
|
||||
const nameMap: Record<number, string> = {};
|
||||
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
|
||||
|
||||
const data = rows.map((r: any, i) => ({
|
||||
rank: i + 1,
|
||||
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
||||
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
||||
maxTime: Number(r.max_time),
|
||||
}));
|
||||
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/top-count',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: [
|
||||
'ref_id',
|
||||
[fn('SUM', col('run_count')), 'run_count'],
|
||||
[fn('SUM', col('total_time')), 'total_time'],
|
||||
[fn('SUM', col('success_count')), 'success_count'],
|
||||
],
|
||||
where: { date: today, run_count: { [Op.gt]: 0 } },
|
||||
group: ['ref_id'],
|
||||
order: [[fn('SUM', col('run_count')), 'DESC']],
|
||||
limit: 5,
|
||||
raw: true,
|
||||
})) as any[];
|
||||
|
||||
const ids = rows.map((r) => Number(r.ref_id));
|
||||
const crons = await CrontabModel.findAll({
|
||||
where: { id: { [Op.in]: ids } },
|
||||
raw: true,
|
||||
});
|
||||
const nameMap: Record<number, string> = {};
|
||||
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
|
||||
|
||||
const data = rows.map((r: any, i) => ({
|
||||
rank: i + 1,
|
||||
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
|
||||
runCount: Number(r.run_count),
|
||||
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
|
||||
successRate:
|
||||
Number(r.run_count) > 0
|
||||
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
|
||||
: '0',
|
||||
}));
|
||||
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/runtime',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const runningInstances = await RunningInstanceModel.findAll({
|
||||
where: {
|
||||
status: InstanceStatus.running,
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const queuedCrons = await CrontabModel.findAll({
|
||||
where: {
|
||||
status: 3, // queued
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
|
||||
// Fetch cron names for running instances
|
||||
const cronIds = [
|
||||
...new Set(runningInstances.map((i: any) => i.cron_id)),
|
||||
];
|
||||
const crons =
|
||||
cronIds.length > 0
|
||||
? await CrontabModel.findAll({
|
||||
where: { id: cronIds },
|
||||
raw: true,
|
||||
})
|
||||
: [];
|
||||
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
|
||||
|
||||
const now = dayjs().unix();
|
||||
const running = runningInstances.map((inst: any) => {
|
||||
const cron = cronMap.get(inst.cron_id);
|
||||
return {
|
||||
instanceId: inst.id,
|
||||
id: inst.cron_id,
|
||||
name: cron?.name || cron?.command || tf('任务#%s', inst.cron_id),
|
||||
pid: inst.pid,
|
||||
elapsed: inst.started_at ? now - inst.started_at : 0,
|
||||
logPath: inst.log_path,
|
||||
};
|
||||
});
|
||||
|
||||
const dayAgo = dayjs().subtract(24, 'hour').unix();
|
||||
const idleTasks = await CrontabModel.findAll({
|
||||
where: {
|
||||
isDisabled: 0,
|
||||
status: 1,
|
||||
last_execution_time: { [Op.lt]: dayAgo },
|
||||
},
|
||||
order: [['last_execution_time', 'ASC']],
|
||||
limit: 5,
|
||||
raw: true,
|
||||
});
|
||||
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
runningCount: running.length,
|
||||
queuedCount: queuedCrons.length,
|
||||
running,
|
||||
idleTasks: idleTasks.map((c: any) => ({
|
||||
id: c.id,
|
||||
name: c.name || c.command || tf('任务#%s', c.id),
|
||||
lastRun: c.last_execution_time
|
||||
? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm')
|
||||
: '-',
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/labels',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const [crons, stats] = (await Promise.all([
|
||||
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
|
||||
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
|
||||
]));
|
||||
|
||||
const statMap: Record<number, any> = {};
|
||||
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
|
||||
|
||||
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
|
||||
crons.forEach((c) => {
|
||||
let rawLabels = c.labels;
|
||||
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
|
||||
const labels: string[] = Array.isArray(rawLabels)
|
||||
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
|
||||
: [];
|
||||
if (labels.length === 0) {
|
||||
labels.push(t('未分类'));
|
||||
}
|
||||
const st = statMap[c.id!];
|
||||
labels.forEach((label: string) => {
|
||||
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
|
||||
labelMap[label].count += 1;
|
||||
if (st) {
|
||||
labelMap[label].runs += Number(st.run_count) || 0;
|
||||
labelMap[label].success += Number(st.success_count) || 0;
|
||||
labelMap[label].totalTime += Number(st.total_time) || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const data = Object.entries(labelMap)
|
||||
.map(([label, v]) => ({
|
||||
label,
|
||||
count: v.count,
|
||||
todayRuns: v.runs,
|
||||
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
|
||||
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.todayRuns - a.todayRuns);
|
||||
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/system',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const memUsage = process.memoryUsage();
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
platform: os.platform(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
memTotal: os.totalmem(),
|
||||
memFree: os.freemem(),
|
||||
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
|
||||
cpus: os.cpus().length,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+13
-8
@@ -8,7 +8,17 @@ const route = Router();
|
||||
export default (app: Router) => {
|
||||
app.use('/dependencies', route);
|
||||
|
||||
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
route.get(
|
||||
'/',
|
||||
celebrate({
|
||||
query:
|
||||
Joi.object({
|
||||
searchValue: Joi.string().optional().allow(''),
|
||||
type: Joi.string().optional().allow(''),
|
||||
status: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
@@ -18,7 +28,8 @@ export default (app: Router) => {
|
||||
logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/',
|
||||
@@ -32,7 +43,6 @@ export default (app: Router) => {
|
||||
),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
const data = await dependenceService.create(req.body);
|
||||
@@ -54,7 +64,6 @@ export default (app: Router) => {
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
const data = await dependenceService.update(req.body);
|
||||
@@ -71,7 +80,6 @@ export default (app: Router) => {
|
||||
body: Joi.array().items(Joi.number().required()),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
const data = await dependenceService.remove(req.body);
|
||||
@@ -88,7 +96,6 @@ export default (app: Router) => {
|
||||
body: Joi.array().items(Joi.number().required()),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
const data = await dependenceService.remove(req.body, true);
|
||||
@@ -107,7 +114,6 @@ export default (app: Router) => {
|
||||
}),
|
||||
}),
|
||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
const data = await dependenceService.getDb({ id: req.params.id });
|
||||
@@ -124,7 +130,6 @@ export default (app: Router) => {
|
||||
body: Joi.array().items(Joi.number().required()),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const dependenceService = Container.get(DependenceService);
|
||||
const data = await dependenceService.reInstall(req.body);
|
||||
|
||||
+89
-9
@@ -1,12 +1,13 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import EnvService from '../services/env';
|
||||
import { Logger } from 'winston';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import multer from 'multer';
|
||||
import config from '../config';
|
||||
import { Joi, celebrate } from 'celebrate';
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import multer from 'multer';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { safeJSONParse } from '../config/util';
|
||||
import { t } from '../shared/i18n';
|
||||
import EnvService from '../services/env';
|
||||
const route = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
@@ -18,6 +19,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 +49,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().allow(null),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -52,7 +58,7 @@ export default (app: Router) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
if (!req.body?.length) {
|
||||
return res.send({ code: 400, message: '参数不正确' });
|
||||
return res.send({ code: 400, message: t('参数不正确') });
|
||||
}
|
||||
const data = await envService.create(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
@@ -70,6 +76,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().allow(null),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -196,6 +203,78 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/pin',
|
||||
celebrate({
|
||||
body: Joi.array().items(Joi.number().required()),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.pin(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/unpin',
|
||||
celebrate({
|
||||
body: Joi.array().items(Joi.number().required()),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.unPin(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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'),
|
||||
@@ -214,13 +293,14 @@ export default (app: Router) => {
|
||||
name: x.name,
|
||||
value: x.value,
|
||||
remarks: x.remarks,
|
||||
labels: x.labels,
|
||||
})),
|
||||
);
|
||||
return res.send({ code: 200, data: result });
|
||||
} else {
|
||||
return res.send({
|
||||
code: 400,
|
||||
message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式',
|
||||
message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import Logger from '../loaders/logger';
|
||||
import { HealthService } from '../services/health';
|
||||
import Container from 'typedi';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/', route);
|
||||
|
||||
route.get('/health', async (req, res) => {
|
||||
try {
|
||||
const healthService = Container.get(HealthService);
|
||||
const health = await healthService.check();
|
||||
res.status(200).send({
|
||||
code: 200,
|
||||
data: health,
|
||||
});
|
||||
} catch (err: any) {
|
||||
Logger.error('Health check failed:', err);
|
||||
res.status(500).send({
|
||||
code: 500,
|
||||
message: 'Health check failed',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -9,6 +9,9 @@ import open from './open';
|
||||
import dependence from './dependence';
|
||||
import system from './system';
|
||||
import subscription from './subscription';
|
||||
import update from './update';
|
||||
import dashboard from './dashboard';
|
||||
import health from './health';
|
||||
|
||||
export default () => {
|
||||
const app = Router();
|
||||
@@ -22,6 +25,9 @@ export default () => {
|
||||
dependence(app);
|
||||
system(app);
|
||||
subscription(app);
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
+78
-24
@@ -1,10 +1,17 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { getFileContentByName, readDirs, removeAnsi, rmPath } from '../config/util';
|
||||
import { join, resolve } from 'path';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { t } from '../shared/i18n';
|
||||
import {
|
||||
getFileContentByName,
|
||||
readDirs,
|
||||
removeAnsi,
|
||||
rmPath,
|
||||
} from '../config/util';
|
||||
import LogService from '../services/log';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
const route = Router();
|
||||
const blacklist = ['.tmp'];
|
||||
|
||||
@@ -29,20 +36,28 @@ export default (app: Router) => {
|
||||
'/detail',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const finalPath = resolve(
|
||||
config.logPath,
|
||||
const logService = Container.get(LogService);
|
||||
const finalPath = logService.checkFilePath(
|
||||
(req.query.path as string) || '',
|
||||
(req.query.file as string) || '',
|
||||
);
|
||||
|
||||
if (
|
||||
blacklist.includes(req.query.path as string) ||
|
||||
!finalPath.startsWith(config.logPath)
|
||||
) {
|
||||
return res.send({ code: 403, message: '暂无权限' });
|
||||
if (!finalPath || blacklist.includes(req.query.path as string)) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
const logPath = `${req.query.path as string}/${req.query.file as string}`;
|
||||
const runningInstance = await RunningInstanceModel.findOne({
|
||||
where: { log_path: logPath, status: InstanceStatus.running },
|
||||
});
|
||||
|
||||
const content = await getFileContentByName(finalPath);
|
||||
res.send({ code: 200, data: removeAnsi(content) });
|
||||
res.send({
|
||||
code: 200,
|
||||
data: removeAnsi(content),
|
||||
logStatus: runningInstance ? 'running' : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -53,16 +68,16 @@ export default (app: Router) => {
|
||||
'/:file',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const finalPath = resolve(
|
||||
config.logPath,
|
||||
const logService = Container.get(LogService);
|
||||
const finalPath = logService.checkFilePath(
|
||||
(req.query.path as string) || '',
|
||||
(req.params.file as string) || '',
|
||||
);
|
||||
if (
|
||||
blacklist.includes(req.path) ||
|
||||
!finalPath.startsWith(config.logPath)
|
||||
) {
|
||||
return res.send({ code: 403, message: '暂无权限' });
|
||||
if (!finalPath || blacklist.includes(req.query.path as string)) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
const content = await getFileContentByName(finalPath);
|
||||
res.send({ code: 200, data: content });
|
||||
@@ -83,17 +98,56 @@ export default (app: Router) => {
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let { filename, path, type } = req.body as {
|
||||
let { filename, path } = req.body as {
|
||||
filename: string;
|
||||
path: string;
|
||||
type: string;
|
||||
};
|
||||
const filePath = join(config.logPath, path, filename);
|
||||
await rmPath(filePath);
|
||||
const logService = Container.get(LogService);
|
||||
const finalPath = logService.checkFilePath(path, filename);
|
||||
if (!finalPath || blacklist.includes(path)) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await rmPath(finalPath);
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/download',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().allow(''),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let { filename, path } = req.body as {
|
||||
filename: string;
|
||||
path: string;
|
||||
};
|
||||
const logService = Container.get(LogService);
|
||||
const filePath = logService.checkFilePath(path, filename);
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
return res.download(filePath, filename, (err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
+119
-37
@@ -1,4 +1,4 @@
|
||||
import { fileExist, readDirs, readDir, rmPath } from '../config/util';
|
||||
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
@@ -7,10 +7,16 @@ import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import path, { join, parse } from 'path';
|
||||
import ScriptService from '../services/script';
|
||||
import { t } from '../shared/i18n';
|
||||
import multer from 'multer';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
const route = Router();
|
||||
|
||||
function isPathAllowed(targetPath: string): boolean {
|
||||
const resolved = path.resolve(targetPath);
|
||||
return config.writePathList.some((x) => resolved.startsWith(x));
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.scriptPath);
|
||||
@@ -24,10 +30,17 @@ const upload = multer({ storage: storage });
|
||||
export default (app: Router) => {
|
||||
app.use('/scripts', route);
|
||||
|
||||
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
route.get(
|
||||
'/',
|
||||
celebrate({
|
||||
query: Joi.object({
|
||||
path: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
let result = [];
|
||||
let result: IFile[] = [];
|
||||
const blacklist = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
@@ -37,11 +50,11 @@ export default (app: Router) => {
|
||||
'package-lock.json',
|
||||
];
|
||||
if (req.query.path) {
|
||||
const targetPath = path.join(
|
||||
config.scriptPath,
|
||||
result = await readDir(
|
||||
req.query.path as string,
|
||||
config.scriptPath,
|
||||
blacklist,
|
||||
);
|
||||
result = await readDir(targetPath, config.scriptPath, blacklist);
|
||||
} else {
|
||||
result = await readDirs(
|
||||
config.scriptPath,
|
||||
@@ -68,11 +81,17 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/detail',
|
||||
celebrate({
|
||||
query: Joi.object({
|
||||
path: Joi.string().optional().allow(''),
|
||||
file: Joi.string().required(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const content = await scriptService.getFile(
|
||||
req.query.path as string,
|
||||
req.query?.path as string || '',
|
||||
req.query.file as string,
|
||||
);
|
||||
res.send({ code: 200, data: content });
|
||||
@@ -84,11 +103,19 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
celebrate({
|
||||
params: Joi.object({
|
||||
file: Joi.string().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
path: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const content = await scriptService.getFile(
|
||||
req.query.path as string,
|
||||
req.query?.path as string || '',
|
||||
req.params.file,
|
||||
);
|
||||
res.send({ code: 200, data: content });
|
||||
@@ -101,8 +128,17 @@ export default (app: Router) => {
|
||||
route.post(
|
||||
'/',
|
||||
upload.single('file'),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().optional().allow(''),
|
||||
originFilename: Joi.string().optional().allow(''),
|
||||
directory: Joi.string().optional().allow(''),
|
||||
file: Joi.string().optional().allow(''),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
let { filename, path, content, originFilename, directory } =
|
||||
req.body as {
|
||||
@@ -124,29 +160,38 @@ export default (app: Router) => {
|
||||
}
|
||||
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
||||
return res.send({
|
||||
code: 430,
|
||||
message: '文件路径禁止访问',
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.file) {
|
||||
await fs.rename(req.file.path, join(path, filename));
|
||||
const uploadPath = join(path, filename);
|
||||
if (!isPathAllowed(uploadPath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await fs.rename(req.file.path, uploadPath);
|
||||
return res.send({ code: 200 });
|
||||
}
|
||||
|
||||
if (directory) {
|
||||
await fs.mkdir(join(path, directory), { recursive: true });
|
||||
const dirPath = join(path, directory);
|
||||
if (!isPathAllowed(dirPath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
return res.send({ code: 200 });
|
||||
}
|
||||
|
||||
if (!originFilename) {
|
||||
originFilename = filename;
|
||||
}
|
||||
const originFilePath = join(
|
||||
path,
|
||||
`${originFilename.replace(/\//g, '')}`,
|
||||
);
|
||||
const filePath = join(path, `${filename.replace(/\//g, '')}`);
|
||||
const originFilePath = join(path, originFilename);
|
||||
const filePath = join(path, filename);
|
||||
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
const fileExists = await fileExist(filePath);
|
||||
if (fileExists) {
|
||||
await fs.copyFile(
|
||||
@@ -175,14 +220,20 @@ export default (app: Router) => {
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
let { filename, content, path } = req.body as {
|
||||
filename: string;
|
||||
content: string;
|
||||
path: string;
|
||||
};
|
||||
const filePath = join(config.scriptPath, path, filename);
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const filePath = scriptService.checkFilePath(path, filename);
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
@@ -196,19 +247,27 @@ export default (app: Router) => {
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().allow(''),
|
||||
path: Joi.string().optional().allow(''),
|
||||
type: Joi.string().optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
let { filename, path, type } = req.body as {
|
||||
let { filename, path } = req.body as {
|
||||
filename: string;
|
||||
path: string;
|
||||
type: string;
|
||||
};
|
||||
const filePath = join(config.scriptPath, path, filename);
|
||||
if (!path) {
|
||||
path = '';
|
||||
}
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const filePath = scriptService.checkFilePath(path, filename);
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await rmPath(filePath);
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
@@ -222,24 +281,30 @@ export default (app: Router) => {
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
let { filename } = req.body as {
|
||||
let { filename, path } = req.body as {
|
||||
filename: string;
|
||||
path: string;
|
||||
};
|
||||
const filePath = join(config.scriptPath, filename);
|
||||
// const stats = fs.statSync(filePath);
|
||||
// res.set({
|
||||
// 'Content-Type': 'application/octet-stream', //告诉浏览器这是一个二进制文件
|
||||
// 'Content-Disposition': 'attachment; filename=' + filename, //告诉浏览器这是一个需要下载的文件
|
||||
// 'Content-Length': stats.size //文件大小
|
||||
// });
|
||||
// fs.createReadStream(filePath).pipe(res);
|
||||
if (!path) {
|
||||
path = '';
|
||||
}
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const filePath = scriptService.checkFilePath(path, filename);
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
return res.download(filePath, filename, (err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -260,8 +325,14 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
let { filename, content, path } = req.body;
|
||||
if (!path) {
|
||||
path = '';
|
||||
}
|
||||
const { name, ext } = parse(filename);
|
||||
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await writeFileWithLock(filePath, content || '');
|
||||
|
||||
const scriptService = Container.get(ScriptService);
|
||||
@@ -285,8 +356,14 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let { filename, path, pid } = req.body;
|
||||
if (!path) {
|
||||
path = '';
|
||||
}
|
||||
const { name, ext } = parse(filename);
|
||||
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
const logPath = join(config.logPath, path, `${name}.swap`);
|
||||
|
||||
const scriptService = Container.get(ScriptService);
|
||||
@@ -312,14 +389,19 @@ export default (app: Router) => {
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let { filename, path, type, newFilename } = req.body as {
|
||||
let { filename, path, newFilename } = req.body as {
|
||||
filename: string;
|
||||
path: string;
|
||||
type: string;
|
||||
newFilename: string;
|
||||
};
|
||||
if (!path) {
|
||||
path = '';
|
||||
}
|
||||
const filePath = join(config.scriptPath, path, filename);
|
||||
const newPath = join(config.scriptPath, path, newFilename);
|
||||
if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await fs.rename(filePath, newPath);
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import SubscriptionService from '../services/subscription';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import cron_parser from 'cron-parser';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -60,7 +60,7 @@ export default (app: Router) => {
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
cron_parser.parseExpression(req.body.schedule).hasNext()
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.create(req.body);
|
||||
@@ -193,7 +193,7 @@ export default (app: Router) => {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
typeof req.body.schedule === 'object' ||
|
||||
cron_parser.parseExpression(req.body.schedule).hasNext()
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.update(req.body);
|
||||
|
||||
+95
-15
@@ -6,6 +6,8 @@ import config from '../config';
|
||||
import SystemService from '../services/system';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import UserService from '../services/user';
|
||||
import { t } from '../shared/i18n';
|
||||
import { isDefaultAuthInfo } from '../shared/auth';
|
||||
import {
|
||||
getUniqPath,
|
||||
handleLogPath,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
} from '../config/util';
|
||||
import dayjs from 'dayjs';
|
||||
import multer from 'multer';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
|
||||
const route = Router();
|
||||
const storage = multer.diskStorage({
|
||||
@@ -37,14 +40,7 @@ export default (app: Router) => {
|
||||
const { version, changeLog, changeLogLink, publishTime } =
|
||||
await parseVersion(config.versionFile);
|
||||
|
||||
let isInitialized = true;
|
||||
if (
|
||||
Object.keys(authInfo).length === 2 &&
|
||||
authInfo.username === 'admin' &&
|
||||
authInfo.password === 'admin'
|
||||
) {
|
||||
isInitialized = false;
|
||||
}
|
||||
const isInitialized = !isDefaultAuthInfo(authInfo);
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
@@ -273,19 +269,22 @@ export default (app: Router) => {
|
||||
{
|
||||
onStart: async (cp, startTime) => {
|
||||
res.setHeader('QL-Task-Pid', `${cp.pid}`);
|
||||
res.setHeader('QL-Task-Log', `${logPath}`);
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
res.end();
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
res.write(`\n${message}`);
|
||||
res.write(message);
|
||||
const absolutePath = await handleLogPath(logPath);
|
||||
await fs.appendFile(absolutePath, `\n${message}`);
|
||||
await logStreamManager.write(absolutePath, message);
|
||||
},
|
||||
onLog: async (message: string) => {
|
||||
res.write(`\n${message}`);
|
||||
res.write(message);
|
||||
const absolutePath = await handleLogPath(logPath);
|
||||
await fs.appendFile(absolutePath, `\n${message}`);
|
||||
await logStreamManager.write(absolutePath, message);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -316,10 +315,15 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/data/export',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
type: Joi.array().items(Joi.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
await systemService.exportData(res);
|
||||
await systemService.exportData(res, req.body.type);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -385,13 +389,17 @@ export default (app: Router) => {
|
||||
retries: Joi.number().optional(),
|
||||
twoFactorActivated: Joi.boolean().optional(),
|
||||
password: Joi.string().optional(),
|
||||
username: Joi.string().optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
await userService.resetAuthInfo(req.body);
|
||||
res.send({ code: 200, message: '更新成功' });
|
||||
const result = await userService.resetAuthInfo(req.body);
|
||||
if (result) {
|
||||
return res.send(result);
|
||||
}
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -415,4 +423,76 @@ export default (app: Router) => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/config/lang',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
lang: Joi.string().allow('').allow(null),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.updateLanguage(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/config/panel-title',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
panelTitle: Joi.string().max(100).allow('').allow(null),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.updatePanelTitle(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/config/global-ssh-key',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
globalSshKey: Joi.string().allow('').allow(null),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.updateGlobalSshKey(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/config/dependence-clean',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
type: Joi.string().allow(''),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.cleanDependence(req.body.type);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import Container from 'typedi';
|
||||
import Logger from '../loaders/logger';
|
||||
import SystemService from '../services/system';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/update', route);
|
||||
|
||||
route.put(
|
||||
'/reload',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.reloadSystem();
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
Logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/system',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.reloadSystem('system');
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
Logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/data',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.reloadSystem('data');
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
Logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+10
-8
@@ -8,6 +8,8 @@ import path from 'path';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import config from '../config';
|
||||
import { t } from '../shared/i18n';
|
||||
import { isDemoEnv, getToken } from '../config/util';
|
||||
const route = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
@@ -55,7 +57,8 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
await userService.logout(req.platform);
|
||||
const token = getToken(req);
|
||||
await userService.logout(req.platform, token);
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -72,14 +75,13 @@ export default (app: Router) => {
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (process.env.DeployEnv === 'demo') {
|
||||
return res.send({ code: 450, message: '未知错误' });
|
||||
if (isDemoEnv()) {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
}
|
||||
const userService = Container.get(UserService);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: '更新成功' });
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -139,12 +141,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);
|
||||
@@ -228,7 +230,7 @@ export default (app: Router) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: '更新成功' });
|
||||
res.send({ code: 200, message: t('更新成功') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
+295
-18
@@ -1,31 +1,308 @@
|
||||
import 'reflect-metadata'; // We need this in order to use @Decorators
|
||||
import config from './config';
|
||||
import 'reflect-metadata';
|
||||
import cluster, { type Worker } from 'cluster';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { Container } from 'typedi';
|
||||
import config from './config';
|
||||
import Logger from './loaders/logger';
|
||||
import { monitoringMiddleware } from './middlewares/monitoring';
|
||||
import { errStack } from './config/util';
|
||||
import { type GrpcServerService } from './services/grpc';
|
||||
import { type HttpServerService } from './services/http';
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
interface WorkerMetadata {
|
||||
id: number;
|
||||
pid: number;
|
||||
serviceType: string;
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
await require('./loaders/db').default();
|
||||
class Application {
|
||||
private app: express.Application;
|
||||
private httpServerService?: HttpServerService;
|
||||
private grpcServerService?: GrpcServerService;
|
||||
private isShuttingDown = false;
|
||||
private workerMetadataMap = new Map<number, WorkerMetadata>();
|
||||
private httpWorker?: Worker;
|
||||
|
||||
await require('./loaders/initFile').default();
|
||||
constructor() {
|
||||
this.app = express();
|
||||
// 创建一个全局中间件,删除查询参数中的t
|
||||
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (req.query.t) {
|
||||
delete req.query.t;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
await require('./loaders/app').default({ expressApp: app });
|
||||
async start() {
|
||||
try {
|
||||
if (cluster.isPrimary) {
|
||||
await this.initializeDatabase();
|
||||
}
|
||||
if (cluster.isPrimary) {
|
||||
this.startMasterProcess();
|
||||
} else {
|
||||
await this.startWorkerProcess();
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to start application:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const server = app
|
||||
.listen(config.port, '0.0.0.0', () => {
|
||||
Logger.debug(`✌️ 后端服务启动成功!`);
|
||||
console.debug(`✌️ 后端服务启动成功!`);
|
||||
process.send?.('ready');
|
||||
require('./loaders/bootAfter').default();
|
||||
private startMasterProcess() {
|
||||
// Fork gRPC worker first and wait for it to be ready
|
||||
const grpcWorker = this.forkWorker('grpc');
|
||||
|
||||
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||
this.waitForWorkerReady(grpcWorker, 30000)
|
||||
.then(() => {
|
||||
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||
this.httpWorker = this.forkWorker('http');
|
||||
})
|
||||
.on('error', (err) => {
|
||||
Logger.error(err);
|
||||
console.error(err);
|
||||
.catch((error) => {
|
||||
Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await require('./loaders/server').default({ server });
|
||||
cluster.on('exit', (worker, code, signal) => {
|
||||
const metadata = this.workerMetadataMap.get(worker.id);
|
||||
if (metadata) {
|
||||
if (!this.isShuttingDown) {
|
||||
Logger.error(
|
||||
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
|
||||
}). Restarting...`,
|
||||
);
|
||||
// If gRPC worker died, restart it and wait for it to be ready
|
||||
if (metadata.serviceType === 'grpc') {
|
||||
const newGrpcWorker = this.forkWorker('grpc');
|
||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||
.then(() => {
|
||||
Logger.info('gRPC worker restarted and ready');
|
||||
// Re-register cron jobs by notifying the HTTP worker
|
||||
if (this.httpWorker) {
|
||||
try {
|
||||
this.httpWorker.send('reregister-crons');
|
||||
Logger.info('Sent reregister-crons message to HTTP worker');
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
// For HTTP worker, just restart it
|
||||
const newWorker = this.forkWorker(metadata.serviceType);
|
||||
this.httpWorker = newWorker;
|
||||
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
this.workerMetadataMap.delete(worker.id);
|
||||
}
|
||||
});
|
||||
|
||||
this.setupMasterShutdown();
|
||||
}
|
||||
|
||||
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const messageHandler = (msg: any) => {
|
||||
if (msg === 'ready') {
|
||||
worker.removeListener('message', messageHandler);
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
worker.on('message', messageHandler);
|
||||
|
||||
// Timeout after specified milliseconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
worker.removeListener('message', messageHandler);
|
||||
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
private forkWorker(serviceType: string): Worker {
|
||||
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
|
||||
|
||||
this.workerMetadataMap.set(worker.id, {
|
||||
id: worker.id,
|
||||
pid: worker.process.pid!,
|
||||
serviceType,
|
||||
startTime: new Date(),
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
private async initializeDatabase() {
|
||||
const dbLoader = await import('./loaders/db');
|
||||
await dbLoader.default();
|
||||
}
|
||||
|
||||
private setupMiddlewares() {
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}));
|
||||
this.app.use(cors(config.cors));
|
||||
this.app.use(compression());
|
||||
this.app.use(monitoringMiddleware);
|
||||
}
|
||||
|
||||
private setupMasterShutdown() {
|
||||
const shutdown = async () => {
|
||||
if (this.isShuttingDown) return;
|
||||
this.isShuttingDown = true;
|
||||
|
||||
const workers = Object.values(cluster.workers || {});
|
||||
const workerPromises: Promise<void>[] = [];
|
||||
|
||||
workers.forEach((worker) => {
|
||||
if (worker) {
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
worker.once('exit', () => {
|
||||
Logger.info(`Worker ${worker.process.pid} exited`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
try {
|
||||
worker.send('shutdown');
|
||||
} catch (error) {
|
||||
Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
|
||||
}
|
||||
});
|
||||
|
||||
workerPromises.push(exitPromise);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.all(workerPromises),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
Logger.warn('Worker shutdown timeout reached');
|
||||
resolve();
|
||||
}, 10000);
|
||||
}),
|
||||
]);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
private async startWorkerProcess() {
|
||||
const serviceType = process.env.SERVICE_TYPE;
|
||||
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
|
||||
Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
|
||||
|
||||
try {
|
||||
if (serviceType === 'http') {
|
||||
await this.startHttpService();
|
||||
} else {
|
||||
await this.startGrpcService();
|
||||
}
|
||||
|
||||
process.send?.('ready');
|
||||
} catch (error) {
|
||||
Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async startHttpService() {
|
||||
// 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
|
||||
const { initGrpcCerts } = await import('./config/grpcCerts');
|
||||
await initGrpcCerts();
|
||||
|
||||
this.setupMiddlewares();
|
||||
|
||||
const { HttpServerService } = await import('./services/http');
|
||||
this.httpServerService = Container.get(HttpServerService);
|
||||
|
||||
const appLoader = await import('./loaders/app');
|
||||
await appLoader.default({ app: this.app });
|
||||
|
||||
const server = await this.httpServerService.initialize(
|
||||
this.app,
|
||||
config.port,
|
||||
);
|
||||
|
||||
const serverLoader = await import('./loaders/server');
|
||||
await (serverLoader.default as any)({ server });
|
||||
this.setupWorkerShutdown('http');
|
||||
}
|
||||
|
||||
private async startGrpcService() {
|
||||
const { GrpcServerService } = await import('./services/grpc');
|
||||
this.grpcServerService = Container.get(GrpcServerService);
|
||||
|
||||
await this.grpcServerService.initialize();
|
||||
this.setupWorkerShutdown('grpc');
|
||||
}
|
||||
|
||||
private setupWorkerShutdown(serviceType: string) {
|
||||
process.on('message', async (msg) => {
|
||||
if (msg === 'shutdown') {
|
||||
this.gracefulShutdown(serviceType);
|
||||
} else if (msg === 'reregister-crons' && serviceType === 'http') {
|
||||
// Re-register cron jobs when gRPC worker restarts
|
||||
try {
|
||||
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
|
||||
const CronService = (await import('./services/cron')).default;
|
||||
const cronService = Container.get(CronService);
|
||||
await cronService.autosave_crontab();
|
||||
Logger.info('[boot] Cron jobs re-registered successfully');
|
||||
} catch (error) {
|
||||
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const shutdown = () => this.gracefulShutdown(serviceType);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
private async gracefulShutdown(serviceType: string) {
|
||||
if (this.isShuttingDown) return;
|
||||
this.isShuttingDown = true;
|
||||
|
||||
try {
|
||||
if (serviceType === 'http') {
|
||||
await this.httpServerService?.shutdown();
|
||||
} else {
|
||||
await this.grpcServerService?.shutdown();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Application();
|
||||
app.start().catch((error) => {
|
||||
Logger.error(`🙅♀️ Application failed to start:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { maybeSudo } from './container';
|
||||
|
||||
export const LOG_END_SYMBOL = ' ';
|
||||
|
||||
export const TASK_COMMAND = 'task';
|
||||
@@ -23,3 +25,65 @@ export const SAMPLE_FILES = [
|
||||
target: 'data/scripts/notify.py',
|
||||
},
|
||||
];
|
||||
|
||||
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
|
||||
|
||||
export const NotificationModeStringMap = {
|
||||
0: 'gotify',
|
||||
1: 'goCqHttpBot',
|
||||
2: 'serverChan',
|
||||
3: 'pushDeer',
|
||||
4: 'bark',
|
||||
5: 'chat',
|
||||
6: 'telegramBot',
|
||||
7: 'dingtalkBot',
|
||||
8: 'weWorkBot',
|
||||
9: 'weWorkApp',
|
||||
10: 'aibotk',
|
||||
11: 'iGot',
|
||||
12: 'pushPlus',
|
||||
13: 'wePlusBot',
|
||||
14: 'email',
|
||||
15: 'pushMe',
|
||||
16: 'feishu',
|
||||
17: 'webhook',
|
||||
18: 'chronocat',
|
||||
19: 'ntfy',
|
||||
20: 'wxPusherBot',
|
||||
21: 'wxPusherSpt',
|
||||
} as const;
|
||||
|
||||
export const LINUX_DEPENDENCE_COMMAND: Record<
|
||||
'Debian' | 'Ubuntu' | 'Alpine',
|
||||
{
|
||||
install: string;
|
||||
uninstall: string;
|
||||
info: string;
|
||||
check(info: string): boolean;
|
||||
}
|
||||
> = {
|
||||
Debian: {
|
||||
install: maybeSudo('apt-get install -y'),
|
||||
uninstall: maybeSudo('apt-get remove -y'),
|
||||
info: maybeSudo('dpkg-query -s'),
|
||||
check(info: string) {
|
||||
return info.includes('install ok installed');
|
||||
},
|
||||
},
|
||||
Ubuntu: {
|
||||
install: maybeSudo('apt-get install -y'),
|
||||
uninstall: maybeSudo('apt-get remove -y'),
|
||||
info: maybeSudo('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');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function isInContainer(): boolean {
|
||||
return process.env.QL_CONTAINER === 'true';
|
||||
}
|
||||
|
||||
export function maybeSudo(cmd: string): string {
|
||||
return isInContainer() ? `sudo ${cmd}` : cmd;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import path from 'path';
|
||||
import config from './index';
|
||||
import { fileExist } from './util';
|
||||
import Logger from '../loaders/logger';
|
||||
|
||||
export interface GrpcTlsConfig {
|
||||
caCert: string;
|
||||
serverCert: string;
|
||||
serverKey: string;
|
||||
clientCert: string;
|
||||
clientKey: string;
|
||||
}
|
||||
|
||||
const certDir = path.join(config.configPath, 'grpc');
|
||||
const caKeyPath = path.join(certDir, 'ca.key');
|
||||
const caCertPath = path.join(certDir, 'ca.crt');
|
||||
const serverKeyPath = path.join(certDir, 'server.key');
|
||||
const serverCertPath = path.join(certDir, 'server.crt');
|
||||
const clientKeyPath = path.join(certDir, 'client.key');
|
||||
const clientCertPath = path.join(certDir, 'client.crt');
|
||||
|
||||
let cachedConfig: GrpcTlsConfig | null = null;
|
||||
|
||||
function run(cmd: string, execOpts?: Record<string, unknown>): string {
|
||||
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
|
||||
return (execSync(cmd, opts) as string).trim();
|
||||
}
|
||||
|
||||
async function tmpFile(prefix: string): Promise<string> {
|
||||
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
|
||||
}
|
||||
|
||||
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
||||
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
||||
|
||||
const caKeyTmp = await tmpFile('ca_key');
|
||||
const caCertTmp = await tmpFile('ca_cert');
|
||||
const serverKeyTmp = await tmpFile('server_key');
|
||||
const serverCsrTmp = await tmpFile('server_csr');
|
||||
const serverExtTmp = await tmpFile('server_ext');
|
||||
const clientKeyTmp = await tmpFile('client_key');
|
||||
const clientCsrTmp = await tmpFile('client_csr');
|
||||
const clientExtTmp = await tmpFile('client_ext');
|
||||
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
|
||||
|
||||
const cleanup = async () => {
|
||||
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
|
||||
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
|
||||
try { await fs.unlink(f); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
|
||||
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
|
||||
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
|
||||
const caCert = await fs.readFile(caCertTmp, 'utf-8');
|
||||
await fs.mkdir(certDir, { recursive: true });
|
||||
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
|
||||
|
||||
// 2. 服务端
|
||||
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
|
||||
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
|
||||
const serverCert = run(
|
||||
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
|
||||
);
|
||||
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
|
||||
|
||||
// 3. 客户端
|
||||
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
|
||||
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
|
||||
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
|
||||
const clientCert = run(
|
||||
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
|
||||
);
|
||||
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
||||
|
||||
await cleanup();
|
||||
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
||||
|
||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||
} catch (e) {
|
||||
await cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCerts(tlsConfig: GrpcTlsConfig): Promise<void> {
|
||||
await fs.mkdir(certDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(caCertPath, tlsConfig.caCert, { mode: 0o644 });
|
||||
await fs.writeFile(serverCertPath, tlsConfig.serverCert, { mode: 0o644 });
|
||||
await fs.writeFile(serverKeyPath, tlsConfig.serverKey, { mode: 0o600 });
|
||||
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
|
||||
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
|
||||
|
||||
Logger.info(`[boot] gRPC mTLS certificates saved to ${certDir}`);
|
||||
}
|
||||
|
||||
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
|
||||
const exists = await Promise.all([
|
||||
fileExist(caCertPath),
|
||||
fileExist(serverCertPath),
|
||||
fileExist(serverKeyPath),
|
||||
fileExist(clientCertPath),
|
||||
fileExist(clientKeyPath),
|
||||
]);
|
||||
|
||||
if (exists.some((e) => !e)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [caCert, serverCert, serverKey, clientCert, clientKey] = await Promise.all([
|
||||
fs.readFile(caCertPath, 'utf-8'),
|
||||
fs.readFile(serverCertPath, 'utf-8'),
|
||||
fs.readFile(serverKeyPath, 'utf-8'),
|
||||
fs.readFile(clientCertPath, 'utf-8'),
|
||||
fs.readFile(clientKeyPath, 'utf-8'),
|
||||
]);
|
||||
|
||||
Logger.info('[boot] Loaded existing gRPC mTLS certificates from disk');
|
||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||
}
|
||||
|
||||
export async function initGrpcCerts(): Promise<GrpcTlsConfig> {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
let tlsConfig = await loadExistingCerts();
|
||||
|
||||
if (!tlsConfig) {
|
||||
tlsConfig = await generateAllCerts();
|
||||
await saveCerts(tlsConfig);
|
||||
}
|
||||
|
||||
cachedConfig = tlsConfig;
|
||||
return tlsConfig;
|
||||
}
|
||||
|
||||
export function getGrpcCerts(): GrpcTlsConfig | null {
|
||||
return cachedConfig;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { request as undiciRequest, Dispatcher } from 'undici';
|
||||
|
||||
type RequestBaseOptions = {
|
||||
dispatcher?: Dispatcher;
|
||||
json?: Record<string, any>;
|
||||
form?: string;
|
||||
headers?: Record<string, string>;
|
||||
} & Omit<Dispatcher.RequestOptions<null>, 'origin' | 'path' | 'method'>;
|
||||
|
||||
type RequestOptionsWithOptions = RequestBaseOptions &
|
||||
Partial<Pick<Dispatcher.RequestOptions, 'method'>>;
|
||||
|
||||
type ResponseTypeMap = {
|
||||
json: Record<string, any>;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ResponseTypeKey = keyof ResponseTypeMap;
|
||||
|
||||
async function request(
|
||||
url: string,
|
||||
options?: RequestOptionsWithOptions,
|
||||
): Promise<Dispatcher.ResponseData<null>> {
|
||||
const { json, form, body, headers = {}, ...rest } = options || {};
|
||||
const finalHeaders = { ...headers } as Record<string, string>;
|
||||
let finalBody = body;
|
||||
|
||||
if (json) {
|
||||
finalHeaders['content-type'] = 'application/json';
|
||||
finalBody = JSON.stringify(json);
|
||||
} else if (form) {
|
||||
finalBody = form;
|
||||
delete finalHeaders['content-type'];
|
||||
}
|
||||
|
||||
const res = await undiciRequest(url, {
|
||||
method: 'POST',
|
||||
headers: finalHeaders,
|
||||
body: finalBody,
|
||||
...rest,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function post<T extends ResponseTypeKey = 'json'>(
|
||||
url: string,
|
||||
options?: RequestBaseOptions & { responseType?: T },
|
||||
): Promise<ResponseTypeMap[T]> {
|
||||
const resp = await request(url, { ...options, method: 'POST' });
|
||||
|
||||
const rawText = await resp.body.text();
|
||||
|
||||
if (options?.responseType === 'text') {
|
||||
return rawText as ResponseTypeMap[T];
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawText) as ResponseTypeMap[T];
|
||||
} catch {
|
||||
return rawText as ResponseTypeMap[T];
|
||||
}
|
||||
}
|
||||
|
||||
export const httpClient = {
|
||||
post,
|
||||
request,
|
||||
};
|
||||
+78
-18
@@ -1,13 +1,64 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { createRandomString } from './share';
|
||||
|
||||
dotenv.config({
|
||||
path: path.join(__dirname, '../../.env'),
|
||||
});
|
||||
|
||||
interface Config {
|
||||
port: number;
|
||||
grpcPort: number;
|
||||
bindHost: string;
|
||||
bindHostGrpc: string;
|
||||
nodeEnv: string;
|
||||
isDevelopment: boolean;
|
||||
isProduction: boolean;
|
||||
jwt: {
|
||||
secret: string;
|
||||
expiresIn?: string;
|
||||
};
|
||||
cors: {
|
||||
origin: string[];
|
||||
methods: string[];
|
||||
};
|
||||
logs: {
|
||||
level: string;
|
||||
};
|
||||
api: {
|
||||
prefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
logs: {
|
||||
level: process.env.LOG_LEVEL || 'silly',
|
||||
},
|
||||
api: {
|
||||
prefix: '/api',
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'whyour-secret',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN,
|
||||
},
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN
|
||||
? process.env.CORS_ORIGIN.split(',')
|
||||
: ['*'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
},
|
||||
};
|
||||
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
if (!process.env.QL_DIR) {
|
||||
// 声明QL_DIR环境变量
|
||||
let qlHomePath = path.join(__dirname, '../../');
|
||||
// 生产环境
|
||||
if (qlHomePath.endsWith('/static/')) {
|
||||
qlHomePath = path.join(qlHomePath, '../');
|
||||
}
|
||||
@@ -16,6 +67,19 @@ if (!process.env.QL_DIR) {
|
||||
|
||||
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
|
||||
|
||||
// Get and normalize QlBaseUrl
|
||||
let baseUrl = process.env.QlBaseUrl || '';
|
||||
if (baseUrl) {
|
||||
// Ensure it starts with /
|
||||
if (!baseUrl.startsWith('/')) {
|
||||
baseUrl = `/${baseUrl}`;
|
||||
}
|
||||
// Remove trailing slash for consistency in route definitions
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
const rootPath = process.env.QL_DIR as string;
|
||||
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
|
||||
|
||||
@@ -38,12 +102,14 @@ const dbPath = path.join(dataPath, 'db/');
|
||||
const uploadPath = path.join(dataPath, 'upload/');
|
||||
const sshdPath = path.join(dataPath, 'ssh.d/');
|
||||
const systemLogPath = path.join(dataPath, 'syslog/');
|
||||
const dependenceCachePath = path.join(dataPath, 'dep_cache/');
|
||||
|
||||
const envFile = path.join(preloadPath, 'env.sh');
|
||||
const jsEnvFile = path.join(preloadPath, 'env.js');
|
||||
const pyEnvFile = path.join(preloadPath, 'env.py');
|
||||
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
|
||||
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
|
||||
const langEnvFile = path.join(preloadPath, 'lang_env.sh');
|
||||
const confFile = path.join(configPath, 'config.sh');
|
||||
const crontabFile = path.join(configPath, 'crontab.list');
|
||||
const authConfigFile = path.join(configPath, 'auth.json');
|
||||
@@ -52,8 +118,6 @@ const confBakDir = path.join(dataPath, 'config/bak/');
|
||||
const sampleFile = path.join(samplePath, 'config.sample.sh');
|
||||
const sqliteFile = path.join(samplePath, 'database.sqlite');
|
||||
|
||||
const authError = '错误的用户名密码,请重试';
|
||||
const loginFaild = '请先登录!';
|
||||
const configString = 'config sample crontab shareCode diy';
|
||||
const versionFile = path.join(rootPath, 'version.yaml');
|
||||
const dataTgzFile = path.join(tmpPath, 'data.tgz');
|
||||
@@ -65,17 +129,9 @@ if (envFound.error) {
|
||||
}
|
||||
|
||||
export default {
|
||||
port: parseInt(process.env.BACK_PORT as string, 10),
|
||||
cronPort: parseInt(process.env.CRON_PORT as string, 10),
|
||||
publicPort: parseInt(process.env.PUBLIC_PORT as string, 10),
|
||||
updatePort: parseInt(process.env.UPDATE_PORT as string, 10),
|
||||
secret: process.env.SECRET || createRandomString(16, 32),
|
||||
logs: {
|
||||
level: process.env.LOG_LEVEL || 'silly',
|
||||
},
|
||||
api: {
|
||||
prefix: '/api',
|
||||
},
|
||||
...config,
|
||||
jwt: config.jwt,
|
||||
baseUrl,
|
||||
rootPath,
|
||||
tmpPath,
|
||||
dataPath,
|
||||
@@ -83,8 +139,6 @@ export default {
|
||||
shareShellFile,
|
||||
dependenceProxyFile,
|
||||
configString,
|
||||
loginFaild,
|
||||
authError,
|
||||
logPath,
|
||||
extraFile,
|
||||
authConfigFile,
|
||||
@@ -97,6 +151,7 @@ export default {
|
||||
pyEnvFile,
|
||||
jsNotifyFile,
|
||||
pyNotifyFile,
|
||||
langEnvFile,
|
||||
dbPath,
|
||||
uploadPath,
|
||||
configPath,
|
||||
@@ -113,11 +168,14 @@ export default {
|
||||
'env.js',
|
||||
'env.py',
|
||||
'token.json',
|
||||
'grpc',
|
||||
'__pycache__',
|
||||
],
|
||||
writePathList: [configPath, scriptPath],
|
||||
bakPath,
|
||||
apiWhiteList: [
|
||||
'/api/user/login',
|
||||
'/api/health',
|
||||
'/open/auth/token',
|
||||
'/api/user/two-factor/login',
|
||||
'/api/system',
|
||||
@@ -134,4 +192,6 @@ export default {
|
||||
sqliteFile,
|
||||
sshdPath,
|
||||
systemLogPath,
|
||||
dependenceCachePath,
|
||||
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
|
||||
};
|
||||
|
||||
+319
-99
@@ -1,19 +1,48 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import got from 'got';
|
||||
import iconv from 'iconv-lite';
|
||||
import { exec } from 'child_process';
|
||||
import FormData from 'form-data';
|
||||
import psTreeFun from 'pstree.remy';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import psTreeFun from 'ps-tree';
|
||||
import { promisify } from 'util';
|
||||
import { load } from 'js-yaml';
|
||||
import config from './index';
|
||||
import { TASK_COMMAND } from './const';
|
||||
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
|
||||
import Logger from '../loaders/logger';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import { DependenceTypes } from '../data/dependence';
|
||||
import { FormData } from 'undici';
|
||||
import os from 'os';
|
||||
import { maybeSudo, isInContainer } from './container';
|
||||
|
||||
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) {
|
||||
@@ -57,78 +86,6 @@ export function getToken(req: any) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function getNetIp(req: any) {
|
||||
const ipArray = [
|
||||
...new Set([
|
||||
...(req.headers['x-real-ip'] || '').split(','),
|
||||
...(req.headers['x-forwarded-for'] || '').split(','),
|
||||
req.ip,
|
||||
...req.ips,
|
||||
req.socket.remoteAddress,
|
||||
]),
|
||||
].filter(Boolean);
|
||||
|
||||
let ip = ipArray[0];
|
||||
|
||||
if (ipArray.length > 1) {
|
||||
for (let i = 0; i < ipArray.length; i++) {
|
||||
const ipNumArray = ipArray[i].split('.');
|
||||
const tmp = ipNumArray[0] + '.' + ipNumArray[1];
|
||||
if (
|
||||
tmp === '192.168' ||
|
||||
(ipNumArray[0] === '172' &&
|
||||
ipNumArray[1] >= 16 &&
|
||||
ipNumArray[1] <= 32) ||
|
||||
tmp === '10.7' ||
|
||||
tmp === '127.0'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ip = ipArray[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ip = ip.substr(ip.lastIndexOf(':') + 1, ip.length);
|
||||
if (ip.includes('127.0') || ip.includes('192.168') || ip.includes('10.7')) {
|
||||
ip = '';
|
||||
}
|
||||
|
||||
if (!ip) {
|
||||
return { address: `获取失败`, ip };
|
||||
}
|
||||
|
||||
try {
|
||||
const csdnApi = got
|
||||
.get(`https://searchplugin.csdn.net/api/v1/ip/get?ip=${ip}`, {
|
||||
timeout: 10000,
|
||||
retry: 0,
|
||||
})
|
||||
.text();
|
||||
const pconlineApi = got
|
||||
.get(`https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`, {
|
||||
timeout: 10000,
|
||||
retry: 0,
|
||||
})
|
||||
.buffer();
|
||||
const [csdnBody, pconlineBody] = await await Promise.all<any>([
|
||||
csdnApi,
|
||||
pconlineApi,
|
||||
]);
|
||||
const csdnRes = JSON.parse(csdnBody);
|
||||
const pconlineRes = JSON.parse(iconv.decode(pconlineBody, 'GBK'));
|
||||
let address = '';
|
||||
if (csdnBody && csdnRes.code == 200) {
|
||||
address = csdnRes.data.address;
|
||||
} else if (pconlineRes && pconlineRes.addr) {
|
||||
address = pconlineRes.addr;
|
||||
}
|
||||
return { address, ip };
|
||||
} catch (error) {
|
||||
return { address: `获取失败`, ip };
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
|
||||
const ua = userAgent.toLowerCase();
|
||||
const testUa = (regexp: RegExp) => regexp.test(ua);
|
||||
@@ -237,7 +194,7 @@ enum FileType {
|
||||
'file',
|
||||
}
|
||||
|
||||
interface IFile {
|
||||
export interface IFile {
|
||||
title: string;
|
||||
key: string;
|
||||
type: 'directory' | 'file';
|
||||
@@ -305,23 +262,54 @@ export async function readDir(
|
||||
dir: string,
|
||||
baseDir: string = '',
|
||||
blacklist: string[] = [],
|
||||
) {
|
||||
const relativePath = path.relative(baseDir, dir);
|
||||
const files = await fs.readdir(dir);
|
||||
const result: any = files
|
||||
.filter((x) => !blacklist.includes(x))
|
||||
.map(async (file: string) => {
|
||||
const subPath = path.join(dir, file);
|
||||
): Promise<IFile[]> {
|
||||
const absoluteDir = path.resolve(baseDir, dir);
|
||||
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
|
||||
return [];
|
||||
}
|
||||
const relativePath = path.relative(baseDir, absoluteDir);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(absoluteDir);
|
||||
const result: IFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const subPath = path.join(absoluteDir, file);
|
||||
const stats = await fs.lstat(subPath);
|
||||
const key = path.join(relativePath, file);
|
||||
return {
|
||||
|
||||
if (blacklist.includes(file) || stats.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
result.push({
|
||||
title: file,
|
||||
type: stats.isDirectory() ? 'directory' : 'file',
|
||||
type: 'directory',
|
||||
key,
|
||||
parent: relativePath,
|
||||
};
|
||||
createTime: stats.birthtime.getTime(),
|
||||
children: [],
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
title: file,
|
||||
type: 'file',
|
||||
key,
|
||||
parent: relativePath,
|
||||
size: stats.size,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function promiseExec(command: string): Promise<string> {
|
||||
@@ -352,9 +340,9 @@ export function parseHeaders(headers: string) {
|
||||
if (!headers) return {};
|
||||
|
||||
const parsed: any = {};
|
||||
let key;
|
||||
let val;
|
||||
let i;
|
||||
let key: string;
|
||||
let val: string;
|
||||
let i: number;
|
||||
|
||||
headers &&
|
||||
headers.split('\n').forEach(function parser(line) {
|
||||
@@ -433,11 +421,11 @@ export function parseBody(
|
||||
|
||||
export function psTree(pid: number): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
psTreeFun(pid, (err: any, pids: number[]) => {
|
||||
psTreeFun(pid, (err: any, children) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(pids.filter((x) => !isNaN(x)));
|
||||
resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -462,6 +450,27 @@ export async function getPid(cmd: string) {
|
||||
return pid ? Number(pid) : undefined;
|
||||
}
|
||||
|
||||
export async function getAllPids(cmd: string): Promise<number[]> {
|
||||
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
|
||||
const pidsStr = await promiseExec(taskCommand);
|
||||
if (!pidsStr) return [];
|
||||
return pidsStr
|
||||
.split('\n')
|
||||
.map((p) => Number(p.trim()))
|
||||
.filter((p) => !isNaN(p) && p > 0);
|
||||
}
|
||||
|
||||
export async function killAllTasks(cmd: string): Promise<void> {
|
||||
const pids = await getAllPids(cmd);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
await killTask(pid);
|
||||
} catch (error) {
|
||||
// Ignore errors if process already terminated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IVersion {
|
||||
version: string;
|
||||
changeLogLink: string;
|
||||
@@ -527,11 +536,17 @@ export function safeJSONParse(value?: string) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
Logger.error('[safeJSONParse失败]', error);
|
||||
Logger.error('[safeJSONParse error]', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function errStack(error: unknown): string {
|
||||
return error instanceof Error && error.stack
|
||||
? error.stack
|
||||
: String(error);
|
||||
}
|
||||
|
||||
export async function rmPath(path: string) {
|
||||
try {
|
||||
const _exsit = await fileExist(path);
|
||||
@@ -539,7 +554,7 @@ export async function rmPath(path: string) {
|
||||
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[rmPath失败]', error);
|
||||
Logger.error('[rmPath error]', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,12 +564,217 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
|
||||
throw new Error('Invalid timezone');
|
||||
}
|
||||
|
||||
await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`);
|
||||
await promiseExec(`echo "${timezone}" > /etc/timezone`);
|
||||
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
|
||||
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.error('[setSystemTimezone失败]', error);
|
||||
Logger.error('[setSystemTimezone error]', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
|
||||
[DependenceTypes.python3]: `
|
||||
python3 -c "exec('''
|
||||
name='${name}'
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
print(version(name))
|
||||
except:
|
||||
import importlib.util as u
|
||||
import importlib.metadata as m
|
||||
spec=u.find_spec(name)
|
||||
print(name if spec else '')
|
||||
''')"`,
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${name}`
|
||||
: maybeSudo(`dpkg-query -s ${name}`),
|
||||
};
|
||||
|
||||
return baseCommands[type];
|
||||
}
|
||||
|
||||
export function getInstallCommand(type: DependenceTypes, name: string): string {
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: 'pnpm add -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk add --no-check-certificate'
|
||||
: maybeSudo('apt-get install -y'),
|
||||
};
|
||||
|
||||
let command = baseCommands[type];
|
||||
|
||||
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
|
||||
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
|
||||
}
|
||||
|
||||
return `${command} ${name.trim()}`;
|
||||
}
|
||||
|
||||
export function getUninstallCommand(
|
||||
type: DependenceTypes,
|
||||
name: string,
|
||||
): string {
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk del'
|
||||
: maybeSudo('apt-get remove -y'),
|
||||
};
|
||||
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
}
|
||||
|
||||
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> {
|
||||
const S = isInContainer() ? 'sudo ' : '';
|
||||
let filePath: string, currentDomainWithScheme: string | null;
|
||||
switch (osType) {
|
||||
case 'Debian':
|
||||
filePath = '/etc/apt/sources.list.d/debian.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||
}
|
||||
case 'Ubuntu':
|
||||
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||
}
|
||||
case 'Alpine':
|
||||
filePath = '/etc/apk/repositories';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
|
||||
} else {
|
||||
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
+11
-2
@@ -21,6 +21,9 @@ export class Crontab {
|
||||
extra_schedules?: Array<{ schedule: string }>;
|
||||
task_before?: string;
|
||||
task_after?: string;
|
||||
log_name?: string;
|
||||
allow_multiple_instances?: 1 | 0;
|
||||
work_dir?: string;
|
||||
|
||||
constructor(options: Crontab) {
|
||||
this.name = options.name;
|
||||
@@ -45,14 +48,17 @@ export class Crontab {
|
||||
this.extra_schedules = options.extra_schedules;
|
||||
this.task_before = options.task_before;
|
||||
this.task_after = options.task_after;
|
||||
this.log_name = options.log_name;
|
||||
this.allow_multiple_instances = options.allow_multiple_instances || 0;
|
||||
this.work_dir = options.work_dir;
|
||||
}
|
||||
}
|
||||
|
||||
export enum CrontabStatus {
|
||||
'running' = 0,
|
||||
'queued' = 0.5,
|
||||
'idle' = 1,
|
||||
'disabled',
|
||||
'disabled' = 2,
|
||||
'queued' = 3,
|
||||
}
|
||||
|
||||
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
|
||||
@@ -84,4 +90,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
extra_schedules: DataTypes.JSON,
|
||||
task_before: DataTypes.STRING,
|
||||
task_after: DataTypes.STRING,
|
||||
log_name: DataTypes.STRING,
|
||||
allow_multiple_instances: DataTypes.NUMBER,
|
||||
work_dir: DataTypes.STRING,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
import { sequelize } from '.';
|
||||
|
||||
export class CrontabStat {
|
||||
id?: number;
|
||||
ref_id!: number;
|
||||
date!: string;
|
||||
run_count?: number;
|
||||
success_count?: number;
|
||||
fail_count?: number;
|
||||
total_time?: number;
|
||||
max_time?: number;
|
||||
|
||||
constructor(options: CrontabStat) {
|
||||
this.id = options.id;
|
||||
this.ref_id = options.ref_id;
|
||||
this.date = options.date;
|
||||
this.run_count = options.run_count || 0;
|
||||
this.success_count = options.success_count || 0;
|
||||
this.fail_count = options.fail_count || 0;
|
||||
this.total_time = options.total_time || 0;
|
||||
this.max_time = options.max_time || 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {}
|
||||
|
||||
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
|
||||
'CrontabStat',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
ref_id: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: false,
|
||||
},
|
||||
date: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
run_count: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
success_count: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
fail_count: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
total_time: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
max_time: {
|
||||
type: DataTypes.NUMBER,
|
||||
defaultValue: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{ unique: true, fields: ['ref_id', 'date'] },
|
||||
{ fields: ['date'] },
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -41,30 +41,12 @@ export enum DependenceTypes {
|
||||
'linux',
|
||||
}
|
||||
|
||||
export enum InstallDependenceCommandTypes {
|
||||
'pnpm add -g',
|
||||
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
||||
'apk add --no-check-certificate',
|
||||
}
|
||||
|
||||
export enum GetDependenceCommandTypes {
|
||||
'pnpm ls -g ',
|
||||
'pip3 show --disable-pip-version-check',
|
||||
'apk info -es',
|
||||
}
|
||||
|
||||
export enum versionDependenceCommandTypes {
|
||||
'@',
|
||||
'==',
|
||||
'=',
|
||||
}
|
||||
|
||||
export enum unInstallDependenceCommandTypes {
|
||||
'pnpm remove -g',
|
||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
||||
'apk del',
|
||||
}
|
||||
|
||||
export interface DependenceInstance
|
||||
extends Model<Dependence, Dependence>,
|
||||
Dependence {}
|
||||
|
||||
+7
-1
@@ -1,5 +1,5 @@
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
import { sequelize } from '.';
|
||||
import { DataTypes, Model, ModelDefined } from 'sequelize';
|
||||
|
||||
export class Env {
|
||||
value?: string;
|
||||
@@ -9,6 +9,8 @@ export class Env {
|
||||
position?: number;
|
||||
name?: string;
|
||||
remarks?: string;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
|
||||
constructor(options: Env) {
|
||||
this.value = options.value;
|
||||
@@ -21,6 +23,8 @@ export class Env {
|
||||
this.position = options.position;
|
||||
this.name = options.name;
|
||||
this.remarks = options.remarks || '';
|
||||
this.isPinned = options.isPinned || 0;
|
||||
this.labels = options.labels || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +46,6 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
|
||||
position: DataTypes.NUMBER,
|
||||
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
||||
remarks: DataTypes.STRING,
|
||||
isPinned: DataTypes.NUMBER,
|
||||
labels: DataTypes.JSON,
|
||||
});
|
||||
|
||||
+20
-3
@@ -1,5 +1,3 @@
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
export enum NotificationMode {
|
||||
'gotify' = 'gotify',
|
||||
'goCqHttpBot' = 'goCqHttpBot',
|
||||
@@ -22,6 +20,7 @@ export enum NotificationMode {
|
||||
'chronocat' = 'Chronocat',
|
||||
'ntfy' = 'ntfy',
|
||||
'wxPusherBot' = 'wxPusherBot',
|
||||
'openiLink' = 'openiLink',
|
||||
}
|
||||
|
||||
abstract class NotificationBaseInfo {
|
||||
@@ -117,6 +116,7 @@ export class EmailNotification extends NotificationBaseInfo {
|
||||
public emailService: string = '';
|
||||
public emailUser: string = '';
|
||||
public emailPass: string = '';
|
||||
public emailTo: string = '';
|
||||
}
|
||||
|
||||
export class PushMeNotification extends NotificationBaseInfo {
|
||||
@@ -143,12 +143,17 @@ export class WebhookNotification extends NotificationBaseInfo {
|
||||
|
||||
export class LarkNotification extends NotificationBaseInfo {
|
||||
public larkKey = '';
|
||||
public larkSecret = '';
|
||||
}
|
||||
|
||||
export class NtfyNotification extends NotificationBaseInfo {
|
||||
public ntfyUrl = '';
|
||||
public ntfyTopic = '';
|
||||
public ntfyPriority = '';
|
||||
public ntfyToken = '';
|
||||
public ntfyUsername = '';
|
||||
public ntfyPassword = '';
|
||||
public ntfyActions = '';
|
||||
}
|
||||
|
||||
export class WxPusherBotNotification extends NotificationBaseInfo {
|
||||
@@ -157,6 +162,16 @@ export class WxPusherBotNotification extends NotificationBaseInfo {
|
||||
public wxPusherBotUids = '';
|
||||
}
|
||||
|
||||
export class WxPusherSptNotification extends NotificationBaseInfo {
|
||||
public wxPusherSptList = '';
|
||||
}
|
||||
|
||||
export class OpeniLinkNotification extends NotificationBaseInfo {
|
||||
public openiLinkAppToken = '';
|
||||
public openiLinkHubUrl = '';
|
||||
public openiLinkContextToken = '';
|
||||
}
|
||||
|
||||
export interface NotificationInfo
|
||||
extends GoCqHttpBotNotification,
|
||||
GotifyNotification,
|
||||
@@ -178,4 +193,6 @@ export interface NotificationInfo
|
||||
ChronocatNotification,
|
||||
LarkNotification,
|
||||
NtfyNotification,
|
||||
WxPusherBotNotification {}
|
||||
WxPusherBotNotification,
|
||||
WxPusherSptNotification,
|
||||
OpeniLinkNotification {}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export interface AppToken {
|
||||
expiration: number;
|
||||
}
|
||||
|
||||
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system';
|
||||
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
|
||||
|
||||
export interface AppInstance extends Model<App, App>, App {}
|
||||
export const AppModel = sequelize.define<AppInstance>('App', {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { sequelize } from '.';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
|
||||
export enum InstanceStatus {
|
||||
'running' = 0,
|
||||
'finished' = 1,
|
||||
'stopped' = 2,
|
||||
'error' = 3,
|
||||
}
|
||||
|
||||
export interface RunningInstanceAttributes {
|
||||
id?: number;
|
||||
cron_id: number;
|
||||
pid?: number;
|
||||
log_path?: string;
|
||||
started_at: number;
|
||||
finished_at?: number;
|
||||
status: InstanceStatus;
|
||||
exit_code?: number;
|
||||
}
|
||||
|
||||
export class RunningInstance {
|
||||
id?: number;
|
||||
cron_id!: number;
|
||||
pid?: number;
|
||||
log_path?: string;
|
||||
started_at!: number;
|
||||
finished_at?: number;
|
||||
status!: InstanceStatus;
|
||||
exit_code?: number;
|
||||
|
||||
constructor(options: RunningInstanceAttributes) {
|
||||
this.id = options.id;
|
||||
this.cron_id = options.cron_id;
|
||||
this.pid = options.pid;
|
||||
this.log_path = options.log_path;
|
||||
this.started_at = options.started_at;
|
||||
this.finished_at = options.finished_at;
|
||||
this.status = options.status;
|
||||
this.exit_code = options.exit_code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunningInstanceModel
|
||||
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
|
||||
RunningInstanceAttributes {}
|
||||
|
||||
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
|
||||
'RunningInstance',
|
||||
{
|
||||
cron_id: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: false,
|
||||
},
|
||||
pid: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: true,
|
||||
},
|
||||
log_path: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
started_at: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: false,
|
||||
},
|
||||
finished_at: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: false,
|
||||
defaultValue: InstanceStatus.running,
|
||||
},
|
||||
exit_code: {
|
||||
type: DataTypes.NUMBER,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -2,11 +2,13 @@ export class SockMessage {
|
||||
message?: string;
|
||||
type?: SockMessageType;
|
||||
references?: number[];
|
||||
status?: number | string;
|
||||
|
||||
constructor(options: SockMessage) {
|
||||
this.type = options.type;
|
||||
this.message = options.message;
|
||||
this.references = options.references;
|
||||
this.status = options.status;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -31,6 +31,8 @@ export enum AuthDataType {
|
||||
}
|
||||
|
||||
export interface SystemConfigInfo {
|
||||
lang?: string;
|
||||
panelTitle?: string;
|
||||
logRemoveFrequency?: number;
|
||||
cronConcurrency?: number;
|
||||
dependenceProxy?: string;
|
||||
@@ -38,6 +40,7 @@ export interface SystemConfigInfo {
|
||||
pythonMirror?: string;
|
||||
linuxMirror?: string;
|
||||
timezone?: string;
|
||||
globalSshKey?: string;
|
||||
}
|
||||
|
||||
export interface LoginLogInfo {
|
||||
@@ -48,6 +51,19 @@ export interface LoginLogInfo {
|
||||
status?: LoginStatus;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
value: string;
|
||||
timestamp: number;
|
||||
ip: string;
|
||||
address: string;
|
||||
platform: string;
|
||||
/**
|
||||
* Token expiration time in seconds since Unix epoch.
|
||||
* If undefined, the token uses JWT's built-in expiration.
|
||||
*/
|
||||
expiration?: number;
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -58,7 +74,7 @@ export interface AuthInfo {
|
||||
platform: string;
|
||||
isTwoFactorChecking: boolean;
|
||||
token: string;
|
||||
tokens: Record<string, string>;
|
||||
tokens: Record<string, string | TokenInfo[]>;
|
||||
twoFactorActivated: boolean;
|
||||
twoFactorSecret: string;
|
||||
avatar: string;
|
||||
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
platform: 'desktop' | 'mobile';
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'pstree.remy';
|
||||
+13
-14
@@ -5,25 +5,24 @@ import initData from './initData';
|
||||
import { Application } from 'express';
|
||||
import linkDeps from './deps';
|
||||
import initTask from './initTask';
|
||||
import initFile from './initFile';
|
||||
|
||||
export default async ({ expressApp }: { expressApp: Application }) => {
|
||||
export default async ({ app }: { app: Application }) => {
|
||||
depInjectorLoader();
|
||||
Logger.info('✌️ Dependency loaded');
|
||||
console.log('✌️ Dependency loaded');
|
||||
|
||||
await initData();
|
||||
Logger.info('✌️ Init data loaded');
|
||||
console.log('✌️ Init data loaded');
|
||||
Logger.info('[boot] Dependency loaded');
|
||||
|
||||
await linkDeps();
|
||||
Logger.info('✌️ Link deps loaded');
|
||||
console.log('✌️ Link deps loaded');
|
||||
Logger.info('[boot] Link deps loaded');
|
||||
|
||||
await initFile();
|
||||
Logger.info('[boot] Init file loaded');
|
||||
|
||||
await initData();
|
||||
Logger.info('[boot] Init data loaded');
|
||||
|
||||
initTask();
|
||||
Logger.info('✌️ Init task loaded');
|
||||
console.log('✌️ Init task loaded');
|
||||
Logger.info('[boot] Init task loaded');
|
||||
|
||||
expressLoader({ app: expressApp });
|
||||
Logger.info('✌️ Express loaded');
|
||||
console.log('✌️ Express loaded');
|
||||
expressLoader({ app });
|
||||
Logger.info('[boot] Express loaded');
|
||||
};
|
||||
|
||||
+40
-41
@@ -6,6 +6,8 @@ import { AppModel } from '../data/open';
|
||||
import { SystemModel } from '../data/system';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
import { CrontabViewModel } from '../data/cronView';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import { RunningInstanceModel } from '../data/runningInstance';
|
||||
import { sequelize } from '../data';
|
||||
|
||||
export default async () => {
|
||||
@@ -17,50 +19,47 @@ export default async () => {
|
||||
await EnvModel.sync();
|
||||
await SubscriptionModel.sync();
|
||||
await CrontabViewModel.sync();
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
|
||||
// 初始化新增字段
|
||||
try {
|
||||
await sequelize.query(
|
||||
'alter table CrontabViews add column filterRelation VARCHAR(255)',
|
||||
);
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query(
|
||||
'alter table Subscriptions add column proxy VARCHAR(255)',
|
||||
);
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query('alter table CrontabViews add column type NUMBER');
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query(
|
||||
'alter table Subscriptions add column autoAddCron NUMBER',
|
||||
);
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query(
|
||||
'alter table Subscriptions add column autoDelCron NUMBER',
|
||||
);
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query(
|
||||
'alter table Crontabs add column extra_schedules JSON',
|
||||
);
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query('alter table Crontabs add column task_before TEXT');
|
||||
} catch (error) {}
|
||||
try {
|
||||
await sequelize.query('alter table Crontabs add column task_after TEXT');
|
||||
} catch (error) {}
|
||||
const migrations = [
|
||||
{
|
||||
table: 'CrontabViews',
|
||||
column: 'filterRelation',
|
||||
type: 'VARCHAR(255)',
|
||||
},
|
||||
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
|
||||
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
|
||||
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
|
||||
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
|
||||
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
|
||||
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
|
||||
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
|
||||
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
|
||||
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
|
||||
{
|
||||
table: 'Crontabs',
|
||||
column: 'allow_multiple_instances',
|
||||
type: 'NUMBER',
|
||||
},
|
||||
{ table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
|
||||
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
|
||||
{ table: 'Envs', column: 'labels', type: 'JSON' },
|
||||
];
|
||||
|
||||
console.log('✌️ DB loaded');
|
||||
Logger.info('✌️ DB loaded');
|
||||
for (const migration of migrations) {
|
||||
try {
|
||||
await sequelize.query(
|
||||
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('✌️ DB load failed');
|
||||
Logger.error(error);
|
||||
// Column already exists or other error, continue
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
Logger.error('[boot] DB load failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
+22
-28
@@ -1,24 +1,22 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import chokidar from 'chokidar';
|
||||
import os from 'os';
|
||||
import config from '../config/index';
|
||||
import { fileExist, promiseExec, rmPath } from '../config/util';
|
||||
|
||||
async function linkToNodeModule(src: string, dst?: string) {
|
||||
const target = path.join(config.rootPath, 'node_modules', dst || src);
|
||||
const source = path.join(config.rootPath, src);
|
||||
|
||||
try {
|
||||
const stats = await fs.lstat(target);
|
||||
if (!stats) {
|
||||
await fs.symlink(source, target, 'dir');
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
import Logger from './logger';
|
||||
|
||||
async function linkCommand() {
|
||||
const commandPath = await promiseExec('which node');
|
||||
const commandDir = path.dirname(commandPath);
|
||||
const homeDir = os.homedir();
|
||||
let userBinDir = path.join(homeDir, 'bin');
|
||||
|
||||
try {
|
||||
await fs.mkdir(userBinDir, { recursive: true });
|
||||
await linkCommandToDir(userBinDir);
|
||||
} catch (error) {
|
||||
Logger.error('Linking command failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkCommandToDir(commandDir: string) {
|
||||
const linkShell = [
|
||||
{
|
||||
src: 'update.sh',
|
||||
@@ -36,22 +34,18 @@ async function linkCommand() {
|
||||
const source = path.join(config.rootPath, 'shell', link.src);
|
||||
const target = path.join(commandDir, link.dest);
|
||||
const tmpTarget = path.join(commandDir, link.tmp);
|
||||
try {
|
||||
const stats = await fs.lstat(tmpTarget);
|
||||
if (stats) {
|
||||
await fs.unlink(tmpTarget);
|
||||
}
|
||||
} catch (error) { }
|
||||
|
||||
await fs.symlink(source, tmpTarget);
|
||||
await fs.rename(tmpTarget, target);
|
||||
}
|
||||
}
|
||||
|
||||
export default async (src: string = 'deps') => {
|
||||
export default async () => {
|
||||
await linkCommand();
|
||||
await linkToNodeModule(src);
|
||||
|
||||
const source = path.join(config.rootPath, src);
|
||||
const watcher = chokidar.watch(source, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
watcher
|
||||
.on('add', (path) => linkToNodeModule(src))
|
||||
.on('change', (path) => linkToNodeModule(src));
|
||||
};
|
||||
|
||||
+83
-43
@@ -7,36 +7,59 @@ import { UnauthorizedError, expressjwt } from 'express-jwt';
|
||||
import { getPlatform, getToken } from '../config/util';
|
||||
import rewrite from 'express-urlrewrite';
|
||||
import { errors } from 'celebrate';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { serveEnv } from '../config/serverEnv';
|
||||
import Logger from './logger';
|
||||
import { IKeyvStore, shareStore } from '../shared/store';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||
import { AuthInfo } from '../data/system';
|
||||
import path from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import { AppScope } from '../data/open';
|
||||
|
||||
export default ({ app }: { app: Application }) => {
|
||||
// Security: Enable strict routing to prevent case-insensitive path bypass
|
||||
app.set('case sensitive routing', true);
|
||||
app.set('strict routing', true);
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.use(cors());
|
||||
|
||||
// Security: Path normalization middleware to prevent case variation attacks
|
||||
app.use((req, res, next) => {
|
||||
const originalPath = req.path;
|
||||
const normalizedPath = originalPath.toLowerCase();
|
||||
|
||||
// Block requests with case variations on protected paths
|
||||
if (originalPath !== normalizedPath &&
|
||||
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: 'Invalid path format'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Rewrite URLs to strip baseUrl prefix if configured
|
||||
// This allows the rest of the app to work without baseUrl awareness
|
||||
if (config.baseUrl) {
|
||||
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
|
||||
}
|
||||
|
||||
app.get(`${config.api.prefix}/env.js`, serveEnv);
|
||||
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
|
||||
|
||||
app.use(
|
||||
'/api/public',
|
||||
createProxyMiddleware({
|
||||
target: `http://0.0.0.0:${config.publicPort}/api`,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '/api/public': '' },
|
||||
logger: Logger,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(bodyParser.json({ limit: '50mb' }));
|
||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
const frontendPath = path.join(config.rootPath, 'static/dist');
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
app.use(
|
||||
expressjwt({
|
||||
secret: config.secret,
|
||||
secret: config.jwt.secret,
|
||||
algorithms: ['HS384'],
|
||||
}).unless({
|
||||
path: [...config.apiWhiteList, /^\/open\//],
|
||||
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -50,28 +73,42 @@ export default ({ app }: { app: Application }) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
app.use(async (req: Request, res, next) => {
|
||||
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) &&
|
||||
currentToken &&
|
||||
currentToken.expiration >= Math.round(Date.now() / 1000)
|
||||
) {
|
||||
|
||||
if (!doc.scopes.includes(key as AppScope)) {
|
||||
const err = new UnauthorizedError('credentials_bad_scheme', {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
|
||||
const err = new UnauthorizedError('invalid_token', {
|
||||
message: t('Token 已失效'),
|
||||
});
|
||||
return next(err);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const originPath = `${req.baseUrl}${req.path === '/' ? '' : req.path}`;
|
||||
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
|
||||
if (
|
||||
!headerToken &&
|
||||
originPath &&
|
||||
@@ -81,39 +118,37 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (authInfo && headerToken) {
|
||||
const { token = '', tokens = {} } = authInfo;
|
||||
if (headerToken === token || tokens[req.platform] === headerToken) {
|
||||
if (isValidToken(authInfo, headerToken, req.platform)) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
||||
const errorMessage = headerToken
|
||||
? 'jwt malformed'
|
||||
: 'No authorization token was found';
|
||||
? t('Token 已失效')
|
||||
: t('请先登录');
|
||||
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
||||
next(err);
|
||||
});
|
||||
|
||||
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(pathLower)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const authInfo =
|
||||
(await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']);
|
||||
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||
|
||||
let isInitialized = true;
|
||||
if (
|
||||
Object.keys(authInfo).length === 2 &&
|
||||
authInfo.username === 'admin' &&
|
||||
authInfo.password === 'admin'
|
||||
) {
|
||||
isInitialized = false;
|
||||
}
|
||||
let isInitialized = !isDefaultAuthInfo(authInfo);
|
||||
|
||||
if (isInitialized) {
|
||||
return res.send({ code: 450, message: '未知错误' });
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
@@ -122,10 +157,15 @@ export default ({ app }: { app: Application }) => {
|
||||
app.use(rewrite('/open/*', '/api/$1'));
|
||||
app.use(config.api.prefix, routes());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
app.get('*', (_, res, next) => {
|
||||
const indexPath = path.join(frontendPath, 'index.html');
|
||||
res.sendFile(indexPath, (err) => {
|
||||
if (err) {
|
||||
const err: any = new Error('Not Found');
|
||||
err['status'] = 404;
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.use(errors());
|
||||
|
||||
+42
-10
@@ -13,11 +13,13 @@ import { AuthDataType, SystemModel } from '../data/system';
|
||||
import SystemService from '../services/system';
|
||||
import UserService from '../services/user';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { createRandomString, 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';
|
||||
import { AppModel } from '../data/open';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { setLang, systemLang } from '../shared/i18n';
|
||||
|
||||
export default async () => {
|
||||
const cronService = Container.get(CronService);
|
||||
@@ -36,10 +38,15 @@ export default async () => {
|
||||
if (!systemApp) {
|
||||
systemApp = await AppModel.create({
|
||||
name: 'system',
|
||||
scopes: ['crons', 'system'],
|
||||
scopes: ['crons', 'system', 'dashboard'],
|
||||
client_id: createRandomString(12, 12),
|
||||
client_secret: createRandomString(24, 24),
|
||||
});
|
||||
} else if (!systemApp.scopes.includes('dashboard')) {
|
||||
await AppModel.update(
|
||||
{ scopes: [...systemApp.scopes, 'dashboard'] },
|
||||
{ where: { name: 'system' } },
|
||||
);
|
||||
}
|
||||
const [systemConfig] = await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.systemConfig },
|
||||
@@ -50,14 +57,17 @@ 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',
|
||||
};
|
||||
try {
|
||||
const authFileExist = await fileExist(config.authConfigFile);
|
||||
if (authFileExist) {
|
||||
const content = await readFile(config.authConfigFile, 'utf8');
|
||||
authInfo = safeJSONParse(content);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn('Failed to read auth config file, using default credentials');
|
||||
}
|
||||
@@ -68,24 +78,27 @@ export default async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const installDependencies = () => {
|
||||
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖
|
||||
DependenceModel.findAll({
|
||||
const installDependencies = async () => {
|
||||
const docs = await DependenceModel.findAll({
|
||||
where: {},
|
||||
order: [
|
||||
['type', 'DESC'],
|
||||
['createdAt', 'DESC'],
|
||||
],
|
||||
raw: true,
|
||||
}).then(async (docs) => {
|
||||
});
|
||||
|
||||
await DependenceModel.update(
|
||||
{ status: DependenceStatus.queued, log: [] },
|
||||
{ where: { id: docs.map((x) => x.id!) } },
|
||||
);
|
||||
setTimeout(() => {
|
||||
dependenceService.installDependenceOneByOne(docs);
|
||||
|
||||
setTimeout(async () => {
|
||||
await dependenceService.installDependenceOneByOne(docs);
|
||||
|
||||
const bootAfterLoader = await import('./bootAfter');
|
||||
bootAfterLoader.default();
|
||||
}, 5000);
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化更新 linux/python/nodejs 镜像源配置
|
||||
@@ -128,6 +141,12 @@ export default async () => {
|
||||
// 初始化更新所有任务状态为空闲
|
||||
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
|
||||
|
||||
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped },
|
||||
{ where: { status: InstanceStatus.running } },
|
||||
);
|
||||
|
||||
// 初始化时执行一次所有的 ql repo 任务
|
||||
CrontabModel.findAll({
|
||||
where: {
|
||||
@@ -204,8 +223,21 @@ export default async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化语言(必须在 autosave_crontab 之前)
|
||||
const lang = systemConfig.info?.lang || systemLang();
|
||||
setLang(lang);
|
||||
|
||||
// 确保 lang_env.sh 存在
|
||||
try {
|
||||
const langEnvExist = await fileExist(config.langEnvFile);
|
||||
if (!langEnvExist) {
|
||||
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// 初始化保存一次ck和定时任务数据
|
||||
await cronService.autosave_crontab();
|
||||
|
||||
await envService.set_envs();
|
||||
|
||||
const authInfo = await userService.getAuthInfo();
|
||||
|
||||
@@ -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,
|
||||
@@ -116,11 +118,12 @@ export default async () => {
|
||||
`Neither content nor source specified for ${item.target}`,
|
||||
);
|
||||
}
|
||||
const content = item.content || (await fs.readFile(item.source!));
|
||||
const content =
|
||||
item.content ||
|
||||
(await fs.readFile(item.source!, { encoding: 'utf-8' }));
|
||||
await writeFileWithLock(item.target, content);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info('✌️ Init file down');
|
||||
console.log('✌️ Init file down');
|
||||
Logger.info('[boot] Init file down');
|
||||
};
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Container } from 'typedi';
|
||||
import SystemService from '../services/system';
|
||||
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
|
||||
import SubscriptionService from '../services/subscription';
|
||||
import SshKeyService from '../services/sshKey';
|
||||
import config from '../config';
|
||||
import { fileExist } from '../config/util';
|
||||
import { join } from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
export default async () => {
|
||||
const systemService = Container.get(SystemService);
|
||||
const scheduleService = Container.get(ScheduleService);
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const sshKeyService = Container.get(SshKeyService);
|
||||
|
||||
// 生成内置token
|
||||
let tokenCommand = `ts-node-transpile-only ${join(
|
||||
@@ -23,7 +26,7 @@ export default async () => {
|
||||
}
|
||||
const cron = {
|
||||
id: NaN,
|
||||
name: '生成token',
|
||||
name: t('生成token'),
|
||||
command: tokenCommand,
|
||||
runOrigin: 'system',
|
||||
} as ScheduleTaskType;
|
||||
@@ -42,7 +45,7 @@ export default async () => {
|
||||
if (data.info.logRemoveFrequency) {
|
||||
const rmlogCron = {
|
||||
id: data.id as number,
|
||||
name: '删除日志',
|
||||
name: t('删除日志'),
|
||||
command: `ql rmlog ${data.info.logRemoveFrequency}`,
|
||||
runOrigin: 'system' as const,
|
||||
};
|
||||
@@ -57,6 +60,11 @@ export default async () => {
|
||||
}
|
||||
|
||||
systemService.updateTimezone(data.info);
|
||||
|
||||
// Apply global SSH key if configured
|
||||
if (data.info.globalSshKey) {
|
||||
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
|
||||
}
|
||||
}
|
||||
|
||||
await subscriptionService.setSshConfig();
|
||||
|
||||
+51
-26
@@ -4,35 +4,60 @@ import config from '../config';
|
||||
import path from 'path';
|
||||
|
||||
const levelMap: Record<string, string> = {
|
||||
info: '\ue6f5',
|
||||
warn: '\ue880',
|
||||
error: '\ue602',
|
||||
debug: '\ue67f'
|
||||
}
|
||||
|
||||
const customFormat = winston.format.combine(
|
||||
winston.format.splat(),
|
||||
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
||||
winston.format.align(),
|
||||
winston.format.printf((i) => `[${levelMap[i.level]}${i.level}] [${[i.timestamp]}]: ${i.message}`),
|
||||
);
|
||||
|
||||
const defaultOptions = {
|
||||
format: customFormat,
|
||||
datePattern: "YYYY-MM-DD",
|
||||
maxSize: "20m",
|
||||
maxFiles: "7d",
|
||||
info: 'ℹ️', // info图标
|
||||
warn: '⚠️', // 警告图标
|
||||
error: '❌', // 错误图标
|
||||
debug: '🐛', // debug调试图标
|
||||
};
|
||||
|
||||
const LoggerInstance = winston.createLogger({
|
||||
level: config.logs.level,
|
||||
levels: winston.config.npm.levels,
|
||||
transports: [
|
||||
new winston.transports.DailyRotateFile({
|
||||
const baseFormat = [
|
||||
winston.format.splat(),
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.align(),
|
||||
];
|
||||
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize({ level: true }),
|
||||
...baseFormat,
|
||||
winston.format.printf((info) => {
|
||||
return `[${info.level} ${info.timestamp}]:${info.message}`;
|
||||
}),
|
||||
);
|
||||
|
||||
const plainFormat = winston.format.combine(
|
||||
winston.format.uncolorize(),
|
||||
...baseFormat,
|
||||
winston.format.printf((info) => {
|
||||
return `[${levelMap[info.level] || ''}${info.level} ${info.timestamp}]:${
|
||||
info.message
|
||||
}`;
|
||||
}),
|
||||
);
|
||||
|
||||
const consoleTransport = new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
level: 'debug',
|
||||
});
|
||||
|
||||
const fileTransport = new winston.transports.DailyRotateFile({
|
||||
filename: path.join(config.systemLogPath, '%DATE%.log'),
|
||||
...defaultOptions,
|
||||
})
|
||||
],
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '7d',
|
||||
format: plainFormat,
|
||||
level: config.logs.level || 'info',
|
||||
});
|
||||
|
||||
const LoggerInstance = winston.createLogger({
|
||||
level: 'debug',
|
||||
levels: winston.config.npm.levels,
|
||||
transports: [consoleTransport, fileTransport],
|
||||
exceptionHandlers: [consoleTransport, fileTransport],
|
||||
rejectionHandlers: [consoleTransport, fileTransport],
|
||||
});
|
||||
|
||||
LoggerInstance.on('error', (error) => {
|
||||
console.error('Logger error:', error);
|
||||
});
|
||||
|
||||
export default LoggerInstance;
|
||||
|
||||
@@ -4,7 +4,7 @@ import Sock from './sock';
|
||||
|
||||
export default async ({ server }: { server: Server }) => {
|
||||
await Sock({ server });
|
||||
Logger.info('✌️ Sock loaded');
|
||||
Logger.info('[boot] Sock loaded');
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
Logger.error('Uncaught exception:', error);
|
||||
|
||||
@@ -4,9 +4,11 @@ import { Container } from 'typedi';
|
||||
import SockService from '../services/sock';
|
||||
import { getPlatform } from '../config/util';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { isValidToken } from '../shared/auth';
|
||||
import config from '../config';
|
||||
|
||||
export default async ({ server }: { server: Server }) => {
|
||||
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
|
||||
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
|
||||
const sockService = Container.get(SockService);
|
||||
|
||||
echo.on('connection', async (conn) => {
|
||||
@@ -17,9 +19,8 @@ export default async ({ server }: { server: Server }) => {
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||
if (authInfo) {
|
||||
const { token = '', tokens = {} } = authInfo;
|
||||
if (headerToken === token || tokens[platform] === headerToken) {
|
||||
|
||||
if (isValidToken(authInfo, headerToken, platform)) {
|
||||
sockService.addClient(conn);
|
||||
|
||||
conn.on('data', (message) => {
|
||||
@@ -32,7 +33,6 @@ export default async ({ server }: { server: Server }) => {
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
conn.close('404');
|
||||
});
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import bodyParser from 'body-parser';
|
||||
import { errors } from 'celebrate';
|
||||
import cors from 'cors';
|
||||
import { Application, NextFunction, Request, Response } from 'express';
|
||||
import { expressjwt } from 'express-jwt';
|
||||
import Container from 'typedi';
|
||||
import config from '../config';
|
||||
import SystemService from '../services/system';
|
||||
import Logger from './logger';
|
||||
|
||||
export default ({ app }: { app: Application }) => {
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.use(cors());
|
||||
|
||||
app.use(bodyParser.json({ limit: '50mb' }));
|
||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
app.use(
|
||||
expressjwt({
|
||||
secret: config.secret,
|
||||
algorithms: ['HS384'],
|
||||
}),
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/api/reload',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.reloadSystem();
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
Logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/api/system',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.reloadSystem('system');
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
Logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/api/data',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const result = await systemService.reloadSystem('data');
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
Logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const err: any = new Error('Not Found');
|
||||
err['status'] = 404;
|
||||
next(err);
|
||||
});
|
||||
|
||||
app.use(errors());
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: Error & { status: number },
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (err.name === 'UnauthorizedError') {
|
||||
return res
|
||||
.status(err.status)
|
||||
.send({ code: 401, message: err.message })
|
||||
.end();
|
||||
}
|
||||
return next(err);
|
||||
},
|
||||
);
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: Error & { status: number },
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
res.status(err.status || 500);
|
||||
res.json({
|
||||
code: err.status || 500,
|
||||
message: err.message,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import Logger from '../loaders/logger';
|
||||
import { performance } from 'perf_hooks';
|
||||
import { metricsService } from '../services/metrics';
|
||||
|
||||
interface RequestMetrics {
|
||||
method: string;
|
||||
path: string;
|
||||
duration: number;
|
||||
statusCode: number;
|
||||
timestamp: number;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
const requestMetrics: RequestMetrics[] = [];
|
||||
|
||||
export const monitoringMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const start = performance.now();
|
||||
const originalEnd = res.end;
|
||||
|
||||
res.end = function (chunk?: any, encoding?: any, cb?: any) {
|
||||
const duration = performance.now() - start;
|
||||
const metric: RequestMetrics = {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
duration,
|
||||
statusCode: res.statusCode,
|
||||
timestamp: Date.now(),
|
||||
platform: req.platform,
|
||||
};
|
||||
|
||||
requestMetrics.push(metric);
|
||||
metricsService.record('http_request', duration, {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode.toString(),
|
||||
...(req.platform && { platform: req.platform }),
|
||||
});
|
||||
|
||||
if (requestMetrics.length > 1000) {
|
||||
requestMetrics.shift();
|
||||
}
|
||||
|
||||
if (duration > 1000) {
|
||||
Logger.warn(
|
||||
`Slow request detected: ${req.method} ${
|
||||
req.path
|
||||
} took ${duration.toFixed(2)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
return originalEnd.call(this, chunk, encoding, cb);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const getMetrics = () => {
|
||||
return {
|
||||
totalRequests: requestMetrics.length,
|
||||
averageDuration:
|
||||
requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) /
|
||||
requestMetrics.length,
|
||||
requestsByMethod: requestMetrics.reduce((acc, curr) => {
|
||||
acc[curr.method] = (acc[curr.method] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
requestsByPlatform: requestMetrics.reduce((acc, curr) => {
|
||||
if (curr.platform) {
|
||||
acc[curr.platform] = (acc[curr.platform] || 0) + 1;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
recentRequests: requestMetrics.slice(-10),
|
||||
};
|
||||
};
|
||||
+144
-8
@@ -53,14 +53,7 @@ message Response {
|
||||
optional string message = 2;
|
||||
}
|
||||
|
||||
message SystemNotifyRequest {
|
||||
string title = 1;
|
||||
string content = 2;
|
||||
}
|
||||
|
||||
message ExtraScheduleItem {
|
||||
string schedule = 1;
|
||||
}
|
||||
message ExtraScheduleItem { string schedule = 1; }
|
||||
|
||||
message CronItem {
|
||||
optional int32 id = 1;
|
||||
@@ -104,6 +97,18 @@ message UpdateCronRequest {
|
||||
|
||||
message DeleteCronsRequest { repeated int32 ids = 1; }
|
||||
|
||||
message GetCronsRequest {
|
||||
optional string searchValue = 1;
|
||||
}
|
||||
|
||||
message GetCronByIdRequest { int32 id = 1; }
|
||||
|
||||
message EnableCronsRequest { repeated int32 ids = 1; }
|
||||
|
||||
message DisableCronsRequest { repeated int32 ids = 1; }
|
||||
|
||||
message RunCronsRequest { repeated int32 ids = 1; }
|
||||
|
||||
message CronsResponse {
|
||||
int32 code = 1;
|
||||
repeated CronItem data = 2;
|
||||
@@ -124,6 +129,132 @@ message CronDetailResponse {
|
||||
optional string message = 3;
|
||||
}
|
||||
|
||||
enum NotificationMode {
|
||||
gotify = 0;
|
||||
goCqHttpBot = 1;
|
||||
serverChan = 2;
|
||||
pushDeer = 3;
|
||||
bark = 4;
|
||||
chat = 5;
|
||||
telegramBot = 6;
|
||||
dingtalkBot = 7;
|
||||
weWorkBot = 8;
|
||||
weWorkApp = 9;
|
||||
aibotk = 10;
|
||||
iGot = 11;
|
||||
pushPlus = 12;
|
||||
wePlusBot = 13;
|
||||
email = 14;
|
||||
pushMe = 15;
|
||||
feishu = 16;
|
||||
webhook = 17;
|
||||
chronocat = 18;
|
||||
ntfy = 19;
|
||||
wxPusherBot = 20;
|
||||
wxPusherSpt = 21;
|
||||
}
|
||||
|
||||
message NotificationInfo {
|
||||
NotificationMode type = 1;
|
||||
|
||||
optional string gotifyUrl = 2;
|
||||
optional string gotifyToken = 3;
|
||||
optional int32 gotifyPriority = 4;
|
||||
|
||||
optional string goCqHttpBotUrl = 5;
|
||||
optional string goCqHttpBotToken = 6;
|
||||
optional string goCqHttpBotQq = 7;
|
||||
|
||||
optional string serverChanKey = 8;
|
||||
|
||||
optional string pushDeerKey = 9;
|
||||
optional string pushDeerUrl = 10;
|
||||
|
||||
optional string synologyChatUrl = 11;
|
||||
|
||||
optional string barkPush = 12;
|
||||
optional string barkIcon = 13;
|
||||
optional string barkSound = 14;
|
||||
optional string barkGroup = 15;
|
||||
optional string barkLevel = 16;
|
||||
optional string barkUrl = 17;
|
||||
optional string barkArchive = 18;
|
||||
|
||||
optional string telegramBotToken = 19;
|
||||
optional string telegramBotUserId = 20;
|
||||
optional string telegramBotProxyHost = 21;
|
||||
optional string telegramBotProxyPort = 22;
|
||||
optional string telegramBotProxyAuth = 23;
|
||||
optional string telegramBotApiHost = 24;
|
||||
|
||||
optional string dingtalkBotToken = 25;
|
||||
optional string dingtalkBotSecret = 26;
|
||||
|
||||
optional string weWorkBotKey = 27;
|
||||
optional string weWorkOrigin = 28;
|
||||
|
||||
optional string weWorkAppKey = 29;
|
||||
|
||||
optional string aibotkKey = 30;
|
||||
optional string aibotkType = 31;
|
||||
optional string aibotkName = 32;
|
||||
|
||||
optional string iGotPushKey = 33;
|
||||
|
||||
optional string pushPlusToken = 34;
|
||||
optional string pushPlusUser = 35;
|
||||
optional string pushPlusTemplate = 36;
|
||||
optional string pushplusChannel = 37;
|
||||
optional string pushplusWebhook = 38;
|
||||
optional string pushplusCallbackUrl = 39;
|
||||
optional string pushplusTo = 40;
|
||||
|
||||
optional string wePlusBotToken = 41;
|
||||
optional string wePlusBotReceiver = 42;
|
||||
optional string wePlusBotVersion = 43;
|
||||
|
||||
optional string emailService = 44;
|
||||
optional string emailUser = 45;
|
||||
optional string emailPass = 46;
|
||||
optional string emailTo = 47;
|
||||
|
||||
optional string pushMeKey = 48;
|
||||
optional string pushMeUrl = 49;
|
||||
|
||||
optional string chronocatURL = 50;
|
||||
optional string chronocatQQ = 51;
|
||||
optional string chronocatToken = 52;
|
||||
|
||||
optional string webhookHeaders = 53;
|
||||
optional string webhookBody = 54;
|
||||
optional string webhookUrl = 55;
|
||||
optional string webhookMethod = 56;
|
||||
optional string webhookContentType = 57;
|
||||
|
||||
optional string larkKey = 58;
|
||||
optional string larkSecret = 69;
|
||||
|
||||
optional string ntfyUrl = 59;
|
||||
optional string ntfyTopic = 60;
|
||||
optional string ntfyPriority = 61;
|
||||
optional string ntfyToken = 62;
|
||||
optional string ntfyUsername = 63;
|
||||
optional string ntfyPassword = 64;
|
||||
optional string ntfyActions = 65;
|
||||
|
||||
optional string wxPusherBotAppToken = 66;
|
||||
optional string wxPusherBotTopicIds = 67;
|
||||
optional string wxPusherBotUids = 68;
|
||||
|
||||
optional string wxPusherSptList = 70;
|
||||
}
|
||||
|
||||
message SystemNotifyRequest {
|
||||
string title = 1;
|
||||
string content = 2;
|
||||
optional NotificationInfo notificationInfo = 3;
|
||||
}
|
||||
|
||||
service Api {
|
||||
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
|
||||
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
|
||||
@@ -139,4 +270,9 @@ service Api {
|
||||
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
|
||||
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
|
||||
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
|
||||
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
|
||||
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
|
||||
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
|
||||
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
|
||||
rpc RunCrons(RunCronsRequest) returns (Response) {}
|
||||
}
|
||||
+2045
-82
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v3.17.3
|
||||
// protoc v3.21.12
|
||||
// source: back/protos/cron.proto
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v3.17.3
|
||||
// protoc v3.21.12
|
||||
// source: back/protos/health.proto
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import express from 'express';
|
||||
import Logger from './loaders/logger';
|
||||
import config from './config';
|
||||
import { HealthClient } from './protos/health';
|
||||
import { credentials } from '@grpc/grpc-js';
|
||||
|
||||
const app = express();
|
||||
const client = new HealthClient(
|
||||
`0.0.0.0:${config.cronPort}`,
|
||||
credentials.createInsecure(),
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
client.check({ service: 'cron' }, (err, response) => {
|
||||
if (err) {
|
||||
return res.status(200).send({ code: 500, error: err });
|
||||
}
|
||||
return res.status(200).send({ code: 200, data: response });
|
||||
});
|
||||
});
|
||||
|
||||
app
|
||||
.listen(config.publicPort, '0.0.0.0', async () => {
|
||||
await require('./loaders/db').default();
|
||||
|
||||
Logger.debug(`✌️ 公共服务启动成功!`);
|
||||
console.debug(`✌️ 公共服务启动成功!`);
|
||||
process.send?.('ready');
|
||||
})
|
||||
.on('error', (err) => {
|
||||
Logger.error(err);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+97
-13
@@ -1,22 +1,85 @@
|
||||
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
||||
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
||||
import nodeSchedule from 'node-schedule';
|
||||
import { scheduleStacks } from './data';
|
||||
import { runCron } from '../shared/runCron';
|
||||
import Logger from '../loaders/logger';
|
||||
import { tf } from '../shared/i18n';
|
||||
|
||||
/**
|
||||
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
|
||||
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
|
||||
* 提前拦截避免走 scheduleJob 后才发现无效。
|
||||
*/
|
||||
const isValidCronField = (cron: string): boolean => {
|
||||
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
|
||||
// node-schedule 会对这种字段返回 null
|
||||
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
|
||||
return false;
|
||||
}
|
||||
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
|
||||
// cron-parser 接受但 node-schedule 拒绝,提前拦截
|
||||
if (/\?/.test(cron)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const addCron = (
|
||||
call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
|
||||
callback: sendUnaryData<AddCronResponse>,
|
||||
) => {
|
||||
// ===== 第一遍:预校验所有 cron 表达式 =====
|
||||
const validationErrors: string[] = [];
|
||||
|
||||
for (const item of call.request.crons) {
|
||||
const { id, schedule, extra_schedules } = item;
|
||||
|
||||
if (!isValidCronField(schedule)) {
|
||||
validationErrors.push(
|
||||
tf(
|
||||
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
||||
String(id),
|
||||
schedule,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (extra_schedules?.length) {
|
||||
extra_schedules.forEach((x) => {
|
||||
if (!isValidCronField(x.schedule)) {
|
||||
validationErrors.push(
|
||||
tf(
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
||||
String(id),
|
||||
x.schedule,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
const details = validationErrors.join('\n');
|
||||
const err: any = new Error(details);
|
||||
err.code = status.INVALID_ARGUMENT;
|
||||
err.details = details;
|
||||
callback(err, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== 第二遍:注册所有任务 =====
|
||||
for (const item of call.request.crons) {
|
||||
const { id, schedule, command, extra_schedules, name } = item;
|
||||
|
||||
// 取消该 id 已有的旧任务
|
||||
if (scheduleStacks.has(id)) {
|
||||
scheduleStacks.get(id)?.forEach((x) => x.cancel());
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||
id,
|
||||
name,
|
||||
schedule,
|
||||
@@ -26,7 +89,7 @@ const addCron = (
|
||||
if (extra_schedules?.length) {
|
||||
extra_schedules.forEach((x) => {
|
||||
Logger.info(
|
||||
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||
id,
|
||||
name,
|
||||
x.schedule,
|
||||
@@ -35,20 +98,41 @@ const addCron = (
|
||||
});
|
||||
}
|
||||
|
||||
scheduleStacks.set(id, [
|
||||
nodeSchedule.scheduleJob(id, schedule, async () => {
|
||||
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
|
||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||
runCron(command, item);
|
||||
}),
|
||||
...(extra_schedules?.length
|
||||
? extra_schedules.map((x) =>
|
||||
nodeSchedule.scheduleJob(id, x.schedule, async () => {
|
||||
});
|
||||
|
||||
if (!mainJob) {
|
||||
Logger.warn(
|
||||
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
||||
id,
|
||||
schedule,
|
||||
);
|
||||
}
|
||||
|
||||
const extraJobs = extra_schedules?.length
|
||||
? extra_schedules.map((x) => {
|
||||
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
|
||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||
runCron(command, item);
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
]);
|
||||
});
|
||||
if (!job) {
|
||||
Logger.warn(
|
||||
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
||||
id,
|
||||
x.schedule,
|
||||
);
|
||||
}
|
||||
return job;
|
||||
})
|
||||
: [];
|
||||
|
||||
// 过滤 null(兜底保护,正常情况下预校验已拦截)
|
||||
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
|
||||
if (jobs.length > 0) {
|
||||
scheduleStacks.set(id, jobs);
|
||||
}
|
||||
}
|
||||
|
||||
callback(null, null);
|
||||
|
||||
+179
-10
@@ -30,7 +30,14 @@ import {
|
||||
UpdateCronRequest,
|
||||
DeleteCronsRequest,
|
||||
CronResponse,
|
||||
GetCronsRequest,
|
||||
CronsResponse,
|
||||
GetCronByIdRequest,
|
||||
EnableCronsRequest,
|
||||
DisableCronsRequest,
|
||||
RunCronsRequest,
|
||||
} from '../protos/api';
|
||||
import { NotificationInfo } from '../data/notify';
|
||||
|
||||
Container.set('logger', LoggerInstance);
|
||||
|
||||
@@ -39,13 +46,6 @@ export const getEnvs = async (
|
||||
callback: sendUnaryData<EnvsResponse>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.searchValue) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
data: [],
|
||||
message: 'searchValue is required',
|
||||
});
|
||||
}
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.envs(call.request.searchValue);
|
||||
callback(null, {
|
||||
@@ -79,9 +79,17 @@ export const updateEnv = async (
|
||||
callback: sendUnaryData<EnvResponse>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.env?.id) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
data: undefined,
|
||||
message: 'id parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.update(
|
||||
pick(call.request.env, ['id', 'name', 'value', 'remark']) as EnvItem,
|
||||
pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem,
|
||||
);
|
||||
callback(null, { code: 200, data });
|
||||
} catch (e: any) {
|
||||
@@ -94,6 +102,13 @@ export const deleteEnvs = async (
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.ids || call.request.ids.length === 0) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
message: 'ids parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const envService = Container.get(EnvService);
|
||||
await envService.remove(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
@@ -107,6 +122,14 @@ export const moveEnv = async (
|
||||
callback: sendUnaryData<EnvResponse>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.id) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
data: undefined,
|
||||
message: 'id parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.move(call.request.id, {
|
||||
fromIndex: call.request.fromIndex,
|
||||
@@ -123,6 +146,13 @@ export const disableEnvs = async (
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.ids || call.request.ids.length === 0) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
message: 'ids parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const envService = Container.get(EnvService);
|
||||
await envService.disabled(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
@@ -136,6 +166,13 @@ export const enableEnvs = async (
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.ids || call.request.ids.length === 0) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
message: 'ids parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const envService = Container.get(EnvService);
|
||||
await envService.enabled(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
@@ -149,6 +186,13 @@ export const updateEnvNames = async (
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.ids || call.request.ids.length === 0) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
message: 'ids parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const envService = Container.get(EnvService);
|
||||
await envService.updateNames({
|
||||
ids: call.request.ids,
|
||||
@@ -165,6 +209,14 @@ export const getEnvById = async (
|
||||
callback: sendUnaryData<EnvResponse>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.id) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
data: undefined,
|
||||
message: 'id parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.getDb({ id: call.request.id });
|
||||
callback(null, {
|
||||
@@ -182,7 +234,11 @@ export const systemNotify = async (
|
||||
) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const data = await systemService.notify(call.request);
|
||||
const data = await systemService.notify({
|
||||
title: call.request.title,
|
||||
content: call.request.content,
|
||||
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
|
||||
});
|
||||
callback(null, data);
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
@@ -194,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,
|
||||
@@ -273,3 +329,116 @@ export const deleteCrons = async (
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getCrons = async (
|
||||
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
|
||||
callback: sendUnaryData<CronsResponse>,
|
||||
) => {
|
||||
try {
|
||||
const cronService = Container.get(CronService);
|
||||
const result = await cronService.crontabs({
|
||||
searchValue: call.request.searchValue || '',
|
||||
page: '0',
|
||||
size: '0',
|
||||
sorter: '',
|
||||
filters: '',
|
||||
queryString: '',
|
||||
});
|
||||
const data = result.data.map((x) => normalizeCronData(x as CronItem));
|
||||
callback(null, {
|
||||
code: 200,
|
||||
data: data.filter((x): x is CronItem => x !== undefined),
|
||||
});
|
||||
} catch (e: any) {
|
||||
callback(null, {
|
||||
code: 500,
|
||||
data: [],
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getCronById = async (
|
||||
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
|
||||
callback: sendUnaryData<CronResponse>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.id) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
data: undefined,
|
||||
message: 'id parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const cronService = Container.get(CronService);
|
||||
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
|
||||
callback(null, { code: 200, data: normalizeCronData(data) });
|
||||
} catch (e: any) {
|
||||
callback(null, {
|
||||
code: 404,
|
||||
data: undefined,
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const enableCrons = async (
|
||||
call: ServerUnaryCall<EnableCronsRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.ids || call.request.ids.length === 0) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
message: 'ids parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const cronService = Container.get(CronService);
|
||||
await cronService.enabled(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const disableCrons = async (
|
||||
call: ServerUnaryCall<DisableCronsRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.ids || call.request.ids.length === 0) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
message: 'ids parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const cronService = Container.get(CronService);
|
||||
await cronService.disabled(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const runCrons = async (
|
||||
call: ServerUnaryCall<RunCronsRequest, Response>,
|
||||
callback: sendUnaryData<Response>,
|
||||
) => {
|
||||
try {
|
||||
if (!call.request.ids || call.request.ids.length === 0) {
|
||||
return callback(null, {
|
||||
code: 400,
|
||||
message: 'ids parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
const cronService = Container.get(CronService);
|
||||
await cronService.run(call.request.ids);
|
||||
callback(null, { code: 200 });
|
||||
} catch (e: any) {
|
||||
callback(e);
|
||||
}
|
||||
};
|
||||
|
||||
+16
-3
@@ -7,13 +7,26 @@ import {
|
||||
DeleteCronResponse,
|
||||
} from '../protos/cron';
|
||||
import config from '../config';
|
||||
import { getGrpcCerts } from '../config/grpcCerts';
|
||||
|
||||
class Client {
|
||||
private client = new CronClient(
|
||||
`0.0.0.0:${config.cronPort}`,
|
||||
credentials.createInsecure(),
|
||||
private _client: CronClient | null = null;
|
||||
|
||||
private get client(): CronClient {
|
||||
if (!this._client) {
|
||||
const tlsConfig = getGrpcCerts()!;
|
||||
this._client = new CronClient(
|
||||
`localhost:${config.grpcPort}`,
|
||||
credentials.createSsl(
|
||||
Buffer.from(tlsConfig.caCert),
|
||||
Buffer.from(tlsConfig.clientKey),
|
||||
Buffer.from(tlsConfig.clientCert),
|
||||
),
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
}
|
||||
return this._client;
|
||||
}
|
||||
|
||||
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -10,10 +10,23 @@ const delCron = (
|
||||
for (const id of call.request.ids) {
|
||||
if (scheduleStacks.has(id)) {
|
||||
Logger.info(
|
||||
'[schedule][取消定时任务], 任务ID: %s',
|
||||
'[schedule][取消定时任务] 任务ID: %s',
|
||||
id,
|
||||
);
|
||||
scheduleStacks.get(id)?.forEach(x => x.cancel());
|
||||
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
|
||||
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
|
||||
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
|
||||
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
|
||||
try {
|
||||
x.cancel();
|
||||
} catch (error: any) {
|
||||
Logger.warn(
|
||||
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
|
||||
id,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
});
|
||||
scheduleStacks.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,18 @@ 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')) {
|
||||
return callback(null, { status: 1 });
|
||||
}
|
||||
|
||||
const panelErrLog = await promiseExec(
|
||||
`tail -n 300 ~/.pm2/logs/panel-error.log`,
|
||||
);
|
||||
const scheduleErrLog = await promiseExec(
|
||||
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
|
||||
const qinglongErrLog = await promiseExec(
|
||||
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
|
||||
);
|
||||
return callback(
|
||||
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()),
|
||||
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
|
||||
);
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Server, ServerCredentials } from '@grpc/grpc-js';
|
||||
import { CronService } from '../protos/cron';
|
||||
import { addCron } from './addCron';
|
||||
import { delCron } from './delCron';
|
||||
import { HealthService } from '../protos/health';
|
||||
import { check } from './health';
|
||||
import config from '../config';
|
||||
import Logger from '../loaders/logger';
|
||||
import { ApiService } from '../protos/api';
|
||||
import * as Api from './api';
|
||||
|
||||
const server = new Server({ 'grpc.enable_http_proxy': 0 });
|
||||
server.addService(HealthService, { check });
|
||||
server.addService(CronService, { addCron, delCron });
|
||||
server.addService(ApiService, Api);
|
||||
server.bindAsync(
|
||||
`0.0.0.0:${config.cronPort}`,
|
||||
ServerCredentials.createInsecure(),
|
||||
(err, port) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
Logger.debug(`✌️ 定时服务启动成功!`);
|
||||
console.debug(`✌️ 定时服务启动成功!`);
|
||||
process.send?.('ready');
|
||||
},
|
||||
);
|
||||
+23
-16
@@ -2,8 +2,9 @@ import { Service, Inject } from 'typedi';
|
||||
import path, { join } from 'path';
|
||||
import config from '../config';
|
||||
import { getFileContentByName } from '../config/util';
|
||||
import { t } from '../shared/i18n';
|
||||
import { Response } from 'express';
|
||||
import got from 'got';
|
||||
import { request } from 'undici';
|
||||
|
||||
@Service()
|
||||
export default class ConfigService {
|
||||
@@ -11,26 +12,32 @@ export default class ConfigService {
|
||||
|
||||
public async getFile(filePath: string, res: Response) {
|
||||
let content = '';
|
||||
const avaliablePath = [config.rootPath, config.configPath].map((x) =>
|
||||
path.resolve(x, filePath),
|
||||
);
|
||||
|
||||
if (
|
||||
config.blackFileList.includes(filePath) ||
|
||||
avaliablePath.every(
|
||||
(x) =>
|
||||
!x.startsWith(config.scriptPath) && !x.startsWith(config.configPath),
|
||||
) ||
|
||||
!filePath
|
||||
) {
|
||||
return res.send({ code: 403, message: '文件无法访问' });
|
||||
if (!filePath) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
const resolvedRoot = path.resolve(config.rootPath, normalized);
|
||||
const resolvedConfig = path.resolve(config.configPath, normalized);
|
||||
const isValidPath =
|
||||
resolvedRoot.startsWith(config.scriptPath) ||
|
||||
resolvedRoot.startsWith(config.configPath) ||
|
||||
resolvedConfig.startsWith(config.scriptPath) ||
|
||||
resolvedConfig.startsWith(config.configPath);
|
||||
if (!isValidPath) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
if (config.blackFileList.includes(path.basename(normalized))) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
|
||||
if (filePath.startsWith('sample/')) {
|
||||
const res = await got.get(
|
||||
const res = await request(
|
||||
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
|
||||
);
|
||||
content = res.body;
|
||||
content = await res.body.text();
|
||||
} else if (filePath.startsWith('data/scripts/')) {
|
||||
content = await getFileContentByName(join(config.rootPath, filePath));
|
||||
} else {
|
||||
|
||||
+309
-44
@@ -2,15 +2,21 @@ import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
||||
import {
|
||||
RunningInstanceModel,
|
||||
InstanceStatus,
|
||||
} from '../data/runningInstance';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import cron_parser from 'cron-parser';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import {
|
||||
getFileContentByName,
|
||||
fileExist,
|
||||
killTask,
|
||||
killAllTasks,
|
||||
getUniqPath,
|
||||
safeJSONParse,
|
||||
isDemoEnv,
|
||||
} from '../config/util';
|
||||
import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize';
|
||||
import path from 'path';
|
||||
@@ -22,7 +28,10 @@ import dayjs from 'dayjs';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import omit from 'lodash/omit';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import { t } from '../shared/i18n';
|
||||
import { ScheduleType } from '../interface/schedule';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
@Service()
|
||||
export default class CronService {
|
||||
@@ -36,6 +45,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);
|
||||
}
|
||||
@@ -48,12 +76,37 @@ export default class CronService {
|
||||
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
|
||||
}
|
||||
|
||||
private async getLogName(cron: Crontab) {
|
||||
const { log_name, command, id } = cron;
|
||||
if (log_name === '/dev/null') {
|
||||
return log_name;
|
||||
}
|
||||
let uniqPath = await getUniqPath(command, `${id}`);
|
||||
if (log_name) {
|
||||
const normalizedLogName = log_name.startsWith('/')
|
||||
? log_name
|
||||
: path.join(config.logPath, log_name);
|
||||
if (normalizedLogName.startsWith(config.logPath)) {
|
||||
uniqPath = log_name;
|
||||
}
|
||||
}
|
||||
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
|
||||
await fs.mkdir(logDirPath, { recursive: true });
|
||||
return uniqPath;
|
||||
}
|
||||
|
||||
public async create(payload: Crontab): Promise<Crontab> {
|
||||
const tab = new Crontab(payload);
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
const doc = await this.insert(tab);
|
||||
|
||||
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
|
||||
if (isDemoEnv()) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
if (this.shouldUseCronClient(doc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
@@ -63,6 +116,18 @@ export default class CronService {
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
|
||||
// (DB 和 crontab.list 有记录但调度器永远不会执行)
|
||||
await CrontabModel.destroy({ where: { id: doc.id } });
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw new Error(
|
||||
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
@@ -77,17 +142,24 @@ export default class CronService {
|
||||
const doc = await this.getDb({ id: payload.id });
|
||||
const tab = new Crontab({ ...doc, ...payload });
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
const newDoc = await this.updateDb(tab);
|
||||
|
||||
if (doc.isDisabled === 1) {
|
||||
if (doc.isDisabled === 1 || isDemoEnv()) {
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
if (this.isNodeCron(doc)) {
|
||||
await cronClient.delCron([String(doc.id)]);
|
||||
try {
|
||||
await cronClient.delCron([String(newDoc.id)]);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) {
|
||||
if (this.shouldUseCronClient(newDoc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
@@ -97,6 +169,35 @@ export default class CronService {
|
||||
extra_schedules: newDoc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
|
||||
await CrontabModel.update(doc, { where: { id: doc.id } });
|
||||
if (this.shouldUseCronClient(doc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (_recoveryError: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to restore old cron job in scheduler after rollback:',
|
||||
_recoveryError?.message || _recoveryError,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw new Error(
|
||||
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
@@ -115,6 +216,7 @@ export default class CronService {
|
||||
log_path,
|
||||
last_running_time = 0,
|
||||
last_execution_time = 0,
|
||||
exit_code,
|
||||
}: {
|
||||
ids: number[];
|
||||
status: CrontabStatus;
|
||||
@@ -122,6 +224,7 @@ export default class CronService {
|
||||
log_path: string;
|
||||
last_running_time: number;
|
||||
last_execution_time: number;
|
||||
exit_code?: number;
|
||||
}) {
|
||||
let options: any = {
|
||||
status,
|
||||
@@ -144,6 +247,40 @@ export default class CronService {
|
||||
if (status === CrontabStatus.idle && log_path !== cron.log_path) {
|
||||
options = omit(options, ['status', 'log_path', 'pid']);
|
||||
}
|
||||
|
||||
// Manage RunningInstance records for status transitions from shell scripts
|
||||
if (status === CrontabStatus.running) {
|
||||
// Create a new running instance record
|
||||
await RunningInstanceModel.create({
|
||||
cron_id: id,
|
||||
pid: pid || undefined,
|
||||
log_path: log_path || undefined,
|
||||
started_at: last_execution_time || dayjs().unix(),
|
||||
status: InstanceStatus.running,
|
||||
});
|
||||
} else if (status === CrontabStatus.idle) {
|
||||
// Mark the matching running instance as finished
|
||||
const finishedAt = dayjs().unix();
|
||||
const instanceStatus =
|
||||
exit_code !== undefined && exit_code !== null && exit_code !== 0
|
||||
? InstanceStatus.error
|
||||
: InstanceStatus.finished;
|
||||
await RunningInstanceModel.update(
|
||||
{
|
||||
finished_at: finishedAt,
|
||||
status: instanceStatus,
|
||||
exit_code: exit_code ?? undefined,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
cron_id: id,
|
||||
pid: pid || undefined,
|
||||
status: InstanceStatus.running,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await CrontabModel.update(
|
||||
{ ...pickBy(options, (v) => v === 0 || !!v) },
|
||||
{ where: { id } },
|
||||
@@ -153,7 +290,14 @@ export default class CronService {
|
||||
|
||||
public async remove(ids: number[]) {
|
||||
await CrontabModel.destroy({ where: { id: ids } });
|
||||
try {
|
||||
await cronClient.delCron(ids.map(String));
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
}
|
||||
|
||||
@@ -210,14 +354,24 @@ export default class CronService {
|
||||
operate2 = Op.and;
|
||||
break;
|
||||
case 'In':
|
||||
if (
|
||||
property === 'status' &&
|
||||
!value.includes(CrontabStatus.disabled)
|
||||
) {
|
||||
q[Op.and] = [
|
||||
{ [property]: Array.isArray(value) ? value : [value] },
|
||||
{ isDisabled: 0 },
|
||||
];
|
||||
} else {
|
||||
q[Op.or] = [
|
||||
{
|
||||
[property]: Array.isArray(value) ? value : [value],
|
||||
},
|
||||
property === 'status' && value.includes(2)
|
||||
property === 'status' && value.includes(CrontabStatus.disabled)
|
||||
? { isDisabled: 1 }
|
||||
: {},
|
||||
];
|
||||
}
|
||||
break;
|
||||
case 'Nin':
|
||||
q[Op.and] = [
|
||||
@@ -312,7 +466,7 @@ export default class CronService {
|
||||
}
|
||||
|
||||
private formatFilterQuery(query: any, filterQuery: any) {
|
||||
if (filterQuery) {
|
||||
if (!isEmpty(filterQuery)) {
|
||||
if (!query[Op.and]) {
|
||||
query[Op.and] = [];
|
||||
}
|
||||
@@ -427,21 +581,70 @@ export default class CronService {
|
||||
public async stop(ids: number[]) {
|
||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
if (doc.pid) {
|
||||
// Kill all running instances of this task
|
||||
try {
|
||||
if (doc.pid) {
|
||||
await killTask(doc.pid);
|
||||
}
|
||||
const command = doc.command.replace(/\s+/g, ' ').trim();
|
||||
await killAllTasks(command);
|
||||
this.logger.info(
|
||||
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
}
|
||||
this.logger.error(
|
||||
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all running instances as stopped
|
||||
const finishedAt = dayjs().unix();
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: finishedAt },
|
||||
{ where: { cron_id: ids, status: InstanceStatus.running } },
|
||||
);
|
||||
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id: ids } },
|
||||
);
|
||||
}
|
||||
|
||||
public async stopInstance(instanceId: number) {
|
||||
const instance = await RunningInstanceModel.findOne({
|
||||
where: { id: instanceId, status: InstanceStatus.running },
|
||||
});
|
||||
if (!instance) {
|
||||
return { code: 400, message: t('实例不存在或已停止') };
|
||||
}
|
||||
if (instance.pid) {
|
||||
try {
|
||||
await killTask(instance.pid);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: dayjs().unix(), exit_code: 143 },
|
||||
{ where: { id: instanceId } },
|
||||
);
|
||||
|
||||
// Check if there are still other running instances for this cron
|
||||
const otherRunning = await RunningInstanceModel.count({
|
||||
where: { cron_id: instance.cron_id, status: InstanceStatus.running },
|
||||
});
|
||||
if (otherRunning === 0) {
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id: instance.cron_id } },
|
||||
);
|
||||
}
|
||||
return { code: 200, message: t('实例已停止') };
|
||||
}
|
||||
|
||||
private async runSingle(cronId: number): Promise<number | void> {
|
||||
return taskLimit.manualRunWithCronLimit(() => {
|
||||
return new Promise(async (resolve: any) => {
|
||||
@@ -461,13 +664,15 @@ export default class CronService {
|
||||
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
|
||||
);
|
||||
|
||||
let { id, command, log_path } = cron;
|
||||
const uniqPath = await getUniqPath(command, `${id}`);
|
||||
let { id, command, log_name } = cron;
|
||||
|
||||
const uniqPath =
|
||||
log_name === '/dev/null' || !log_name
|
||||
? await getUniqPath(command, `${id}`)
|
||||
: log_name;
|
||||
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
||||
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
|
||||
if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
|
||||
await fs.mkdir(logDirPath, { recursive: true });
|
||||
}
|
||||
const logPath = `${uniqPath}/${logTime}.log`;
|
||||
const absolutePath = path.resolve(config.logPath, `${logPath}`);
|
||||
const cp = spawn(
|
||||
@@ -483,7 +688,7 @@ export default class CronService {
|
||||
{ where: { id } },
|
||||
);
|
||||
cp.stdout.on('data', async (data) => {
|
||||
await fs.appendFile(absolutePath, data.toString());
|
||||
await logStreamManager.write(absolutePath, data.toString());
|
||||
});
|
||||
cp.stderr.on('data', async (data) => {
|
||||
this.logger.info(
|
||||
@@ -491,7 +696,7 @@ export default class CronService {
|
||||
command,
|
||||
data.toString(),
|
||||
);
|
||||
await fs.appendFile(absolutePath, data.toString());
|
||||
await logStreamManager.write(absolutePath, data.toString());
|
||||
});
|
||||
cp.on('error', async (err) => {
|
||||
this.logger.error(
|
||||
@@ -499,7 +704,7 @@ export default class CronService {
|
||||
command,
|
||||
err,
|
||||
);
|
||||
await fs.appendFile(absolutePath, JSON.stringify(err));
|
||||
await logStreamManager.write(absolutePath, JSON.stringify(err));
|
||||
});
|
||||
|
||||
cp.on('exit', async (code) => {
|
||||
@@ -508,10 +713,7 @@ export default class CronService {
|
||||
JSON.stringify(params),
|
||||
code,
|
||||
);
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id } },
|
||||
);
|
||||
await logStreamManager.closeStream(absolutePath);
|
||||
resolve({ ...params, pid: cp.pid, code });
|
||||
});
|
||||
});
|
||||
@@ -520,15 +722,22 @@ export default class CronService {
|
||||
|
||||
public async disabled(ids: number[]) {
|
||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||
try {
|
||||
await cronClient.delCron(ids.map(String));
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
}
|
||||
|
||||
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))
|
||||
const crons = docs
|
||||
.filter((x) => this.shouldUseCronClient(x))
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
@@ -536,22 +745,48 @@ export default class CronService {
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
await cronClient.addCron(sixCron);
|
||||
|
||||
if (isDemoEnv()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cronClient.addCron(crons);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
|
||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw new Error(
|
||||
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
}
|
||||
|
||||
public async log(id: number) {
|
||||
public async log(id: number): Promise<{ content: string; status: string }> {
|
||||
const doc = await this.getDb({ id });
|
||||
if (!doc) {
|
||||
return '';
|
||||
return { content: '', status: 'empty' };
|
||||
}
|
||||
if (doc.log_name === '/dev/null') {
|
||||
return { content: t('日志设置为忽略'), status: 'ignored' };
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
|
||||
const logFileExist = doc.log_path && (await fileExist(absolutePath));
|
||||
if (logFileExist) {
|
||||
return await getFileContentByName(`${absolutePath}`);
|
||||
const content = await getFileContentByName(`${absolutePath}`);
|
||||
const isRunning =
|
||||
typeof doc.status === 'number' &&
|
||||
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
|
||||
return { content, status: isRunning ? 'running' : 'completed' };
|
||||
} else {
|
||||
return '任务未运行';
|
||||
return typeof doc.status === 'number' &&
|
||||
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
|
||||
? { content: t('运行中...'), status: 'running' }
|
||||
: { content: t('日志不存在...'), status: 'notFound' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +806,7 @@ export default class CronService {
|
||||
files.map(async (x) => ({
|
||||
filename: x,
|
||||
directory: relativeDir.replace(config.logPath, ''),
|
||||
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
|
||||
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
|
||||
})),
|
||||
)
|
||||
).sort((a, b) => b.time - a.time);
|
||||
@@ -585,9 +820,11 @@ export default class CronService {
|
||||
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
|
||||
command = `${TASK_PREFIX}${tab.command}`;
|
||||
}
|
||||
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${
|
||||
tab.id
|
||||
} `;
|
||||
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
|
||||
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell
|
||||
if (tab.log_name) {
|
||||
commandVariable += `log_name=${tab.log_name} `;
|
||||
}
|
||||
if (tab.task_before) {
|
||||
commandVariable += `task_before='${tab.task_before
|
||||
.replace(/'/g, "'\\''")
|
||||
@@ -600,6 +837,9 @@ export default class CronService {
|
||||
.replace(/;? *\n/g, ';')
|
||||
.trim()}' `;
|
||||
}
|
||||
if (tab.work_dir) {
|
||||
commandVariable += `work_dir='${tab.work_dir.replace(/'/g, "'\\''")}' `;
|
||||
}
|
||||
|
||||
const crontab_job_string = `${commandVariable}${command}`;
|
||||
return crontab_job_string;
|
||||
@@ -609,11 +849,9 @@ export default class CronService {
|
||||
const tabs = data ?? (await this.crontabs());
|
||||
var crontab_string = '';
|
||||
tabs.data.forEach((tab) => {
|
||||
const _schedule = tab.schedule && tab.schedule.split(/ +/);
|
||||
if (
|
||||
tab.isDisabled === 1 ||
|
||||
_schedule!.length !== 5 ||
|
||||
tab.extra_schedules?.length ||
|
||||
this.isNodeCron(tab) ||
|
||||
this.isSpecialSchedule(tab.schedule)
|
||||
) {
|
||||
crontab_string += '# ';
|
||||
@@ -631,12 +869,25 @@ export default class CronService {
|
||||
|
||||
await writeFileWithLock(config.crontabFile, crontab_string);
|
||||
|
||||
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: {} });
|
||||
}
|
||||
|
||||
public importCrontab() {
|
||||
exec('crontab -l', (error, stdout, stderr) => {
|
||||
exec('crontab -l', (error, stdout) => {
|
||||
if (error) {
|
||||
const errorMsg = error.message || String(error);
|
||||
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
|
||||
}
|
||||
|
||||
const lines = stdout.split('\n');
|
||||
const namePrefix = new Date().getTime();
|
||||
|
||||
@@ -650,7 +901,7 @@ export default class CronService {
|
||||
if (
|
||||
command &&
|
||||
schedule &&
|
||||
cron_parser.parseExpression(schedule).hasNext()
|
||||
CronExpressionParser.parse(schedule).hasNext()
|
||||
) {
|
||||
const name = namePrefix + '_' + index;
|
||||
|
||||
@@ -671,14 +922,11 @@ export default class CronService {
|
||||
|
||||
public async autosave_crontab() {
|
||||
const tabs = await this.crontabs();
|
||||
this.setCrontab(tabs);
|
||||
|
||||
const regularCrons = tabs.data
|
||||
.filter(
|
||||
(x) =>
|
||||
this.isNodeCron(x) &&
|
||||
x.isDisabled !== 1 &&
|
||||
!this.isSpecialSchedule(x.schedule),
|
||||
this.shouldUseCronClient(x),
|
||||
)
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
@@ -687,7 +935,24 @@ export default class CronService {
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
|
||||
if (isDemoEnv()) {
|
||||
await writeFileWithLock(config.crontabFile, '');
|
||||
return;
|
||||
}
|
||||
|
||||
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
|
||||
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
|
||||
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
|
||||
await this.setCrontab(tabs);
|
||||
try {
|
||||
await cronClient.addCron(regularCrons);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to register cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async bootTask() {
|
||||
@@ -701,7 +966,7 @@ export default class CronService {
|
||||
{ where: { id: bootTasks.map((t) => t.id!) } },
|
||||
);
|
||||
for (const task of bootTasks) {
|
||||
await this.runSingle(task.id!);
|
||||
this.runSingle(task.id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+130
-75
@@ -3,12 +3,9 @@ import winston from 'winston';
|
||||
import config from '../config';
|
||||
import {
|
||||
Dependence,
|
||||
InstallDependenceCommandTypes,
|
||||
DependenceStatus,
|
||||
DependenceTypes,
|
||||
unInstallDependenceCommandTypes,
|
||||
DependenceModel,
|
||||
GetDependenceCommandTypes,
|
||||
versionDependenceCommandTypes,
|
||||
} from '../data/dependence';
|
||||
import { spawn } from 'cross-spawn';
|
||||
@@ -19,9 +16,15 @@ import {
|
||||
getPid,
|
||||
killTask,
|
||||
promiseExecSuccess,
|
||||
getInstallCommand,
|
||||
getUninstallCommand,
|
||||
getGetCommand,
|
||||
} from '../config/util';
|
||||
import dayjs from 'dayjs';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { detectOS } from '../config/util';
|
||||
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
|
||||
@Service()
|
||||
export default class DependenceService {
|
||||
@@ -67,6 +70,9 @@ export default class DependenceService {
|
||||
|
||||
public async remove(ids: number[], force = false): Promise<Dependence[]> {
|
||||
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
taskLimit.removeQueuedDependency(doc);
|
||||
}
|
||||
const unInstalledDeps = docs.filter(
|
||||
(x) => x.status !== DependenceStatus.installed,
|
||||
);
|
||||
@@ -95,34 +101,32 @@ export default class DependenceService {
|
||||
searchValue,
|
||||
type,
|
||||
status,
|
||||
}: { searchValue: string; type: string; status: string },
|
||||
}: {
|
||||
searchValue: string;
|
||||
type: keyof typeof DependenceTypes;
|
||||
status: string;
|
||||
},
|
||||
sort: any = [],
|
||||
query: any = {},
|
||||
): Promise<Dependence[]> {
|
||||
let condition = {
|
||||
...query,
|
||||
type: DependenceTypes[type as any],
|
||||
};
|
||||
let condition = query;
|
||||
if (type && DependenceTypes[type] !== undefined) {
|
||||
condition.type = DependenceTypes[type];
|
||||
}
|
||||
if (status) {
|
||||
condition.status = status.split(',').map(Number);
|
||||
}
|
||||
if (searchValue) {
|
||||
const encodeText = encodeURI(searchValue);
|
||||
const reg = {
|
||||
condition.name = {
|
||||
[Op.or]: [
|
||||
{ [Op.like]: `%${searchValue}%` },
|
||||
{ [Op.like]: `%${encodeText}%` },
|
||||
],
|
||||
};
|
||||
|
||||
condition = {
|
||||
...condition,
|
||||
name: reg,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await this.find(condition, sort);
|
||||
return result as any;
|
||||
return await this.find(condition, sort);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -132,10 +136,12 @@ export default class DependenceService {
|
||||
docs: Dependence[],
|
||||
isInstall: boolean = true,
|
||||
force: boolean = false,
|
||||
) {
|
||||
): Promise<void> {
|
||||
docs.forEach((dep) => {
|
||||
this.installOrUninstallDependency(dep, isInstall, force);
|
||||
});
|
||||
|
||||
return taskLimit.waitDependencyQueueDone();
|
||||
}
|
||||
|
||||
public async reInstall(ids: number[]): Promise<Dependence[]> {
|
||||
@@ -145,6 +151,9 @@ export default class DependenceService {
|
||||
);
|
||||
|
||||
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
taskLimit.removeQueuedDependency(doc);
|
||||
}
|
||||
this.installDependenceOneByOne(docs, true, true);
|
||||
return docs;
|
||||
}
|
||||
@@ -153,13 +162,22 @@ export default class DependenceService {
|
||||
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
taskLimit.removeQueuedDependency(doc);
|
||||
const depInstallCommand = InstallDependenceCommandTypes[doc.type];
|
||||
const depUnInstallCommand = unInstallDependenceCommandTypes[doc.type];
|
||||
const installCmd = `${depInstallCommand} ${doc.name.trim()}`;
|
||||
const unInstallCmd = `${depUnInstallCommand} ${doc.name.trim()}`;
|
||||
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(installCmd),
|
||||
getPid(unInstallCmd),
|
||||
getPid(depInstallCommand),
|
||||
getPid(depUnInstallCommand),
|
||||
]);
|
||||
for (const pid of pids) {
|
||||
pid && (await killTask(pid));
|
||||
@@ -213,41 +231,83 @@ 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 ? t('安装') : t('删除');
|
||||
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 = tf(
|
||||
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒',
|
||||
actionText,
|
||||
depName,
|
||||
startTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
actionText,
|
||||
startTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
String(startTime.diff(startTime, 'second')),
|
||||
);
|
||||
this.sockService.sendMessage({
|
||||
type: socketMessageType,
|
||||
message,
|
||||
references: depIds,
|
||||
status: DependenceStatus.installFailed,
|
||||
});
|
||||
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 depRunCommand = (
|
||||
isInstall
|
||||
? InstallDependenceCommandTypes
|
||||
: unInstallDependenceCommandTypes
|
||||
)[dependency.type];
|
||||
const actionText = isInstall ? '安装' : '删除';
|
||||
let command = isInstall
|
||||
? getInstallCommand(dependency.type, depName)
|
||||
: getUninstallCommand(dependency.type, depName);
|
||||
if (isLinuxDependence) {
|
||||
command = isInstall
|
||||
? `${linuxCommand.install} ${depName.trim()}`
|
||||
: `${linuxCommand.uninstall} ${depName.trim()}`;
|
||||
}
|
||||
const startTime = dayjs();
|
||||
|
||||
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)}\n\n`;
|
||||
const message = tf(
|
||||
'开始%s依赖 %s,开始时间 %s\n\n',
|
||||
actionText,
|
||||
depName,
|
||||
startTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
);
|
||||
this.sockService.sendMessage({
|
||||
type: socketMessageType,
|
||||
message,
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, message);
|
||||
|
||||
// 判断是否已经安装过依赖
|
||||
if (isInstall && !force) {
|
||||
const getCommandPrefix = GetDependenceCommandTypes[dependency.type];
|
||||
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(
|
||||
@@ -259,17 +319,7 @@ 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(
|
||||
isNodeDependence
|
||||
? `${getCommandPrefix} | grep "${depName}" | head -1`
|
||||
: `${getCommandPrefix} ${depName}`,
|
||||
)
|
||||
)
|
||||
const depInfo = (await promiseExecSuccess(getCommand))
|
||||
.replace(/\s{2,}/, ' ')
|
||||
.replace(/\s+$/, '');
|
||||
|
||||
@@ -277,18 +327,24 @@ export default class DependenceService {
|
||||
depInfo &&
|
||||
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
|
||||
(isLinuxDependence &&
|
||||
depInfo.toLocaleLowerCase().includes('installed')) ||
|
||||
linuxCommand.check(depInfo.toLocaleLowerCase())) ||
|
||||
isPythonDependence) &&
|
||||
(!depVersion || depInfo.includes(depVersion))
|
||||
) {
|
||||
const endTime = dayjs();
|
||||
const _message = `检测到已经安装 ${depName}\n\n${depInfo}\n\n跳过安装\n\n依赖${actionText}成功,结束时间 ${endTime.format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)},耗时 ${endTime.diff(startTime, 'second')} 秒`;
|
||||
const _message = tf(
|
||||
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒',
|
||||
depName,
|
||||
depInfo,
|
||||
actionText,
|
||||
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
String(endTime.diff(startTime, 'second')),
|
||||
);
|
||||
this.sockService.sendMessage({
|
||||
type: socketMessageType,
|
||||
message: _message,
|
||||
references: depIds,
|
||||
status: DependenceStatus.installed,
|
||||
});
|
||||
this.updateLog(depIds, _message);
|
||||
await DependenceModel.update(
|
||||
@@ -304,18 +360,16 @@ export default class DependenceService {
|
||||
const proxyStr = dependenceProxyFileExist
|
||||
? `source ${config.dependenceProxyFile} &&`
|
||||
: '';
|
||||
const cp = spawn(
|
||||
`${proxyStr} ${depRunCommand} ${dependency.name.trim()}`,
|
||||
{
|
||||
const cp = spawn(`${proxyStr} ${command}`, {
|
||||
shell: '/bin/bash',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
cp.stdout.on('data', async (data) => {
|
||||
this.sockService.sendMessage({
|
||||
type: socketMessageType,
|
||||
message: data.toString(),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, data.toString());
|
||||
});
|
||||
@@ -325,6 +379,7 @@ export default class DependenceService {
|
||||
type: socketMessageType,
|
||||
message: data.toString(),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, data.toString());
|
||||
});
|
||||
@@ -334,6 +389,7 @@ export default class DependenceService {
|
||||
type: socketMessageType,
|
||||
message: JSON.stringify(err),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, JSON.stringify(err));
|
||||
});
|
||||
@@ -341,28 +397,27 @@ export default class DependenceService {
|
||||
cp.on('exit', async (code) => {
|
||||
const endTime = dayjs();
|
||||
const isSucceed = code === 0;
|
||||
const resultText = isSucceed ? '成功' : '失败';
|
||||
const resultText = isSucceed ? t('成功') : t('失败');
|
||||
|
||||
const message = `\n依赖${actionText}${resultText},结束时间 ${endTime.format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)},耗时 ${endTime.diff(startTime, 'second')} 秒`;
|
||||
const message =
|
||||
'\n' +
|
||||
tf('依赖%s%s,结束时间 %s,耗时 %s 秒',
|
||||
actionText,
|
||||
resultText,
|
||||
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
String(endTime.diff(startTime, 'second')),
|
||||
);
|
||||
const exitStatus = isSucceed
|
||||
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed)
|
||||
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed);
|
||||
this.sockService.sendMessage({
|
||||
type: socketMessageType,
|
||||
message,
|
||||
references: depIds,
|
||||
status: exitStatus,
|
||||
});
|
||||
this.updateLog(depIds, message);
|
||||
|
||||
let status: number;
|
||||
if (isSucceed) {
|
||||
status = isInstall
|
||||
? DependenceStatus.installed
|
||||
: DependenceStatus.removed;
|
||||
} else {
|
||||
status = isInstall
|
||||
? DependenceStatus.installFailed
|
||||
: DependenceStatus.removeFailed;
|
||||
}
|
||||
const docs = await DependenceModel.findAll({ where: { id: depIds } });
|
||||
const _docIds = docs
|
||||
.filter((x) => x.status !== DependenceStatus.cancelled)
|
||||
@@ -370,7 +425,7 @@ export default class DependenceService {
|
||||
|
||||
if (_docIds.length > 0) {
|
||||
await DependenceModel.update(
|
||||
{ status },
|
||||
{ status: exitStatus },
|
||||
{ where: { id: _docIds } },
|
||||
);
|
||||
}
|
||||
|
||||
+54
-7
@@ -1,7 +1,8 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { FindOptions, Op } from 'sequelize';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
import {
|
||||
Env,
|
||||
EnvModel,
|
||||
@@ -11,9 +12,8 @@ import {
|
||||
minPosition,
|
||||
stepPosition,
|
||||
} from '../data/env';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { FindOptions, Op } from 'sequelize';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import { sequelize } from '../data';
|
||||
|
||||
@Service()
|
||||
export default class EnvService {
|
||||
@@ -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[]> {
|
||||
@@ -147,6 +147,7 @@ export default class EnvService {
|
||||
}
|
||||
try {
|
||||
const result = await this.find(condition, [
|
||||
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'],
|
||||
['position', 'DESC'],
|
||||
['createdAt', 'ASC'],
|
||||
]);
|
||||
@@ -190,6 +191,52 @@ export default class EnvService {
|
||||
await this.set_envs();
|
||||
}
|
||||
|
||||
public async pin(ids: number[]) {
|
||||
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } });
|
||||
}
|
||||
|
||||
public async unPin(ids: number[]) {
|
||||
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 },
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Server, ServerCredentials } from '@grpc/grpc-js';
|
||||
import { CronService } from '../protos/cron';
|
||||
import { HealthService } from '../protos/health';
|
||||
import { ApiService } from '../protos/api';
|
||||
import { addCron } from '../schedule/addCron';
|
||||
import { delCron } from '../schedule/delCron';
|
||||
import { check } from '../schedule/health';
|
||||
import * as Api from '../schedule/api';
|
||||
import Logger from '../loaders/logger';
|
||||
import { promisify } from 'util';
|
||||
import config from '../config';
|
||||
import { metricsService } from './metrics';
|
||||
import { Service } from 'typedi';
|
||||
import { initGrpcCerts } from '../config/grpcCerts';
|
||||
|
||||
@Service()
|
||||
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 });
|
||||
this.server.addService(CronService, { addCron, delCron });
|
||||
this.server.addService(ApiService, Api);
|
||||
|
||||
const tlsConfig = await initGrpcCerts();
|
||||
const credentials = ServerCredentials.createSsl(
|
||||
Buffer.from(tlsConfig.caCert),
|
||||
[{ cert_chain: Buffer.from(tlsConfig.serverCert), private_key: Buffer.from(tlsConfig.serverKey) }],
|
||||
true,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const host of hostsToTry) {
|
||||
try {
|
||||
const address = this.formatGrpcAddress(host, grpcPort);
|
||||
await bindAsync(address, credentials);
|
||||
Logger.debug(`[boot] 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;
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
try {
|
||||
if (this.server) {
|
||||
await new Promise((resolve) => {
|
||||
this.server.tryShutdown(() => {
|
||||
Logger.debug('gRPC service stopped');
|
||||
metricsService.record('grpc_service_stop', 1);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error('Error while shutting down gRPC service:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
getServer() {
|
||||
return this.server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Service } from 'typedi';
|
||||
import Logger from '../loaders/logger';
|
||||
import { GrpcServerService } from './grpc';
|
||||
import { HttpServerService } from './http';
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'ok' | 'error';
|
||||
services: {
|
||||
http: boolean;
|
||||
grpc: boolean;
|
||||
};
|
||||
metrics: {
|
||||
uptime: number;
|
||||
memory: {
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class HealthService {
|
||||
private startTime = Date.now();
|
||||
|
||||
constructor(
|
||||
private grpcServerService: GrpcServerService,
|
||||
private httpServerService: HttpServerService,
|
||||
) {}
|
||||
|
||||
async check(): Promise<HealthStatus> {
|
||||
const status: HealthStatus = {
|
||||
status: 'ok',
|
||||
services: {
|
||||
http: true,
|
||||
grpc: true,
|
||||
},
|
||||
metrics: {
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
memory: {
|
||||
used: process.memoryUsage().heapUsed,
|
||||
total: process.memoryUsage().heapTotal,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const httpServer = this.httpServerService.getServer();
|
||||
if (!httpServer) {
|
||||
status.services.http = false;
|
||||
status.status = 'error';
|
||||
}
|
||||
} catch (err) {
|
||||
status.services.http = false;
|
||||
status.status = 'error';
|
||||
Logger.error('HTTP server check failed:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const grpcServer = this.grpcServerService.getServer();
|
||||
if (!grpcServer) {
|
||||
status.services.grpc = false;
|
||||
status.status = 'error';
|
||||
}
|
||||
} catch (err) {
|
||||
status.services.grpc = false;
|
||||
status.status = 'error';
|
||||
Logger.error('gRPC server check failed:', err);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import express from 'express';
|
||||
import Logger from '../loaders/logger';
|
||||
import { metricsService } from './metrics';
|
||||
import { Service } from 'typedi';
|
||||
import { Server } from 'http';
|
||||
import config from '../config';
|
||||
|
||||
@Service()
|
||||
export class HttpServerService {
|
||||
private server?: Server = undefined;
|
||||
|
||||
async initialize(expressApp: express.Application, port: number) {
|
||||
const hostsToTry = [
|
||||
config.bindHost,
|
||||
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const host of hostsToTry) {
|
||||
try {
|
||||
const server = await this.tryListen(expressApp, port, host);
|
||||
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
|
||||
metricsService.record('http_service_start', 1, {
|
||||
port: port.toString(),
|
||||
host
|
||||
});
|
||||
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() {
|
||||
try {
|
||||
if (this.server) {
|
||||
await new Promise((resolve) => {
|
||||
this.server?.close(() => {
|
||||
Logger.debug('HTTP service stopped');
|
||||
metricsService.record('http_service_stop', 1);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error('Error while shutting down HTTP service:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
getServer() {
|
||||
return this.server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import path from 'path';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
|
||||
@Service()
|
||||
export default class LogService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
public checkFilePath(filePath: string, fileName: string) {
|
||||
const finalPath = path.resolve(config.logPath, filePath, fileName);
|
||||
return finalPath.startsWith(config.logPath) ? finalPath : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { performance } from 'perf_hooks';
|
||||
import Logger from '../loaders/logger';
|
||||
|
||||
interface Metric {
|
||||
name: string;
|
||||
value: number;
|
||||
timestamp: number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
class MetricsService {
|
||||
private metrics: Metric[] = [];
|
||||
private static instance: MetricsService;
|
||||
|
||||
private constructor() {
|
||||
// 定期清理旧数据
|
||||
setInterval(() => {
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
static getInstance(): MetricsService {
|
||||
if (!MetricsService.instance) {
|
||||
MetricsService.instance = new MetricsService();
|
||||
}
|
||||
return MetricsService.instance;
|
||||
}
|
||||
|
||||
record(name: string, value: number, tags?: Record<string, string>) {
|
||||
this.metrics.push({
|
||||
name,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
measure(name: string, fn: () => void, tags?: Record<string, string>) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
const duration = performance.now() - start;
|
||||
this.record(name, duration, tags);
|
||||
}
|
||||
}
|
||||
|
||||
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
const duration = performance.now() - start;
|
||||
this.record(name, duration, tags);
|
||||
}
|
||||
}
|
||||
|
||||
getMetrics(name?: string, tags?: Record<string, string>) {
|
||||
let filtered = this.metrics;
|
||||
|
||||
if (name) {
|
||||
filtered = filtered.filter(m => m.name === name);
|
||||
}
|
||||
|
||||
if (tags) {
|
||||
filtered = filtered.filter(m => {
|
||||
if (!m.tags) return false;
|
||||
return Object.entries(tags).every(([key, value]) => m.tags![key] === value);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
count: filtered.length,
|
||||
average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length,
|
||||
min: Math.min(...filtered.map(m => m.value)),
|
||||
max: Math.max(...filtered.map(m => m.value)),
|
||||
metrics: filtered,
|
||||
};
|
||||
}
|
||||
|
||||
report() {
|
||||
const report = {
|
||||
timestamp: Date.now(),
|
||||
metrics: this.getMetrics(),
|
||||
};
|
||||
Logger.info('性能指标报告:', report);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
export const metricsService = MetricsService.getInstance();
|
||||
+213
-111
@@ -1,11 +1,12 @@
|
||||
import crypto from 'crypto';
|
||||
import got from 'got';
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { parseBody, parseHeaders } from '../config/util';
|
||||
import { NotificationInfo } from '../data/notify';
|
||||
import { t } from '../shared/i18n';
|
||||
import UserService from './user';
|
||||
import { httpClient } from '../config/http';
|
||||
import { ProxyAgent } from 'undici';
|
||||
|
||||
@Service()
|
||||
export default class NotificationService {
|
||||
@@ -34,6 +35,8 @@ export default class NotificationService {
|
||||
['chronocat', this.chronocat],
|
||||
['ntfy', this.ntfy],
|
||||
['wxPusherBot', this.wxPusherBot],
|
||||
['wxPusherSpt', this.wxPusherSpt],
|
||||
['openiLink', this.openiLink],
|
||||
]);
|
||||
|
||||
private title = '';
|
||||
@@ -49,17 +52,26 @@ export default class NotificationService {
|
||||
public async notify(
|
||||
title: string,
|
||||
content: string,
|
||||
notificationInfo?: NotificationInfo,
|
||||
): Promise<boolean | undefined> {
|
||||
const { type, ...rest } = await this.userService.getNotificationMode();
|
||||
let { type, ...rest } = await this.userService.getNotificationMode();
|
||||
if (notificationInfo?.type) {
|
||||
type = notificationInfo?.type;
|
||||
}
|
||||
if (type) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.params = rest;
|
||||
let params = rest;
|
||||
if (notificationInfo) {
|
||||
const { type: _, ...others } = notificationInfo;
|
||||
params = { ...rest, ...others };
|
||||
}
|
||||
this.params = params;
|
||||
const notificationModeAction = this.modeMap.get(type);
|
||||
try {
|
||||
return await notificationModeAction?.call(this);
|
||||
} catch (error: any) {
|
||||
throw error;
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -81,11 +93,20 @@ 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 {
|
||||
const res: any = await got
|
||||
.post(`${gotifyUrl}/message?token=${gotifyToken}`, {
|
||||
const res = await httpClient.post(
|
||||
`${gotifyUrl}/message?token=${gotifyToken}`,
|
||||
{
|
||||
...this.gotOption,
|
||||
body: `title=${encodeURIComponent(
|
||||
this.title,
|
||||
@@ -95,8 +116,8 @@ export default class NotificationService {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
})
|
||||
.json();
|
||||
},
|
||||
);
|
||||
if (typeof res.id === 'number') {
|
||||
return true;
|
||||
} else {
|
||||
@@ -110,13 +131,11 @@ export default class NotificationService {
|
||||
private async goCqHttpBot() {
|
||||
const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params;
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
|
||||
const res = await httpClient.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
|
||||
...this.gotOption,
|
||||
json: { message: `${this.title}\n${this.content}` },
|
||||
headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (res.retcode === 0) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -136,15 +155,13 @@ export default class NotificationService {
|
||||
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
|
||||
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
body: `title=${encodeURIComponent(
|
||||
this.title,
|
||||
)}&desp=${encodeURIComponent(this.content)}`,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (res.errno === 0 || res.data.errno === 0) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -159,15 +176,13 @@ export default class NotificationService {
|
||||
const { pushDeerKey, pushDeerUrl } = this.params;
|
||||
const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`;
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
|
||||
this.title,
|
||||
)}&desp=${encodeURIComponent(this.content)}&type=markdown`,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (
|
||||
res.content.result.length !== undefined &&
|
||||
res.content.result.length > 0
|
||||
@@ -184,13 +199,11 @@ export default class NotificationService {
|
||||
private async chat() {
|
||||
const { synologyChatUrl } = this.params;
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(synologyChatUrl, {
|
||||
const res = await httpClient.post(synologyChatUrl, {
|
||||
...this.gotOption,
|
||||
body: `payload={"text":"${this.title}\n${this.content}"}`,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (res.success) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -226,13 +239,11 @@ export default class NotificationService {
|
||||
url: barkUrl,
|
||||
};
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json: body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (res.code === 200) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -258,29 +269,17 @@ export default class NotificationService {
|
||||
}/bot${telegramBotToken}/sendMessage`;
|
||||
let agent;
|
||||
if (telegramBotProxyHost && telegramBotProxyPort) {
|
||||
const options: any = {
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 1000,
|
||||
maxSockets: 256,
|
||||
maxFreeSockets: 256,
|
||||
proxy: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
|
||||
};
|
||||
const httpAgent = new HttpProxyAgent(options);
|
||||
const httpsAgent = new HttpsProxyAgent(options);
|
||||
agent = {
|
||||
http: httpAgent,
|
||||
https: httpsAgent,
|
||||
};
|
||||
agent = new ProxyAgent({
|
||||
uri: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
agent,
|
||||
})
|
||||
.json();
|
||||
dispatcher: agent,
|
||||
});
|
||||
if (res.ok) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -303,8 +302,7 @@ export default class NotificationService {
|
||||
}
|
||||
const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`;
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json: {
|
||||
msgtype: 'text',
|
||||
@@ -312,8 +310,7 @@ export default class NotificationService {
|
||||
content: ` ${this.title}\n\n${this.content}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (res.errcode === 0) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -329,8 +326,7 @@ export default class NotificationService {
|
||||
this.params;
|
||||
const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`;
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json: {
|
||||
msgtype: 'text',
|
||||
@@ -338,8 +334,7 @@ export default class NotificationService {
|
||||
content: ` ${this.title}\n\n${this.content}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (res.errcode === 0) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -356,15 +351,13 @@ export default class NotificationService {
|
||||
const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] =
|
||||
weWorkAppKey.split(',');
|
||||
const url = `${weWorkOrigin}/cgi-bin/gettoken`;
|
||||
const tokenRes: any = await got
|
||||
.post(url, {
|
||||
const tokenRes = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json: {
|
||||
corpid,
|
||||
corpsecret,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
});
|
||||
|
||||
let options: any = {
|
||||
msgtype: 'mpnews',
|
||||
@@ -373,7 +366,7 @@ export default class NotificationService {
|
||||
{
|
||||
title: `${this.title}`,
|
||||
thumb_media_id,
|
||||
author: `智能助手`,
|
||||
author: t('智能助手'),
|
||||
content_source_url: ``,
|
||||
content: `${this.content.replace(/\n/g, '<br/>')}`,
|
||||
digest: `${this.content}`,
|
||||
@@ -389,7 +382,7 @@ export default class NotificationService {
|
||||
title: `${this.title}`,
|
||||
description: `${this.content}`,
|
||||
url: 'https://github.com/whyour/qinglong',
|
||||
btntxt: '更多',
|
||||
btntxt: t('更多'),
|
||||
},
|
||||
};
|
||||
break;
|
||||
@@ -405,8 +398,7 @@ export default class NotificationService {
|
||||
}
|
||||
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(
|
||||
const res = await httpClient.post(
|
||||
`${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
|
||||
{
|
||||
...this.gotOption,
|
||||
@@ -417,8 +409,7 @@ export default class NotificationService {
|
||||
...options,
|
||||
},
|
||||
},
|
||||
)
|
||||
.json();
|
||||
);
|
||||
|
||||
if (res.errcode === 0) {
|
||||
return true;
|
||||
@@ -442,7 +433,7 @@ export default class NotificationService {
|
||||
roomName: `${aibotkName}`,
|
||||
message: {
|
||||
type: 1,
|
||||
content: `【青龙快讯】\n\n${this.title}\n${this.content}`,
|
||||
content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`,
|
||||
},
|
||||
};
|
||||
break;
|
||||
@@ -453,21 +444,19 @@ export default class NotificationService {
|
||||
name: `${aibotkName}`,
|
||||
message: {
|
||||
type: 1,
|
||||
content: `【青龙快讯】\n\n${this.title}\n${this.content}`,
|
||||
content: `【${t('青龙快讯')}】\n\n${this.title}\n${this.content}`,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json: {
|
||||
...json,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (res.code === 0) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -482,13 +471,11 @@ export default class NotificationService {
|
||||
const { iGotPushKey } = this.params;
|
||||
const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`;
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
body: `title=${this.title}&content=${this.content}`,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
.json();
|
||||
});
|
||||
|
||||
if (res.ret === 0) {
|
||||
return true;
|
||||
@@ -527,7 +514,7 @@ export default class NotificationService {
|
||||
},
|
||||
};
|
||||
|
||||
const res: any = await got.post(url, body).json();
|
||||
const res = await httpClient.post(url, body);
|
||||
|
||||
if (res.code === 200) {
|
||||
return true;
|
||||
@@ -551,8 +538,7 @@ export default class NotificationService {
|
||||
|
||||
const url = `https://www.weplusbot.com/send`;
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json: {
|
||||
token: `${wePlusBotToken}`,
|
||||
@@ -562,8 +548,7 @@ export default class NotificationService {
|
||||
receiver: `${wePlusBotReceiver || ''}`,
|
||||
version: `${wePlusBotVersion || 'pro'}`,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
});
|
||||
|
||||
if (res.code === 200) {
|
||||
return true;
|
||||
@@ -576,23 +561,35 @@ export default class NotificationService {
|
||||
}
|
||||
|
||||
private async lark() {
|
||||
let { larkKey } = this.params;
|
||||
let { larkKey, larkSecret } = this.params;
|
||||
|
||||
if (!larkKey.startsWith('http')) {
|
||||
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(larkKey, {
|
||||
...this.gotOption,
|
||||
json: {
|
||||
const body: Record<string, any> = {
|
||||
msg_type: 'text',
|
||||
content: { text: `${this.title}\n\n${this.content}` },
|
||||
},
|
||||
};
|
||||
|
||||
// Add signature if secret is provided
|
||||
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
|
||||
// and signs an empty message, which differs from typical HMAC usage
|
||||
if (larkSecret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
const stringToSign = `${timestamp}\n${larkSecret}`;
|
||||
const hmac = crypto.createHmac('sha256', stringToSign);
|
||||
const sign = hmac.digest('base64');
|
||||
body.timestamp = timestamp;
|
||||
body.sign = sign;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await httpClient.post(larkKey, {
|
||||
...this.gotOption,
|
||||
json: body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
.json();
|
||||
});
|
||||
if (res.StatusCode === 0 || res.code === 0) {
|
||||
return true;
|
||||
} else {
|
||||
@@ -604,7 +601,8 @@ export default class NotificationService {
|
||||
}
|
||||
|
||||
private async email() {
|
||||
const { emailPass, emailService, emailUser } = this.params;
|
||||
const { emailPass, emailService, emailUser, emailTo } = this.params;
|
||||
const recipients = this.parseMailRecipients(emailTo) || emailUser;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
@@ -616,8 +614,8 @@ export default class NotificationService {
|
||||
});
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: `"青龙快讯" <${emailUser}>`,
|
||||
to: `${emailUser}`,
|
||||
from: `"${t('青龙快讯')}" <${emailUser}>`,
|
||||
to: recipients,
|
||||
subject: `${this.title}`,
|
||||
html: `${this.content.replace(/\n/g, '<br/>')}`,
|
||||
});
|
||||
@@ -637,7 +635,9 @@ export default class NotificationService {
|
||||
private async pushMe() {
|
||||
const { pushMeKey, pushMeUrl } = this.params;
|
||||
try {
|
||||
const res: any = await got.post(pushMeUrl || 'https://push.i-i.me/', {
|
||||
const res = await httpClient.post<'text'>(
|
||||
pushMeUrl || 'https://push.i-i.me/',
|
||||
{
|
||||
...this.gotOption,
|
||||
json: {
|
||||
push_key: pushMeKey,
|
||||
@@ -645,11 +645,12 @@ export default class NotificationService {
|
||||
content: this.content,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.body === 'success') {
|
||||
},
|
||||
);
|
||||
if (res === 'success') {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(res.body);
|
||||
throw new Error(res);
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
@@ -657,26 +658,49 @@ export default class NotificationService {
|
||||
}
|
||||
|
||||
private async ntfy() {
|
||||
const { ntfyUrl, ntfyTopic, ntfyPriority } = this.params;
|
||||
const {
|
||||
ntfyUrl,
|
||||
ntfyTopic,
|
||||
ntfyPriority,
|
||||
ntfyToken,
|
||||
ntfyUsername,
|
||||
ntfyPassword,
|
||||
ntfyActions,
|
||||
} = this.params;
|
||||
// 编码函数
|
||||
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
|
||||
const encodedText = Buffer.from(text).toString('base64');
|
||||
return `=?${charset}?B?${encodedText}?=`;
|
||||
};
|
||||
try {
|
||||
const encodedTitle = encodeRfc2047(this.title);
|
||||
const res: any = await got.post(
|
||||
const headers: Record<string, string> = {
|
||||
Title: encodeRfc2047(this.title),
|
||||
Priority: `${ntfyPriority || '3'}`,
|
||||
Icon: 'https://qn.whyour.cn/logo.png',
|
||||
};
|
||||
if (ntfyToken) {
|
||||
headers['Authorization'] = `Bearer ${ntfyToken}`;
|
||||
} else if (ntfyUsername && ntfyPassword) {
|
||||
headers['Authorization'] = `Basic ${Buffer.from(
|
||||
`${ntfyUsername}:${ntfyPassword}`,
|
||||
).toString('base64')}`;
|
||||
}
|
||||
if (ntfyActions) {
|
||||
headers['Actions'] = encodeRfc2047(ntfyActions);
|
||||
}
|
||||
const res = await httpClient.request(
|
||||
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
|
||||
{
|
||||
...this.gotOption,
|
||||
body: `${this.content}`,
|
||||
headers: { Title: encodedTitle, Priority: `${ntfyPriority || '3'}` },
|
||||
headers: headers,
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
if (res.statusCode === 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(JSON.stringify(res));
|
||||
throw new Error(await res.body.text());
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
@@ -705,13 +729,12 @@ export default class NotificationService {
|
||||
|
||||
// topic_ids 和 uids 至少要有一个
|
||||
if (!topicIds.length && !uids.length) {
|
||||
throw new Error('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行');
|
||||
throw new Error(t('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'));
|
||||
}
|
||||
|
||||
const url = `https://wxpusher.zjiecode.com/api/send/message`;
|
||||
try {
|
||||
const res: any = await got
|
||||
.post(url, {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json: {
|
||||
appToken: wxPusherBotAppToken,
|
||||
@@ -722,8 +745,53 @@ export default class NotificationService {
|
||||
uids: uids,
|
||||
verifyPayType: 0,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
});
|
||||
|
||||
if (res.code === 1000) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(JSON.stringify(res));
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
}
|
||||
}
|
||||
|
||||
private async wxPusherSpt() {
|
||||
const { wxPusherSptList } = this.params;
|
||||
// 处理 SPT,将逗号分隔的字符串转为数组
|
||||
const spts = wxPusherSptList
|
||||
? wxPusherSptList
|
||||
.split(',')
|
||||
.map((spt) => spt.trim())
|
||||
.filter((spt) => spt)
|
||||
: [];
|
||||
|
||||
if (!spts.length) {
|
||||
throw new Error(t('wxPusher SPT 不能为空'));
|
||||
}
|
||||
if (spts.length > 10) {
|
||||
throw new Error(t('wxPusher SPT 最多支持 10 个'));
|
||||
}
|
||||
|
||||
const url = `https://wxpusher.zjiecode.com/api/send/message/simple-push`;
|
||||
const json: any = {
|
||||
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
|
||||
summary: this.title,
|
||||
contentType: 2,
|
||||
};
|
||||
// 单个 SPT 用 spt,多个用 sptList
|
||||
if (spts.length === 1) {
|
||||
json.spt = spts[0];
|
||||
} else {
|
||||
json.sptList = spts;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json,
|
||||
});
|
||||
|
||||
if (res.code === 1000) {
|
||||
return true;
|
||||
@@ -774,15 +842,16 @@ export default class NotificationService {
|
||||
},
|
||||
],
|
||||
};
|
||||
const res: any = await got.post(url, {
|
||||
const res = await httpClient.request(url, {
|
||||
...this.gotOption,
|
||||
json: data,
|
||||
headers,
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.statusCode === 200) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(res.body);
|
||||
throw new Error(await res.body.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -802,7 +871,7 @@ export default class NotificationService {
|
||||
} = this.params;
|
||||
|
||||
if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
|
||||
throw new Error('Url 或者 Body 中必须包含 $title');
|
||||
throw new Error(t('Url 或者 Body 中必须包含 $title'));
|
||||
}
|
||||
|
||||
const headers = parseHeaders(webhookHeaders);
|
||||
@@ -817,15 +886,17 @@ export default class NotificationService {
|
||||
allowGetBody: true,
|
||||
...bodyParam,
|
||||
};
|
||||
|
||||
try {
|
||||
const formatUrl = webhookUrl
|
||||
?.replaceAll('$title', encodeURIComponent(this.title))
|
||||
?.replaceAll('$content', encodeURIComponent(this.content));
|
||||
const res = await got(formatUrl, options);
|
||||
const res = await httpClient.request(formatUrl, options);
|
||||
const text = await res.body.text();
|
||||
if (String(res.statusCode).startsWith('20')) {
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(JSON.stringify(res));
|
||||
throw new Error(await res.body.text());
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
@@ -845,4 +916,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { App, AppModel } from '../data/open';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import sequelize, { Op } from 'sequelize';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
@Service()
|
||||
export default class OpenService {
|
||||
@@ -151,7 +152,7 @@ export default class OpenService {
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return { code: 400, message: 'client_id 或 client_seret 有误' };
|
||||
return { code: 400, message: t('client_id 或 client_seret 有误') };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export default class ScheduleService {
|
||||
) {
|
||||
const _id = this.formatId(id);
|
||||
this.logger.info(
|
||||
'[panel][创建cron任务], 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
|
||||
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
|
||||
_id,
|
||||
schedule,
|
||||
name,
|
||||
@@ -172,7 +172,7 @@ export default class ScheduleService {
|
||||
|
||||
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
|
||||
const _id = this.formatId(id);
|
||||
this.logger.info('[panel][取消定时任务], 任务名: %s', name);
|
||||
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
|
||||
if (this.scheduleStacks.has(_id)) {
|
||||
this.scheduleStacks.get(_id)?.cancel();
|
||||
this.scheduleStacks.delete(_id);
|
||||
@@ -187,7 +187,7 @@ export default class ScheduleService {
|
||||
) {
|
||||
const _id = this.formatId(id);
|
||||
this.logger.info(
|
||||
'[panel][创建interval任务], 任务ID: %s, 任务名: %s, 执行命令: %s',
|
||||
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
|
||||
_id,
|
||||
name,
|
||||
command,
|
||||
@@ -232,7 +232,7 @@ export default class ScheduleService {
|
||||
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
|
||||
const _id = this.formatId(id);
|
||||
this.logger.info(
|
||||
'[panel][取消interval任务], 任务ID: %s, 任务名: %s',
|
||||
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
|
||||
_id,
|
||||
name,
|
||||
);
|
||||
|
||||
@@ -64,10 +64,15 @@ export default class ScriptService {
|
||||
return { code: 200 };
|
||||
}
|
||||
|
||||
public async getFile(filePath: string, fileName: string) {
|
||||
public checkFilePath(filePath: string, fileName: string) {
|
||||
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
|
||||
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
|
||||
}
|
||||
|
||||
if (!finalPath.startsWith(config.scriptPath)) {
|
||||
public async getFile(filePath: string, fileName: string) {
|
||||
const finalPath = this.checkFilePath(filePath, fileName);
|
||||
|
||||
if (!finalPath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
+39
-9
@@ -26,12 +26,13 @@ export default class SshKeyService {
|
||||
if (_exist) {
|
||||
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
|
||||
} else {
|
||||
await writeFileWithLock(this.sshConfigFilePath, '');
|
||||
await writeFileWithLock(this.sshConfigFilePath, '', { mode: '600' });
|
||||
}
|
||||
if (!config.includes(this.sshConfigHeader)) {
|
||||
await writeFileWithLock(
|
||||
this.sshConfigFilePath,
|
||||
`${this.sshConfigHeader}\n\n${config}`,
|
||||
{ mode: '600' },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,14 +42,11 @@ export default class SshKeyService {
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await writeFileWithLock(
|
||||
path.join(this.sshPath, alias),
|
||||
`${key}${os.EOL}`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: '400',
|
||||
},
|
||||
);
|
||||
const filePath = path.join(this.sshPath, alias);
|
||||
try {
|
||||
await rmPath(filePath);
|
||||
} catch { }
|
||||
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
|
||||
} catch (error) {
|
||||
this.logger.error('生成私钥文件失败', error);
|
||||
}
|
||||
@@ -81,6 +79,10 @@ export default class SshKeyService {
|
||||
await writeFileWithLock(
|
||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
||||
config,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: '600',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,4 +129,32 @@ export default class SshKeyService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
|
||||
await this.generatePrivateKeyFile(`~global_${alias}`, key);
|
||||
// Create a global SSH config entry that matches all hosts
|
||||
// This allows the key to be used for any Git repository
|
||||
await this.generateGlobalSshConfig(`~global_${alias}`);
|
||||
}
|
||||
|
||||
public async removeGlobalSSHKey(alias: string): Promise<void> {
|
||||
await this.removePrivateKeyFile(`~global_${alias}`);
|
||||
await this.removeSshConfig(`~global_${alias}`);
|
||||
}
|
||||
|
||||
private async generateGlobalSshConfig(alias: string) {
|
||||
// Create a config that matches all hosts, making this key globally available
|
||||
const config = `Host *\n IdentityFile ${path.join(
|
||||
this.sshPath,
|
||||
alias,
|
||||
)}\n StrictHostKeyChecking no\n`;
|
||||
await writeFileWithLock(
|
||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
||||
config,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: '600',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import path, { join } from 'path';
|
||||
import ScheduleService, { TaskCallbacks } from './schedule';
|
||||
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
||||
import SockService from './sock';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
import SshKeyService from './sshKey';
|
||||
import dayjs from 'dayjs';
|
||||
import { LOG_END_SYMBOL } from '../config/const';
|
||||
@@ -31,6 +32,7 @@ import { formatCommand, formatUrl } from '../config/subscription';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import CrontabService from './cron';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
|
||||
@Service()
|
||||
export default class SubscriptionService {
|
||||
@@ -129,14 +131,14 @@ export default class SubscriptionService {
|
||||
);
|
||||
const absolutePath = await handleLogPath(
|
||||
logPath as string,
|
||||
`## 开始执行... ${startTime.format('YYYY-MM-DD HH:mm:ss')}\n`,
|
||||
tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')),
|
||||
);
|
||||
|
||||
// 执行sub_before
|
||||
let beforeStr = '';
|
||||
try {
|
||||
if (doc.sub_before) {
|
||||
await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`);
|
||||
await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`);
|
||||
beforeStr = await promiseExec(doc.sub_before);
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -144,7 +146,7 @@ export default class SubscriptionService {
|
||||
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
||||
}
|
||||
if (beforeStr) {
|
||||
await fs.appendFile(absolutePath, `${beforeStr}\n`);
|
||||
await logStreamManager.write(absolutePath, `${beforeStr}\n`);
|
||||
}
|
||||
},
|
||||
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
|
||||
@@ -163,7 +165,7 @@ export default class SubscriptionService {
|
||||
let afterStr = '';
|
||||
try {
|
||||
if (sub.sub_after) {
|
||||
await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`);
|
||||
await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`);
|
||||
afterStr = await promiseExec(sub.sub_after);
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -171,16 +173,23 @@ export default class SubscriptionService {
|
||||
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
||||
}
|
||||
if (afterStr) {
|
||||
await fs.appendFile(absolutePath, `${afterStr}\n`);
|
||||
await logStreamManager.write(absolutePath, `${afterStr}\n`);
|
||||
}
|
||||
|
||||
await fs.appendFile(
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
`\n## 执行结束... ${endTime.format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)} 耗时 ${diff} 秒${LOG_END_SYMBOL}`,
|
||||
'\n' +
|
||||
tf(
|
||||
'## 执行结束... %s 耗时 %s 秒',
|
||||
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
String(diff),
|
||||
) +
|
||||
LOG_END_SYMBOL,
|
||||
);
|
||||
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(absolutePath);
|
||||
|
||||
await SubscriptionModel.update(
|
||||
{ status: SubscriptionStatus.idle, pid: undefined },
|
||||
{ where: { id: sub.id } },
|
||||
@@ -188,19 +197,19 @@ export default class SubscriptionService {
|
||||
|
||||
this.sockService.sendMessage({
|
||||
type: 'runSubscriptionEnd',
|
||||
message: '订阅执行完成',
|
||||
message: t('订阅执行完成'),
|
||||
references: [doc.id as number],
|
||||
});
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
const absolutePath = await handleLogPath(sub.log_path as string);
|
||||
await fs.appendFile(absolutePath, `\n${message}`);
|
||||
await logStreamManager.write(absolutePath, `\n${message}`);
|
||||
},
|
||||
onLog: async (message: string) => {
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
const absolutePath = await handleLogPath(sub.log_path as string);
|
||||
await fs.appendFile(absolutePath, `\n${message}`);
|
||||
await logStreamManager.write(absolutePath, `\n${message}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -384,7 +393,7 @@ export default class SubscriptionService {
|
||||
files.map(async (x) => ({
|
||||
filename: x,
|
||||
directory: relativeDir.replace(config.logPath, ''),
|
||||
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
|
||||
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
|
||||
})),
|
||||
)
|
||||
).sort((a, b) => b.time - a.time);
|
||||
|
||||
+142
-45
@@ -1,13 +1,13 @@
|
||||
import { spawn } from 'cross-spawn';
|
||||
import { Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import got from 'got';
|
||||
import { Agent, request } from 'undici';
|
||||
import sum from 'lodash/sum';
|
||||
import path from 'path';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
import { TASK_COMMAND } from '../config/const';
|
||||
import { NotificationModeStringMap, TASK_COMMAND } from '../config/const';
|
||||
import {
|
||||
getPid,
|
||||
killTask,
|
||||
@@ -37,6 +37,8 @@ import ScheduleService, { TaskCallbacks } from './schedule';
|
||||
import SockService from './sock';
|
||||
import os from 'os';
|
||||
import dayjs from 'dayjs';
|
||||
import { t, setLang } from '../shared/i18n';
|
||||
import { updateLinuxMirrorFile } from '../config/util';
|
||||
|
||||
@Service()
|
||||
export default class SystemService {
|
||||
@@ -76,8 +78,8 @@ export default class SystemService {
|
||||
const code = Math.random().toString().slice(-6);
|
||||
const isSuccess = await this.notificationService.testNotify(
|
||||
notificationInfo,
|
||||
'青龙',
|
||||
`【蛟龙】测试通知 https://t.me/jiao_long`,
|
||||
t('青龙'),
|
||||
t('【蛟龙】测试通知 https://t.me/jiao_long'),
|
||||
);
|
||||
if (isSuccess) {
|
||||
const result = await this.updateAuthDb({
|
||||
@@ -86,7 +88,7 @@ export default class SystemService {
|
||||
});
|
||||
return { code: 200, data: { ...result, code } };
|
||||
} else {
|
||||
return { code: 400, message: '通知发送失败,请检查参数' };
|
||||
return { code: 400, message: t('通知发送失败,请检查参数') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ export default class SystemService {
|
||||
});
|
||||
const cron = {
|
||||
id: result.id as number,
|
||||
name: '删除日志',
|
||||
name: t('删除日志'),
|
||||
command: `ql rmlog ${info.logRemoveFrequency}`,
|
||||
runOrigin: 'system' as const,
|
||||
};
|
||||
@@ -177,6 +179,7 @@ export default class SystemService {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateNodeMirror',
|
||||
message: 'update node mirror end',
|
||||
status: 'completed',
|
||||
});
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
@@ -214,33 +217,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,
|
||||
{
|
||||
@@ -252,10 +233,18 @@ export default class SystemService {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateLinuxMirror',
|
||||
message: 'update linux mirror end',
|
||||
status: 'completed',
|
||||
});
|
||||
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) => {
|
||||
@@ -276,13 +265,17 @@ export default class SystemService {
|
||||
|
||||
let lastVersionContent;
|
||||
try {
|
||||
const result = await got.get(
|
||||
const { body } = await request(
|
||||
`${config.lastVersionFile}?t=${Date.now()}`,
|
||||
{
|
||||
timeout: 30000,
|
||||
dispatcher: new Agent({
|
||||
keepAliveTimeout: 30000,
|
||||
keepAliveMaxTimeout: 30000,
|
||||
}),
|
||||
},
|
||||
);
|
||||
lastVersionContent = parseContentVersion(result.body);
|
||||
const text = await body.text();
|
||||
lastVersionContent = parseContentVersion(text);
|
||||
} catch (error) { }
|
||||
|
||||
if (!lastVersionContent) {
|
||||
@@ -349,6 +342,15 @@ export default class SystemService {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: JSON.stringify(err),
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
cp.on('exit', (code) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateSystemVersion',
|
||||
message: '',
|
||||
status: code === 0 ? 'success' : 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -357,25 +359,52 @@ export default class SystemService {
|
||||
|
||||
public async reloadSystem(target?: 'system' | 'data') {
|
||||
const cmd = `real_time=true ql reload ${target || ''}`;
|
||||
const cp = spawn(cmd, { shell: '/bin/bash' });
|
||||
const cp = spawn(cmd, {
|
||||
shell: '/bin/bash',
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
cp.unref();
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
return { code: 200 };
|
||||
}
|
||||
|
||||
public async notify({ title, content }: { title: string; content: string }) {
|
||||
const isSuccess = await this.notificationService.notify(title, content);
|
||||
public async notify({
|
||||
title,
|
||||
content,
|
||||
notificationInfo,
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
notificationInfo?: NotificationInfo;
|
||||
}) {
|
||||
const typeString =
|
||||
typeof notificationInfo?.type === 'number'
|
||||
? NotificationModeStringMap[notificationInfo.type]
|
||||
: undefined;
|
||||
if (notificationInfo && typeString) {
|
||||
notificationInfo.type = typeString;
|
||||
}
|
||||
const isSuccess = await this.notificationService.notify(
|
||||
title,
|
||||
content,
|
||||
notificationInfo,
|
||||
);
|
||||
if (isSuccess) {
|
||||
return { code: 200, message: '通知发送成功' };
|
||||
return { code: 200, message: t('通知发送成功') };
|
||||
} else {
|
||||
return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' };
|
||||
return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
|
||||
}
|
||||
}
|
||||
|
||||
public async run({ command }: { command: string }, callback: TaskCallbacks) {
|
||||
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
|
||||
if (!command.startsWith(TASK_COMMAND)) {
|
||||
command = `${TASK_COMMAND} ${command}`;
|
||||
}
|
||||
this.scheduleService.runTask(`real_time=true ${command}`, callback, {
|
||||
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
|
||||
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
|
||||
command,
|
||||
id: command.replace(/ /g, '-'),
|
||||
runOrigin: 'system',
|
||||
@@ -384,7 +413,7 @@ export default class SystemService {
|
||||
|
||||
public async stop({ command, pid }: { command: string; pid: number }) {
|
||||
if (!pid && !command) {
|
||||
return { code: 400, message: '参数错误' };
|
||||
return { code: 400, message: t('参数错误') };
|
||||
}
|
||||
|
||||
if (pid) {
|
||||
@@ -400,14 +429,20 @@ export default class SystemService {
|
||||
await killTask(_pid);
|
||||
return { code: 200 };
|
||||
} else {
|
||||
return { code: 400, message: '任务未找到' };
|
||||
return { code: 400, message: t('任务未找到') };
|
||||
}
|
||||
}
|
||||
|
||||
public async exportData(res: Response) {
|
||||
public async exportData(res: Response, type?: string[]) {
|
||||
try {
|
||||
let dataDirs = ['db', 'upload'];
|
||||
if (type && type.length) {
|
||||
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
||||
}
|
||||
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
|
||||
await promiseExec(
|
||||
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile} data/`,
|
||||
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
|
||||
} ${dataPaths.join(' ')}`,
|
||||
);
|
||||
res.download(config.dataTgzFile);
|
||||
} catch (error: any) {
|
||||
@@ -489,7 +524,69 @@ export default class SystemService {
|
||||
if (success) {
|
||||
return { code: 200, data: info };
|
||||
} else {
|
||||
return { code: 400, message: '设置时区失败' };
|
||||
return { code: 400, message: t('设置时区失败') };
|
||||
}
|
||||
}
|
||||
|
||||
public async updateLanguage(info: SystemModelInfo) {
|
||||
const oDoc = await this.getSystemConfig();
|
||||
const lang = info.lang || 'zh';
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, lang },
|
||||
});
|
||||
// Write to standalone lang_env.sh, sourced by shell scripts
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
config.langEnvFile,
|
||||
`export QL_LANG='${lang}'\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write lang_env.sh: ${error}`);
|
||||
}
|
||||
setLang(lang);
|
||||
return { code: 200, data: { lang } };
|
||||
}
|
||||
|
||||
public async updatePanelTitle(info: SystemModelInfo) {
|
||||
const oDoc = await this.getSystemConfig();
|
||||
const panelTitle = info.panelTitle?.trim() || '';
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, panelTitle },
|
||||
});
|
||||
return { code: 200, data: { panelTitle } };
|
||||
}
|
||||
|
||||
public async updateGlobalSshKey(info: SystemModelInfo) {
|
||||
const oDoc = await this.getSystemConfig();
|
||||
const result = await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, ...info },
|
||||
});
|
||||
|
||||
// Apply the global SSH key
|
||||
const SshKeyService = require('./sshKey').default;
|
||||
const Container = require('typedi').Container;
|
||||
const sshKeyService = Container.get(SshKeyService);
|
||||
|
||||
if (info.globalSshKey) {
|
||||
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
|
||||
} else {
|
||||
await sshKeyService.removeGlobalSSHKey('global');
|
||||
}
|
||||
|
||||
return { code: 200, data: result };
|
||||
}
|
||||
|
||||
public async cleanDependence(type: 'node' | 'python3') {
|
||||
if (!type || !['node', 'python3'].includes(type)) {
|
||||
return { code: 400, message: t('参数错误') };
|
||||
}
|
||||
try {
|
||||
const finalPath = path.join(config.dependenceCachePath, type);
|
||||
await fs.promises.rm(finalPath, { recursive: true });
|
||||
} catch (error) { }
|
||||
return { code: 200 };
|
||||
}
|
||||
}
|
||||
|
||||
+214
-36
@@ -1,6 +1,6 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import { createRandomString, getNetIp } from '../config/util';
|
||||
import { createRandomString } from '../config/util';
|
||||
import config from '../config';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticator } from '@otplib/preset-default';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SystemModelInfo,
|
||||
LoginStatus,
|
||||
AuthInfo,
|
||||
TokenInfo,
|
||||
} from '../data/system';
|
||||
import { NotificationInfo } from '../data/notify';
|
||||
import NotificationService from './notify';
|
||||
@@ -21,7 +22,10 @@ import dayjs from 'dayjs';
|
||||
import IP2Region from 'ip2region';
|
||||
import requestIp from 'request-ip';
|
||||
import uniq from 'lodash/uniq';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import isNil from 'lodash/isNil';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
|
||||
@Service()
|
||||
export default class UserService {
|
||||
@@ -63,7 +67,7 @@ export default class UserService {
|
||||
);
|
||||
return {
|
||||
code: 410,
|
||||
message: `失败次数过多,请${waitTime}秒后重试`,
|
||||
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||
data: waitTime,
|
||||
};
|
||||
}
|
||||
@@ -93,18 +97,29 @@ export default class UserService {
|
||||
}
|
||||
if (username === cUsername && password === cPassword) {
|
||||
const data = createRandomString(50, 100);
|
||||
const expiration = twoFactorActivated ? 60 : 20;
|
||||
let token = jwt.sign({ data }, config.secret as any, {
|
||||
expiresIn: 60 * 60 * 24 * expiration,
|
||||
const expiration = twoFactorActivated ? '60d' : '20d';
|
||||
let token = jwt.sign({ data }, config.jwt.secret, {
|
||||
expiresIn: config.jwt.expiresIn || expiration,
|
||||
algorithm: 'HS384',
|
||||
});
|
||||
|
||||
const tokenInfo: TokenInfo = {
|
||||
value: token,
|
||||
timestamp,
|
||||
ip,
|
||||
address,
|
||||
platform: req.platform,
|
||||
};
|
||||
|
||||
const updatedTokens = this.addTokenToList(
|
||||
tokens,
|
||||
req.platform,
|
||||
tokenInfo,
|
||||
);
|
||||
|
||||
await this.updateAuthInfo(content, {
|
||||
token,
|
||||
tokens: {
|
||||
...tokens,
|
||||
[req.platform]: token,
|
||||
},
|
||||
tokens: updatedTokens,
|
||||
lastlogon: timestamp,
|
||||
retries: 0,
|
||||
lastip: ip,
|
||||
@@ -113,10 +128,19 @@ export default class UserService {
|
||||
isTwoFactorChecking: false,
|
||||
});
|
||||
this.notificationService.notify(
|
||||
'登录通知',
|
||||
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${
|
||||
req.platform
|
||||
}端 登录成功,ip地址 ${ip}`,
|
||||
t('登录通知'),
|
||||
t('你于') +
|
||||
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||
t('在') +
|
||||
address +
|
||||
' ' +
|
||||
req.platform +
|
||||
t('端') +
|
||||
' ' +
|
||||
t('登录成功') +
|
||||
t(',ip地址') +
|
||||
' ' +
|
||||
ip,
|
||||
);
|
||||
await this.insertDb({
|
||||
type: AuthDataType.loginLog,
|
||||
@@ -131,7 +155,14 @@ export default class UserService {
|
||||
this.getLoginLog();
|
||||
return {
|
||||
code: 200,
|
||||
data: { token, lastip, lastaddr, lastlogon, retries, platform },
|
||||
data: {
|
||||
token,
|
||||
lastip,
|
||||
lastaddr,
|
||||
lastlogon,
|
||||
retries,
|
||||
platform,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
await this.updateAuthInfo(content, {
|
||||
@@ -142,10 +173,19 @@ export default class UserService {
|
||||
platform: req.platform,
|
||||
});
|
||||
this.notificationService.notify(
|
||||
'登录通知',
|
||||
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${
|
||||
req.platform
|
||||
}端 登录失败,ip地址 ${ip}`,
|
||||
t('登录通知'),
|
||||
t('你于') +
|
||||
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
|
||||
t('在') +
|
||||
address +
|
||||
' ' +
|
||||
req.platform +
|
||||
t('端') +
|
||||
' ' +
|
||||
t('登录失败') +
|
||||
t(',ip地址') +
|
||||
' ' +
|
||||
ip,
|
||||
);
|
||||
await this.insertDb({
|
||||
type: AuthDataType.loginLog,
|
||||
@@ -162,20 +202,46 @@ export default class UserService {
|
||||
const waitTime = Math.round(Math.pow(3, retries + 1));
|
||||
return {
|
||||
code: 410,
|
||||
message: `失败次数过多,请${waitTime}秒后重试`,
|
||||
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||
data: waitTime,
|
||||
};
|
||||
} else {
|
||||
return { code: 400, message: config.authError };
|
||||
return { code: 400, message: t('错误的用户名密码,请重试') };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async logout(platform: string): Promise<any> {
|
||||
public async logout(platform: string, tokenValue: string): Promise<any> {
|
||||
if (!platform || !tokenValue) {
|
||||
this.logger.warn('Invalid logout parameters - empty platform or token');
|
||||
return;
|
||||
}
|
||||
|
||||
const authInfo = await this.getAuthInfo();
|
||||
|
||||
// Verify the token exists before attempting to remove it
|
||||
const tokenExists = this.findTokenInList(
|
||||
authInfo.tokens,
|
||||
platform,
|
||||
tokenValue,
|
||||
);
|
||||
if (!tokenExists && authInfo.token !== tokenValue) {
|
||||
// Token not found, but don't throw error - user may have already logged out
|
||||
this.logger.info(
|
||||
`Logout attempted for non-existent token on platform: ${platform}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTokens = this.removeTokenFromList(
|
||||
authInfo.tokens,
|
||||
platform,
|
||||
tokenValue,
|
||||
);
|
||||
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
token: '',
|
||||
tokens: { ...authInfo.tokens, [platform]: '' },
|
||||
token: authInfo.token === tokenValue ? '' : authInfo.token,
|
||||
tokens: updatedTokens,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,17 +277,17 @@ export default class UserService {
|
||||
password: string;
|
||||
}) {
|
||||
if (password === 'admin') {
|
||||
return { code: 400, message: '密码不能设置为admin' };
|
||||
return { code: 400, message: t('密码不能设置为admin') };
|
||||
}
|
||||
const authInfo = await this.getAuthInfo();
|
||||
await this.updateAuthInfo(authInfo, { username, password });
|
||||
return { code: 200, message: '更新成功' };
|
||||
return { code: 200, message: t('更新成功') };
|
||||
}
|
||||
|
||||
public async updateAvatar(avatar: string) {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
await this.updateAuthInfo(authInfo, { avatar });
|
||||
return { code: 200, data: avatar, message: '更新成功' };
|
||||
return { code: 200, data: avatar, message: t('更新成功') };
|
||||
}
|
||||
|
||||
public async initTwoFactor() {
|
||||
@@ -255,7 +321,7 @@ export default class UserService {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
|
||||
if (!isTwoFactorChecking) {
|
||||
return { code: 450, message: '未知错误' };
|
||||
return { code: 450, message: t('未知错误') };
|
||||
}
|
||||
const isValid = authenticator.verify({
|
||||
token: code,
|
||||
@@ -264,17 +330,26 @@ export default class UserService {
|
||||
if (isValid) {
|
||||
return this.login({ username, password }, req, false);
|
||||
} else {
|
||||
const { ip, address } = await getNetIp(req);
|
||||
const ip = requestIp.getClientIp(req) || '';
|
||||
const query = new IP2Region();
|
||||
const ipAddress = query.search(ip);
|
||||
let address = '';
|
||||
if (ipAddress) {
|
||||
const { country, province, city, isp } = ipAddress;
|
||||
address = uniq([country, province, city, isp])
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
lastip: ip,
|
||||
lastaddr: address,
|
||||
platform: req.platform,
|
||||
});
|
||||
return { code: 430, message: '验证失败' };
|
||||
return { code: 430, message: t('验证失败') };
|
||||
}
|
||||
}
|
||||
|
||||
public async deactiveTwoFactor() {
|
||||
public async deactivateTwoFactor() {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
twoFactorActivated: false,
|
||||
@@ -332,8 +407,8 @@ export default class UserService {
|
||||
const code = Math.random().toString().slice(-6);
|
||||
const isSuccess = await this.notificationService.testNotify(
|
||||
notificationInfo,
|
||||
'青龙',
|
||||
`【蛟龙】测试通知 https://t.me/jiao_long`,
|
||||
t('青龙'),
|
||||
t('【蛟龙】测试通知 https://t.me/jiao_long'),
|
||||
);
|
||||
if (isSuccess) {
|
||||
const result = await this.updateAuthDb({
|
||||
@@ -342,17 +417,120 @@ export default class UserService {
|
||||
});
|
||||
return { code: 200, data: { ...result, code } };
|
||||
} else {
|
||||
return { code: 400, message: '通知发送失败,请检查参数' };
|
||||
return { code: 400, message: t('通知发送失败,请检查参数') };
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeTokens(
|
||||
tokens: Record<string, string | TokenInfo[]>,
|
||||
): Record<string, TokenInfo[]> {
|
||||
const normalized: Record<string, TokenInfo[]> = {};
|
||||
|
||||
for (const [platform, value] of Object.entries(tokens)) {
|
||||
if (typeof value === 'string') {
|
||||
// Legacy format: convert string token to TokenInfo array
|
||||
if (value) {
|
||||
normalized[platform] = [
|
||||
{
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
ip: '',
|
||||
address: '',
|
||||
platform,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
normalized[platform] = [];
|
||||
}
|
||||
} else {
|
||||
// Already in new format
|
||||
normalized[platform] = value || [];
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private addTokenToList(
|
||||
tokens: Record<string, string | TokenInfo[]>,
|
||||
platform: string,
|
||||
tokenInfo: TokenInfo,
|
||||
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
|
||||
): Record<string, TokenInfo[]> {
|
||||
// Validate maxTokensPerPlatform parameter
|
||||
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
|
||||
this.logger.warn(
|
||||
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
|
||||
);
|
||||
maxTokensPerPlatform = config.maxTokensPerPlatform;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeTokens(tokens);
|
||||
|
||||
if (!normalized[platform]) {
|
||||
normalized[platform] = [];
|
||||
}
|
||||
|
||||
// Add new token
|
||||
normalized[platform].unshift(tokenInfo);
|
||||
|
||||
// Limit the number of active tokens per platform
|
||||
if (normalized[platform].length > maxTokensPerPlatform) {
|
||||
normalized[platform] = normalized[platform].slice(
|
||||
0,
|
||||
maxTokensPerPlatform,
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private removeTokenFromList(
|
||||
tokens: Record<string, string | TokenInfo[]>,
|
||||
platform: string,
|
||||
tokenValue: string,
|
||||
): Record<string, TokenInfo[]> {
|
||||
const normalized = this.normalizeTokens(tokens);
|
||||
|
||||
if (normalized[platform]) {
|
||||
normalized[platform] = normalized[platform].filter(
|
||||
(t) => t.value !== tokenValue,
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private findTokenInList(
|
||||
tokens: Record<string, string | TokenInfo[]>,
|
||||
platform: string,
|
||||
tokenValue: string,
|
||||
): TokenInfo | undefined {
|
||||
const normalized = this.normalizeTokens(tokens);
|
||||
|
||||
if (normalized[platform]) {
|
||||
return normalized[platform].find((t) => t.value === tokenValue);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async resetAuthInfo(info: Partial<AuthInfo>) {
|
||||
const { retries, twoFactorActivated, password } = info;
|
||||
const { retries, twoFactorActivated, password, username } = info;
|
||||
if (password === 'admin') {
|
||||
return { code: 400, message: t('密码不能设置为admin') };
|
||||
}
|
||||
const authInfo = await this.getAuthInfo();
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
const payload = pickBy(
|
||||
{
|
||||
retries,
|
||||
twoFactorActivated,
|
||||
password,
|
||||
});
|
||||
username,
|
||||
},
|
||||
(x) => !isNil(x),
|
||||
);
|
||||
|
||||
await this.updateAuthInfo(authInfo, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AuthInfo, TokenInfo } from '../data/system';
|
||||
|
||||
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
|
||||
return (
|
||||
Object.keys(authInfo).length === 2 &&
|
||||
authInfo.username === 'admin' &&
|
||||
authInfo.password === 'admin'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a token exists in the authentication info.
|
||||
* Supports both legacy string tokens and new TokenInfo array format.
|
||||
*
|
||||
* @param authInfo - The authentication information
|
||||
* @param headerToken - The token to validate
|
||||
* @param platform - The platform (desktop, mobile)
|
||||
* @returns true if the token is valid, false otherwise
|
||||
*/
|
||||
export function isValidToken(
|
||||
authInfo: AuthInfo | null | undefined,
|
||||
headerToken: string,
|
||||
platform: string,
|
||||
): boolean {
|
||||
if (!authInfo || !headerToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { token = '', tokens = {} } = authInfo;
|
||||
|
||||
// Check legacy token field
|
||||
if (headerToken === token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
|
||||
const platformTokens = tokens[platform];
|
||||
|
||||
// Handle null/undefined platformTokens
|
||||
if (platformTokens === null || platformTokens === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof platformTokens === 'string') {
|
||||
// Legacy format: single string token
|
||||
return headerToken === platformTokens;
|
||||
} else if (Array.isArray(platformTokens)) {
|
||||
// New format: array of TokenInfo objects
|
||||
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
|
||||
}
|
||||
|
||||
// Unexpected type - log warning and reject
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { shareStore } from './store';
|
||||
|
||||
const messages: Record<string, Record<string, string>> = {
|
||||
zh: {},
|
||||
en: {
|
||||
'暂无权限': 'Access denied',
|
||||
'参数错误': 'Invalid parameter',
|
||||
'参数不正确': 'Invalid parameter',
|
||||
'文件无法访问': 'File not accessible',
|
||||
'文件不存在': 'File not found',
|
||||
'路径不存在': 'Path does not exist',
|
||||
'路径不正确': 'Invalid path',
|
||||
'通知发送失败,请检查参数': 'Notification failed, check parameters',
|
||||
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
|
||||
'通知发送成功': 'Notification sent successfully',
|
||||
'设置时区失败': 'Failed to set timezone',
|
||||
'任务未找到': 'Task not found',
|
||||
'密码不能设置为admin': 'Password cannot be admin',
|
||||
'更新成功': 'Update successful',
|
||||
'未知错误': 'Unknown error',
|
||||
'验证失败': 'Verification failed',
|
||||
'实例不存在或已停止': 'Instance does not exist or stopped',
|
||||
'实例已停止': 'Instance stopped',
|
||||
'确认停止实例': 'Confirm to stop instance',
|
||||
'确认停止运行实例': 'Confirm to stop running instance',
|
||||
'确认停止': 'Confirm to stop',
|
||||
'确认停止定时任务': 'Confirm to stop scheduled task',
|
||||
'确认删除': 'Confirm to delete',
|
||||
'确认删除定时任务': 'Confirm to delete scheduled task',
|
||||
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
|
||||
'确认运行': 'Confirm to run',
|
||||
'确认运行定时任务': 'Confirm to run scheduled task',
|
||||
'确认保存': 'Confirm to save',
|
||||
'确认保存文件': 'Confirm to save file',
|
||||
'确认重启': 'Confirm restart',
|
||||
'确认启用': 'Confirm to enable',
|
||||
'确认禁用': 'Confirm to disable',
|
||||
'确认': 'Confirm',
|
||||
'删除成功': 'Deleted successfully',
|
||||
'操作成功': 'Operation successful',
|
||||
'参数不完整': 'Incomplete parameters',
|
||||
'默认路径不支持删除': 'Default path cannot be deleted',
|
||||
'必须在日志目录下': 'Must be within log directory',
|
||||
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
|
||||
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
|
||||
'系统将在': 'System will',
|
||||
'秒后自动刷新': 'refresh in seconds',
|
||||
'生成数据中...': 'Generating data...',
|
||||
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
|
||||
'不支持当前依赖类型': 'Unsupported dependency type',
|
||||
'依赖已存在': 'Dependency already exists',
|
||||
'依赖不存在': 'Dependency does not exist',
|
||||
'该脚本正在运行中': 'Script is running',
|
||||
'该脚本未在运行中': 'Script is not running',
|
||||
'文件内容为空': 'File content is empty',
|
||||
'文件名不能为空': 'File name cannot be empty',
|
||||
'标签不能为空': 'Label cannot be empty',
|
||||
'名称不能为空': 'Name cannot be empty',
|
||||
'名称不能为保留关键字': 'Name cannot be reserved keyword',
|
||||
'名称已存在': 'Name already exists',
|
||||
'密码错误': 'Incorrect password',
|
||||
'用户不存在': 'User does not exist',
|
||||
'请输入用户名和密码': 'Please enter username and password',
|
||||
'无权访问': 'Access denied',
|
||||
'登录成功': 'Login successful',
|
||||
'退出成功': 'Logout successful',
|
||||
'Token 已失效': 'Token expired',
|
||||
'Token 无效': 'Invalid token',
|
||||
'两步骤验证已开启': '2FA enabled',
|
||||
'两步骤验证已关闭': '2FA disabled',
|
||||
'验证码错误': 'Invalid verification code',
|
||||
'验证码已过期': 'Verification code expired',
|
||||
'请先开启两步骤验证': 'Please enable 2FA first',
|
||||
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
|
||||
'用户已存在': 'User already exists',
|
||||
'用户名不能为admin': 'Username cannot be admin',
|
||||
'不能删除自己': 'Cannot delete yourself',
|
||||
'不能禁用自己': 'Cannot disable yourself',
|
||||
'文件路径无效': 'Invalid file path',
|
||||
'保存成功': 'Saved successfully',
|
||||
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
|
||||
'订阅执行完成': 'Subscription completed',
|
||||
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
|
||||
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
|
||||
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
|
||||
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
|
||||
'绝对路径必须在日志目录内或使用 /dev/null':
|
||||
'Absolute path must be within log directory or use /dev/null',
|
||||
'请先登录': 'Please login first',
|
||||
'运行中...': 'Running...',
|
||||
'日志不存在...': 'Log does not exist...',
|
||||
'未分类': 'Uncategorized',
|
||||
'任务重复运行': 'Duplicate task execution',
|
||||
'日志设置为忽略': 'Log set to ignore',
|
||||
'定时规则不能为空': 'Schedule rule cannot be empty',
|
||||
'无效的定时规则': 'Invalid schedule rule',
|
||||
'日志名称只能包含字母、数字、下划线和连字符':
|
||||
'Log name can only contain letters, numbers, underscores, and hyphens',
|
||||
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
|
||||
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
|
||||
'青龙快讯': 'QingLong',
|
||||
'登录通知': 'Login Notification',
|
||||
'你于': 'You at ',
|
||||
'在': ' in ',
|
||||
端: '',
|
||||
登录失败: 'login failed',
|
||||
',ip地址': ', IP: ',
|
||||
'任务#%s': 'Task#%s',
|
||||
安装: 'Install',
|
||||
删除: 'Uninstall',
|
||||
成功: 'Succeeded',
|
||||
失败: 'Failed',
|
||||
'失败次数过多,请%s秒后重试':
|
||||
'Too many failed attempts, please retry in %s seconds',
|
||||
智能助手: 'Smart Assistant',
|
||||
更多: 'More',
|
||||
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒':
|
||||
'Start %s dependency %s, start time %s\n\nCurrent system not supported\n\nDependency %s failed, end time %s, elapsed %s seconds',
|
||||
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒':
|
||||
'Already installed %s\n\n%s\n\nSkipping install\n\nDependency %s succeeded, end time %s, elapsed %s seconds',
|
||||
'开始%s依赖 %s,开始时间 %s\n\n':
|
||||
'Start %s dependency %s, start time %s\n\n',
|
||||
'依赖%s%s,结束时间 %s,耗时 %s 秒':
|
||||
'Dependency %s%s, end time %s, elapsed %s seconds',
|
||||
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置':
|
||||
'Task: %s, command: %s, schedule: %s, more than %d instances running, please check schedule settings',
|
||||
青龙: 'QingLong',
|
||||
'【蛟龙】测试通知 https://t.me/jiao_long':
|
||||
'[JiaoLong] Test notification https://t.me/jiao_long',
|
||||
'生成token': 'Generate token',
|
||||
'删除日志': 'Delete logs',
|
||||
'## 开始执行... %s\n': '## Start executing... %s\n',
|
||||
'执行before命令...': 'Execute before command...',
|
||||
'执行after命令...': 'Execute after command...',
|
||||
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
|
||||
'调度器注册失败,任务创建已回滚':
|
||||
'Scheduler registration failed, task creation rolled back',
|
||||
'调度器注册失败,任务更新已回滚':
|
||||
'Scheduler registration failed, task update rolled back',
|
||||
'调度器注册失败,任务启用已回滚':
|
||||
'Scheduler registration failed, task enable rolled back',
|
||||
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
||||
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
||||
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
||||
},
|
||||
};
|
||||
|
||||
let currentLang: string = 'zh';
|
||||
|
||||
export function setLang(lang: string) {
|
||||
currentLang = lang || 'zh';
|
||||
shareStore.setLang(currentLang);
|
||||
}
|
||||
|
||||
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
|
||||
export function systemLang(): string {
|
||||
const prefix = Intl.DateTimeFormat().resolvedOptions().locale.split('-')[0];
|
||||
return prefix === 'en' ? 'en' : 'zh';
|
||||
}
|
||||
|
||||
export function t(key: string, lang?: string): string {
|
||||
const effectiveLang = lang || currentLang;
|
||||
if (effectiveLang === 'en' && messages.en[key]) {
|
||||
return messages.en[key];
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function tf(key: string, ...args: (string | number)[]): string {
|
||||
return args.reduce<string>(
|
||||
(str, arg) => str.replace(/%s|%d/, String(arg)),
|
||||
t(key),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createWriteStream, WriteStream } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Manages write streams for log files to improve performance by avoiding repeated file opens
|
||||
*/
|
||||
export class LogStreamManager extends EventEmitter {
|
||||
private streams: Map<string, WriteStream> = new Map();
|
||||
private pendingWrites: Map<string, Promise<void>> = new Map();
|
||||
|
||||
/**
|
||||
* Write data to a log file using a managed stream
|
||||
* @param filePath - Absolute path to the log file
|
||||
* @param data - Data to write to the log file
|
||||
*/
|
||||
async write(filePath: string, data: string): Promise<void> {
|
||||
// Wait for any pending writes to this file to complete
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
if (pending) {
|
||||
await pending;
|
||||
}
|
||||
|
||||
// Create a new promise for this write operation
|
||||
const writePromise = new Promise<void>((resolve, reject) => {
|
||||
let stream = this.streams.get(filePath);
|
||||
|
||||
if (!stream) {
|
||||
// Create a new write stream if one doesn't exist
|
||||
stream = createWriteStream(filePath, { flags: 'a' });
|
||||
this.streams.set(filePath, stream);
|
||||
|
||||
// Handle stream errors
|
||||
stream.on('error', (error) => {
|
||||
this.emit('error', { filePath, error });
|
||||
// Remove the stream from the map on error
|
||||
this.streams.delete(filePath);
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
// Write the data
|
||||
const canContinue = stream.write(data, 'utf8', (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle backpressure
|
||||
if (!canContinue) {
|
||||
stream.once('drain', () => {
|
||||
// Stream is ready for more data
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.pendingWrites.set(filePath, writePromise);
|
||||
|
||||
try {
|
||||
await writePromise;
|
||||
} finally {
|
||||
this.pendingWrites.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream for a specific file path
|
||||
* @param filePath - Absolute path to the log file
|
||||
*/
|
||||
async closeStream(filePath: string): Promise<void> {
|
||||
// Wait for any pending writes to complete
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
if (pending) {
|
||||
await pending.catch(() => {
|
||||
// Ignore errors on pending writes during close
|
||||
});
|
||||
}
|
||||
|
||||
const stream = this.streams.get(filePath);
|
||||
if (stream) {
|
||||
return new Promise<void>((resolve) => {
|
||||
stream.end(() => {
|
||||
this.streams.delete(filePath);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all open streams
|
||||
*/
|
||||
async closeAll(): Promise<void> {
|
||||
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
|
||||
this.closeStream(filePath),
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of open streams
|
||||
*/
|
||||
getOpenStreamCount(): number {
|
||||
return this.streams.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance for shared use
|
||||
export const logStreamManager = new LogStreamManager();
|
||||
+41
-5
@@ -4,6 +4,7 @@ import { AuthDataType, SystemModel } from '../data/system';
|
||||
import Logger from '../loaders/logger';
|
||||
import { Dependence } from '../data/dependence';
|
||||
import NotificationService from '../services/notify';
|
||||
import { t, tf } from '../shared/i18n';
|
||||
import {
|
||||
ICronFn,
|
||||
IDependencyFn,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import config from '../config';
|
||||
import { credentials } from '@grpc/grpc-js';
|
||||
import { ApiClient } from '../protos/api';
|
||||
import { getGrpcCerts } from '../config/grpcCerts';
|
||||
|
||||
class TaskLimit {
|
||||
private dependenyLimit = new PQueue({ concurrency: 1 });
|
||||
@@ -36,11 +38,26 @@ class TaskLimit {
|
||||
private systemLimit = new PQueue({
|
||||
concurrency: Math.max(os.cpus().length, 4),
|
||||
});
|
||||
private client = new ApiClient(
|
||||
`0.0.0.0:${config.cronPort}`,
|
||||
credentials.createInsecure(),
|
||||
private _client: ApiClient | null = null;
|
||||
|
||||
private get client(): ApiClient {
|
||||
if (!this._client) {
|
||||
const tlsConfig = getGrpcCerts();
|
||||
const creds = tlsConfig
|
||||
? credentials.createSsl(
|
||||
Buffer.from(tlsConfig.caCert),
|
||||
Buffer.from(tlsConfig.clientKey),
|
||||
Buffer.from(tlsConfig.clientCert),
|
||||
)
|
||||
: credentials.createInsecure();
|
||||
this._client = new ApiClient(
|
||||
`localhost:${config.grpcPort}`,
|
||||
creds,
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
}
|
||||
return this._client;
|
||||
}
|
||||
|
||||
get cronLimitActiveCount() {
|
||||
return this.cronLimit.pending;
|
||||
@@ -136,8 +153,14 @@ class TaskLimit {
|
||||
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
|
||||
this.client.systemNotify(
|
||||
{
|
||||
title: '任务重复运行',
|
||||
content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`,
|
||||
title: t('任务重复运行'),
|
||||
content: tf(
|
||||
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置',
|
||||
cron.name || '',
|
||||
cron.command || '',
|
||||
cron.schedule || '',
|
||||
5,
|
||||
),
|
||||
},
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
@@ -189,6 +212,19 @@ class TaskLimit {
|
||||
return this.scriptLimit.add(fn, options);
|
||||
}
|
||||
|
||||
public async waitDependencyQueueDone(): Promise<void> {
|
||||
if (this.dependenyLimit.size === 0 && this.dependenyLimit.pending === 0) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onIdle = () => {
|
||||
this.dependenyLimit.removeListener('idle', onIdle);
|
||||
resolve();
|
||||
};
|
||||
this.dependenyLimit.on('idle', onIdle);
|
||||
});
|
||||
}
|
||||
|
||||
public runDependeny<T>(
|
||||
dependency: Dependence,
|
||||
fn: IDependencyFn<T>,
|
||||
|
||||
@@ -2,10 +2,56 @@ import { spawn } from 'cross-spawn';
|
||||
import taskLimit from './pLimit';
|
||||
import Logger from '../loaders/logger';
|
||||
import { ICron } from '../protos/cron';
|
||||
import { CrontabModel, CrontabStatus } from '../data/cron';
|
||||
import { killTask } from '../config/util';
|
||||
import {
|
||||
RunningInstanceModel,
|
||||
InstanceStatus,
|
||||
} from '../data/runningInstance';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
return taskLimit.runWithCronLimit(cron, () => {
|
||||
return new Promise(async (resolve: any) => {
|
||||
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
|
||||
try {
|
||||
const existingCron = await CrontabModel.findOne({
|
||||
where: { id: Number(cron.id) },
|
||||
});
|
||||
|
||||
// Default to single instance mode (0) for backward compatibility
|
||||
const allowSingleInstances =
|
||||
existingCron?.allow_multiple_instances === 0;
|
||||
|
||||
if (
|
||||
allowSingleInstances &&
|
||||
existingCron &&
|
||||
existingCron.pid &&
|
||||
(existingCron.status === CrontabStatus.running ||
|
||||
existingCron.status === CrontabStatus.queued)
|
||||
) {
|
||||
Logger.info(
|
||||
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
|
||||
);
|
||||
await killTask(existingCron.pid);
|
||||
// Mark old running instances as stopped
|
||||
const stoppedAt = dayjs().unix();
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
|
||||
{ where: { cron_id: Number(cron.id), status: InstanceStatus.running } },
|
||||
);
|
||||
// Update the status to idle after killing
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id: Number(cron.id) } },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
`[schedule][开始执行任务] 参数 ${JSON.stringify({
|
||||
...cron,
|
||||
|
||||
@@ -8,11 +8,13 @@ import path from 'path';
|
||||
export enum EKeyv {
|
||||
'apps' = 'apps',
|
||||
'authInfo' = 'authInfo',
|
||||
'lang' = 'lang',
|
||||
}
|
||||
|
||||
export interface IKeyvStore {
|
||||
apps: App[];
|
||||
authInfo: AuthInfo;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite'));
|
||||
@@ -31,4 +33,10 @@ export const shareStore = {
|
||||
updateApps(apps: App[]) {
|
||||
return keyvStore.set<IKeyvStore['apps']>(EKeyv.apps, apps);
|
||||
},
|
||||
getLang() {
|
||||
return keyvStore.get<IKeyvStore['lang']>(EKeyv.lang);
|
||||
},
|
||||
setLang(value: IKeyvStore['lang']) {
|
||||
return keyvStore.set<IKeyvStore['lang']>(EKeyv.lang, value);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ function getUniqueLockPath(filePath: string) {
|
||||
|
||||
export async function writeFileWithLock(
|
||||
filePath: string,
|
||||
content: string | Buffer,
|
||||
content: string,
|
||||
options: Parameters<typeof writeFile>[2] = {},
|
||||
) {
|
||||
if (typeof options === 'string') {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'reflect-metadata';
|
||||
import OpenService from './services/open';
|
||||
import { Container } from 'typedi';
|
||||
import LoggerInstance from './loaders/logger';
|
||||
import fs from 'fs';
|
||||
import config from './config';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"lib": ["ESNext"],
|
||||
"typeRoots": ["./node_modules/celebrate/lib", "./node_modules/@types"],
|
||||
"typeRoots": [
|
||||
"./types",
|
||||
"../node_modules/celebrate/lib",
|
||||
"../node_modules/@types"
|
||||
],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
@@ -13,12 +17,11 @@
|
||||
"module": "commonjs",
|
||||
"pretty": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./static/build",
|
||||
"outDir": "../static/build",
|
||||
"allowJs": true,
|
||||
"noEmit": false,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["./back/**/*", "./back.d.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
"files": ["./back/index.d.ts"]
|
||||
"include": ["./**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="express" />
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
platform: 'desktop' | 'mobile';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'reflect-metadata'; // We need this in order to use @Decorators
|
||||
import config from './config';
|
||||
import express from 'express';
|
||||
import depInjectorLoader from './loaders/depInjector';
|
||||
import Logger from './loaders/logger';
|
||||
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
depInjectorLoader();
|
||||
|
||||
await require('./loaders/update').default({ app });
|
||||
|
||||
app
|
||||
.listen(config.updatePort, '0.0.0.0', () => {
|
||||
Logger.debug(`✌️ 更新服务启动成功!`);
|
||||
console.debug(`✌️ 更新服务启动成功!`);
|
||||
process.send?.('ready');
|
||||
})
|
||||
.on('error', (err) => {
|
||||
Logger.error(err);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Joi } from 'celebrate';
|
||||
import cron_parser from 'cron-parser';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { ScheduleType } from '../interface/schedule';
|
||||
import path from 'path';
|
||||
import config from '../config';
|
||||
|
||||
const validateSchedule = (value: string, helpers: any) => {
|
||||
if (
|
||||
@@ -10,8 +12,18 @@ const validateSchedule = (value: string, helpers: any) => {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 检测裸 /N 模式:cron-parser 会接受,但 node-schedule 会返回 null
|
||||
// 提前拦截,避免任务入库后调度器注册失败
|
||||
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
|
||||
return helpers.error('any.invalid');
|
||||
}
|
||||
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
|
||||
if (/\?/.test(value)) {
|
||||
return helpers.error('any.invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
if (cron_parser.parseExpression(value).hasNext()) {
|
||||
if (CronExpressionParser.parse(value).hasNext()) {
|
||||
return value;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -37,4 +49,48 @@ export const commonCronSchema = {
|
||||
extra_schedules: Joi.array().optional().allow(null),
|
||||
task_before: Joi.string().optional().allow('').allow(null),
|
||||
task_after: Joi.string().optional().allow('').allow(null),
|
||||
log_name: Joi.string()
|
||||
.optional()
|
||||
.allow('')
|
||||
.allow(null)
|
||||
.custom((value, helpers) => {
|
||||
if (!value) return value;
|
||||
|
||||
// Check if it's an absolute path
|
||||
if (value.startsWith('/')) {
|
||||
// Allow /dev/null as special case
|
||||
if (value === '/dev/null') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// For other absolute paths, ensure they are within the safe log directory
|
||||
const normalizedValue = path.normalize(value);
|
||||
const normalizedLogPath = path.normalize(config.logPath);
|
||||
|
||||
if (!normalizedValue.startsWith(normalizedLogPath)) {
|
||||
return helpers.error('string.unsafePath');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
return helpers.error('string.pattern.base');
|
||||
}
|
||||
if (value.length > 100) {
|
||||
return helpers.error('string.max');
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.messages({
|
||||
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
|
||||
'string.max': '日志名称不能超过100个字符',
|
||||
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
|
||||
}),
|
||||
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
|
||||
work_dir: Joi.string().optional().allow('').allow(null),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Kubernetes deployment
|
||||
|
||||
This deploys Qinglong as a single-replica `StatefulSet` with persistent data at `/ql/data`.
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/kubernetes/overlays/local
|
||||
kubectl -n qinglong rollout status statefulset/qinglong
|
||||
```
|
||||
|
||||
Open the panel locally:
|
||||
|
||||
```bash
|
||||
kubectl -n qinglong port-forward svc/qinglong 5700:5700
|
||||
```
|
||||
|
||||
Then visit <http://127.0.0.1:5700>.
|
||||
|
||||
## Image registry overlays
|
||||
|
||||
Use `overlays/example` as the committed template for registry customization:
|
||||
|
||||
```yaml
|
||||
whyour/qinglong:debian -> registry.example.com/whyour/qinglong:debian
|
||||
```
|
||||
|
||||
Create `overlays/local/kustomization.yaml` for the actual cluster image. The `local` overlay is ignored by git so private registry names, digests, and credentials-related references stay local.
|
||||
|
||||
## Storage
|
||||
|
||||
The manifest creates a 5 GiB `ReadWriteOnce` PVC from the cluster's default `StorageClass`.
|
||||
If your cluster has no default storage class, add `storageClassName` under:
|
||||
|
||||
```yaml
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
storageClassName: your-storage-class
|
||||
```
|
||||
|
||||
Keep `replicas: 1`. Qinglong stores state in the persistent data directory, including SQLite files, so multiple replicas should not share the same data volume.
|
||||
|
||||
## Ingress example
|
||||
|
||||
If you expose Qinglong through an Ingress path other than `/`, set `QlBaseUrl` to the same path with leading and trailing slashes.
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: QlBaseUrl
|
||||
value: "/qinglong/"
|
||||
```
|
||||
|
||||
Example Ingress:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: qinglong
|
||||
namespace: qinglong
|
||||
spec:
|
||||
rules:
|
||||
- host: qinglong.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: qinglong
|
||||
port:
|
||||
number: 5700
|
||||
```
|
||||
|
||||
## Maintenance commands
|
||||
|
||||
```bash
|
||||
kubectl -n qinglong logs -f statefulset/qinglong
|
||||
kubectl -n qinglong exec -it statefulset/qinglong -- ql check
|
||||
kubectl -n qinglong exec -it statefulset/qinglong -- ql update
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- qinglong.yaml
|
||||
@@ -0,0 +1,95 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: qinglong
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: qinglong
|
||||
namespace: qinglong
|
||||
labels:
|
||||
app.kubernetes.io/name: qinglong
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: qinglong
|
||||
ports:
|
||||
- name: http
|
||||
port: 5700
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: qinglong
|
||||
namespace: qinglong
|
||||
labels:
|
||||
app.kubernetes.io/name: qinglong
|
||||
spec:
|
||||
serviceName: qinglong
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: qinglong
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: qinglong
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 5432
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
containers:
|
||||
- name: qinglong
|
||||
image: whyour/qinglong:debian
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: QlBaseUrl
|
||||
value: "/"
|
||||
- name: TZ
|
||||
value: Asia/Shanghai
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 5700
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 60
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /ql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
images:
|
||||
- name: whyour/qinglong
|
||||
newName: registry.example.com/whyour/qinglong
|
||||
newTag: debian
|
||||
@@ -1,83 +0,0 @@
|
||||
FROM python:3.10-alpine3.18 AS builder
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
RUN set -x \
|
||||
&& apk update \
|
||||
&& apk add nodejs npm git \
|
||||
&& npm i -g pnpm@8.3.1 pm2 ts-node \
|
||||
&& cd /tmp/build \
|
||||
&& pnpm install --prod
|
||||
|
||||
FROM python:3.10-alpine
|
||||
|
||||
ARG QL_MAINTAINER="whyour"
|
||||
LABEL maintainer="${QL_MAINTAINER}"
|
||||
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
|
||||
ARG QL_BRANCH=develop
|
||||
|
||||
ENV PNPM_HOME=/root/.local/share/pnpm \
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules \
|
||||
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
|
||||
LANG=C.UTF-8 \
|
||||
SHELL=/bin/bash \
|
||||
PS1="\u@\h:\w \$ " \
|
||||
QL_DIR=/ql \
|
||||
QL_BRANCH=${QL_BRANCH}
|
||||
|
||||
VOLUME /ql/data
|
||||
|
||||
EXPOSE 5700
|
||||
|
||||
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /usr/local/bin/. /usr/local/bin/
|
||||
|
||||
RUN set -x \
|
||||
&& apk update -f \
|
||||
&& apk upgrade \
|
||||
&& apk --no-cache add -f bash \
|
||||
coreutils \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
tzdata \
|
||||
perl \
|
||||
openssl \
|
||||
nginx \
|
||||
nodejs \
|
||||
jq \
|
||||
openssh \
|
||||
procps \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
npm \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& 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 \
|
||||
&& rm -rf /root/.pnpm-store \
|
||||
&& rm -rf /root/.local/share/pnpm/store \
|
||||
&& rm -rf /root/.cache \
|
||||
&& ulimit -c 0 \
|
||||
&& pip3 install requests
|
||||
|
||||
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 /static \
|
||||
&& mkdir -p ${QL_DIR}/static \
|
||||
&& cp -rf /static/* ${QL_DIR}/static \
|
||||
&& rm -rf /static
|
||||
|
||||
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||
|
||||
WORKDIR ${QL_DIR}
|
||||
|
||||
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
|
||||
CMD curl -sf --noproxy '*' http://127.0.0.1:5400/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
||||
+46
-20
@@ -1,11 +1,26 @@
|
||||
FROM python:3.11-alpine3.18 AS builder
|
||||
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||
# 100% CPU when Alpine s390x is emulated through QEMU.
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
||||
ENV PATH=/opt/node-global/bin:${PATH}
|
||||
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
RUN set -x \
|
||||
&& apk update \
|
||||
&& apk add nodejs npm git \
|
||||
&& npm i -g pnpm@8.3.1 pm2 ts-node \
|
||||
&& apk add --no-cache git \
|
||||
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
|
||||
&& cd /tmp/build \
|
||||
&& pnpm install --prod
|
||||
&& case "${TARGETARCH}" in \
|
||||
amd64) NODE_ARCH=x64 ;; \
|
||||
386) NODE_ARCH=ia32 ;; \
|
||||
ppc64le) NODE_ARCH=ppc64 ;; \
|
||||
*) NODE_ARCH="${TARGETARCH}" ;; \
|
||||
esac \
|
||||
&& npm_config_target_platform=linux \
|
||||
npm_config_target_arch="${NODE_ARCH}" \
|
||||
npm_config_target_libc=musl \
|
||||
pnpm install --prod
|
||||
|
||||
FROM python:3.11-alpine
|
||||
|
||||
@@ -13,22 +28,21 @@ 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 PNPM_HOME=/root/.local/share/pnpm \
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules \
|
||||
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
|
||||
ENV QL_DIR=/ql \
|
||||
QL_BRANCH=${QL_BRANCH} \
|
||||
QL_CONTAINER=true \
|
||||
LANG=C.UTF-8 \
|
||||
SHELL=/bin/bash \
|
||||
PS1="\u@\h:\w \$ " \
|
||||
QL_DIR=/ql \
|
||||
QL_BRANCH=${QL_BRANCH}
|
||||
PS1="\u@\h:\w \$ "
|
||||
|
||||
VOLUME /ql/data
|
||||
|
||||
EXPOSE 5700
|
||||
|
||||
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /usr/local/bin/. /usr/local/bin/
|
||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||
|
||||
RUN set -x \
|
||||
&& apk update -f \
|
||||
@@ -41,7 +55,6 @@ RUN set -x \
|
||||
tzdata \
|
||||
perl \
|
||||
openssl \
|
||||
nginx \
|
||||
nodejs \
|
||||
jq \
|
||||
openssh \
|
||||
@@ -53,14 +66,11 @@ RUN set -x \
|
||||
&& apk update \
|
||||
&& 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.email "qinglong@users.noreply.github.com" \
|
||||
&& git config --global user.name "qinglong" \
|
||||
&& git config --global http.postBuffer 524288000 \
|
||||
&& rm -rf /root/.pnpm-store \
|
||||
&& rm -rf /root/.local/share/pnpm/store \
|
||||
&& rm -rf /root/.cache \
|
||||
&& ulimit -c 0 \
|
||||
&& pip3 install requests
|
||||
&& ulimit -c 0
|
||||
|
||||
ARG SOURCE_COMMIT
|
||||
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
||||
@@ -73,11 +83,27 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
|
||||
&& cp -rf /static/* ${QL_DIR}/static \
|
||||
&& rm -rf /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=/root
|
||||
|
||||
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 --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:5400/api/health || exit 1
|
||||
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Run Node package installation natively on the builder. Node/npm can spin at
|
||||
# 100% CPU when Alpine s390x is emulated through QEMU.
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ENV NPM_CONFIG_PREFIX=/opt/node-global
|
||||
ENV PATH=/opt/node-global/bin:${PATH}
|
||||
|
||||
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
|
||||
RUN set -x \
|
||||
&& apk add --no-cache git \
|
||||
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
|
||||
&& cd /tmp/build \
|
||||
&& case "${TARGETARCH}" in \
|
||||
amd64) NODE_ARCH=x64 ;; \
|
||||
386) NODE_ARCH=ia32 ;; \
|
||||
ppc64le) NODE_ARCH=ppc64 ;; \
|
||||
*) NODE_ARCH="${TARGETARCH}" ;; \
|
||||
esac \
|
||||
&& npm_config_target_platform=linux \
|
||||
npm_config_target_arch="${NODE_ARCH}" \
|
||||
npm_config_target_libc=musl \
|
||||
pnpm install --prod
|
||||
|
||||
FROM python:3.10-alpine
|
||||
|
||||
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} \
|
||||
QL_CONTAINER=true \
|
||||
LANG=C.UTF-8 \
|
||||
SHELL=/bin/bash \
|
||||
PS1="\u@\h:\w \$ "
|
||||
|
||||
VOLUME /ql/data
|
||||
|
||||
EXPOSE 5700
|
||||
|
||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||
|
||||
RUN set -x \
|
||||
&& apk update -f \
|
||||
&& apk upgrade \
|
||||
&& apk --no-cache add -f bash \
|
||||
coreutils \
|
||||
git \
|
||||
curl \
|
||||
wget \
|
||||
tzdata \
|
||||
perl \
|
||||
openssl \
|
||||
nodejs \
|
||||
jq \
|
||||
openssh \
|
||||
procps \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
npm \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& 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 \
|
||||
&& rm -rf /root/.cache \
|
||||
&& ulimit -c 0
|
||||
|
||||
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 /static \
|
||||
&& mkdir -p ${QL_DIR}/static \
|
||||
&& cp -rf /static/* ${QL_DIR}/static \
|
||||
&& rm -rf /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=/root
|
||||
|
||||
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 --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://localhost:${QlPort:-5700}/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user