mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85f0e0b85 | ||
|
|
6b20cd326a | ||
|
|
91e6005152 | ||
|
|
ea9461c085 | ||
|
|
bf63425805 | ||
|
|
a8310e7d51 | ||
|
|
a67d44cde5 | ||
|
|
6c487018c2 | ||
|
|
801a71d740 | ||
|
|
f051135fc4 | ||
|
|
2d6c2206b3 | ||
|
|
6acb665369 | ||
|
|
d9833c17d5 | ||
|
|
a60ec5587a | ||
|
|
4eb27427f8 | ||
|
|
041a437453 | ||
|
|
841740f19a | ||
|
|
a94e665054 | ||
|
|
d62d8f3025 | ||
|
|
4df52094f2 | ||
|
|
be2580d0e8 | ||
|
|
5f6049d80a | ||
|
|
0e975d1d6d | ||
|
|
134cd8337c | ||
|
|
88200f2805 | ||
|
|
decdab093f | ||
|
|
dfa73af104 | ||
|
|
f71fd5ebd6 | ||
|
|
afe9b5702b | ||
|
|
02e02491c4 | ||
|
|
276dfc2382 | ||
|
|
31f78e4d1f | ||
|
|
83e4490b57 | ||
|
|
2ac4f07fca | ||
|
|
6618240c61 | ||
|
|
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 |
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitnexus
|
||||
.claude
|
||||
.codex
|
||||
.env*
|
||||
node_modules
|
||||
data
|
||||
audit
|
||||
docs
|
||||
test
|
||||
src/.umi*
|
||||
**/__pycache__
|
||||
**/*.log
|
||||
@@ -1,6 +1,18 @@
|
||||
GRPC_PORT=5500
|
||||
BACK_PORT=5700
|
||||
|
||||
# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6)
|
||||
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
|
||||
# BIND_HOST=0.0.0.0
|
||||
# BIND_HOST_GRPC=0.0.0.0
|
||||
|
||||
# 可信反向代理。未设置时可在「系统设置 → 客户端 IP」中配置和诊断。
|
||||
# 环境变量优先级高于系统设置。支持固定层数、IP、CIDR 或逗号分隔列表:
|
||||
# QL_TRUST_PROXY=1
|
||||
# QL_TRUST_PROXY=loopback,172.18.0.0/16,10.20.0.8/32
|
||||
# 仅在面板端口绝对无法绕过代理直接访问时使用 true。
|
||||
# QL_TRUST_PROXY=loopback
|
||||
|
||||
LOG_LEVEL='info'
|
||||
|
||||
JWT_SECRET=
|
||||
|
||||
@@ -11,21 +11,29 @@ on:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
uses: ./.github/workflows/validate.yml
|
||||
|
||||
code_gitlab:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: Yikun/hub-mirror-action@master
|
||||
with:
|
||||
src: github/whyour
|
||||
dst: gitlab/whyour
|
||||
dst_key: ${{ secrets.GITLAB_SSH_PK }}
|
||||
dst_token: ${{ secrets.GITLAB_TOKEN }}
|
||||
static_list: "qinglong"
|
||||
force_update: true
|
||||
- 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
|
||||
@@ -33,40 +41,63 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: Yikun/hub-mirror-action@master
|
||||
with:
|
||||
src: github/whyour
|
||||
dst: gitee/whyour
|
||||
dst_key: ${{ secrets.GITLAB_SSH_PK }}
|
||||
dst_token: ${{ secrets.GITEE_TOKEN }}
|
||||
static_list: "qinglong"
|
||||
force_update: true
|
||||
- name: 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:
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: build front and back
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
pnpm build:info
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
- 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
|
||||
@@ -81,73 +112,88 @@ jobs:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: Yikun/hub-mirror-action@master
|
||||
with:
|
||||
src: github/whyour
|
||||
dst: gitlab/whyour
|
||||
dst_key: ${{ secrets.GITLAB_SSH_PK }}
|
||||
dst_token: ${{ secrets.GITLAB_TOKEN }}
|
||||
static_list: "qinglong-static"
|
||||
force_update: true
|
||||
- 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:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: Yikun/hub-mirror-action@master
|
||||
with:
|
||||
src: github/whyour
|
||||
dst: gitee/whyour
|
||||
dst_key: ${{ secrets.GITLAB_SSH_PK }}
|
||||
dst_token: ${{ secrets.GITEE_TOKEN }}
|
||||
static_list: "qinglong-static"
|
||||
force_update: true
|
||||
- name: 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
|
||||
|
||||
build:
|
||||
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@v4
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Read version from version.yaml
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Setup timezone
|
||||
uses: szenius/set-timezone@v2.0
|
||||
with:
|
||||
timezoneLinux: Asia/Shanghai
|
||||
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -155,7 +201,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
@@ -169,104 +215,306 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Build and push (Alpine)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
# linux/s390x npm 暂不可用
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: |
|
||||
echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
build310:
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
build-debian:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
needs: build-static
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Read version from version.yaml
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Setup timezone
|
||||
uses: szenius/set-timezone@v2.0
|
||||
with:
|
||||
timezoneLinux: Asia/Shanghai
|
||||
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ github.repository }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
|
||||
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@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: |
|
||||
QL_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
|
||||
provenance: mode=max
|
||||
sbom: 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: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build and push python3.10
|
||||
id: docker_build_310
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Build and push (Alpine Python 3.10)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
MAINTAINER=${{ github.repository_owner }}
|
||||
QL_MAINTAINER=${{ github.repository_owner }}
|
||||
QL_BRANCH=${{ github.ref_name }}
|
||||
SOURCE_COMMIT=${{ github.sha }}
|
||||
network: host
|
||||
# linux/s390x npm 暂不可用
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
context: .
|
||||
file: ./docker/310.Dockerfile
|
||||
file: ./docker/Dockerfile.310
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: |
|
||||
whyour/qinglong:python3.10
|
||||
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
|
||||
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
|
||||
|
||||
- name: Image digest
|
||||
build-debian310:
|
||||
if: ${{ github.ref_name == 'master' }}
|
||||
needs: build-static
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-static-${{ github.sha }}
|
||||
path: static/
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: "8.3.1"
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Read version from version.yaml
|
||||
id: version
|
||||
run: |
|
||||
echo ${{ steps.docker_build_310.outputs.digest }}
|
||||
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup timezone
|
||||
run: sudo timedatectl set-timezone Asia/Shanghai
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
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: |
|
||||
QL_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
|
||||
provenance: mode=max
|
||||
sbom: 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.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
||||
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: "8.3.1"
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: build front and back
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:front
|
||||
pnpm build:back
|
||||
|
||||
- name: Set up Node for trusted publishing
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: https://registry.npmjs.org
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Publish npm package with OIDC
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name=$(node -p 'require("./package.json").name')
|
||||
version=$(node -p 'require("./package.json").version')
|
||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
|
||||
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
||||
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
||||
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
||||
npm publish --access public --registry=https://registry.npmjs.org
|
||||
else
|
||||
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
name: CLI package
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'shell/**'
|
||||
- 'back/api/**'
|
||||
- 'back/loaders/deps.ts'
|
||||
- 'package.json'
|
||||
- '.github/workflows/cli-package.yml'
|
||||
push:
|
||||
branches: [develop, master]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'shell/**'
|
||||
- 'back/api/**'
|
||||
- 'back/loaders/deps.ts'
|
||||
- 'package.json'
|
||||
- '.github/workflows/cli-package.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Publish the verified CLI package (master only)
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: cli-package-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['22.12.0', '24']
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
cache-dependency-path: cli/package-lock.json
|
||||
- name: Install test interpreters and tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip
|
||||
npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2
|
||||
- run: npm ci --prefix cli --no-audit --no-fund
|
||||
- run: npm run check:cli
|
||||
- run: npm run test:cli
|
||||
- name: Verify offline package installation
|
||||
env:
|
||||
QL_CLI_PACKAGE_OUTPUT: ${{ runner.temp }}/cli-package
|
||||
run: node cli/scripts/verify-package.cjs
|
||||
- name: Upload verified npm archive
|
||||
if: matrix.node == '24'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: qinglong-cli-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/cli-package/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
publish:
|
||||
needs: package
|
||||
if: >-
|
||||
github.repository == 'whyour/qinglong' &&
|
||||
github.ref == 'refs/heads/master' &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
registry-url: https://registry.npmjs.org
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: qinglong-cli-${{ github.sha }}
|
||||
path: cli-package
|
||||
- name: Publish verified npm archive
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
archives=(cli-package/*.tgz)
|
||||
if [ "${#archives[@]}" -ne 1 ]; then
|
||||
echo '::error::Expected exactly one verified CLI archive.'
|
||||
exit 1
|
||||
fi
|
||||
archive="${archives[0]}"
|
||||
metadata=$(tar -xOf "$archive" package/package.json)
|
||||
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata")
|
||||
version=$(jq -er '.version | select(type == "string" and test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))' <<< "$metadata")
|
||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then
|
||||
jq -e --arg version "$version" '. == $version' version.json > /dev/null
|
||||
echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
|
||||
exit 0
|
||||
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
|
||||
echo '::error::Could not check the published CLI version; refusing to publish.'
|
||||
exit 1
|
||||
fi
|
||||
npm publish "./$archive" --access public --tag latest --ignore-scripts --registry=https://registry.npmjs.org
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
regression:
|
||||
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:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cp .env.example .env
|
||||
- run: pnpm test
|
||||
- run: pnpm build:back
|
||||
- run: pnpm benchmark:execution -- --runs=10 --output=execution-baseline.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: execution-baseline
|
||||
path: execution-baseline.json
|
||||
+10
@@ -28,3 +28,13 @@ __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/
|
||||
|
||||
# Local security audit artifacts
|
||||
/audit/
|
||||
|
||||
+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
|
||||
@@ -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 -->
|
||||
@@ -34,6 +34,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
||||
- Support system level notification
|
||||
- Support dark mode
|
||||
- Support cell phone operation
|
||||
- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill)
|
||||
|
||||
## Version
|
||||
|
||||
@@ -48,6 +49,17 @@ docker pull whyour/qinglong:latest
|
||||
docker pull whyour/qinglong:debian
|
||||
```
|
||||
|
||||
When running the `debian` image as a non-root user, specify `--user qinglong`:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-v /path/to/ql/data:/ql/data \
|
||||
-p 5700:5700 \
|
||||
--user qinglong \
|
||||
--name qinglong \
|
||||
whyour/qinglong:debian
|
||||
```
|
||||
|
||||
### npm
|
||||
|
||||
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
|
||||
@@ -68,6 +80,29 @@ npm i @whyour/qinglong
|
||||
|
||||
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
|
||||
|
||||
## Remote CLI and Agent Skill
|
||||
|
||||
Use the standalone npm package [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli) to manage a remote QingLong panel from your terminal or automation workflows. Create, update and run tasks and subscriptions, inspect logs, and manage applications, environment variables, scripts, configuration, dependencies and other OpenAPI resources. JSON output supports scripts and AI agents.
|
||||
|
||||
Requires Node.js >=22.12; Node.js 24 is recommended. Install the package to use `ql`:
|
||||
|
||||
```bash
|
||||
npm install -g @whyour/qinglong-cli
|
||||
ql login --url https://ql.example.com
|
||||
ql auth status --json
|
||||
ql task list --json
|
||||
ql subscription list --json
|
||||
ql --help
|
||||
```
|
||||
|
||||
Create an application in the panel's System Settings → Application Settings and grant the required permissions. Enter its Client ID/Secret when prompted during login. Existing tokens are also supported through `QL_URL` and `QL_ACCESS_TOKEN`. Each command supports `--help`; set `QL_LANG=en` for English help.
|
||||
|
||||
The npm CLI manages remote panels. The panel's built-in executable is also named `ql`; use a separate installation directory on panel hosts to avoid replacing it. For local script execution and maintenance, see [panel-internal tools](cli/LOCAL.en.md).
|
||||
|
||||
[CLI guide](cli/README.en.md) · [简体中文](cli/README.md) · [Full command reference](cli/skills/qinglong-cli/references/openapi.md)
|
||||
|
||||
The package includes a [remote management skill](cli/skills/qinglong-cli/SKILL.md) covering authentication, command selection and result verification for AI agents. Local execution and maintenance have a separate [internal skill](cli/skills/qinglong-local/SKILL.md).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
|
||||
- 支持系统级通知
|
||||
- 支持暗黑模式
|
||||
- 支持手机端操作
|
||||
- 支持通过 [远程 CLI 与 Agent Skill](#远程-cli-与-agent-skill) 管理面板
|
||||
|
||||
## 版本
|
||||
|
||||
@@ -50,6 +51,17 @@ docker pull whyour/qinglong:latest
|
||||
docker pull whyour/qinglong:debian
|
||||
```
|
||||
|
||||
使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-v /path/to/ql/data:/ql/data \
|
||||
-p 5700:5700 \
|
||||
--user qinglong \
|
||||
--name qinglong \
|
||||
whyour/qinglong:debian
|
||||
```
|
||||
|
||||
### npm
|
||||
|
||||
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
|
||||
@@ -70,6 +82,29 @@ npm i @whyour/qinglong
|
||||
|
||||
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
|
||||
|
||||
## 远程 CLI 与 Agent Skill
|
||||
|
||||
使用独立 npm 包 [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli),可以从本机终端或自动化流程管理远程青龙面板。支持任务与订阅的创建、修改、运行和日志查看,以及应用、环境变量、脚本、配置、依赖等 OpenAPI 资源;JSON 输出便于脚本和 AI Agent 使用。
|
||||
|
||||
需要 Node.js >=22.12,推荐 Node.js 24。安装后使用 `ql` 命令:
|
||||
|
||||
```bash
|
||||
npm install -g @whyour/qinglong-cli
|
||||
ql login --url https://ql.example.com
|
||||
ql auth status --json
|
||||
ql task list --json
|
||||
ql subscription list --json
|
||||
ql --help
|
||||
```
|
||||
|
||||
在面板「系统设置 → 应用设置」创建应用并授予所需权限,登录时按提示输入 Client ID/Secret;也支持通过 `QL_URL` 和 `QL_ACCESS_TOKEN` 使用已有令牌。各命令支持 `--help`,`QL_LANG=en` 可切换英文帮助。
|
||||
|
||||
npm CLI 用于远程管理。面板内置 `ql` 同样使用这个命令名,已有面板的机器请使用独立安装目录,避免覆盖内置命令。本机脚本执行和运维见 [面板内部工具](cli/LOCAL.md)。
|
||||
|
||||
[CLI 中文说明](cli/README.md) · [English guide](cli/README.en.md) · [完整命令参考](cli/skills/qinglong-cli/references/openapi.md)
|
||||
|
||||
包内附带 [远程管理 Skill](cli/skills/qinglong-cli/SKILL.md),涵盖认证、命令选择和操作结果检查,可供 AI Agent 使用。本机执行与运维另见 [内部 Skill](cli/skills/qinglong-local/SKILL.md)。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import {
|
||||
diagnoseClientIp,
|
||||
getTrustProxyConfig,
|
||||
updateTrustProxy,
|
||||
} from '../shared/trustProxy';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/system/client-ip', route);
|
||||
|
||||
route.get(
|
||||
'/config',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send({ code: 200, data: await getTrustProxyConfig() });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/config',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
trustProxy: Joi.string().max(500).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await updateTrustProxy(req.body.trustProxy);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
res.send({
|
||||
code: 400,
|
||||
message: error instanceof Error ? error.message : '配置更新失败',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/diagnose',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send({ code: 200, data: await diagnoseClientIp(req) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+24
-20
@@ -4,17 +4,19 @@ 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';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/configs', route);
|
||||
|
||||
route.get(
|
||||
'/sample',
|
||||
'/samples',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.send({
|
||||
@@ -71,30 +73,32 @@ 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 normalized = resolveFileAccess(
|
||||
basePath,
|
||||
[cleanName],
|
||||
config.blackFileList,
|
||||
);
|
||||
if (!normalized) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
await writeFileWithLock(normalized, content);
|
||||
res.send({ code: 200, message: t('保存成功') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const configService = Container.get(ConfigService);
|
||||
await configService.getFile(req.params.file, res);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /configs/detail 接口'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+87
-3
@@ -5,12 +5,31 @@ 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';
|
||||
import cronClient from '../schedule/client';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/crons', route);
|
||||
|
||||
route.use(async (req, res, next) => {
|
||||
// Keep stop/status callbacks available even when the scheduler is down.
|
||||
if (['POST', 'PUT', 'DELETE'].includes(req.method) &&
|
||||
['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) {
|
||||
try {
|
||||
await cronClient.readiness.ensureReady();
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
route.get(
|
||||
'/views',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -60,7 +79,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 });
|
||||
@@ -302,13 +321,35 @@ export default (app: Router) => {
|
||||
params: Joi.object({
|
||||
id: Joi.number().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
offset: Joi.number().integer().min(0).optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(1024 * 1024)
|
||||
.optional(),
|
||||
tail: Joi.boolean().optional(),
|
||||
t: Joi.string().optional(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||
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, {
|
||||
offset: req.query.offset as unknown as number,
|
||||
limit: req.query.limit as unknown as number,
|
||||
tail: req.query.tail as unknown as boolean,
|
||||
});
|
||||
return res.send({
|
||||
code: 200,
|
||||
data: result.content,
|
||||
logStatus: result.status,
|
||||
offset: result.offset,
|
||||
nextOffset: result.nextOffset,
|
||||
total: result.total,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -429,6 +470,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 +488,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,437 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const isSuccess of [false, true]) {
|
||||
route.get(
|
||||
isSuccess ? '/successes' : '/failures',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const countColumn = isSuccess ? 'success_count' : 'fail_count';
|
||||
const rows = (await CrontabStatModel.findAll({
|
||||
attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']],
|
||||
where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } },
|
||||
group: ['ref_id'],
|
||||
order: [[fn('SUM', col(countColumn)), 'DESC'], ['ref_id', 'ASC']],
|
||||
raw: true,
|
||||
})) as any[];
|
||||
const crons = rows.length > 0 ? await CrontabModel.findAll({
|
||||
attributes: ['id', 'name', 'command'],
|
||||
where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } },
|
||||
raw: true,
|
||||
}) : [];
|
||||
const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
|
||||
res.send({
|
||||
code: 200,
|
||||
data: rows.map((row) => {
|
||||
const id = Number(row.ref_id);
|
||||
const cron = cronMap.get(id);
|
||||
return {
|
||||
id,
|
||||
name: cron?.name || cron?.command || tf('任务#%s', id),
|
||||
command: cron?.command || '',
|
||||
[isSuccess ? 'successCount' : 'failCount']: Number(row.result_count),
|
||||
deleted: !cron,
|
||||
};
|
||||
}),
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+48
-2
@@ -6,6 +6,7 @@ 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();
|
||||
|
||||
@@ -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) => {
|
||||
@@ -230,6 +237,44 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/labels',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
ids: Joi.array().items(Joi.number().required()).min(1).required(),
|
||||
labels: labelSchema,
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.addLabels(req.body.ids, req.body.labels);
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.delete(
|
||||
'/labels',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
ids: Joi.array().items(Joi.number().required()).min(1).required(),
|
||||
labels: labelSchema,
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const data = await envService.removeLabels(req.body.ids, req.body.labels);
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/upload',
|
||||
upload.single('env'),
|
||||
@@ -248,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) {
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ export default (app: Router) => {
|
||||
try {
|
||||
const healthService = Container.get(HealthService);
|
||||
const health = await healthService.check();
|
||||
res.status(200).send({
|
||||
code: 200,
|
||||
res.status(health.status === 'ok' ? 200 : 503).send({
|
||||
code: health.status === 'ok' ? 200 : 503,
|
||||
data: health,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -10,7 +10,10 @@ import dependence from './dependence';
|
||||
import system from './system';
|
||||
import subscription from './subscription';
|
||||
import update from './update';
|
||||
import dashboard from './dashboard';
|
||||
import health from './health';
|
||||
import clientIp from './clientIp';
|
||||
import retention from './retention';
|
||||
|
||||
export default () => {
|
||||
const app = Router();
|
||||
@@ -25,7 +28,10 @@ export default () => {
|
||||
system(app);
|
||||
subscription(app);
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
clientIp(app);
|
||||
retention(app);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
+44
-24
@@ -3,13 +3,15 @@ import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { t } from '../shared/i18n';
|
||||
import {
|
||||
getFileContentByName,
|
||||
readDirs,
|
||||
removeAnsi,
|
||||
rmPath,
|
||||
} from '../config/util';
|
||||
import LogService from '../services/log';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { MAX_LOG_CHUNK_BYTES, readLogChunk } from '../shared/logReader';
|
||||
const route = Router();
|
||||
const blacklist = ['.tmp'];
|
||||
|
||||
@@ -32,6 +34,20 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/detail',
|
||||
celebrate({
|
||||
query: Joi.object({
|
||||
path: Joi.string().allow('').optional(),
|
||||
file: Joi.string().required(),
|
||||
offset: Joi.number().integer().min(0).optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(MAX_LOG_CHUNK_BYTES)
|
||||
.optional(),
|
||||
tail: Joi.boolean().optional(),
|
||||
t: Joi.string().optional(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const logService = Container.get(LogService);
|
||||
@@ -42,11 +58,28 @@ export default (app: Router) => {
|
||||
if (!finalPath || blacklist.includes(req.query.path as string)) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
const content = await getFileContentByName(finalPath);
|
||||
res.send({ code: 200, data: removeAnsi(content) });
|
||||
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 chunk = await readLogChunk(finalPath, {
|
||||
offset: req.query.offset as unknown as number,
|
||||
limit: req.query.limit as unknown as number,
|
||||
tail: req.query.tail as unknown as boolean,
|
||||
});
|
||||
res.send({
|
||||
code: 200,
|
||||
data: removeAnsi(chunk.content),
|
||||
logStatus: runningInstance ? 'running' : undefined,
|
||||
offset: chunk.offset,
|
||||
nextOffset: chunk.nextOffset,
|
||||
total: chunk.total,
|
||||
truncated: chunk.truncated,
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -55,24 +88,11 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const logService = Container.get(LogService);
|
||||
const finalPath = logService.checkFilePath(
|
||||
(req.query.path as string) || '',
|
||||
(req.params.file as string) || '',
|
||||
);
|
||||
if (!finalPath || blacklist.includes(req.query.path as string)) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
});
|
||||
}
|
||||
const content = await getFileContentByName(finalPath);
|
||||
res.send({ code: 200, data: content });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
(req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /logs/detail 接口'),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -96,7 +116,7 @@ export default (app: Router) => {
|
||||
if (!finalPath || blacklist.includes(path)) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await rmPath(finalPath);
|
||||
@@ -126,7 +146,7 @@ export default (app: Router) => {
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
return res.download(filePath, filename, (err) => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { Container } from 'typedi';
|
||||
import RetentionService from '../services/retention';
|
||||
import { MAX_RETENTION_DAYS } from '../shared/retention';
|
||||
|
||||
const route = Router();
|
||||
const policySchema = {
|
||||
runningInstanceRetentionDays: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(MAX_RETENTION_DAYS)
|
||||
.required(),
|
||||
cronStatRetentionDays: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(MAX_RETENTION_DAYS)
|
||||
.required(),
|
||||
};
|
||||
const cleanupSchema = {
|
||||
...policySchema,
|
||||
dependenceCacheTypes: Joi.array()
|
||||
.items(Joi.string().valid('node', 'python3'))
|
||||
.unique()
|
||||
.default([]),
|
||||
compactDatabase: Joi.boolean().default(false),
|
||||
};
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/system/storage-retention', route);
|
||||
|
||||
route.put(
|
||||
'/config',
|
||||
celebrate({ body: Joi.object(policySchema) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(RetentionService);
|
||||
const data = await service.updatePolicy(req.body);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/preview',
|
||||
celebrate({ body: Joi.object(cleanupSchema) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(RetentionService);
|
||||
const data = await service.preview(req.body);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.post(
|
||||
'/cleanup',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
...cleanupSchema,
|
||||
confirmation: Joi.string().valid('CLEAN').required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(RetentionService);
|
||||
const data = await service.cleanup(req.body);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+184
-31
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
@@ -7,16 +9,29 @@ import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import path, { join, parse } from 'path';
|
||||
import ScriptService from '../services/script';
|
||||
import ScriptHistoryService from '../services/scriptHistory';
|
||||
import { t } from '../shared/i18n';
|
||||
import multer from 'multer';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import {
|
||||
ScriptHistoryError,
|
||||
HistoryUnavailableError,
|
||||
} from '../shared/scriptHistory';
|
||||
const route = Router();
|
||||
|
||||
function isPathAllowed(targetPath: string): boolean {
|
||||
const resolved = path.resolve(targetPath);
|
||||
return config.writePathList.some((x) =>
|
||||
Boolean(resolveFileAccess(x, [resolved], config.blackFileList)),
|
||||
);
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.scriptPath);
|
||||
cb(null, config.tmpPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, file.originalname);
|
||||
cb(null, randomUUID());
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
@@ -44,6 +59,15 @@ export default (app: Router) => {
|
||||
'package-lock.json',
|
||||
];
|
||||
if (req.query.path) {
|
||||
if (
|
||||
!resolveFileAccess(
|
||||
config.scriptPath,
|
||||
[req.query.path as string],
|
||||
config.blackFileList,
|
||||
)
|
||||
) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
result = await readDir(
|
||||
req.query.path as string,
|
||||
config.scriptPath,
|
||||
@@ -71,7 +95,8 @@ export default (app: Router) => {
|
||||
logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/detail',
|
||||
@@ -85,7 +110,7 @@ export default (app: Router) => {
|
||||
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 });
|
||||
@@ -95,38 +120,108 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
const historyError = (error: unknown, res: Response, next: NextFunction) => {
|
||||
if (error instanceof ScriptHistoryError) {
|
||||
return res.status(error.status).send({
|
||||
code: error.status,
|
||||
message: t(error.message),
|
||||
...(error instanceof HistoryUnavailableError
|
||||
? { historyUnavailable: true, currentHash: error.currentHash }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return next(error);
|
||||
};
|
||||
const historyQuery = {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
};
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
'/history',
|
||||
celebrate({ query: Joi.object(historyQuery).unknown(true) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.list(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.get(
|
||||
'/history/detail',
|
||||
celebrate({
|
||||
params: Joi.object({
|
||||
file: Joi.string().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
path: Joi.string().optional().allow(''),
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().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.params.file,
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.detail(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
req.query.id as string,
|
||||
);
|
||||
res.send({ code: 200, data: content });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.put(
|
||||
'/history/restore',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
expectedHash: Joi.string().hex().length(64).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { path = '', filename, id, expectedHash } = req.body;
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.restore(path, filename, id, expectedHash);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /scripts/detail 接口'),
|
||||
});
|
||||
});
|
||||
|
||||
route.post(
|
||||
'/',
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
res.on('finish', () => {
|
||||
if (req.file?.path) fs.unlink(req.file.path).catch(() => undefined);
|
||||
});
|
||||
next();
|
||||
},
|
||||
upload.single('file'),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().optional().allow(''),
|
||||
skipHistory: Joi.boolean().optional(),
|
||||
expectedHash: Joi.string()
|
||||
.hex()
|
||||
.length(64)
|
||||
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||
originFilename: Joi.string().optional().allow(''),
|
||||
directory: Joi.string().optional().allow(''),
|
||||
file: Joi.string().optional().allow(''),
|
||||
@@ -155,30 +250,70 @@ export default (app: Router) => {
|
||||
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
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.copyFile(req.file.path, uploadPath);
|
||||
await fs.unlink(req.file.path);
|
||||
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 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 filePath = join(path, `${filename.replace(/\//g, '')}`);
|
||||
const fileExists = await fileExist(filePath);
|
||||
if (
|
||||
fileExists &&
|
||||
resolveFileAccess(config.scriptPath, [filePath], config.blackFileList)
|
||||
) {
|
||||
let removeSource = filename !== originFilename;
|
||||
if (removeSource) {
|
||||
const [originRealPath, targetRealPath] = await Promise.all([
|
||||
fs.realpath(originFilePath),
|
||||
fs.realpath(filePath),
|
||||
]);
|
||||
// Aliases of one script are an in-place save. Removing the source
|
||||
// would also remove the destination behind a target symlink.
|
||||
removeSource = originRealPath !== targetRealPath;
|
||||
}
|
||||
// Save-as removes the source after committing the destination. Keep
|
||||
// its original content too: destination history only protects the
|
||||
// file being overwritten, not the source being deleted.
|
||||
if (removeSource) {
|
||||
await fs.copyFile(
|
||||
originFilePath,
|
||||
join(config.bakPath, originFilename.replace(/\//g, '')),
|
||||
);
|
||||
}
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path, filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
if (removeSource) await rmPath(originFilePath);
|
||||
return res.send({ code: 200, data });
|
||||
}
|
||||
if (fileExists) {
|
||||
await fs.copyFile(
|
||||
originFilePath,
|
||||
@@ -191,7 +326,7 @@ export default (app: Router) => {
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -203,6 +338,11 @@ export default (app: Router) => {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().required().allow(''),
|
||||
skipHistory: Joi.boolean().optional(),
|
||||
expectedHash: Joi.string()
|
||||
.hex()
|
||||
.length(64)
|
||||
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -217,13 +357,17 @@ export default (app: Router) => {
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path || '', filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -251,7 +395,7 @@ export default (app: Router) => {
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await rmPath(filePath);
|
||||
@@ -284,7 +428,7 @@ export default (app: Router) => {
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
return res.download(filePath, filename, (err) => {
|
||||
@@ -316,6 +460,9 @@ export default (app: Router) => {
|
||||
}
|
||||
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);
|
||||
@@ -344,6 +491,9 @@ export default (app: Router) => {
|
||||
}
|
||||
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);
|
||||
@@ -379,6 +529,9 @@ export default (app: Router) => {
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -148,13 +148,27 @@ export default (app: Router) => {
|
||||
params: Joi.object({
|
||||
id: Joi.number().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
offset: Joi.number().integer().min(0).optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(1024 * 1024)
|
||||
.optional(),
|
||||
tail: Joi.boolean().optional(),
|
||||
t: Joi.string().optional(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.log(req.params.id);
|
||||
return res.send({ code: 200, data });
|
||||
const result = await subscriptionService.log(req.params.id, {
|
||||
offset: req.query.offset as unknown as number,
|
||||
limit: req.query.limit as unknown as number,
|
||||
tail: req.query.tail as unknown as boolean,
|
||||
});
|
||||
return res.send({ code: 200, data: result.content, ...result });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
+55
-12
@@ -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,
|
||||
@@ -38,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: {
|
||||
@@ -278,8 +273,11 @@ export default (app: Router) => {
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
res.end();
|
||||
try {
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
res.write(message);
|
||||
@@ -355,6 +353,11 @@ export default (app: Router) => {
|
||||
query: {
|
||||
startTime: Joi.string().allow('').optional(),
|
||||
endTime: Joi.string().allow('').optional(),
|
||||
limit: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(1024 * 1024)
|
||||
.optional(),
|
||||
t: Joi.string().optional(),
|
||||
},
|
||||
}),
|
||||
@@ -366,6 +369,7 @@ export default (app: Router) => {
|
||||
req.query as {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -400,8 +404,11 @@ export default (app: Router) => {
|
||||
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);
|
||||
}
|
||||
@@ -426,6 +433,42 @@ 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({
|
||||
|
||||
+90
-16
@@ -8,6 +8,7 @@ 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();
|
||||
|
||||
@@ -21,7 +22,25 @@ const storage = multer.diskStorage({
|
||||
cb(null, key + ext);
|
||||
},
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const imageTypes: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
};
|
||||
if (imageTypes[ext] !== file.mimetype) {
|
||||
return cb(new Error(t('仅支持 PNG、JPEG、GIF、WebP、AVIF 图片')));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/user', route);
|
||||
@@ -34,8 +53,8 @@ export default (app: Router) => {
|
||||
}),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -69,18 +88,18 @@ export default (app: Router) => {
|
||||
'/',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (isDemoEnv()) {
|
||||
return res.send({ code: 450, message: '未知错误' });
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
}
|
||||
const userService = Container.get(UserService);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: '更新成功' });
|
||||
const result = await userService.updateUsernameAndPassword(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
@@ -140,12 +159,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);
|
||||
@@ -155,11 +174,12 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/two-factor/login',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
code: Joi.string().required(),
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -188,6 +208,59 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/ip-blacklist',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const data = await userService.getIpBlacklist();
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/ip-blacklist',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
ip: Joi.string()
|
||||
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const data = await userService.blockIp(req.body.ip);
|
||||
res.send({ code: 200, data, message: t('已加入 IP 黑名单') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.delete(
|
||||
'/ip-blacklist',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
ip: Joi.string()
|
||||
.ip({ version: ['ipv4', 'ipv6'], cidr: 'forbidden' })
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const data = await userService.unblockIp(req.body.ip);
|
||||
res.send({ code: 200, data, message: t('已移出 IP 黑名单') });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/notification',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -218,18 +291,19 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/init',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
username: Joi.string().max(1024).required(),
|
||||
password: Joi.string().max(1024).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
await userService.updateUsernameAndPassword(req.body);
|
||||
res.send({ code: 200, message: '更新成功' });
|
||||
const result = await userService.initializeUser(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
|
||||
+71
-60
@@ -1,13 +1,10 @@
|
||||
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 type express from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import config from './config';
|
||||
import Logger from './loaders/logger';
|
||||
import { monitoringMiddleware } from './middlewares/monitoring';
|
||||
import { errStack } from './shared/errors';
|
||||
import { type GrpcServerService } from './services/grpc';
|
||||
import { type HttpServerService } from './services/http';
|
||||
|
||||
@@ -19,24 +16,12 @@ interface WorkerMetadata {
|
||||
}
|
||||
|
||||
class Application {
|
||||
private app: express.Application;
|
||||
private httpServerService?: HttpServerService;
|
||||
private grpcServerService?: GrpcServerService;
|
||||
private isShuttingDown = false;
|
||||
private workerMetadataMap = new Map<number, WorkerMetadata>();
|
||||
private httpWorker?: Worker;
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
try {
|
||||
if (cluster.isPrimary) {
|
||||
@@ -48,7 +33,7 @@ class Application {
|
||||
await this.startWorkerProcess();
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to start application:', error);
|
||||
Logger.error(`Failed to start application:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -60,11 +45,11 @@ class Application {
|
||||
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||
this.waitForWorkerReady(grpcWorker, 30000)
|
||||
.then(() => {
|
||||
Logger.info('✌️ gRPC worker is ready, starting HTTP worker');
|
||||
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||
this.httpWorker = this.forkWorker('http');
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('✌️ Failed to wait for gRPC worker:', error);
|
||||
Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -73,34 +58,39 @@ class Application {
|
||||
if (metadata) {
|
||||
if (!this.isShuttingDown) {
|
||||
Logger.error(
|
||||
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
|
||||
`${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') {
|
||||
try {
|
||||
this.httpWorker?.send('scheduler-unavailable');
|
||||
} catch (error) {
|
||||
Logger.warn('Unable to notify HTTP worker of scheduler exit');
|
||||
}
|
||||
const newGrpcWorker = this.forkWorker('grpc');
|
||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||
.then(() => {
|
||||
Logger.info('✌️ gRPC worker restarted and ready');
|
||||
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');
|
||||
Logger.info('Sent reregister-crons message to HTTP worker');
|
||||
} catch (error) {
|
||||
Logger.error('✌️ Failed to send reregister-crons message:', error);
|
||||
Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('✌️ Failed to restart gRPC worker:', 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})`);
|
||||
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +121,14 @@ class Application {
|
||||
}
|
||||
|
||||
private forkWorker(serviceType: string): Worker {
|
||||
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
|
||||
const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType };
|
||||
// PM2's fork launcher is inherited by our own cluster workers. Their APM
|
||||
// messages go to this primary, not PM2, and duplicate its sampling work.
|
||||
// Keep primary monitoring and allow restoring the inherited worker APM.
|
||||
if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') {
|
||||
workerEnv.pmx = 'false';
|
||||
}
|
||||
const worker = cluster.fork(workerEnv);
|
||||
|
||||
this.workerMetadataMap.set(worker.id, {
|
||||
id: worker.id,
|
||||
@@ -144,17 +141,37 @@ class Application {
|
||||
}
|
||||
|
||||
private async initializeDatabase() {
|
||||
const dbLoader = await import('./loaders/db');
|
||||
await dbLoader.default();
|
||||
const { runStartupProcess } = await import('./shared/startupProcess');
|
||||
await runStartupProcess(require.resolve('./bootstrap/database'));
|
||||
}
|
||||
|
||||
private setupMiddlewares() {
|
||||
this.app.use(helmet({
|
||||
private async setupMiddlewares(): Promise<express.Application> {
|
||||
// Only the HTTP worker needs an Express application and its middleware.
|
||||
const [
|
||||
{ default: createExpress },
|
||||
{ default: helmet },
|
||||
{ default: cors },
|
||||
{ default: compression },
|
||||
{ monitoringMiddleware },
|
||||
] = await Promise.all([
|
||||
import('express'),
|
||||
import('helmet'),
|
||||
import('cors'),
|
||||
import('compression'),
|
||||
import('./middlewares/monitoring'),
|
||||
]);
|
||||
const app = createExpress();
|
||||
app.use((req, res, next) => {
|
||||
if (req.query.t) delete req.query.t;
|
||||
next();
|
||||
});
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}));
|
||||
this.app.use(cors(config.cors));
|
||||
this.app.use(compression());
|
||||
this.app.use(monitoringMiddleware);
|
||||
app.use(cors(config.cors));
|
||||
app.use(compression());
|
||||
app.use(monitoringMiddleware);
|
||||
return app;
|
||||
}
|
||||
|
||||
private setupMasterShutdown() {
|
||||
@@ -169,17 +186,14 @@ class Application {
|
||||
if (worker) {
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
worker.once('exit', () => {
|
||||
Logger.info(`✌️ Worker ${worker.process.pid} exited`);
|
||||
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}:`,
|
||||
error,
|
||||
);
|
||||
Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -192,14 +206,14 @@ class Application {
|
||||
Promise.all(workerPromises),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
Logger.warn('✌️ Worker shutdown timeout reached');
|
||||
Logger.warn('Worker shutdown timeout reached');
|
||||
resolve();
|
||||
}, 10000);
|
||||
}),
|
||||
]);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
Logger.error('✌️ Error during worker shutdown:', error);
|
||||
Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
@@ -211,11 +225,11 @@ class Application {
|
||||
private async startWorkerProcess() {
|
||||
const serviceType = process.env.SERVICE_TYPE;
|
||||
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
|
||||
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType);
|
||||
Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`);
|
||||
Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
|
||||
|
||||
try {
|
||||
if (serviceType === 'http') {
|
||||
@@ -226,22 +240,26 @@ class Application {
|
||||
|
||||
process.send?.('ready');
|
||||
} catch (error) {
|
||||
Logger.error(`✌️ ${serviceType} worker failed:`, error);
|
||||
Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async startHttpService() {
|
||||
this.setupMiddlewares();
|
||||
// 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
|
||||
const { initGrpcCerts } = await import('./config/grpcCerts');
|
||||
await initGrpcCerts();
|
||||
|
||||
const app = await 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 });
|
||||
await appLoader.default({ app });
|
||||
|
||||
const server = await this.httpServerService.initialize(
|
||||
this.app,
|
||||
app,
|
||||
config.port,
|
||||
);
|
||||
|
||||
@@ -262,17 +280,10 @@ class Application {
|
||||
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('✌️ Received reregister-crons message, re-registering cron jobs...');
|
||||
const CronService = (await import('./services/cron')).default;
|
||||
const cronService = Container.get(CronService);
|
||||
await cronService.autosave_crontab();
|
||||
Logger.info('✌️ Cron jobs re-registered successfully');
|
||||
} catch (error) {
|
||||
Logger.error('✌️ Failed to re-register cron jobs:', error);
|
||||
}
|
||||
} else if (serviceType === 'http' &&
|
||||
(msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
|
||||
const { default: cronClient } = await import('./schedule/client');
|
||||
cronClient.readiness.invalidate();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -293,7 +304,7 @@ class Application {
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error);
|
||||
Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -301,6 +312,6 @@ class Application {
|
||||
|
||||
const app = new Application();
|
||||
app.start().catch((error) => {
|
||||
Logger.error('🙅♀️ Application failed to start:', error);
|
||||
Logger.error(`🙅♀️ Application failed to start:\n${errStack(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { errStack } from '../shared/errors';
|
||||
|
||||
// Also terminate if the primary disappears without forwarding a signal.
|
||||
const onParentDisconnect = () => process.exit(1);
|
||||
process.once('disconnect', onParentDisconnect);
|
||||
|
||||
async function initializeDatabase() {
|
||||
const { sequelize } = await import('../data');
|
||||
try {
|
||||
const { default: loadDatabase } = await import('../loaders/db');
|
||||
await loadDatabase();
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
initializeDatabase()
|
||||
.catch((error) => {
|
||||
console.error(`[boot] Database initialization failed:\n${errStack(error)}`);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
process.removeListener('disconnect', onParentDisconnect);
|
||||
// Let pending log writes drain and exit naturally after closing SQLite.
|
||||
if (process.connected) process.disconnect();
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { maybeSudo } from './container';
|
||||
|
||||
export const LOG_END_SYMBOL = ' ';
|
||||
|
||||
export const TASK_COMMAND = 'task';
|
||||
@@ -48,4 +50,41 @@ export const NotificationModeStringMap = {
|
||||
18: 'chronocat',
|
||||
19: 'ntfy',
|
||||
20: 'wxPusherBot',
|
||||
21: 'wxPusherSpt',
|
||||
22: 'wpush',
|
||||
} 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;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { createRandomString } from './share';
|
||||
|
||||
dotenv.config({
|
||||
path: path.join(__dirname, '../../.env'),
|
||||
@@ -9,6 +8,8 @@ dotenv.config({
|
||||
interface Config {
|
||||
port: number;
|
||||
grpcPort: number;
|
||||
bindHost: string;
|
||||
bindHostGrpc: string;
|
||||
nodeEnv: string;
|
||||
isDevelopment: boolean;
|
||||
isProduction: boolean;
|
||||
@@ -31,6 +32,8 @@ interface Config {
|
||||
const config: Config = {
|
||||
port: parseInt(process.env.BACK_PORT || '5700', 10),
|
||||
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
|
||||
bindHost: process.env.BIND_HOST || '::',
|
||||
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
isDevelopment: process.env.NODE_ENV === 'development',
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
@@ -106,6 +109,7 @@ 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');
|
||||
@@ -114,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');
|
||||
@@ -137,8 +139,6 @@ export default {
|
||||
shareShellFile,
|
||||
dependenceProxyFile,
|
||||
configString,
|
||||
loginFaild,
|
||||
authError,
|
||||
logPath,
|
||||
extraFile,
|
||||
authConfigFile,
|
||||
@@ -151,6 +151,7 @@ export default {
|
||||
pyEnvFile,
|
||||
jsNotifyFile,
|
||||
pyNotifyFile,
|
||||
langEnvFile,
|
||||
dbPath,
|
||||
uploadPath,
|
||||
configPath,
|
||||
@@ -167,6 +168,8 @@ export default {
|
||||
'env.js',
|
||||
'env.py',
|
||||
'token.json',
|
||||
'grpc',
|
||||
'__pycache__',
|
||||
],
|
||||
writePathList: [configPath, scriptPath],
|
||||
bakPath,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
export function createRandomString(min: number, max: number): string {
|
||||
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||
const english = [
|
||||
@@ -65,20 +67,20 @@ export function createRandomString(min: number, max: number): string {
|
||||
arr.push(getOne(ENGLISH));
|
||||
arr.push(getOne(special));
|
||||
|
||||
const len = min + Math.floor(Math.random() * (max - min + 1));
|
||||
const len = min + randomInt(max - min + 1);
|
||||
|
||||
for (let i = 4; i < len; i++) {
|
||||
arr.push(config[Math.floor(Math.random() * config.length)]);
|
||||
arr.push(config[randomInt(config.length)]);
|
||||
}
|
||||
|
||||
const newArr = [];
|
||||
for (let j = 0; j < len; j++) {
|
||||
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
|
||||
newArr.push(arr.splice(randomInt(arr.length), 1)[0]);
|
||||
}
|
||||
|
||||
function getOne(arr: any[]) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
return arr[randomInt(arr.length)];
|
||||
}
|
||||
|
||||
return newArr.join('');
|
||||
}
|
||||
}
|
||||
|
||||
+351
-57
@@ -1,6 +1,6 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import psTreeFun from 'ps-tree';
|
||||
import { promisify } from 'util';
|
||||
import { load } from 'js-yaml';
|
||||
@@ -10,9 +10,40 @@ 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';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
|
||||
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) {
|
||||
@@ -114,7 +145,8 @@ export async function handleLogPath(
|
||||
logPath: string,
|
||||
data: string = '',
|
||||
): Promise<string> {
|
||||
const absolutePath = path.resolve(config.logPath, logPath);
|
||||
const absolutePath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (!absolutePath) throw new Error('Log path is outside the log directory');
|
||||
const logFileExist = await fileExist(absolutePath);
|
||||
if (!logFileExist) {
|
||||
await createFile(absolutePath, data);
|
||||
@@ -184,48 +216,105 @@ export function dirSort(a: IFile, b: IFile): number {
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_SYSTEM_READ_CONCURRENCY = 32;
|
||||
|
||||
type FileSystemTaskRunner = <T>(task: () => Promise<T>) => Promise<T>;
|
||||
|
||||
function createFileSystemTaskRunner(concurrency: number): FileSystemTaskRunner {
|
||||
let activeCount = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
|
||||
const runNext = () => {
|
||||
while (activeCount < concurrency && queue.length > 0) {
|
||||
activeCount += 1;
|
||||
queue.shift()?.();
|
||||
}
|
||||
};
|
||||
|
||||
return <T>(task: () => Promise<T>) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
queue.push(() => {
|
||||
task()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeCount -= 1;
|
||||
runNext();
|
||||
});
|
||||
});
|
||||
runNext();
|
||||
});
|
||||
}
|
||||
|
||||
async function readDirsWithRunner(
|
||||
dir: string,
|
||||
baseDir: string,
|
||||
blacklist: string[],
|
||||
sort: (a: IFile, b: IFile) => number,
|
||||
runFileSystemTask: FileSystemTaskRunner,
|
||||
): Promise<IFile[]> {
|
||||
const relativePath = path.relative(baseDir, dir);
|
||||
const entries = await runFileSystemTask(() =>
|
||||
fs.readdir(dir, { withFileTypes: true }),
|
||||
);
|
||||
|
||||
const items = await Promise.all(
|
||||
entries.map(async (entry): Promise<IFile | undefined> => {
|
||||
if (blacklist.includes(entry.name) || entry.isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const subPath = path.join(dir, entry.name);
|
||||
const stats = await runFileSystemTask(() => fs.lstat(subPath));
|
||||
if (stats.isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
const key = path.join(relativePath, entry.name);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const children = await readDirsWithRunner(
|
||||
subPath,
|
||||
baseDir,
|
||||
blacklist,
|
||||
sort,
|
||||
runFileSystemTask,
|
||||
);
|
||||
return {
|
||||
title: entry.name,
|
||||
key,
|
||||
type: 'directory',
|
||||
parent: relativePath,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: entry.name,
|
||||
type: 'file',
|
||||
key,
|
||||
parent: relativePath,
|
||||
size: stats.size,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return items.filter((item): item is IFile => Boolean(item)).sort(sort);
|
||||
}
|
||||
|
||||
export async function readDirs(
|
||||
dir: string,
|
||||
baseDir: string = '',
|
||||
blacklist: string[] = [],
|
||||
sort: (a: IFile, b: IFile) => number = dirSort,
|
||||
): Promise<IFile[]> {
|
||||
const relativePath = path.relative(baseDir, dir);
|
||||
const files = await fs.readdir(dir);
|
||||
const result: IFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const subPath = path.join(dir, file);
|
||||
const stats = await fs.lstat(subPath);
|
||||
const key = path.join(relativePath, file);
|
||||
|
||||
if (blacklist.includes(file) || stats.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const children = await readDirs(subPath, baseDir, blacklist, sort);
|
||||
result.push({
|
||||
title: file,
|
||||
key,
|
||||
type: 'directory',
|
||||
parent: relativePath,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
children: children.sort(sort),
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
title: file,
|
||||
type: 'file',
|
||||
key,
|
||||
parent: relativePath,
|
||||
size: stats.size,
|
||||
createTime: stats.birthtime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort(sort);
|
||||
return readDirsWithRunner(
|
||||
dir,
|
||||
baseDir,
|
||||
blacklist,
|
||||
sort,
|
||||
createFileSystemTaskRunner(FILE_SYSTEM_READ_CONCURRENCY),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readDir(
|
||||
@@ -233,7 +322,10 @@ export async function readDir(
|
||||
baseDir: string = '',
|
||||
blacklist: string[] = [],
|
||||
): Promise<IFile[]> {
|
||||
const absoluteDir = path.join(baseDir, dir);
|
||||
const absoluteDir = path.resolve(baseDir, dir);
|
||||
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
|
||||
return [];
|
||||
}
|
||||
const relativePath = path.relative(baseDir, absoluteDir);
|
||||
|
||||
try {
|
||||
@@ -397,18 +489,69 @@ export function psTree(pid: number): Promise<number[]> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function killTask(pid: number) {
|
||||
const pids = await psTree(pid);
|
||||
|
||||
if (pids.length) {
|
||||
export async function killTask(pid: number, waitForExit = false) {
|
||||
const descendants = await psTree(pid);
|
||||
const signal = (target: number, sig: NodeJS.Signals) => {
|
||||
try {
|
||||
[pid, ...pids].reverse().forEach((x) => {
|
||||
process.kill(x, 15);
|
||||
});
|
||||
} catch (error) {}
|
||||
} else {
|
||||
process.kill(pid, 2);
|
||||
process.kill(target, sig);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
};
|
||||
if (!waitForExit) {
|
||||
if (descendants.length) {
|
||||
// A child may exit after psTree; keep signalling the remaining tree.
|
||||
for (const target of [pid, ...descendants].reverse()) {
|
||||
signal(target, 'SIGTERM');
|
||||
}
|
||||
} else signal(pid, 'SIGINT');
|
||||
return;
|
||||
}
|
||||
const pids = [...descendants.reverse(), pid];
|
||||
for (const target of pids) signal(target, 'SIGTERM');
|
||||
const alive = async (target: number) => {
|
||||
try {
|
||||
process.kill(target, 0);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ESRCH') return false;
|
||||
throw error;
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const stat = await fs.readFile(`/proc/${target}/stat`, 'utf8');
|
||||
// The command field may contain spaces and parentheses. Zombies have
|
||||
// exited even while their parent has not reaped the PID yet.
|
||||
const state = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0];
|
||||
if (['Z', 'X', 'x'].includes(state)) return false;
|
||||
} catch {
|
||||
// /proc may be unavailable, or the process may have just exited.
|
||||
// Retain the portable signal probe rather than assuming it is dead.
|
||||
try {
|
||||
process.kill(target, 0);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ESRCH') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const wait = async () => {
|
||||
const deadline = Date.now() + 1000;
|
||||
let remaining = pids;
|
||||
while (true) {
|
||||
const states = await Promise.all(remaining.map(alive));
|
||||
remaining = remaining.filter((_, index) => states[index]);
|
||||
if (!remaining.length || Date.now() >= deadline) return remaining;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
};
|
||||
let remaining = await wait();
|
||||
if (!remaining.length) return;
|
||||
for (const target of remaining) signal(target, 'SIGKILL');
|
||||
remaining = await wait();
|
||||
if (remaining.length)
|
||||
throw new Error(`Task processes did not exit: ${remaining.join(', ')}`);
|
||||
}
|
||||
|
||||
export async function getPid(cmd: string) {
|
||||
@@ -503,11 +646,13 @@ export function safeJSONParse(value?: string) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
Logger.error('[safeJSONParse失败]', error);
|
||||
Logger.error('[safeJSONParse error]', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export { errStack } from '../shared/errors';
|
||||
|
||||
export async function rmPath(path: string) {
|
||||
try {
|
||||
const _exsit = await fileExist(path);
|
||||
@@ -515,7 +660,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,12 +670,12 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -550,7 +695,9 @@ except:
|
||||
spec=u.find_spec(name)
|
||||
print(name if spec else '')
|
||||
''')"`,
|
||||
[DependenceTypes.linux]: `apk info -es ${name}`,
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${name}`
|
||||
: maybeSudo(`dpkg-query -s ${name}`),
|
||||
};
|
||||
|
||||
return baseCommands[type];
|
||||
@@ -561,7 +708,9 @@ export function getInstallCommand(type: DependenceTypes, name: string): string {
|
||||
[DependenceTypes.nodejs]: 'pnpm add -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 install --disable-pip-version-check --root-user-action=ignore',
|
||||
[DependenceTypes.linux]: 'apk add --no-check-certificate',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk add --no-check-certificate'
|
||||
: maybeSudo('apt-get install -y'),
|
||||
};
|
||||
|
||||
let command = baseCommands[type];
|
||||
@@ -581,7 +730,9 @@ export function getUninstallCommand(
|
||||
[DependenceTypes.nodejs]: 'pnpm remove -g',
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
||||
[DependenceTypes.linux]: 'apk del',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk del'
|
||||
: maybeSudo('apt-get remove -y'),
|
||||
};
|
||||
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
@@ -590,3 +741,146 @@ export function getUninstallCommand(
|
||||
export function isDemoEnv() {
|
||||
return process.env.DeployEnv === 'demo';
|
||||
}
|
||||
|
||||
async function getOSReleaseInfo(): Promise<string> {
|
||||
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
|
||||
return osRelease;
|
||||
}
|
||||
|
||||
function isDebian(osReleaseInfo: string): boolean {
|
||||
return osReleaseInfo.includes('Debian');
|
||||
}
|
||||
|
||||
function isUbuntu(osReleaseInfo: string): boolean {
|
||||
return osReleaseInfo.includes('Ubuntu');
|
||||
}
|
||||
|
||||
function isCentOS(osReleaseInfo: string): boolean {
|
||||
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
|
||||
}
|
||||
|
||||
function isAlpine(osReleaseInfo: string): boolean {
|
||||
return osReleaseInfo.includes('Alpine');
|
||||
}
|
||||
|
||||
export async function detectOS(): Promise<
|
||||
'Debian' | 'Ubuntu' | 'Alpine' | undefined
|
||||
> {
|
||||
if (osType) return osType;
|
||||
|
||||
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
|
||||
if (envOs === 'alpine') {
|
||||
osType = 'Alpine';
|
||||
return osType;
|
||||
}
|
||||
if (envOs === 'debian') {
|
||||
osType = 'Debian';
|
||||
return osType;
|
||||
}
|
||||
if (envOs === 'ubuntu') {
|
||||
osType = 'Ubuntu';
|
||||
return osType;
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'linux') {
|
||||
const osReleaseInfo = await getOSReleaseInfo();
|
||||
if (isDebian(osReleaseInfo)) {
|
||||
osType = 'Debian';
|
||||
} else if (isUbuntu(osReleaseInfo)) {
|
||||
osType = 'Ubuntu';
|
||||
} else if (isAlpine(osReleaseInfo)) {
|
||||
osType = 'Alpine';
|
||||
} else {
|
||||
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
|
||||
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
|
||||
}
|
||||
} else if (platform === 'darwin') {
|
||||
osType = undefined;
|
||||
} else {
|
||||
Logger.error(`Unsupported platform: ${platform}`);
|
||||
console.error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
return osType;
|
||||
}
|
||||
|
||||
async function getCurrentMirrorDomain(
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||
const lines = fileContent.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/https?:\/\/[^\/]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function replaceDomainInFile(
|
||||
filePath: string,
|
||||
oldDomainWithScheme: string,
|
||||
newDomainWithScheme: string,
|
||||
): Promise<void> {
|
||||
let fileContent = await fs.readFile(filePath, 'utf8');
|
||||
let updatedContent = fileContent.replace(
|
||||
new RegExp(oldDomainWithScheme, 'g'),
|
||||
newDomainWithScheme,
|
||||
);
|
||||
|
||||
if (!newDomainWithScheme.endsWith('/')) {
|
||||
newDomainWithScheme += '/';
|
||||
}
|
||||
|
||||
await writeFileWithLock(filePath, updatedContent);
|
||||
}
|
||||
|
||||
async function _updateLinuxMirror(
|
||||
osType: string,
|
||||
mirrorDomainWithScheme: string,
|
||||
): Promise<string> {
|
||||
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);
|
||||
}
|
||||
|
||||
+7
-2
@@ -13,6 +13,7 @@ export class Crontab {
|
||||
pid?: number;
|
||||
isDisabled?: 1 | 0;
|
||||
log_path?: string;
|
||||
queued_token?: string | null;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
last_running_time?: number;
|
||||
@@ -23,6 +24,7 @@ export class Crontab {
|
||||
task_after?: string;
|
||||
log_name?: string;
|
||||
allow_multiple_instances?: 1 | 0;
|
||||
work_dir?: string;
|
||||
|
||||
constructor(options: Crontab) {
|
||||
this.name = options.name;
|
||||
@@ -49,14 +51,15 @@ export class Crontab {
|
||||
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 {}
|
||||
@@ -81,6 +84,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
isDisabled: DataTypes.NUMBER,
|
||||
isPinned: DataTypes.NUMBER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
labels: DataTypes.JSON,
|
||||
last_running_time: DataTypes.NUMBER,
|
||||
last_execution_time: DataTypes.NUMBER,
|
||||
@@ -90,4 +94,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
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'] },
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -10,6 +10,7 @@ export class Env {
|
||||
name?: string;
|
||||
remarks?: string;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
|
||||
constructor(options: Env) {
|
||||
this.value = options.value;
|
||||
@@ -23,6 +24,7 @@ export class Env {
|
||||
this.name = options.name;
|
||||
this.remarks = options.remarks || '';
|
||||
this.isPinned = options.isPinned || 0;
|
||||
this.labels = options.labels || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +47,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
|
||||
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
||||
remarks: DataTypes.STRING,
|
||||
isPinned: DataTypes.NUMBER,
|
||||
labels: DataTypes.JSON,
|
||||
});
|
||||
|
||||
+22
-1
@@ -20,6 +20,8 @@ export enum NotificationMode {
|
||||
'chronocat' = 'Chronocat',
|
||||
'ntfy' = 'ntfy',
|
||||
'wxPusherBot' = 'wxPusherBot',
|
||||
'openiLink' = 'openiLink',
|
||||
'wpush' = 'wpush',
|
||||
}
|
||||
|
||||
abstract class NotificationBaseInfo {
|
||||
@@ -161,6 +163,22 @@ 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 class WpushNotification extends NotificationBaseInfo {
|
||||
public wpushApiKey = '';
|
||||
public wpushChannel = '';
|
||||
public wpushTopicCode = '';
|
||||
}
|
||||
|
||||
export interface NotificationInfo
|
||||
extends GoCqHttpBotNotification,
|
||||
GotifyNotification,
|
||||
@@ -182,4 +200,7 @@ export interface NotificationInfo
|
||||
ChronocatNotification,
|
||||
LarkNotification,
|
||||
NtfyNotification,
|
||||
WxPusherBotNotification {}
|
||||
WxPusherBotNotification,
|
||||
WxPusherSptNotification,
|
||||
OpeniLinkNotification,
|
||||
WpushNotification {}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export class Subscription {
|
||||
pull_option?:
|
||||
| { private_key: string }
|
||||
| { username: string; password: string };
|
||||
pid?: number;
|
||||
pid?: number | null;
|
||||
is_disabled?: 1 | 0;
|
||||
log_path?: string;
|
||||
alias: string;
|
||||
|
||||
@@ -31,6 +31,8 @@ export enum AuthDataType {
|
||||
}
|
||||
|
||||
export interface SystemConfigInfo {
|
||||
lang?: string;
|
||||
panelTitle?: string;
|
||||
logRemoveFrequency?: number;
|
||||
cronConcurrency?: number;
|
||||
dependenceProxy?: string;
|
||||
@@ -39,6 +41,8 @@ export interface SystemConfigInfo {
|
||||
linuxMirror?: string;
|
||||
timezone?: string;
|
||||
globalSshKey?: string;
|
||||
runningInstanceRetentionDays?: number;
|
||||
cronStatRetentionDays?: number;
|
||||
}
|
||||
|
||||
export interface LoginLogInfo {
|
||||
@@ -71,11 +75,14 @@ export interface AuthInfo {
|
||||
lastaddr: string;
|
||||
platform: string;
|
||||
isTwoFactorChecking: boolean;
|
||||
twoFactorExpiresAt?: number;
|
||||
lastTwoFactorStep?: number;
|
||||
token: string;
|
||||
tokens: Record<string, string | TokenInfo[]>;
|
||||
twoFactorActivated: boolean;
|
||||
twoFactorSecret: string;
|
||||
avatar: string;
|
||||
blockedIps?: string[];
|
||||
}
|
||||
|
||||
export type SystemModelInfo = SystemConfigInfo &
|
||||
|
||||
+9
-7
@@ -6,23 +6,25 @@ import { Application } from 'express';
|
||||
import linkDeps from './deps';
|
||||
import initTask from './initTask';
|
||||
import initFile from './initFile';
|
||||
import { initializeTrustProxy } from '../shared/trustProxy';
|
||||
|
||||
export default async ({ app }: { app: Application }) => {
|
||||
depInjectorLoader();
|
||||
Logger.info('✌️ Dependency loaded');
|
||||
Logger.info('[boot] Dependency loaded');
|
||||
|
||||
await linkDeps();
|
||||
Logger.info('✌️ Link deps loaded');
|
||||
Logger.info('[boot] Link deps loaded');
|
||||
|
||||
initFile();
|
||||
Logger.info('✌️ Init file loaded');
|
||||
await initFile();
|
||||
Logger.info('[boot] Init file loaded');
|
||||
|
||||
await initData();
|
||||
Logger.info('✌️ Init data loaded');
|
||||
Logger.info('[boot] Init data loaded');
|
||||
|
||||
initTask();
|
||||
Logger.info('✌️ Init task loaded');
|
||||
Logger.info('[boot] Init task loaded');
|
||||
|
||||
expressLoader({ app });
|
||||
Logger.info('✌️ Express loaded');
|
||||
await initializeTrustProxy(app);
|
||||
Logger.info('[boot] Express loaded');
|
||||
};
|
||||
|
||||
+9
-35
@@ -6,7 +6,10 @@ 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';
|
||||
import { migrateSchema } from '../shared/schemaMigrations';
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
@@ -17,43 +20,14 @@ export default async () => {
|
||||
await EnvModel.sync();
|
||||
await SubscriptionModel.sync();
|
||||
await CrontabViewModel.sync();
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
|
||||
// 初始化新增字段
|
||||
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: 'Envs', column: 'isPinned', type: 'NUMBER' },
|
||||
];
|
||||
await migrateSchema(sequelize);
|
||||
|
||||
for (const migration of migrations) {
|
||||
try {
|
||||
await sequelize.query(
|
||||
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Column already exists or other error, continue
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info('✌️ DB loaded');
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
Logger.error('✌️ DB load failed', error);
|
||||
Logger.error('[boot] DB load failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
+46
-26
@@ -1,22 +1,9 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import chokidar from 'chokidar';
|
||||
import config from '../config/index';
|
||||
import Logger from './logger';
|
||||
|
||||
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) { }
|
||||
}
|
||||
|
||||
async function linkCommand() {
|
||||
const homeDir = os.homedir();
|
||||
let userBinDir = path.join(homeDir, 'bin');
|
||||
@@ -30,6 +17,50 @@ async function linkCommand() {
|
||||
}
|
||||
|
||||
async function linkCommandToDir(commandDir: string) {
|
||||
const cliRoot = process.env.QL_CLI_ROOT;
|
||||
if (cliRoot) {
|
||||
if (!path.isAbsolute(cliRoot)) {
|
||||
throw new Error('QL_CLI_ROOT must be an absolute CLI installation path');
|
||||
}
|
||||
const { installCliEntrypoints } = require(path.join(
|
||||
cliRoot,
|
||||
'dist/local/entrypoints.js',
|
||||
));
|
||||
await installCliEntrypoints(commandDir, cliRoot);
|
||||
const { installCronEntrypoint } = require(path.join(
|
||||
cliRoot,
|
||||
'dist/local/cronEntrypoint.js',
|
||||
));
|
||||
await installCronEntrypoint(commandDir, cliRoot, config.crontabFile, {
|
||||
...process.env,
|
||||
QL_DIR: config.rootPath,
|
||||
QL_DATA_DIR: config.dataPath,
|
||||
});
|
||||
// Container-wide legacy links may precede ~/bin. Ensure panel children use
|
||||
// the explicitly selected entries without changing system-wide links.
|
||||
process.env.PATH = [
|
||||
commandDir,
|
||||
...(process.env.PATH?.split(path.delimiter) ?? []).filter(
|
||||
(entry) => entry !== commandDir,
|
||||
),
|
||||
].join(path.delimiter);
|
||||
return;
|
||||
}
|
||||
|
||||
const cronBridge = path.join(commandDir, 'crontab');
|
||||
try {
|
||||
if (
|
||||
(await fs.lstat(cronBridge)).isFile() &&
|
||||
(await fs.readFile(cronBridge, 'utf8')).includes(
|
||||
'// QingLong CLI crontab bridge',
|
||||
)
|
||||
) {
|
||||
await fs.unlink(cronBridge);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const linkShell = [
|
||||
{
|
||||
src: 'update.sh',
|
||||
@@ -52,24 +83,13 @@ async function linkCommandToDir(commandDir: string) {
|
||||
if (stats) {
|
||||
await fs.unlink(tmpTarget);
|
||||
}
|
||||
} catch (error) { }
|
||||
} 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', () => linkToNodeModule(src))
|
||||
.on('change', () => linkToNodeModule(src));
|
||||
};
|
||||
|
||||
+72
-46
@@ -8,42 +8,66 @@ import { getPlatform, getToken } from '../config/util';
|
||||
import rewrite from 'express-urlrewrite';
|
||||
import { errors } from 'celebrate';
|
||||
import { serveEnv } from '../config/serverEnv';
|
||||
import { IKeyvStore, shareStore } from '../shared/store';
|
||||
import { isValidToken } from '../shared/auth';
|
||||
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';
|
||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||
|
||||
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
||||
const setting = value?.trim();
|
||||
if (!setting) {
|
||||
return 'loopback';
|
||||
}
|
||||
if (setting === 'true' || setting === 'false') {
|
||||
return setting === 'true';
|
||||
}
|
||||
if (/^\d+$/.test(setting)) {
|
||||
return Number(setting);
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
export default ({ app }: { app: Application }) => {
|
||||
// Security: Enable strict routing to prevent case-insensitive path bypass
|
||||
app.set('case sensitive routing', true);
|
||||
app.set('strict routing', true);
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.set('trust proxy', resolveTrustProxy());
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
// Security: Reject case variations under protected API namespaces before
|
||||
// authentication checks can interpret the request differently from routing.
|
||||
app.use(protectedPathCase);
|
||||
|
||||
// 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(
|
||||
`${config.api.prefix}/static`,
|
||||
express.static(config.uploadPath, {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialPaths = ['/api', '/open'].flatMap((prefix) =>
|
||||
['/user/login', '/user/init', '/user/two-factor/login'].map(
|
||||
(route) => `${prefix}${route}`,
|
||||
),
|
||||
);
|
||||
app.use(
|
||||
credentialPaths,
|
||||
bodyParser.json({ limit: '16kb' }),
|
||||
bodyParser.urlencoded({ limit: '16kb', extended: false }),
|
||||
);
|
||||
|
||||
app.use(bodyParser.json({ limit: '50mb' }));
|
||||
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
||||
@@ -86,17 +110,29 @@ export default ({ app }: { app: Application }) => {
|
||||
const currentToken = doc.tokens.find((x) => x.value === headerToken);
|
||||
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)
|
||||
) {
|
||||
return next();
|
||||
|
||||
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 &&
|
||||
@@ -106,14 +142,12 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (isValidToken(authInfo, headerToken, req.platform)) {
|
||||
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
|
||||
const errorMessage = headerToken
|
||||
? 'jwt malformed'
|
||||
: 'No authorization token was found';
|
||||
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
|
||||
const err = new UnauthorizedError(errorCode, { message: errorMessage });
|
||||
next(err);
|
||||
});
|
||||
@@ -126,24 +160,16 @@ export default ({ app }: { app: Application }) => {
|
||||
'/api/user/notification/init',
|
||||
'/open/user/init',
|
||||
'/open/user/notification/init',
|
||||
].includes(req.path)
|
||||
].includes(pathLower)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const authInfo =
|
||||
(await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']);
|
||||
const 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();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../conf
|
||||
import OpenService from '../services/open';
|
||||
import { shareStore } from '../shared/store';
|
||||
import Logger from './logger';
|
||||
import cronClient from '../schedule/client';
|
||||
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 +39,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 },
|
||||
@@ -134,6 +142,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: {
|
||||
@@ -210,8 +224,22 @@ 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();
|
||||
cronClient.readiness.configure(() => cronService.autosave_crontab(true));
|
||||
await cronClient.readiness.recover();
|
||||
|
||||
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,
|
||||
@@ -123,5 +125,5 @@ export default async () => {
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info('✌️ Init file down');
|
||||
Logger.info('[boot] Init file down');
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ 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);
|
||||
@@ -25,7 +26,7 @@ export default async () => {
|
||||
}
|
||||
const cron = {
|
||||
id: NaN,
|
||||
name: '生成token',
|
||||
name: t('生成token'),
|
||||
command: tokenCommand,
|
||||
runOrigin: 'system',
|
||||
} as ScheduleTaskType;
|
||||
@@ -44,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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
+66
-8
@@ -1,4 +1,4 @@
|
||||
import sockJs from 'sockjs';
|
||||
import sockJs, { Connection } from 'sockjs';
|
||||
import { Server } from 'http';
|
||||
import { Container } from 'typedi';
|
||||
import SockService from '../services/sock';
|
||||
@@ -8,29 +8,87 @@ import { isValidToken } from '../shared/auth';
|
||||
import config from '../config';
|
||||
|
||||
export default async ({ server }: { server: Server }) => {
|
||||
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
|
||||
const echo = sockJs.createServer({
|
||||
prefix: `${config.baseUrl}/api/ws`,
|
||||
log: () => {},
|
||||
});
|
||||
const sockService = Container.get(SockService);
|
||||
const sessions = new Map<Connection, { token: string; platform: string }>();
|
||||
let sessionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let checking = false;
|
||||
|
||||
const checkSessions = async () => {
|
||||
if (checking || sessions.size === 0) return;
|
||||
checking = true;
|
||||
// Connections accepted during this read must not use an older snapshot.
|
||||
const batch = [...sessions];
|
||||
try {
|
||||
const current = await shareStore.getAuthInfo();
|
||||
// Reuse validation only within this synchronous check of one auth snapshot.
|
||||
const validated = new Map<string, Map<string, boolean>>();
|
||||
for (const [conn, { token, platform }] of batch) {
|
||||
if (!sessions.has(conn)) continue;
|
||||
let platforms = validated.get(token);
|
||||
if (!platforms) {
|
||||
platforms = new Map();
|
||||
validated.set(token, platforms);
|
||||
}
|
||||
let valid = platforms.get(platform);
|
||||
if (valid === undefined) {
|
||||
valid = isValidToken(current, token, platform, config.jwt.secret);
|
||||
platforms.set(platform, valid);
|
||||
}
|
||||
if (!valid) conn.close('401');
|
||||
}
|
||||
} catch {
|
||||
for (const [conn] of batch) {
|
||||
if (sessions.has(conn)) conn.close('401');
|
||||
}
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
|
||||
echo.on('connection', async (conn) => {
|
||||
if (!conn.headers || !conn.url || !conn.pathname) {
|
||||
conn.close('404');
|
||||
return;
|
||||
}
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
let closed = false;
|
||||
conn.on('close', () => {
|
||||
closed = true;
|
||||
sessions.delete(conn);
|
||||
sockService.removeClient(conn);
|
||||
if (sessions.size === 0 && sessionTimer) {
|
||||
clearInterval(sessionTimer);
|
||||
sessionTimer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
let authInfo;
|
||||
try {
|
||||
authInfo = await shareStore.getAuthInfo();
|
||||
} catch {
|
||||
if (!closed) conn.close('401');
|
||||
return;
|
||||
}
|
||||
if (closed) return;
|
||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||
|
||||
if (isValidToken(authInfo, headerToken, platform)) {
|
||||
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||
sockService.addClient(conn);
|
||||
sessions.set(conn, { token: headerToken, platform });
|
||||
if (!sessionTimer) {
|
||||
sessionTimer = setInterval(checkSessions, 1000);
|
||||
sessionTimer.unref();
|
||||
}
|
||||
|
||||
conn.on('data', (message) => {
|
||||
conn.write(message);
|
||||
});
|
||||
|
||||
conn.on('close', function () {
|
||||
sockService.removeClient(conn);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,46 +3,34 @@ 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[] = [];
|
||||
const UNMONITORED_PATH_SUFFIXES = ['/api/health', '/open/health'];
|
||||
const HTTP_METRIC_SAMPLE_INTERVAL = 10;
|
||||
let requestSampleOffset = 0;
|
||||
|
||||
export const monitoringMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (UNMONITORED_PATH_SUFFIXES.some((path) => req.path.endsWith(path))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
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();
|
||||
const shouldSample = requestSampleOffset === 0;
|
||||
requestSampleOffset =
|
||||
(requestSampleOffset + 1) % HTTP_METRIC_SAMPLE_INTERVAL;
|
||||
if (shouldSample) {
|
||||
metricsService.record('http_request', duration, {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode.toString(),
|
||||
...(req.platform && { platform: req.platform }),
|
||||
});
|
||||
}
|
||||
|
||||
if (duration > 1000) {
|
||||
@@ -58,23 +46,3 @@ export const monitoringMiddleware = (
|
||||
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export default function protectedPathCase(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
const originalPath = req.path;
|
||||
const normalizedPath = originalPath.toLowerCase();
|
||||
|
||||
if (
|
||||
originalPath !== normalizedPath &&
|
||||
(normalizedPath.startsWith('/api/') ||
|
||||
normalizedPath.startsWith('/open/'))
|
||||
) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: 'Invalid path format',
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -151,6 +151,8 @@ enum NotificationMode {
|
||||
chronocat = 18;
|
||||
ntfy = 19;
|
||||
wxPusherBot = 20;
|
||||
wxPusherSpt = 21;
|
||||
wpush = 22;
|
||||
}
|
||||
|
||||
message NotificationInfo {
|
||||
@@ -244,6 +246,12 @@ message NotificationInfo {
|
||||
optional string wxPusherBotAppToken = 66;
|
||||
optional string wxPusherBotTopicIds = 67;
|
||||
optional string wxPusherBotUids = 68;
|
||||
|
||||
optional string wxPusherSptList = 70;
|
||||
|
||||
optional string wpushApiKey = 71;
|
||||
optional string wpushChannel = 72;
|
||||
optional string wpushTopicCode = 73;
|
||||
}
|
||||
|
||||
message SystemNotifyRequest {
|
||||
@@ -272,4 +280,4 @@ service Api {
|
||||
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
|
||||
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
|
||||
rpc RunCrons(RunCronsRequest) returns (Response) {}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-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.21.12
|
||||
// protoc v3.17.3
|
||||
// source: back/protos/api.proto
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -43,6 +43,8 @@ export enum NotificationMode {
|
||||
chronocat = 18,
|
||||
ntfy = 19,
|
||||
wxPusherBot = 20,
|
||||
wxPusherSpt = 21,
|
||||
wpush = 22,
|
||||
UNRECOGNIZED = -1,
|
||||
}
|
||||
|
||||
@@ -111,6 +113,12 @@ export function notificationModeFromJSON(object: any): NotificationMode {
|
||||
case 20:
|
||||
case "wxPusherBot":
|
||||
return NotificationMode.wxPusherBot;
|
||||
case 21:
|
||||
case "wxPusherSpt":
|
||||
return NotificationMode.wxPusherSpt;
|
||||
case 22:
|
||||
case "wpush":
|
||||
return NotificationMode.wpush;
|
||||
case -1:
|
||||
case "UNRECOGNIZED":
|
||||
default:
|
||||
@@ -162,6 +170,10 @@ export function notificationModeToJSON(object: NotificationMode): string {
|
||||
return "ntfy";
|
||||
case NotificationMode.wxPusherBot:
|
||||
return "wxPusherBot";
|
||||
case NotificationMode.wxPusherSpt:
|
||||
return "wxPusherSpt";
|
||||
case NotificationMode.wpush:
|
||||
return "wpush";
|
||||
case NotificationMode.UNRECOGNIZED:
|
||||
default:
|
||||
return "UNRECOGNIZED";
|
||||
@@ -393,6 +405,10 @@ export interface NotificationInfo {
|
||||
wxPusherBotAppToken?: string | undefined;
|
||||
wxPusherBotTopicIds?: string | undefined;
|
||||
wxPusherBotUids?: string | undefined;
|
||||
wxPusherSptList?: string | undefined;
|
||||
wpushApiKey?: string | undefined;
|
||||
wpushChannel?: string | undefined;
|
||||
wpushTopicCode?: string | undefined;
|
||||
}
|
||||
|
||||
export interface SystemNotifyRequest {
|
||||
@@ -2959,6 +2975,10 @@ function createBaseNotificationInfo(): NotificationInfo {
|
||||
wxPusherBotAppToken: undefined,
|
||||
wxPusherBotTopicIds: undefined,
|
||||
wxPusherBotUids: undefined,
|
||||
wxPusherSptList: undefined,
|
||||
wpushApiKey: undefined,
|
||||
wpushChannel: undefined,
|
||||
wpushTopicCode: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3171,6 +3191,18 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
if (message.wxPusherBotUids !== undefined) {
|
||||
writer.uint32(546).string(message.wxPusherBotUids);
|
||||
}
|
||||
if (message.wxPusherSptList !== undefined) {
|
||||
writer.uint32(562).string(message.wxPusherSptList);
|
||||
}
|
||||
if (message.wpushApiKey !== undefined) {
|
||||
writer.uint32(570).string(message.wpushApiKey);
|
||||
}
|
||||
if (message.wpushChannel !== undefined) {
|
||||
writer.uint32(578).string(message.wpushChannel);
|
||||
}
|
||||
if (message.wpushTopicCode !== undefined) {
|
||||
writer.uint32(586).string(message.wpushTopicCode);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -3733,6 +3765,38 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
message.wxPusherBotUids = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 70: {
|
||||
if (tag !== 562) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.wxPusherSptList = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 71: {
|
||||
if (tag !== 570) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.wpushApiKey = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 72: {
|
||||
if (tag !== 578) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.wpushChannel = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 73: {
|
||||
if (tag !== 586) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.wpushTopicCode = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -3825,6 +3889,10 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
? globalThis.String(object.wxPusherBotTopicIds)
|
||||
: undefined,
|
||||
wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : undefined,
|
||||
wxPusherSptList: isSet(object.wxPusherSptList) ? globalThis.String(object.wxPusherSptList) : undefined,
|
||||
wpushApiKey: isSet(object.wpushApiKey) ? globalThis.String(object.wpushApiKey) : undefined,
|
||||
wpushChannel: isSet(object.wpushChannel) ? globalThis.String(object.wpushChannel) : undefined,
|
||||
wpushTopicCode: isSet(object.wpushTopicCode) ? globalThis.String(object.wpushTopicCode) : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -4037,6 +4105,18 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
if (message.wxPusherBotUids !== undefined) {
|
||||
obj.wxPusherBotUids = message.wxPusherBotUids;
|
||||
}
|
||||
if (message.wxPusherSptList !== undefined) {
|
||||
obj.wxPusherSptList = message.wxPusherSptList;
|
||||
}
|
||||
if (message.wpushApiKey !== undefined) {
|
||||
obj.wpushApiKey = message.wpushApiKey;
|
||||
}
|
||||
if (message.wpushChannel !== undefined) {
|
||||
obj.wpushChannel = message.wpushChannel;
|
||||
}
|
||||
if (message.wpushTopicCode !== undefined) {
|
||||
obj.wpushTopicCode = message.wpushTopicCode;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -4114,6 +4194,10 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
|
||||
message.wxPusherBotAppToken = object.wxPusherBotAppToken ?? undefined;
|
||||
message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined;
|
||||
message.wxPusherBotUids = object.wxPusherBotUids ?? undefined;
|
||||
message.wxPusherSptList = object.wxPusherSptList ?? undefined;
|
||||
message.wpushApiKey = object.wpushApiKey ?? undefined;
|
||||
message.wpushChannel = object.wpushChannel ?? undefined;
|
||||
message.wpushTopicCode = object.wpushTopicCode ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,7 +17,10 @@ message ICron {
|
||||
string name = 5;
|
||||
}
|
||||
|
||||
message AddCronRequest { repeated ICron crons = 1; }
|
||||
message AddCronRequest {
|
||||
repeated ICron crons = 1;
|
||||
bool replace = 2;
|
||||
}
|
||||
|
||||
message AddCronResponse {}
|
||||
|
||||
|
||||
+22
-3
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v3.21.12
|
||||
// protoc v3.17.3
|
||||
// source: back/protos/cron.proto
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -35,6 +35,7 @@ export interface ICron {
|
||||
|
||||
export interface AddCronRequest {
|
||||
crons: ICron[];
|
||||
replace: boolean;
|
||||
}
|
||||
|
||||
export interface AddCronResponse {
|
||||
@@ -232,7 +233,7 @@ export const ICron: MessageFns<ICron> = {
|
||||
};
|
||||
|
||||
function createBaseAddCronRequest(): AddCronRequest {
|
||||
return { crons: [] };
|
||||
return { crons: [], replace: false };
|
||||
}
|
||||
|
||||
export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
@@ -240,6 +241,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
for (const v of message.crons) {
|
||||
ICron.encode(v!, writer.uint32(10).fork()).join();
|
||||
}
|
||||
if (message.replace !== false) {
|
||||
writer.uint32(16).bool(message.replace);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -258,6 +262,14 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
message.crons.push(ICron.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 16) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.replace = reader.bool();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -268,7 +280,10 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
},
|
||||
|
||||
fromJSON(object: any): AddCronRequest {
|
||||
return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
|
||||
return {
|
||||
crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [],
|
||||
replace: isSet(object.replace) ? globalThis.Boolean(object.replace) : false,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: AddCronRequest): unknown {
|
||||
@@ -276,6 +291,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
if (message.crons?.length) {
|
||||
obj.crons = message.crons.map((e) => ICron.toJSON(e));
|
||||
}
|
||||
if (message.replace !== false) {
|
||||
obj.replace = message.replace;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -285,6 +303,7 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
|
||||
const message = createBaseAddCronRequest();
|
||||
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
|
||||
message.replace = object.replace ?? false;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v3.21.12
|
||||
// protoc v3.17.3
|
||||
// source: back/protos/health.proto
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
+95
-15
@@ -1,16 +1,75 @@
|
||||
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 { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
import { scheduleStacks } from './data';
|
||||
import { runCron } from '../shared/runCron';
|
||||
import Logger from '../loaders/logger';
|
||||
import { tf } from '../shared/i18n';
|
||||
|
||||
// Validate the entire batch before replacing any existing jobs.
|
||||
const isValidCronField = (cron: string): boolean => {
|
||||
return isValidCronSchedule(cron);
|
||||
};
|
||||
|
||||
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"',
|
||||
String(id),
|
||||
schedule,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (extra_schedules?.length) {
|
||||
extra_schedules.forEach((x) => {
|
||||
if (!isValidCronField(x.schedule)) {
|
||||
validationErrors.push(
|
||||
tf(
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"',
|
||||
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;
|
||||
}
|
||||
|
||||
// Recovery replaces the whole snapshot, including deletions and disabled jobs.
|
||||
// Validation above must finish before touching the previous schedule.
|
||||
if (call.request.replace) {
|
||||
for (const jobs of scheduleStacks.values()) {
|
||||
for (const job of jobs) job?.cancel();
|
||||
}
|
||||
scheduleStacks.clear();
|
||||
}
|
||||
|
||||
// ===== 第二遍:注册所有任务 =====
|
||||
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());
|
||||
}
|
||||
@@ -35,20 +94,41 @@ const addCron = (
|
||||
});
|
||||
}
|
||||
|
||||
scheduleStacks.set(id, [
|
||||
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 () => {
|
||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||
runCron(command, item);
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
]);
|
||||
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
|
||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||
runCron(command, item);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
+10
-6
@@ -38,6 +38,7 @@ import {
|
||||
RunCronsRequest,
|
||||
} from '../protos/api';
|
||||
import { NotificationInfo } from '../data/notify';
|
||||
import { Model } from 'sequelize';
|
||||
|
||||
Container.set('logger', LoggerInstance);
|
||||
|
||||
@@ -247,13 +248,16 @@ export const systemNotify = async (
|
||||
|
||||
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
|
||||
if (!data) return undefined;
|
||||
// create() returns a Sequelize instance; spreading it omits attribute getters.
|
||||
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
|
||||
return {
|
||||
...data,
|
||||
sub_id: data.sub_id ?? undefined,
|
||||
extra_schedules: data.extra_schedules ?? undefined,
|
||||
pid: data.pid ?? undefined,
|
||||
task_before: data.task_before ?? undefined,
|
||||
task_after: data.task_after ?? undefined,
|
||||
...cron,
|
||||
labels: cron.labels ?? [],
|
||||
sub_id: cron.sub_id ?? undefined,
|
||||
extra_schedules: cron.extra_schedules ?? [],
|
||||
pid: cron.pid ?? undefined,
|
||||
task_before: cron.task_before ?? undefined,
|
||||
task_after: cron.task_after ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+83
-16
@@ -1,4 +1,4 @@
|
||||
import { credentials } from '@grpc/grpc-js';
|
||||
import { credentials, status, Metadata } from '@grpc/grpc-js';
|
||||
import {
|
||||
AddCronRequest,
|
||||
AddCronResponse,
|
||||
@@ -7,30 +7,97 @@ import {
|
||||
DeleteCronResponse,
|
||||
} from '../protos/cron';
|
||||
import config from '../config';
|
||||
import { getGrpcCerts } from '../config/grpcCerts';
|
||||
|
||||
import { HealthService } from '../protos/health';
|
||||
import { SchedulerReadiness } from '../shared/schedulerReadiness';
|
||||
|
||||
class Client {
|
||||
private client = new CronClient(
|
||||
`0.0.0.0:${config.grpcPort}`,
|
||||
credentials.createInsecure(),
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
readonly readiness = new SchedulerReadiness(() => this.probe());
|
||||
|
||||
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.addCron({ crons: request }, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(res);
|
||||
private async waitForReady(timeoutMs: number) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.client.waitForReady(Date.now() + timeoutMs, (err) =>
|
||||
err ? reject(err) : resolve()
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
this.readiness.invalidate();
|
||||
throw Object.assign(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async probe(): Promise<void> {
|
||||
await this.waitForReady(1000);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.client.makeUnaryRequest(
|
||||
HealthService.check.path,
|
||||
HealthService.check.requestSerialize,
|
||||
HealthService.check.responseDeserialize,
|
||||
{ service: 'scheduler' },
|
||||
{ deadline: Date.now() + 1000 },
|
||||
(err, res) => err ? reject(err) : res?.status === 1 ? resolve() : reject(new Error('Scheduler unavailable')),
|
||||
);
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
async addCron(
|
||||
request: AddCronRequest['crons'],
|
||||
replace = false
|
||||
): Promise<AddCronResponse> {
|
||||
await this.waitForReady(2000);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.addCron(
|
||||
{ crons: request, replace },
|
||||
new Metadata(),
|
||||
{ deadline: Date.now() + 5000 },
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
|
||||
// A timed-out write may already have reached the scheduler.
|
||||
// Reconcile its state from the DB instead of replaying the RPC.
|
||||
this.readiness.invalidate();
|
||||
Object.assign(err, { status: 503 });
|
||||
}
|
||||
return reject(err);
|
||||
}
|
||||
resolve(res);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
|
||||
async delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
|
||||
await this.waitForReady(2000);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.delCron({ ids: request }, (err, res) => {
|
||||
this.client.delCron({ ids: request }, new Metadata(), { deadline: Date.now() + 5000 }, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
|
||||
this.readiness.invalidate();
|
||||
Object.assign(err, { status: 503 });
|
||||
}
|
||||
return reject(err);
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,20 @@ const delCron = (
|
||||
'[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);
|
||||
}
|
||||
}
|
||||
|
||||
+91
-12
@@ -1,28 +1,107 @@
|
||||
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { request } from 'undici';
|
||||
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
|
||||
import config from '../config';
|
||||
import { promiseExec } from '../config/util';
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
const detailedError = error as Error & {
|
||||
cause?: unknown;
|
||||
code?: string;
|
||||
errors?: unknown[];
|
||||
};
|
||||
const details = [error.message, detailedError.code];
|
||||
if (Array.isArray(detailedError.errors)) {
|
||||
details.push(...detailedError.errors.map(formatError));
|
||||
} else if (detailedError.cause) {
|
||||
details.push(formatError(detailedError.cause));
|
||||
}
|
||||
|
||||
return [...new Set(details.filter(Boolean))].join(': ') || error.name;
|
||||
}
|
||||
|
||||
async function getRecentSystemLog(lineLimit = 300): Promise<string> {
|
||||
try {
|
||||
const entries = await fs.readdir(config.systemLogPath);
|
||||
const latestLog = entries
|
||||
.filter((entry) => entry.endsWith('.log'))
|
||||
.sort()
|
||||
.at(-1);
|
||||
if (!latestLog) {
|
||||
return `No system log found in ${config.systemLogPath}`;
|
||||
}
|
||||
const content = await fs.readFile(
|
||||
path.join(config.systemLogPath, latestLog),
|
||||
'utf8',
|
||||
);
|
||||
return content.split('\n').slice(-lineLimit).join('\n').trim();
|
||||
} catch (error) {
|
||||
return `Unable to read system log from ${config.systemLogPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
const check = async (
|
||||
call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>,
|
||||
callback: sendUnaryData<HealthCheckResponse>,
|
||||
) => {
|
||||
switch (call.request.service) {
|
||||
case 'cron':
|
||||
const res = await promiseExec(
|
||||
`curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`,
|
||||
);
|
||||
|
||||
if (res.includes('200')) {
|
||||
return callback(null, { status: 1 });
|
||||
// Local scheduler liveness only: never call HTTP from this probe.
|
||||
case 'scheduler':
|
||||
return callback(null, { status: 1 });
|
||||
case 'cron': {
|
||||
const healthUrl = `http://localhost:${config.port}${
|
||||
config.baseUrl || ''
|
||||
}/api/health`;
|
||||
let failure = '';
|
||||
try {
|
||||
const response = await request(healthUrl, {
|
||||
method: 'GET',
|
||||
headersTimeout: 5000,
|
||||
bodyTimeout: 5000,
|
||||
});
|
||||
const body = (await response.body.json()) as {
|
||||
code?: number;
|
||||
data?: { status?: string };
|
||||
};
|
||||
if (
|
||||
response.statusCode >= 200 &&
|
||||
response.statusCode < 300 &&
|
||||
body.code === 200 &&
|
||||
body.data?.status === 'ok'
|
||||
) {
|
||||
return callback(null, { status: 1 });
|
||||
}
|
||||
failure = `HTTP ${response.statusCode}: ${JSON.stringify(body)}`;
|
||||
} catch (error) {
|
||||
failure = formatError(error);
|
||||
}
|
||||
|
||||
const qinglongErrLog = await promiseExec(
|
||||
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
|
||||
);
|
||||
const systemLog = await getRecentSystemLog();
|
||||
const containerHint =
|
||||
process.env.QL_CONTAINER === 'true'
|
||||
? 'PM2 file logging is disabled in containers. Check `docker logs <container>` for early startup errors.'
|
||||
: 'Check `pm2 logs qinglong --lines 300` for early startup errors.';
|
||||
return callback(
|
||||
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
|
||||
new Error(
|
||||
[
|
||||
`HTTP health check failed: ${healthUrl}`,
|
||||
failure,
|
||||
containerHint,
|
||||
`Recent system log (${config.systemLogPath}):`,
|
||||
systemLog,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return callback(null, { status: 1 });
|
||||
|
||||
+14
-17
@@ -1,9 +1,10 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import path, { join } from 'path';
|
||||
import { Service } from 'typedi';
|
||||
import config from '../config';
|
||||
import { getFileContentByName } from '../config/util';
|
||||
import { t } from '../shared/i18n';
|
||||
import { Response } from 'express';
|
||||
import { request } from 'undici';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
|
||||
@Service()
|
||||
export default class ConfigService {
|
||||
@@ -11,19 +12,17 @@ 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 (!filePath) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
const scriptFile = filePath.startsWith('data/scripts/');
|
||||
const resolved = resolveFileAccess(
|
||||
scriptFile ? config.scriptPath : config.configPath,
|
||||
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
|
||||
config.blackFileList,
|
||||
);
|
||||
|
||||
if (
|
||||
config.blackFileList.includes(filePath) ||
|
||||
avaliablePath.every(
|
||||
(x) =>
|
||||
!x.startsWith(config.scriptPath) && !x.startsWith(config.configPath),
|
||||
) ||
|
||||
!filePath
|
||||
) {
|
||||
return res.send({ code: 403, message: '文件无法访问' });
|
||||
if (!resolved) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
|
||||
if (filePath.startsWith('sample/')) {
|
||||
@@ -31,10 +30,8 @@ export default class ConfigService {
|
||||
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
|
||||
);
|
||||
content = await res.body.text();
|
||||
} else if (filePath.startsWith('data/scripts/')) {
|
||||
content = await getFileContentByName(join(config.rootPath, filePath));
|
||||
} else {
|
||||
content = await getFileContentByName(join(config.configPath, filePath));
|
||||
content = await getFileContentByName(resolved);
|
||||
}
|
||||
|
||||
res.send({ code: 200, data: content });
|
||||
|
||||
+546
-188
@@ -1,7 +1,17 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getInvalidCronSchedules } from '../shared/cronSchedule';
|
||||
import {
|
||||
withSchedulerMutation,
|
||||
schedulerRegistrationError,
|
||||
} from '../shared/schedulerMutationLock';
|
||||
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 CronExpressionParser from 'cron-parser';
|
||||
@@ -9,7 +19,6 @@ import {
|
||||
getFileContentByName,
|
||||
fileExist,
|
||||
killTask,
|
||||
killAllTasks,
|
||||
getUniqPath,
|
||||
safeJSONParse,
|
||||
isDemoEnv,
|
||||
@@ -24,8 +33,13 @@ 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 { observeChildProcess, asError } from '../shared/childProcess';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { LogReadOptions, readLogChunk } from '../shared/logReader';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
|
||||
@Service()
|
||||
export default class CronService {
|
||||
@@ -33,10 +47,32 @@ export default class CronService {
|
||||
|
||||
private isNodeCron(cron: Crontab) {
|
||||
const { schedule, extra_schedules } = cron;
|
||||
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
|
||||
return true;
|
||||
// System crontab only receives portable numeric five-field expressions.
|
||||
// Extended syntax, macros and legacy shorthand belong to node-schedule.
|
||||
return (
|
||||
schedule?.trim().split(/\s+/).length !== 5 ||
|
||||
/[^\d\s*,/\-]/.test(schedule || '') ||
|
||||
Boolean(extra_schedules?.length)
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -71,29 +107,45 @@ export default class CronService {
|
||||
}
|
||||
|
||||
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);
|
||||
return withSchedulerMutation(async () => {
|
||||
const tab = new Crontab(payload);
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
const doc = await this.insert(tab);
|
||||
|
||||
if (isDemoEnv()) {
|
||||
if (isDemoEnv()) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
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 (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 schedulerRegistrationError(
|
||||
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
return doc;
|
||||
}
|
||||
|
||||
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
public async insert(payload: Crontab): Promise<Crontab> {
|
||||
@@ -101,34 +153,68 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async update(payload: Partial<Crontab>): Promise<Crontab> {
|
||||
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);
|
||||
return withSchedulerMutation(async () => {
|
||||
const doc = await this.getDb({ id: payload.id });
|
||||
const tab = new Crontab({ ...doc, ...payload });
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
if (doc.isDisabled === 1 || isDemoEnv()) {
|
||||
return await this.updateDb(tab);
|
||||
}
|
||||
|
||||
if (doc.isDisabled === 1 || isDemoEnv()) {
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
if (this.isNodeCron(doc)) {
|
||||
// Keep the DB snapshot unchanged if deletion has an uncertain outcome.
|
||||
// Recovery uses that snapshot after this mutation releases its lock.
|
||||
await cronClient.delCron([String(doc.id)]);
|
||||
}
|
||||
const newDoc = await this.updateDb(tab);
|
||||
|
||||
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(newDoc.id),
|
||||
schedule: newDoc.schedule!,
|
||||
command: this.makeCommand(newDoc),
|
||||
extra_schedules: newDoc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (this.shouldUseCronClient(newDoc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(newDoc.id),
|
||||
schedule: newDoc.schedule!,
|
||||
command: this.makeCommand(newDoc),
|
||||
extra_schedules: newDoc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
|
||||
await CrontabModel.update(omit(doc, ['queued_token']), {
|
||||
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 schedulerRegistrationError(
|
||||
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
return newDoc;
|
||||
await this.setCrontab();
|
||||
return newDoc;
|
||||
});
|
||||
}
|
||||
|
||||
public async updateDb(payload: Crontab): Promise<Crontab> {
|
||||
@@ -143,6 +229,7 @@ export default class CronService {
|
||||
log_path,
|
||||
last_running_time = 0,
|
||||
last_execution_time = 0,
|
||||
exit_code,
|
||||
}: {
|
||||
ids: number[];
|
||||
status: CrontabStatus;
|
||||
@@ -150,6 +237,7 @@ export default class CronService {
|
||||
log_path: string;
|
||||
last_running_time: number;
|
||||
last_execution_time: number;
|
||||
exit_code?: number;
|
||||
}) {
|
||||
let options: any = {
|
||||
status,
|
||||
@@ -172,6 +260,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 } },
|
||||
@@ -180,9 +302,11 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async remove(ids: number[]) {
|
||||
await CrontabModel.destroy({ where: { id: ids } });
|
||||
await cronClient.delCron(ids.map(String));
|
||||
await this.setCrontab();
|
||||
return withSchedulerMutation(async () => {
|
||||
await cronClient.delCron(ids.map(String));
|
||||
await CrontabModel.destroy({ where: { id: ids } });
|
||||
await this.setCrontab();
|
||||
});
|
||||
}
|
||||
|
||||
public async pin(ids: number[]) {
|
||||
@@ -350,7 +474,7 @@ export default class CronService {
|
||||
}
|
||||
|
||||
private formatFilterQuery(query: any, filterQuery: any) {
|
||||
if (filterQuery) {
|
||||
if (!isEmpty(filterQuery)) {
|
||||
if (!query[Op.and]) {
|
||||
query[Op.and] = [];
|
||||
}
|
||||
@@ -453,164 +577,372 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async run(ids: number[]) {
|
||||
const queuedToken = randomUUID();
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.queued },
|
||||
{ status: CrontabStatus.queued, queued_token: queuedToken },
|
||||
{ where: { id: ids } },
|
||||
);
|
||||
ids.forEach((id) => {
|
||||
this.runSingle(id);
|
||||
this.runSingle(id, queuedToken);
|
||||
});
|
||||
}
|
||||
|
||||
public async stop(ids: number[]) {
|
||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||
// Cancel the queued snapshot first, so a late spawn cannot claim it.
|
||||
for (const doc of docs) {
|
||||
// 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}`,
|
||||
if (doc.status === CrontabStatus.queued) {
|
||||
const [cancelled] = await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
|
||||
{
|
||||
where: {
|
||||
id: doc.id,
|
||||
status: CrontabStatus.queued,
|
||||
[Op.and]: [
|
||||
where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }),
|
||||
where(colFn('queued_token'), {
|
||||
[Op.eq]: doc.queued_token ?? null,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
// A concurrent claim may have won; capture its PID before signalling.
|
||||
if (!cancelled) await doc.reload();
|
||||
}
|
||||
}
|
||||
const stoppingInstances = await RunningInstanceModel.findAll({
|
||||
attributes: ['id', 'pid', 'cron_id'],
|
||||
where: { cron_id: ids, status: InstanceStatus.running },
|
||||
});
|
||||
const targets = new Set<number>(
|
||||
[
|
||||
...stoppingInstances.map((instance) => instance.pid),
|
||||
...docs
|
||||
.filter((doc) => doc.status === CrontabStatus.running)
|
||||
.map((doc) => doc.pid),
|
||||
].filter((pid): pid is number => typeof pid === 'number' && pid > 0)
|
||||
);
|
||||
const stopped = new Set<number>();
|
||||
for (const pid of targets) {
|
||||
try {
|
||||
await killTask(pid, true);
|
||||
stopped.add(pid);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
|
||||
'[panel][停止任务失败] PID: %s, 错误: %s',
|
||||
pid,
|
||||
asError(error).message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id: ids } },
|
||||
);
|
||||
const stoppedIds = stoppingInstances
|
||||
.filter((instance) => instance.pid && stopped.has(instance.pid))
|
||||
.map((instance) => instance.id!);
|
||||
if (stoppedIds.length) {
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
|
||||
{ where: { id: stoppedIds } }
|
||||
);
|
||||
}
|
||||
for (const doc of docs) {
|
||||
if (
|
||||
doc.status !== CrontabStatus.running ||
|
||||
(doc.pid && !stopped.has(doc.pid))
|
||||
)
|
||||
continue;
|
||||
const remaining = await RunningInstanceModel.count({
|
||||
where: { cron_id: doc.id, status: InstanceStatus.running },
|
||||
});
|
||||
if (remaining) continue;
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
|
||||
{
|
||||
where: {
|
||||
id: doc.id,
|
||||
status: CrontabStatus.running,
|
||||
[Op.and]: [
|
||||
where(colFn('queued_token'), { [Op.eq]: doc.queued_token ?? null }),
|
||||
where(colFn('pid'), { [Op.eq]: doc.pid ?? null }),
|
||||
where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runSingle(cronId: number): Promise<number | void> {
|
||||
return taskLimit.manualRunWithCronLimit(() => {
|
||||
return new Promise(async (resolve: any) => {
|
||||
const cron = await this.getDb({ id: cronId });
|
||||
const params = {
|
||||
name: cron.name,
|
||||
command: cron.command,
|
||||
schedule: cron.schedule,
|
||||
extra_schedules: cron.extra_schedules,
|
||||
};
|
||||
if (cron.status !== CrontabStatus.queued) {
|
||||
resolve(params);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
|
||||
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 } },
|
||||
);
|
||||
|
||||
let { id, command, log_name } = cron;
|
||||
// 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,
|
||||
expectedToken?: string,
|
||||
): Promise<number | void> {
|
||||
return taskLimit.manualRunWithCronLimit(async () => {
|
||||
let absolutePath: string | undefined;
|
||||
let logPath: string | undefined;
|
||||
let queuedLogPath: string | null | undefined;
|
||||
let queuedToken: string | null = null;
|
||||
let claimed = false;
|
||||
try {
|
||||
const cron = await this.getDb({ id: cronId });
|
||||
if (
|
||||
cron.status !== CrontabStatus.queued ||
|
||||
(expectedToken !== undefined && cron.queued_token !== expectedToken)
|
||||
) return;
|
||||
queuedToken = cron.queued_token ?? null;
|
||||
queuedLogPath = cron.log_path ?? null;
|
||||
const { 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}`);
|
||||
await fs.mkdir(logDirPath, { recursive: true });
|
||||
const logPath = `${uniqPath}/${logTime}.log`;
|
||||
const absolutePath = path.resolve(config.logPath, `${logPath}`);
|
||||
logPath = `${uniqPath}/${logTime}.log`;
|
||||
absolutePath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (!absolutePath)
|
||||
throw new Error('Log path is outside the log directory');
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
const outputPath = absolutePath;
|
||||
const cp = spawn(
|
||||
`real_log_path=${logPath} no_delay=true ${this.makeCommand(
|
||||
cron,
|
||||
true,
|
||||
true
|
||||
)}`,
|
||||
{ shell: '/bin/bash' },
|
||||
{ shell: '/bin/bash' }
|
||||
);
|
||||
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.running, pid: cp.pid, log_path: logPath },
|
||||
{ where: { id } },
|
||||
);
|
||||
cp.stdout.on('data', async (data) => {
|
||||
await logStreamManager.write(absolutePath, data.toString());
|
||||
// Install observers before the first await: very short children may already exit.
|
||||
const { completed } = observeChildProcess(cp, {
|
||||
onStart: async () => {
|
||||
try {
|
||||
const [count] = await CrontabModel.update(
|
||||
{
|
||||
status: CrontabStatus.running,
|
||||
pid: cp.pid,
|
||||
log_path: logPath,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
id,
|
||||
status: CrontabStatus.queued,
|
||||
[Op.and]: [
|
||||
where(colFn('queued_token'), { [Op.eq]: queuedToken }),
|
||||
where(colFn('log_path'), { [Op.eq]: queuedLogPath }),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
if (count !== 1)
|
||||
throw new Error(
|
||||
'Task was stopped or superseded before startup'
|
||||
);
|
||||
claimed = true;
|
||||
} catch (error) {
|
||||
if (cp.pid) await killTask(cp.pid, true);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onStdout: (message) => logStreamManager.write(outputPath, message),
|
||||
onStderr: (message) => logStreamManager.write(outputPath, message),
|
||||
});
|
||||
cp.stderr.on('data', async (data) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
data.toString(),
|
||||
);
|
||||
await logStreamManager.write(absolutePath, data.toString());
|
||||
});
|
||||
cp.on('error', async (err) => {
|
||||
const result = await completed;
|
||||
if (result.error) {
|
||||
this.logger.error(
|
||||
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
err,
|
||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
||||
id,
|
||||
result.error.message
|
||||
);
|
||||
await logStreamManager.write(absolutePath, JSON.stringify(err));
|
||||
});
|
||||
|
||||
cp.on('exit', async (code) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 参数: %s, 退出码: %j',
|
||||
JSON.stringify(params),
|
||||
code,
|
||||
}
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
id,
|
||||
result.code
|
||||
);
|
||||
return { ...cron, pid: cp.pid, ...result } as any;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
|
||||
cronId,
|
||||
asError(error).message
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][关闭任务日志失败] %s',
|
||||
asError(error).message
|
||||
);
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(absolutePath);
|
||||
}
|
||||
try {
|
||||
// Do not overwrite a newer run's state or its script-reported exit code.
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id } },
|
||||
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
|
||||
{
|
||||
where: {
|
||||
id: cronId,
|
||||
[Op.and]: where(colFn('queued_token'), { [Op.eq]: queuedToken }),
|
||||
[Op.or]: [
|
||||
...(queuedLogPath !== undefined && !claimed
|
||||
? [
|
||||
{
|
||||
status: CrontabStatus.queued,
|
||||
[Op.and]: where(colFn('log_path'), {
|
||||
[Op.eq]: queuedLogPath,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(claimed && logPath
|
||||
? [{ log_path: logPath, status: CrontabStatus.running }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
resolve({ ...params, pid: cp.pid, code });
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][清理任务状态失败] %s',
|
||||
asError(error).message
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async disabled(ids: number[]) {
|
||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||
await cronClient.delCron(ids.map(String));
|
||||
await this.setCrontab();
|
||||
return withSchedulerMutation(async () => {
|
||||
await cronClient.delCron(ids.map(String));
|
||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||
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) && !this.isSpecialSchedule(x.schedule))
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
return withSchedulerMutation(async () => {
|
||||
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
|
||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||
const crons = docs
|
||||
.filter((x) => this.shouldUseCronClient(x))
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
|
||||
if (isDemoEnv()) {
|
||||
return;
|
||||
}
|
||||
await cronClient.addCron(sixCron);
|
||||
await this.setCrontab();
|
||||
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 schedulerRegistrationError(
|
||||
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
});
|
||||
}
|
||||
|
||||
public async log(id: number) {
|
||||
public async log(
|
||||
id: number,
|
||||
options: LogReadOptions = {},
|
||||
): Promise<{
|
||||
content: string;
|
||||
status: string;
|
||||
offset: number;
|
||||
nextOffset: number;
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}> {
|
||||
const doc = await this.getDb({ id });
|
||||
if (!doc) {
|
||||
return '';
|
||||
return {
|
||||
content: '',
|
||||
status: 'empty',
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
total: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
if (doc.log_name === '/dev/null') {
|
||||
return '日志设置为忽略';
|
||||
return {
|
||||
content: t('日志设置为忽略'),
|
||||
status: 'ignored',
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
total: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
|
||||
const logFileExist = doc.log_path && (await fileExist(absolutePath));
|
||||
if (logFileExist) {
|
||||
return await getFileContentByName(`${absolutePath}`);
|
||||
const chunk = await readLogChunk(`${absolutePath}`, options);
|
||||
const isRunning =
|
||||
typeof doc.status === 'number' &&
|
||||
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
|
||||
return {
|
||||
...chunk,
|
||||
status: isRunning ? 'running' : 'completed',
|
||||
};
|
||||
} else {
|
||||
return typeof doc.status === 'number' &&
|
||||
const status =
|
||||
typeof doc.status === 'number' &&
|
||||
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
|
||||
? '运行中...'
|
||||
: '日志不存在...';
|
||||
? 'running'
|
||||
: 'notFound';
|
||||
return {
|
||||
content: status === 'running' ? t('运行中...') : t('日志不存在...'),
|
||||
status,
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
total: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,6 +993,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;
|
||||
@@ -690,11 +1025,13 @@ export default class CronService {
|
||||
|
||||
await writeFileWithLock(config.crontabFile, crontab_string);
|
||||
|
||||
try {
|
||||
execSync(`crontab ${config.crontabFile}`);
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || String(error);
|
||||
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
|
||||
if (this.schedulerMode === 'system') {
|
||||
try {
|
||||
execSync(`crontab ${config.crontabFile}`);
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || String(error);
|
||||
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
await CrontabModel.update({ saved: true }, { where: {} });
|
||||
@@ -739,29 +1076,56 @@ export default class CronService {
|
||||
});
|
||||
}
|
||||
|
||||
public async autosave_crontab() {
|
||||
const tabs = await this.crontabs();
|
||||
const regularCrons = tabs.data
|
||||
.filter(
|
||||
(x) =>
|
||||
x.isDisabled !== 1 &&
|
||||
this.isNodeCron(x) &&
|
||||
!this.isSpecialSchedule(x.schedule),
|
||||
)
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
public async autosave_crontab(requireScheduler = false) {
|
||||
return withSchedulerMutation(async () => {
|
||||
const tabs = await this.crontabs();
|
||||
// A bad persisted rule must not block startup or all other schedules.
|
||||
// Keep the DB row editable; omit it from both runtime scheduler snapshots.
|
||||
tabs.data = tabs.data.filter((doc) => {
|
||||
if (doc.isDisabled === 1) return true;
|
||||
const invalidSchedules = getInvalidCronSchedules(doc);
|
||||
if (invalidSchedules.length === 0) return true;
|
||||
this.logger.warn(
|
||||
'[crontab] Skipping task with invalid schedule (id=%s, name=%s): %s',
|
||||
doc.id,
|
||||
doc.name || '',
|
||||
JSON.stringify(invalidSchedules),
|
||||
);
|
||||
return false;
|
||||
});
|
||||
const regularCrons = tabs.data
|
||||
.filter(
|
||||
(x) =>
|
||||
x.isDisabled !== 1 &&
|
||||
this.shouldUseCronClient(x),
|
||||
)
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
|
||||
if (isDemoEnv()) {
|
||||
await writeFileWithLock(config.crontabFile, '');
|
||||
return;
|
||||
}
|
||||
await cronClient.addCron(regularCrons);
|
||||
this.setCrontab(tabs);
|
||||
if (isDemoEnv()) {
|
||||
await writeFileWithLock(config.crontabFile, '');
|
||||
return;
|
||||
}
|
||||
|
||||
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
|
||||
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
|
||||
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
|
||||
await this.setCrontab(tabs);
|
||||
try {
|
||||
await cronClient.addCron(regularCrons, requireScheduler);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to register cron job in scheduler: %s',
|
||||
error?.message || error,
|
||||
);
|
||||
if (requireScheduler) throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async bootTask() {
|
||||
@@ -770,13 +1134,7 @@ export default class CronService {
|
||||
(x) => !x.isDisabled && this.isBootSchedule(x.schedule),
|
||||
);
|
||||
if (bootTasks.length > 0) {
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.queued },
|
||||
{ where: { id: bootTasks.map((t) => t.id!) } },
|
||||
);
|
||||
for (const task of bootTasks) {
|
||||
await this.runSingle(task.id!);
|
||||
}
|
||||
await this.run(bootTasks.map((task) => task.id!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+169
-38
@@ -16,12 +16,16 @@ import {
|
||||
getPid,
|
||||
killTask,
|
||||
promiseExecSuccess,
|
||||
concurrentRun,
|
||||
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 {
|
||||
@@ -107,6 +111,11 @@ export default class DependenceService {
|
||||
query: any = {},
|
||||
): Promise<Dependence[]> {
|
||||
let condition = query;
|
||||
const dependenceType =
|
||||
type && DependenceTypes[type] !== undefined
|
||||
? DependenceTypes[type]
|
||||
: undefined;
|
||||
await this.refreshInstalledStatuses(dependenceType);
|
||||
if (type && DependenceTypes[type] !== undefined) {
|
||||
condition.type = DependenceTypes[type];
|
||||
}
|
||||
@@ -129,6 +138,69 @@ export default class DependenceService {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshInstalledStatuses(type?: DependenceTypes) {
|
||||
const cacheDependenceTypes = [
|
||||
DependenceTypes.nodejs,
|
||||
DependenceTypes.python3,
|
||||
];
|
||||
if (type !== undefined && !cacheDependenceTypes.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const docs = await DependenceModel.findAll({
|
||||
where: {
|
||||
status: DependenceStatus.installed,
|
||||
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
|
||||
},
|
||||
});
|
||||
const checks = await concurrentRun(
|
||||
docs.map((doc) => async () => {
|
||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||
}),
|
||||
5,
|
||||
);
|
||||
const missingIds = (checks || []).filter(
|
||||
(id): id is number => id !== undefined,
|
||||
);
|
||||
|
||||
if (missingIds.length) {
|
||||
await DependenceModel.update(
|
||||
{ status: DependenceStatus.installFailed },
|
||||
{ where: { id: missingIds } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async isDependenceInstalled(dependency: Dependence) {
|
||||
let depName = dependency.name.trim();
|
||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||
let depVersion = '';
|
||||
if (depName.includes(depVersionStr)) {
|
||||
const symbolRegx = new RegExp(
|
||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||
);
|
||||
const [, parsedName, parsedVersion] = depName.match(symbolRegx) || [];
|
||||
if (parsedVersion && parsedName) {
|
||||
depName = parsedName;
|
||||
depVersion = parsedVersion;
|
||||
}
|
||||
}
|
||||
|
||||
const depInfo = (
|
||||
await promiseExecSuccess(getGetCommand(dependency.type, depName))
|
||||
)
|
||||
.replace(/\s{2,}/, ' ')
|
||||
.replace(/\s+$/, '');
|
||||
const nameMatches =
|
||||
(dependency.type === DependenceTypes.nodejs &&
|
||||
depInfo.split(' ')?.[0] === depName) ||
|
||||
dependency.type === DependenceTypes.python3;
|
||||
|
||||
return Boolean(
|
||||
depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)),
|
||||
);
|
||||
}
|
||||
|
||||
public installDependenceOneByOne(
|
||||
docs: Dependence[],
|
||||
isInstall: boolean = true,
|
||||
@@ -159,8 +231,19 @@ export default class DependenceService {
|
||||
const docs = await DependenceModel.findAll({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
taskLimit.removeQueuedDependency(doc);
|
||||
const depInstallCommand = getInstallCommand(doc.type, doc.name);
|
||||
const depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
|
||||
let depInstallCommand = getInstallCommand(doc.type, doc.name);
|
||||
let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
|
||||
const isLinuxDependence = doc.type === DependenceTypes.linux;
|
||||
|
||||
if (isLinuxDependence) {
|
||||
const osType = await detectOS();
|
||||
if (!osType) {
|
||||
continue;
|
||||
}
|
||||
const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
|
||||
depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`;
|
||||
depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`;
|
||||
}
|
||||
const pids = await Promise.all([
|
||||
getPid(depInstallCommand),
|
||||
getPid(depUnInstallCommand),
|
||||
@@ -217,39 +300,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 command = isInstall
|
||||
let command = isInstall
|
||||
? getInstallCommand(dependency.type, depName)
|
||||
: getUninstallCommand(dependency.type, depName);
|
||||
const actionText = isInstall ? '安装' : '删除';
|
||||
if (isLinuxDependence) {
|
||||
command = isInstall
|
||||
? `${linuxCommand.install} ${depName.trim()}`
|
||||
: `${linuxCommand.uninstall} ${depName.trim()}`;
|
||||
}
|
||||
const startTime = dayjs();
|
||||
|
||||
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
|
||||
'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 getCommand = getGetCommand(dependency.type, depName);
|
||||
let getCommand = getGetCommand(dependency.type, depName);
|
||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||
if (isLinuxDependence) {
|
||||
getCommand = `${linuxCommand.info} ${depName}`;
|
||||
}
|
||||
|
||||
let depVersion = '';
|
||||
if (depName.includes(depVersionStr)) {
|
||||
const symbolRegx = new RegExp(
|
||||
@@ -261,10 +388,6 @@ export default class DependenceService {
|
||||
depVersion = _depVersion;
|
||||
}
|
||||
}
|
||||
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
|
||||
const isLinuxDependence = dependency.type === DependenceTypes.linux;
|
||||
const isPythonDependence =
|
||||
dependency.type === DependenceTypes.python3;
|
||||
const depInfo = (await promiseExecSuccess(getCommand))
|
||||
.replace(/\s{2,}/, ' ')
|
||||
.replace(/\s+$/, '');
|
||||
@@ -273,18 +396,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(
|
||||
@@ -309,6 +438,7 @@ export default class DependenceService {
|
||||
type: socketMessageType,
|
||||
message: data.toString(),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, data.toString());
|
||||
});
|
||||
@@ -318,6 +448,7 @@ export default class DependenceService {
|
||||
type: socketMessageType,
|
||||
message: data.toString(),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, data.toString());
|
||||
});
|
||||
@@ -327,6 +458,7 @@ export default class DependenceService {
|
||||
type: socketMessageType,
|
||||
message: JSON.stringify(err),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, JSON.stringify(err));
|
||||
});
|
||||
@@ -334,28 +466,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)
|
||||
@@ -363,7 +494,7 @@ export default class DependenceService {
|
||||
|
||||
if (_docIds.length > 0) {
|
||||
await DependenceModel.update(
|
||||
{ status },
|
||||
{ status: exitStatus },
|
||||
{ where: { id: _docIds } },
|
||||
);
|
||||
}
|
||||
|
||||
+41
-3
@@ -27,7 +27,7 @@ export default class EnvService {
|
||||
envs.length > 0 &&
|
||||
typeof envs[envs.length - 1].position === 'number'
|
||||
) {
|
||||
position = envs[envs.length - 1].position!;
|
||||
position = this.getPrecisionPosition(envs[envs.length - 1].position!);
|
||||
}
|
||||
const tabs = payloads.map((x) => {
|
||||
position = position - stepPosition;
|
||||
@@ -100,7 +100,7 @@ export default class EnvService {
|
||||
}
|
||||
|
||||
private async checkPosition(position: number, edge: number = 0) {
|
||||
const precisionPosition = parseFloat(position.toPrecision(16));
|
||||
const precisionPosition = this.getPrecisionPosition(position);
|
||||
if (
|
||||
precisionPosition < minPosition ||
|
||||
precisionPosition > maxPosition ||
|
||||
@@ -116,7 +116,7 @@ export default class EnvService {
|
||||
}
|
||||
|
||||
private getPrecisionPosition(position: number): number {
|
||||
return parseFloat(position.toPrecision(16));
|
||||
return Math.trunc(parseFloat(position.toPrecision(16)));
|
||||
}
|
||||
|
||||
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
|
||||
@@ -199,6 +199,44 @@ export default class EnvService {
|
||||
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
|
||||
}
|
||||
|
||||
public async addLabels(ids: number[], labels: string[]) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const docs = await EnvModel.findAll({
|
||||
where: { id: ids },
|
||||
transaction,
|
||||
});
|
||||
for (const doc of docs) {
|
||||
const env = doc.get({ plain: true });
|
||||
await EnvModel.update(
|
||||
{ labels: Array.from(new Set([...(env.labels || []), ...labels])) },
|
||||
{ where: { id: env.id }, transaction },
|
||||
);
|
||||
}
|
||||
});
|
||||
return await this.find({ id: ids });
|
||||
}
|
||||
|
||||
public async removeLabels(ids: number[], labels: string[]) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const docs = await EnvModel.findAll({
|
||||
where: { id: ids },
|
||||
transaction,
|
||||
});
|
||||
for (const doc of docs) {
|
||||
const env = doc.get({ plain: true });
|
||||
await EnvModel.update(
|
||||
{
|
||||
labels: (env.labels || []).filter(
|
||||
(label: string) => !labels.includes(label),
|
||||
),
|
||||
},
|
||||
{ where: { id: env.id }, transaction },
|
||||
);
|
||||
}
|
||||
});
|
||||
return await this.find({ id: ids });
|
||||
}
|
||||
|
||||
public async set_envs() {
|
||||
const envs = await this.envs('', {
|
||||
name: { [Op.not]: null },
|
||||
|
||||
+39
-10
@@ -11,30 +11,59 @@ 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 grpcPort = config.grpcPort;
|
||||
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
|
||||
await bindAsync(
|
||||
`0.0.0.0:${grpcPort}`,
|
||||
ServerCredentials.createInsecure(),
|
||||
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,
|
||||
);
|
||||
Logger.debug(`✌️ gRPC service started successfully`);
|
||||
|
||||
metricsService.record('grpc_service_start', 1, {
|
||||
port: grpcPort.toString(),
|
||||
});
|
||||
const grpcPort = config.grpcPort;
|
||||
const hostsToTry = [
|
||||
config.bindHostGrpc,
|
||||
...(config.bindHostGrpc === '::' ? ['0.0.0.0'] : [])
|
||||
];
|
||||
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
|
||||
|
||||
return grpcPort;
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Service } from 'typedi';
|
||||
import Logger from '../loaders/logger';
|
||||
import { GrpcServerService } from './grpc';
|
||||
import cronClient from '../schedule/client';
|
||||
import { HttpServerService } from './http';
|
||||
|
||||
interface HealthStatus {
|
||||
@@ -23,7 +23,6 @@ export class HealthService {
|
||||
private startTime = Date.now();
|
||||
|
||||
constructor(
|
||||
private grpcServerService: GrpcServerService,
|
||||
private httpServerService: HttpServerService,
|
||||
) {}
|
||||
|
||||
@@ -56,8 +55,7 @@ export class HealthService {
|
||||
}
|
||||
|
||||
try {
|
||||
const grpcServer = this.grpcServerService.getServer();
|
||||
if (!grpcServer) {
|
||||
if (!(await cronClient.readiness.check())) {
|
||||
status.services.grpc = false;
|
||||
status.status = 'error';
|
||||
}
|
||||
|
||||
+42
-16
@@ -3,31 +3,57 @@ import Logger from '../loaders/logger';
|
||||
import { metricsService } from './metrics';
|
||||
import { Service } from 'typedi';
|
||||
import { Server } from 'http';
|
||||
import config from '../config';
|
||||
|
||||
@Service()
|
||||
export class HttpServerService {
|
||||
private server?: Server = undefined;
|
||||
|
||||
async initialize(expressApp: express.Application, port: number) {
|
||||
try {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = expressApp.listen(port, '0.0.0.0', () => {
|
||||
Logger.debug(`✌️ HTTP service started successfully`);
|
||||
metricsService.record('http_service_start', 1, {
|
||||
port: port.toString(),
|
||||
});
|
||||
resolve(this.server);
|
||||
});
|
||||
const hostsToTry = [
|
||||
config.bindHost,
|
||||
...(config.bindHost === '::' ? ['0.0.0.0'] : [])
|
||||
];
|
||||
|
||||
this.server?.on('error', (err: Error) => {
|
||||
Logger.error('Failed to start HTTP service:', err);
|
||||
reject(err);
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const host of hostsToTry) {
|
||||
try {
|
||||
const server = await this.tryListen(expressApp, port, host);
|
||||
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
|
||||
metricsService.record('http_service_start', 1, {
|
||||
port: port.toString(),
|
||||
host
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error('Failed to start HTTP service:', err);
|
||||
throw err;
|
||||
this.server = server;
|
||||
return server;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error('Failed to start HTTP service on all hosts');
|
||||
throw lastError || new Error('Failed to start HTTP service');
|
||||
}
|
||||
|
||||
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// There is one HTTP worker; accepting here avoids primary IPC handoff
|
||||
// for every connection. Restore shared listening for custom clusters.
|
||||
const server = expressApp.listen({
|
||||
port,
|
||||
host,
|
||||
exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true',
|
||||
}, () => {
|
||||
resolve(server);
|
||||
});
|
||||
|
||||
server.on('error', (err: Error) => {
|
||||
server.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import path from 'path';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
@@ -8,7 +9,10 @@ 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 : '';
|
||||
return resolveFileAccess(
|
||||
config.logPath,
|
||||
[filePath || '', fileName],
|
||||
config.blackFileList,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+61
-19
@@ -8,16 +8,19 @@ interface Metric {
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
class MetricsService {
|
||||
private metrics: Metric[] = [];
|
||||
export const MAX_METRIC_SAMPLES = 1000;
|
||||
const METRIC_RETENTION_MS = 60 * 60 * 1000;
|
||||
|
||||
export class MetricsService {
|
||||
private metrics: Array<Metric | undefined> = new Array(MAX_METRIC_SAMPLES);
|
||||
private metricCount = 0;
|
||||
private nextMetricIndex = 0;
|
||||
private static instance: MetricsService;
|
||||
|
||||
private constructor() {
|
||||
// 定期清理旧数据
|
||||
setInterval(() => {
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
|
||||
}, 60000);
|
||||
const cleanupTimer = setInterval(() => this.removeExpiredMetrics(), 60000);
|
||||
cleanupTimer.unref();
|
||||
}
|
||||
|
||||
static getInstance(): MetricsService {
|
||||
@@ -28,12 +31,14 @@ class MetricsService {
|
||||
}
|
||||
|
||||
record(name: string, value: number, tags?: Record<string, string>) {
|
||||
this.metrics.push({
|
||||
this.metrics[this.nextMetricIndex] = {
|
||||
name,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
tags,
|
||||
});
|
||||
};
|
||||
this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES;
|
||||
this.metricCount = Math.min(this.metricCount + 1, MAX_METRIC_SAMPLES);
|
||||
}
|
||||
|
||||
measure(name: string, fn: () => void, tags?: Record<string, string>) {
|
||||
@@ -46,7 +51,11 @@ class MetricsService {
|
||||
}
|
||||
}
|
||||
|
||||
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) {
|
||||
async measureAsync(
|
||||
name: string,
|
||||
fn: () => Promise<void>,
|
||||
tags?: Record<string, string>,
|
||||
) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
await fn();
|
||||
@@ -57,28 +66,61 @@ class MetricsService {
|
||||
}
|
||||
|
||||
getMetrics(name?: string, tags?: Record<string, string>) {
|
||||
let filtered = this.metrics;
|
||||
|
||||
this.removeExpiredMetrics();
|
||||
let filtered = this.getMetricSnapshot();
|
||||
|
||||
if (name) {
|
||||
filtered = filtered.filter(m => m.name === name);
|
||||
filtered = filtered.filter((m) => m.name === name);
|
||||
}
|
||||
|
||||
|
||||
if (tags) {
|
||||
filtered = filtered.filter(m => {
|
||||
filtered = filtered.filter((m) => {
|
||||
if (!m.tags) return false;
|
||||
return Object.entries(tags).every(([key, value]) => m.tags![key] === value);
|
||||
return Object.entries(tags).every(
|
||||
([key, value]) => m.tags![key] === value,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const values = filtered.map((metric) => metric.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)),
|
||||
average: values.length
|
||||
? values.reduce((acc, value) => acc + value, 0) / values.length
|
||||
: 0,
|
||||
min: values.length ? Math.min(...values) : 0,
|
||||
max: values.length ? Math.max(...values) : 0,
|
||||
metrics: filtered,
|
||||
};
|
||||
}
|
||||
|
||||
private getMetricSnapshot(): Metric[] {
|
||||
if (this.metricCount < MAX_METRIC_SAMPLES) {
|
||||
return this.metrics.slice(0, this.metricCount) as Metric[];
|
||||
}
|
||||
return [
|
||||
...this.metrics.slice(this.nextMetricIndex),
|
||||
...this.metrics.slice(0, this.nextMetricIndex),
|
||||
] as Metric[];
|
||||
}
|
||||
|
||||
private removeExpiredMetrics() {
|
||||
const oldestTimestamp = Date.now() - METRIC_RETENTION_MS;
|
||||
const retained = this.getMetricSnapshot().filter(
|
||||
(metric) => metric.timestamp > oldestTimestamp,
|
||||
);
|
||||
if (retained.length === this.metricCount) return;
|
||||
|
||||
this.metrics = new Array(MAX_METRIC_SAMPLES);
|
||||
this.metricCount = 0;
|
||||
this.nextMetricIndex = 0;
|
||||
for (const metric of retained) {
|
||||
this.metrics[this.nextMetricIndex] = metric;
|
||||
this.nextMetricIndex = (this.nextMetricIndex + 1) % MAX_METRIC_SAMPLES;
|
||||
this.metricCount++;
|
||||
}
|
||||
}
|
||||
|
||||
report() {
|
||||
const report = {
|
||||
timestamp: Date.now(),
|
||||
@@ -89,4 +131,4 @@ class MetricsService {
|
||||
}
|
||||
}
|
||||
|
||||
export const metricsService = MetricsService.getInstance();
|
||||
export const metricsService = MetricsService.getInstance();
|
||||
|
||||
+125
-8
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -34,6 +35,9 @@ export default class NotificationService {
|
||||
['chronocat', this.chronocat],
|
||||
['ntfy', this.ntfy],
|
||||
['wxPusherBot', this.wxPusherBot],
|
||||
['wxPusherSpt', this.wxPusherSpt],
|
||||
['openiLink', this.openiLink],
|
||||
['wpush', this.wpush],
|
||||
]);
|
||||
|
||||
private title = '';
|
||||
@@ -90,6 +94,14 @@ export default class NotificationService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseMailRecipients(value?: string) {
|
||||
const recipients = (value || '')
|
||||
.split(/[;;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return recipients.length > 0 ? recipients : undefined;
|
||||
}
|
||||
|
||||
private async gotify() {
|
||||
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
|
||||
try {
|
||||
@@ -355,7 +367,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}`,
|
||||
@@ -371,7 +383,7 @@ export default class NotificationService {
|
||||
title: `${this.title}`,
|
||||
description: `${this.content}`,
|
||||
url: 'https://github.com/whyour/qinglong',
|
||||
btntxt: '更多',
|
||||
btntxt: t('更多'),
|
||||
},
|
||||
};
|
||||
break;
|
||||
@@ -422,7 +434,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;
|
||||
@@ -433,7 +445,7 @@ 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;
|
||||
@@ -591,6 +603,7 @@ export default class NotificationService {
|
||||
|
||||
private async email() {
|
||||
const { emailPass, emailService, emailUser, emailTo } = this.params;
|
||||
const recipients = this.parseMailRecipients(emailTo) || emailUser;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
@@ -602,8 +615,8 @@ export default class NotificationService {
|
||||
});
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: `"青龙快讯" <${emailUser}>`,
|
||||
to: emailTo ? emailTo.split(';') : emailUser,
|
||||
from: `"${t('青龙快讯')}" <${emailUser}>`,
|
||||
to: recipients,
|
||||
subject: `${this.title}`,
|
||||
html: `${this.content.replace(/\n/g, '<br/>')}`,
|
||||
});
|
||||
@@ -717,7 +730,7 @@ 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`;
|
||||
@@ -745,6 +758,52 @@ export default class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
} else {
|
||||
throw new Error(JSON.stringify(res));
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
}
|
||||
}
|
||||
|
||||
private async chronocat() {
|
||||
const { chronocatURL, chronocatQQ, chronocatToken } = this.params;
|
||||
try {
|
||||
@@ -813,7 +872,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);
|
||||
@@ -858,4 +917,62 @@ 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);
|
||||
}
|
||||
}
|
||||
private async wpush() {
|
||||
const { wpushApiKey, wpushChannel, wpushTopicCode } = this.params;
|
||||
const url = 'https://api.wpush.cn/api/v1/send';
|
||||
const json: Record<string, string> = {
|
||||
apikey: `${wpushApiKey}`,
|
||||
title: `${this.title}`,
|
||||
content: `${this.content}`,
|
||||
channel: `${wpushChannel || 'wechat'}`,
|
||||
};
|
||||
if (wpushTopicCode) {
|
||||
json.topic_code = `${wpushTopicCode}`;
|
||||
}
|
||||
try {
|
||||
const res = await httpClient.post(url, {
|
||||
...this.gotOption,
|
||||
json,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
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 有误') };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import dayjs from 'dayjs';
|
||||
import { Op } from 'sequelize';
|
||||
import { Service } from 'typedi';
|
||||
import config from '../config';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import { sequelize } from '../data';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { AuthDataType, SystemModel } from '../data/system';
|
||||
import {
|
||||
DependenceCacheType,
|
||||
getDirectorySize,
|
||||
isDependenceCacheType,
|
||||
normalizeRetentionPolicy,
|
||||
RetentionPolicy,
|
||||
} from '../shared/retention';
|
||||
|
||||
export interface StorageCleanupRequest extends RetentionPolicy {
|
||||
dependenceCacheTypes?: DependenceCacheType[];
|
||||
compactDatabase?: boolean;
|
||||
}
|
||||
|
||||
function runningInstanceWhere(days: number) {
|
||||
const cutoff = dayjs().subtract(days, 'day').unix();
|
||||
return {
|
||||
status: { [Op.ne]: InstanceStatus.running },
|
||||
[Op.or]: [
|
||||
{ finished_at: { [Op.lt]: cutoff } },
|
||||
{
|
||||
finished_at: { [Op.is]: null },
|
||||
started_at: { [Op.lt]: cutoff },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function cronStatWhere(days: number) {
|
||||
return {
|
||||
date: { [Op.lt]: dayjs().subtract(days, 'day').format('YYYY-MM-DD') },
|
||||
};
|
||||
}
|
||||
|
||||
@Service()
|
||||
export default class RetentionService {
|
||||
public async updatePolicy(policy: Partial<RetentionPolicy>) {
|
||||
const normalized = normalizeRetentionPolicy(policy);
|
||||
const systemConfig = await SystemModel.findOne({
|
||||
where: { type: AuthDataType.systemConfig },
|
||||
});
|
||||
if (!systemConfig) {
|
||||
throw new Error('System config not found');
|
||||
}
|
||||
await SystemModel.update(
|
||||
{
|
||||
info: {
|
||||
...systemConfig.info,
|
||||
...normalized,
|
||||
},
|
||||
},
|
||||
{ where: { id: systemConfig.id } },
|
||||
);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public async preview(request: StorageCleanupRequest) {
|
||||
const policy = normalizeRetentionPolicy(request);
|
||||
const dependenceCacheTypes = (request.dependenceCacheTypes || []).filter(
|
||||
isDependenceCacheType,
|
||||
);
|
||||
const [runningInstances, cronStats, dependenceCaches] = await Promise.all([
|
||||
policy.runningInstanceRetentionDays > 0
|
||||
? RunningInstanceModel.count({
|
||||
where: runningInstanceWhere(policy.runningInstanceRetentionDays),
|
||||
})
|
||||
: 0,
|
||||
policy.cronStatRetentionDays > 0
|
||||
? CrontabStatModel.count({
|
||||
where: cronStatWhere(policy.cronStatRetentionDays),
|
||||
})
|
||||
: 0,
|
||||
Promise.all(
|
||||
dependenceCacheTypes.map(async (type) => ({
|
||||
type,
|
||||
bytes: await getDirectorySize(
|
||||
path.join(config.dependenceCachePath, type),
|
||||
),
|
||||
})),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
policy,
|
||||
runningInstances,
|
||||
cronStats,
|
||||
dependenceCaches,
|
||||
dependenceCacheBytes: dependenceCaches.reduce(
|
||||
(total, cache) => total + cache.bytes,
|
||||
0,
|
||||
),
|
||||
compactDatabase: Boolean(request.compactDatabase),
|
||||
};
|
||||
}
|
||||
|
||||
public async cleanup(request: StorageCleanupRequest) {
|
||||
const preview = await this.preview(request);
|
||||
const deleted = await sequelize.transaction(async (transaction) => {
|
||||
const runningInstances =
|
||||
preview.policy.runningInstanceRetentionDays > 0
|
||||
? await RunningInstanceModel.destroy({
|
||||
where: runningInstanceWhere(
|
||||
preview.policy.runningInstanceRetentionDays,
|
||||
),
|
||||
transaction,
|
||||
})
|
||||
: 0;
|
||||
const cronStats =
|
||||
preview.policy.cronStatRetentionDays > 0
|
||||
? await CrontabStatModel.destroy({
|
||||
where: cronStatWhere(preview.policy.cronStatRetentionDays),
|
||||
transaction,
|
||||
})
|
||||
: 0;
|
||||
return { runningInstances, cronStats };
|
||||
});
|
||||
|
||||
const dependenceCaches = [];
|
||||
for (const cache of preview.dependenceCaches) {
|
||||
await fs.rm(path.join(config.dependenceCachePath, cache.type), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
dependenceCaches.push(cache);
|
||||
}
|
||||
|
||||
if (
|
||||
request.compactDatabase &&
|
||||
(deleted.runningInstances || deleted.cronStats)
|
||||
) {
|
||||
await sequelize.query('VACUUM');
|
||||
}
|
||||
|
||||
return {
|
||||
deleted: {
|
||||
...deleted,
|
||||
dependenceCaches,
|
||||
dependenceCacheBytes: preview.dependenceCacheBytes,
|
||||
},
|
||||
compactedDatabase: Boolean(
|
||||
request.compactDatabase &&
|
||||
(deleted.runningInstances || deleted.cronStats),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
+80
-67
@@ -11,6 +11,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { spawn } from 'cross-spawn';
|
||||
import { observeChildProcess, asError, ProcessResult } from '../shared/childProcess';
|
||||
|
||||
export interface ScheduleTaskType {
|
||||
id?: number;
|
||||
@@ -27,7 +28,7 @@ export interface TaskCallbacks {
|
||||
startTime: dayjs.Dayjs,
|
||||
) => Promise<void>;
|
||||
onEnd?: (
|
||||
cp: ChildProcessWithoutNullStreams,
|
||||
cp: ChildProcessWithoutNullStreams | undefined,
|
||||
endTime: dayjs.Dayjs,
|
||||
diff: number,
|
||||
) => Promise<void>;
|
||||
@@ -63,73 +64,85 @@ export default class ScheduleService {
|
||||
) {
|
||||
const { runOrigin, ...others } = params;
|
||||
|
||||
return taskLimit[this.taskLimitMap[runOrigin]](others, () => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
this.logger.info(
|
||||
`[panel][开始执行任务] 参数: ${JSON.stringify({
|
||||
...others,
|
||||
command,
|
||||
})}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const startTime = dayjs();
|
||||
await callbacks.onBefore?.(startTime);
|
||||
|
||||
const cp = spawn(command, { shell: '/bin/bash' });
|
||||
|
||||
callbacks.onStart?.(cp, startTime);
|
||||
completionTime === 'start' && resolve(cp.pid);
|
||||
|
||||
cp.stdout.on('data', async (data) => {
|
||||
await callbacks.onLog?.(data.toString());
|
||||
});
|
||||
|
||||
cp.stderr.on('data', async (data) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
data.toString(),
|
||||
);
|
||||
await callbacks.onError?.(data.toString());
|
||||
});
|
||||
|
||||
cp.on('error', async (err) => {
|
||||
this.logger.error(
|
||||
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
err,
|
||||
);
|
||||
await callbacks.onError?.(JSON.stringify(err));
|
||||
});
|
||||
|
||||
cp.on('exit', async (code) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 参数: %s, 退出码: %j',
|
||||
JSON.stringify({
|
||||
...others,
|
||||
command,
|
||||
}),
|
||||
code,
|
||||
);
|
||||
const endTime = dayjs();
|
||||
await callbacks.onEnd?.(
|
||||
cp,
|
||||
endTime,
|
||||
endTime.diff(startTime, 'seconds'),
|
||||
);
|
||||
resolve({ ...others, pid: cp.pid, code });
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
error,
|
||||
);
|
||||
await callbacks.onError?.(JSON.stringify(error));
|
||||
}
|
||||
});
|
||||
let resolveStart!: (pid: number | undefined) => void;
|
||||
let rejectStart!: (error: Error) => void;
|
||||
const startResult = new Promise<number | undefined>((resolve, reject) => {
|
||||
resolveStart = resolve;
|
||||
rejectStart = reject;
|
||||
});
|
||||
// Most scheduled callers only observe completion (or intentionally detach).
|
||||
void startResult.catch(() => {});
|
||||
const completion = taskLimit[this.taskLimitMap[runOrigin]](
|
||||
others,
|
||||
async () => {
|
||||
const startTime = dayjs();
|
||||
let cp: ChildProcessWithoutNullStreams | undefined;
|
||||
let result: ProcessResult = { code: null, signal: null };
|
||||
try {
|
||||
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
|
||||
await callbacks.onBefore?.(startTime);
|
||||
cp = spawn(command, { shell: '/bin/bash' });
|
||||
const child = cp;
|
||||
const observed = observeChildProcess(child, {
|
||||
onStart: async () => {
|
||||
await callbacks.onStart?.(child, startTime);
|
||||
},
|
||||
onStdout: callbacks.onLog,
|
||||
onStderr: callbacks.onError,
|
||||
});
|
||||
observed.started.then(resolveStart, rejectStart);
|
||||
result = await observed.completed;
|
||||
} catch (error) {
|
||||
result.error = asError(error);
|
||||
rejectStart(result.error);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this.logger.error(
|
||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
||||
others.id,
|
||||
result.error.message,
|
||||
);
|
||||
try {
|
||||
await callbacks.onError?.(result.error.message);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][任务错误回调失败] %s',
|
||||
asError(error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Cleanup also runs after setup/spawn failure, and only after both pipes drain.
|
||||
const endTime = dayjs();
|
||||
try {
|
||||
await callbacks.onEnd?.(
|
||||
cp,
|
||||
endTime,
|
||||
endTime.diff(startTime, 'seconds'),
|
||||
);
|
||||
} catch (error) {
|
||||
result.error ??= asError(error);
|
||||
this.logger.error(
|
||||
'[panel][任务结束回调失败] %s',
|
||||
asError(error).message,
|
||||
);
|
||||
}
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
others.id,
|
||||
result.code,
|
||||
);
|
||||
return { ...others, pid: cp?.pid, ...result };
|
||||
},
|
||||
).catch((error) => {
|
||||
// Queue/setup failures must not become unhandled rejections in detached callers.
|
||||
rejectStart(asError(error));
|
||||
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
|
||||
return { ...others, code: null, signal: null, error: asError(error) };
|
||||
});
|
||||
|
||||
// Returning a PID must not release the execution slot while the process runs.
|
||||
return completionTime === 'start' ? startResult : completion;
|
||||
}
|
||||
|
||||
async createCronTask(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import path, { join } from 'path';
|
||||
@@ -57,16 +58,19 @@ export default class ScriptService {
|
||||
taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-'));
|
||||
pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number;
|
||||
}
|
||||
try {
|
||||
await killTask(pid);
|
||||
} catch (error) {}
|
||||
if (pid) {
|
||||
await killTask(pid, true);
|
||||
}
|
||||
|
||||
return { code: 200 };
|
||||
}
|
||||
|
||||
public checkFilePath(filePath: string, fileName: string) {
|
||||
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
|
||||
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
|
||||
return resolveFileAccess(
|
||||
config.scriptPath,
|
||||
[filePath || '', fileName],
|
||||
config.blackFileList,
|
||||
);
|
||||
}
|
||||
|
||||
public async getFile(filePath: string, fileName: string) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from 'path';
|
||||
import { Service } from 'typedi';
|
||||
import config from '../config';
|
||||
import { ScriptHistory } from '../shared/scriptHistory';
|
||||
|
||||
/** Panel configuration and lifecycle; the storage core stays independently testable. */
|
||||
@Service()
|
||||
export default class ScriptHistoryService extends ScriptHistory {
|
||||
constructor() {
|
||||
super(
|
||||
config.scriptPath,
|
||||
path.join(config.dataPath, 'script-history'),
|
||||
config.blackFileList,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,11 @@ export default class SshKeyService {
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await writeFileWithLock(
|
||||
path.join(this.sshPath, alias),
|
||||
`${key}${os.EOL}`,
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -32,6 +33,7 @@ import { CrontabModel } from '../data/cron';
|
||||
import CrontabService from './cron';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
import { LogReadOptions, readLogChunk } from '../shared/logReader';
|
||||
|
||||
@Service()
|
||||
export default class SubscriptionService {
|
||||
@@ -130,14 +132,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 logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`);
|
||||
await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`);
|
||||
beforeStr = await promiseExec(doc.sub_before);
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -157,44 +159,54 @@ export default class SubscriptionService {
|
||||
);
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
const absolutePath = await handleLogPath(sub.log_path as string);
|
||||
|
||||
// 执行 sub_after
|
||||
let afterStr = '';
|
||||
let absolutePath: string | undefined;
|
||||
try {
|
||||
if (sub.sub_after) {
|
||||
await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`);
|
||||
afterStr = await promiseExec(sub.sub_after);
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
absolutePath = await handleLogPath(sub.log_path as string);
|
||||
|
||||
// 执行 sub_after
|
||||
let afterStr = '';
|
||||
try {
|
||||
if (sub.sub_after) {
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
`\n\n## ${t('执行after命令...')}\n\n`,
|
||||
);
|
||||
afterStr = await promiseExec(sub.sub_after);
|
||||
}
|
||||
} catch (error: any) {
|
||||
afterStr =
|
||||
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
||||
}
|
||||
if (afterStr) {
|
||||
await logStreamManager.write(absolutePath, `${afterStr}\n`);
|
||||
}
|
||||
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
'\n' +
|
||||
tf(
|
||||
'## 执行结束... %s 耗时 %s 秒',
|
||||
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
String(diff),
|
||||
) +
|
||||
LOG_END_SYMBOL,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
||||
} finally {
|
||||
await SubscriptionModel.update(
|
||||
{ status: SubscriptionStatus.idle, pid: null } as any,
|
||||
{ where: { id: doc.id } },
|
||||
);
|
||||
this.sockService.sendMessage({
|
||||
type: 'runSubscriptionEnd',
|
||||
message: t('订阅执行完成'),
|
||||
references: [doc.id as number],
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
afterStr =
|
||||
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
||||
}
|
||||
if (afterStr) {
|
||||
await logStreamManager.write(absolutePath, `${afterStr}\n`);
|
||||
}
|
||||
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
`\n## 执行结束... ${endTime.format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)} 耗时 ${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 } },
|
||||
);
|
||||
|
||||
this.sockService.sendMessage({
|
||||
type: 'runSubscriptionEnd',
|
||||
message: '订阅执行完成',
|
||||
references: [doc.id as number],
|
||||
});
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
@@ -310,20 +322,22 @@ export default class SubscriptionService {
|
||||
|
||||
public async stop(ids: number[]) {
|
||||
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
|
||||
let failure: unknown;
|
||||
for (const doc of docs) {
|
||||
if (doc.pid) {
|
||||
try {
|
||||
await killTask(doc.pid);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
try {
|
||||
if (doc.pid) {
|
||||
await killTask(doc.pid, true);
|
||||
}
|
||||
await SubscriptionModel.update(
|
||||
{ status: SubscriptionStatus.idle, pid: null } as any,
|
||||
{ where: { id: doc.id, pid: doc.pid ?? null } },
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
failure ??= error;
|
||||
}
|
||||
}
|
||||
|
||||
await SubscriptionModel.update(
|
||||
{ status: SubscriptionStatus.idle, pid: undefined },
|
||||
{ where: { id: ids } },
|
||||
);
|
||||
if (failure) throw failure;
|
||||
}
|
||||
|
||||
private async runSingle(subscriptionId: number) {
|
||||
@@ -361,14 +375,20 @@ export default class SubscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
public async log(id: number) {
|
||||
public async log(id: number, options: LogReadOptions = {}) {
|
||||
const doc = await this.getDb({ id });
|
||||
if (!doc || !doc.log_path) {
|
||||
return '';
|
||||
return {
|
||||
content: '',
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
total: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const absolutePath = await handleLogPath(doc.log_path as string);
|
||||
return await getFileContentByName(absolutePath);
|
||||
return await readLogChunk(absolutePath, options);
|
||||
}
|
||||
|
||||
public async logs(id: number) {
|
||||
|
||||
+106
-44
@@ -2,7 +2,6 @@ import { spawn } from 'cross-spawn';
|
||||
import { Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import { Agent, request } from 'undici';
|
||||
import sum from 'lodash/sum';
|
||||
import path from 'path';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
@@ -37,6 +36,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 +77,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 +87,7 @@ export default class SystemService {
|
||||
});
|
||||
return { code: 200, data: { ...result, code } };
|
||||
} else {
|
||||
return { code: 400, message: '通知发送失败,请检查参数' };
|
||||
return { code: 400, message: t('通知发送失败,请检查参数') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +99,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 +178,7 @@ export default class SystemService {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateNodeMirror',
|
||||
message: 'update node mirror end',
|
||||
status: 'completed',
|
||||
});
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
@@ -214,33 +216,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 +232,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) => {
|
||||
@@ -353,6 +341,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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,9 +392,9 @@ export default class SystemService {
|
||||
notificationInfo,
|
||||
);
|
||||
if (isSuccess) {
|
||||
return { code: 200, message: '通知发送成功' };
|
||||
return { code: 200, message: t('通知发送成功') };
|
||||
} else {
|
||||
return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' };
|
||||
return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +412,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) {
|
||||
@@ -431,7 +428,7 @@ export default class SystemService {
|
||||
await killTask(_pid);
|
||||
return { code: 200 };
|
||||
} else {
|
||||
return { code: 400, message: '任务未找到' };
|
||||
return { code: 400, message: t('任务未找到') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +466,7 @@ export default class SystemService {
|
||||
query: {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
},
|
||||
) {
|
||||
const startTime = dayjs(query.startTime || undefined)
|
||||
@@ -483,8 +481,30 @@ export default class SystemService {
|
||||
.filter((x) => x.title.endsWith('.log'))
|
||||
.filter((x) => x.createTime >= startTime && x.createTime <= endTime);
|
||||
|
||||
const limit = Math.min(
|
||||
Math.max(Number(query.limit) || 1024 * 1024, 1),
|
||||
1024 * 1024,
|
||||
);
|
||||
const total = logs.reduce((size, log) => size + (log.size || 0), 0);
|
||||
let remaining = limit;
|
||||
const selected: Array<
|
||||
(typeof logs)[number] & { start: number; length: number }
|
||||
> = [];
|
||||
for (let index = logs.length - 1; index >= 0 && remaining > 0; index--) {
|
||||
const log = logs[index];
|
||||
const size = log.size || 0;
|
||||
const length = Math.min(size, remaining);
|
||||
if (length > 0) {
|
||||
selected.unshift({ ...log, start: size - length, length });
|
||||
remaining -= length;
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = selected.reduce((size, log) => size + log.length, 0);
|
||||
res.set({
|
||||
'Content-Length': sum(logs.map((x) => x.size)),
|
||||
'Content-Length': contentLength,
|
||||
'X-QL-Log-Total': total,
|
||||
'X-QL-Log-Truncated': total > contentLength ? 'true' : 'false',
|
||||
});
|
||||
(function sendFiles(res, fileNames) {
|
||||
if (fileNames.length === 0) {
|
||||
@@ -496,13 +516,17 @@ export default class SystemService {
|
||||
if (currentLog) {
|
||||
const currentFileStream = fs.createReadStream(
|
||||
path.join(config.systemLogPath, currentLog.title),
|
||||
{
|
||||
start: currentLog.start,
|
||||
end: currentLog.start + currentLog.length - 1,
|
||||
},
|
||||
);
|
||||
currentFileStream.on('end', () => {
|
||||
sendFiles(res, fileNames);
|
||||
});
|
||||
currentFileStream.pipe(res, { end: false });
|
||||
}
|
||||
})(res, logs);
|
||||
})(res, selected);
|
||||
}
|
||||
|
||||
public async deleteSystemLog() {
|
||||
@@ -526,39 +550,77 @@ 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: '参数错误' };
|
||||
return { code: 400, message: t('参数错误') };
|
||||
}
|
||||
try {
|
||||
const finalPath = path.join(config.dependenceCachePath, type);
|
||||
await fs.promises.rm(finalPath, { recursive: true });
|
||||
} catch (error) { }
|
||||
const finalPath = path.join(config.dependenceCachePath, type);
|
||||
await fs.promises.rm(finalPath, { recursive: true, force: true });
|
||||
await DependenceModel.update(
|
||||
{ status: DependenceStatus.installFailed },
|
||||
{
|
||||
where: {
|
||||
type:
|
||||
type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3,
|
||||
status: DependenceStatus.installed,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { code: 200 };
|
||||
}
|
||||
}
|
||||
|
||||
+225
-45
@@ -20,11 +20,19 @@ import ScheduleService from './schedule';
|
||||
import SockService from './sock';
|
||||
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';
|
||||
import { getClientIp, normalizeClientIp } from '../shared/clientIp';
|
||||
import { isDefaultAuthInfo } from '../shared/auth';
|
||||
import {
|
||||
hashPassword,
|
||||
isPasswordHash,
|
||||
verifyPassword,
|
||||
} from '../shared/password';
|
||||
import { serializeAuthMutation } from '../shared/authMutation';
|
||||
|
||||
@Service()
|
||||
export default class UserService {
|
||||
@@ -37,7 +45,15 @@ export default class UserService {
|
||||
private sockService: SockService,
|
||||
) {}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async login(
|
||||
payloads: { username: string; password: string },
|
||||
req: Request,
|
||||
): Promise<any> {
|
||||
return this.authenticate(payloads, req);
|
||||
}
|
||||
|
||||
private async authenticate(
|
||||
payloads: {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -47,7 +63,18 @@ export default class UserService {
|
||||
): Promise<any> {
|
||||
let { username, password } = payloads;
|
||||
const content = await this.getAuthInfo();
|
||||
if (isDefaultAuthInfo(content)) {
|
||||
return { code: 450, message: t('请先初始化') };
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const ip = 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(' ');
|
||||
}
|
||||
let {
|
||||
username: cUsername,
|
||||
password: cPassword,
|
||||
@@ -58,7 +85,27 @@ export default class UserService {
|
||||
twoFactorActivated,
|
||||
tokens = {},
|
||||
platform,
|
||||
blockedIps = [],
|
||||
} = content;
|
||||
|
||||
if (
|
||||
ip &&
|
||||
blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip)
|
||||
) {
|
||||
await this.insertDb({
|
||||
type: AuthDataType.loginLog,
|
||||
info: {
|
||||
timestamp,
|
||||
address,
|
||||
ip,
|
||||
platform: req.platform,
|
||||
status: LoginStatus.fail,
|
||||
},
|
||||
});
|
||||
this.getLoginLog();
|
||||
return { code: 403, message: t('该 IP 已被列入黑名单') };
|
||||
}
|
||||
|
||||
const retriesTime = Math.pow(3, retries) * 1000;
|
||||
if (retries > 2 && timestamp - lastlogon < retriesTime) {
|
||||
const waitTime = Math.ceil(
|
||||
@@ -66,19 +113,18 @@ export default class UserService {
|
||||
);
|
||||
return {
|
||||
code: 410,
|
||||
message: `失败次数过多,请${waitTime}秒后重试`,
|
||||
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||
data: waitTime,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
username === cUsername &&
|
||||
password === cPassword &&
|
||||
twoFactorActivated &&
|
||||
needTwoFactor
|
||||
) {
|
||||
const passwordMatches =
|
||||
username === cUsername && (await verifyPassword(password, cPassword));
|
||||
|
||||
if (passwordMatches && twoFactorActivated && needTwoFactor) {
|
||||
await this.updateAuthInfo(content, {
|
||||
isTwoFactorChecking: true,
|
||||
twoFactorExpiresAt: timestamp + 5 * 60 * 1000,
|
||||
});
|
||||
return {
|
||||
code: 420,
|
||||
@@ -86,15 +132,7 @@ export default class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
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(' ');
|
||||
}
|
||||
if (username === cUsername && password === cPassword) {
|
||||
if (passwordMatches) {
|
||||
const data = createRandomString(50, 100);
|
||||
const expiration = twoFactorActivated ? '60d' : '20d';
|
||||
let token = jwt.sign({ data }, config.jwt.secret, {
|
||||
@@ -117,6 +155,9 @@ export default class UserService {
|
||||
);
|
||||
|
||||
await this.updateAuthInfo(content, {
|
||||
password: isPasswordHash(cPassword)
|
||||
? cPassword
|
||||
: await hashPassword(password),
|
||||
token,
|
||||
tokens: updatedTokens,
|
||||
lastlogon: timestamp,
|
||||
@@ -125,12 +166,22 @@ export default class UserService {
|
||||
lastaddr: address,
|
||||
platform: req.platform,
|
||||
isTwoFactorChecking: false,
|
||||
twoFactorExpiresAt: 0,
|
||||
});
|
||||
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,
|
||||
@@ -163,10 +214,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,
|
||||
@@ -183,15 +243,16 @@ 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('错误的用户名密码,请重试') };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async logout(platform: string, tokenValue: string): Promise<any> {
|
||||
if (!platform || !tokenValue) {
|
||||
this.logger.warn('Invalid logout parameters - empty platform or token');
|
||||
@@ -245,11 +306,67 @@ export default class UserService {
|
||||
return [];
|
||||
}
|
||||
|
||||
public async getIpBlacklist(): Promise<string[]> {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter(
|
||||
Boolean,
|
||||
);
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async blockIp(ip: string): Promise<string[]> {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
const blockedIps = uniq([
|
||||
...(authInfo.blockedIps || []).map(normalizeClientIp),
|
||||
normalizeClientIp(ip),
|
||||
]).filter(Boolean);
|
||||
await this.updateAuthInfo(authInfo, { blockedIps });
|
||||
return blockedIps;
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async unblockIp(ip: string): Promise<string[]> {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
const normalizedIp = normalizeClientIp(ip);
|
||||
const blockedIps = (authInfo.blockedIps || [])
|
||||
.map(normalizeClientIp)
|
||||
.filter((blockedIp) => blockedIp && blockedIp !== normalizedIp);
|
||||
await this.updateAuthInfo(authInfo, { blockedIps });
|
||||
return blockedIps;
|
||||
}
|
||||
|
||||
private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
|
||||
const doc = await SystemModel.create({ ...payload }, { returning: true });
|
||||
return doc;
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async initializeUser({
|
||||
username,
|
||||
password,
|
||||
}: {
|
||||
username: string;
|
||||
password: string;
|
||||
}) {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
if (!isDefaultAuthInfo(authInfo)) {
|
||||
return { code: 450, message: t('未知错误') };
|
||||
}
|
||||
if (password === 'admin') {
|
||||
return { code: 400, message: t('密码不能设置为admin') };
|
||||
}
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
token: '',
|
||||
tokens: {},
|
||||
isTwoFactorChecking: false,
|
||||
twoFactorExpiresAt: 0,
|
||||
});
|
||||
return { code: 200, message: t('更新成功') };
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async updateUsernameAndPassword({
|
||||
username,
|
||||
password,
|
||||
@@ -258,27 +375,40 @@ 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: '更新成功' };
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
token: '',
|
||||
tokens: {},
|
||||
isTwoFactorChecking: false,
|
||||
twoFactorExpiresAt: 0,
|
||||
});
|
||||
return { code: 200, message: t('更新成功') };
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
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('更新成功') };
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async initTwoFactor() {
|
||||
const secret = authenticator.generateSecret();
|
||||
const authInfo = await this.getAuthInfo();
|
||||
if (authInfo.twoFactorActivated) {
|
||||
throw new Error(t('请先关闭两步验证'));
|
||||
}
|
||||
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
|
||||
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
|
||||
return { secret, url: otpauth };
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async activeTwoFactor(code: string) {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
const isValid = authenticator.verify({
|
||||
@@ -286,11 +416,18 @@ export default class UserService {
|
||||
secret: authInfo.twoFactorSecret,
|
||||
});
|
||||
if (isValid) {
|
||||
await this.updateAuthInfo(authInfo, { twoFactorActivated: true });
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
twoFactorActivated: true,
|
||||
token: '',
|
||||
tokens: {},
|
||||
isTwoFactorChecking: false,
|
||||
twoFactorExpiresAt: 0,
|
||||
});
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async twoFactorLogin(
|
||||
{
|
||||
username,
|
||||
@@ -301,17 +438,30 @@ export default class UserService {
|
||||
) {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
|
||||
if (!isTwoFactorChecking) {
|
||||
return { code: 450, message: '未知错误' };
|
||||
const now = Date.now();
|
||||
const retries = authInfo.retries || 0;
|
||||
if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) {
|
||||
return { code: 410, message: t('失败次数过多,请稍后重试') };
|
||||
}
|
||||
const isValid = authenticator.verify({
|
||||
token: code,
|
||||
secret: twoFactorSecret,
|
||||
});
|
||||
if (
|
||||
!isTwoFactorChecking ||
|
||||
!authInfo.twoFactorActivated ||
|
||||
!authInfo.twoFactorExpiresAt ||
|
||||
authInfo.twoFactorExpiresAt <= now
|
||||
) {
|
||||
return { code: 450, message: t('未知错误') };
|
||||
}
|
||||
const step = Math.floor(now / 30000);
|
||||
const isValid =
|
||||
username === authInfo.username &&
|
||||
(await verifyPassword(password, authInfo.password)) &&
|
||||
authInfo.lastTwoFactorStep !== step &&
|
||||
authenticator.verify({ token: code, secret: twoFactorSecret });
|
||||
if (isValid) {
|
||||
return this.login({ username, password }, req, false);
|
||||
await this.updateAuthInfo(authInfo, { lastTwoFactorStep: step });
|
||||
return this.authenticate({ username, password }, req, false);
|
||||
} else {
|
||||
const ip = requestIp.getClientIp(req) || '';
|
||||
const ip = getClientIp(req);
|
||||
const query = new IP2Region();
|
||||
const ipAddress = query.search(ip);
|
||||
let address = '';
|
||||
@@ -322,19 +472,27 @@ export default class UserService {
|
||||
.join(' ');
|
||||
}
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
retries: retries + 1,
|
||||
lastlogon: now,
|
||||
isTwoFactorChecking: retries + 1 < 5,
|
||||
lastip: ip,
|
||||
lastaddr: address,
|
||||
platform: req.platform,
|
||||
});
|
||||
return { code: 430, message: '验证失败' };
|
||||
return { code: 430, message: t('验证失败') };
|
||||
}
|
||||
}
|
||||
|
||||
public async deactiveTwoFactor() {
|
||||
@serializeAuthMutation
|
||||
public async deactivateTwoFactor() {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
twoFactorActivated: false,
|
||||
twoFactorSecret: '',
|
||||
token: '',
|
||||
tokens: {},
|
||||
isTwoFactorChecking: false,
|
||||
twoFactorExpiresAt: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -355,6 +513,9 @@ export default class UserService {
|
||||
type: AuthDataType.authConfig,
|
||||
info: result,
|
||||
});
|
||||
if (info.tokens && Object.keys(info.tokens).length === 0) {
|
||||
this.sockService.getClients().forEach((conn) => conn.close('401'));
|
||||
}
|
||||
}
|
||||
|
||||
public async getNotificationMode(): Promise<NotificationInfo> {
|
||||
@@ -388,8 +549,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({
|
||||
@@ -398,7 +559,7 @@ export default class UserService {
|
||||
});
|
||||
return { code: 200, data: { ...result, code } };
|
||||
} else {
|
||||
return { code: 400, message: '通知发送失败,请检查参数' };
|
||||
return { code: 400, message: t('通知发送失败,请检查参数') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,8 +657,12 @@ export default class UserService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@serializeAuthMutation
|
||||
public async resetAuthInfo(info: Partial<AuthInfo>) {
|
||||
const { retries, twoFactorActivated, password, username } = info;
|
||||
if (password === 'admin') {
|
||||
return { code: 400, message: t('密码不能设置为admin') };
|
||||
}
|
||||
const authInfo = await this.getAuthInfo();
|
||||
const payload = pickBy(
|
||||
{
|
||||
@@ -509,6 +674,21 @@ export default class UserService {
|
||||
(x) => !isNil(x),
|
||||
);
|
||||
|
||||
if (password !== undefined) {
|
||||
payload.password = await hashPassword(password);
|
||||
}
|
||||
if (
|
||||
password !== undefined ||
|
||||
username !== undefined ||
|
||||
twoFactorActivated !== undefined
|
||||
) {
|
||||
Object.assign(payload, {
|
||||
token: '',
|
||||
tokens: {},
|
||||
isTwoFactorChecking: false,
|
||||
twoFactorExpiresAt: 0,
|
||||
});
|
||||
}
|
||||
await this.updateAuthInfo(authInfo, payload);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -1,4 +1,9 @@
|
||||
import { AuthInfo, TokenInfo } from '../data/system';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
|
||||
return authInfo.username === 'admin' && authInfo.password === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a token exists in the authentication info.
|
||||
@@ -13,11 +18,21 @@ export function isValidToken(
|
||||
authInfo: AuthInfo | null | undefined,
|
||||
headerToken: string,
|
||||
platform: string,
|
||||
secret: string,
|
||||
): boolean {
|
||||
if (!authInfo || !headerToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const claims = jwt.verify(headerToken, secret, { algorithms: ['HS384'] });
|
||||
if (typeof claims === 'string' || typeof claims.exp !== 'number') {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { token = '', tokens = {} } = authInfo;
|
||||
|
||||
// Check legacy token field
|
||||
@@ -38,7 +53,12 @@ export function isValidToken(
|
||||
return headerToken === platformTokens;
|
||||
} else if (Array.isArray(platformTokens)) {
|
||||
// New format: array of TokenInfo objects
|
||||
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
|
||||
return platformTokens.some(
|
||||
(t: TokenInfo) =>
|
||||
t &&
|
||||
t.value === headerToken &&
|
||||
(t.expiration === undefined || t.expiration > Date.now() / 1000),
|
||||
);
|
||||
}
|
||||
|
||||
// Unexpected type - log warning and reject
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// All account mutations in the HTTP service share one queue. In particular,
|
||||
// a login that read old credentials must finish before a password reset revokes
|
||||
// its session, and two initialization requests must not both claim the account.
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export function serializeAuthMutation(
|
||||
_target: object,
|
||||
_key: string,
|
||||
descriptor: PropertyDescriptor,
|
||||
) {
|
||||
const method = descriptor.value;
|
||||
descriptor.value = function (this: unknown, ...args: unknown[]) {
|
||||
const result = pending.then(() => method.apply(this, args));
|
||||
pending = result.catch(() => undefined);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ChildProcessWithoutNullStreams } from 'child_process';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface ProcessResult {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
/** Attach immediately after spawn, before awaiting database or user callbacks. */
|
||||
export function observeChildProcess(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
callbacks: {
|
||||
onStart?: () => Promise<void>;
|
||||
onStdout?: (message: string) => Promise<void>;
|
||||
onStderr?: (message: string) => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
let failure: Error | undefined;
|
||||
const recordError = (error: unknown) => {
|
||||
failure ??= asError(error);
|
||||
};
|
||||
const spawned = new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
// Keep the listener through close: errors can occur after a successful spawn.
|
||||
child.on('error', (error) => {
|
||||
recordError(error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
const closed = new Promise<ProcessResult>((resolve) => {
|
||||
child.once('close', (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
const started = spawned.then(async () => {
|
||||
await callbacks.onStart?.();
|
||||
return child.pid;
|
||||
});
|
||||
// The caller can ask only for completion, without an unhandled start rejection.
|
||||
const ready = started.catch(recordError);
|
||||
|
||||
const consume = async (
|
||||
stream: Readable,
|
||||
callback?: (message: string) => Promise<void>,
|
||||
) => {
|
||||
// StringDecoder in Readable preserves UTF-8 characters split across chunks.
|
||||
stream.setEncoding('utf8');
|
||||
let callbackFailed = false;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
await ready;
|
||||
if (!callbackFailed && callback) {
|
||||
try {
|
||||
await callback(String(chunk));
|
||||
} catch (error) {
|
||||
recordError(error);
|
||||
callbackFailed = true;
|
||||
}
|
||||
}
|
||||
// Even if a log sink fails, drain the pipe so the child can finish.
|
||||
}
|
||||
} catch (error) {
|
||||
recordError(error);
|
||||
}
|
||||
};
|
||||
const output = Promise.all([
|
||||
consume(child.stdout, callbacks.onStdout),
|
||||
consume(child.stderr, callbacks.onStderr),
|
||||
]);
|
||||
const completed = Promise.all([closed, ready, output]).then(([result]) => ({
|
||||
...result,
|
||||
error: failure,
|
||||
}));
|
||||
return { started, completed };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
const IPV4_MAPPED_PREFIX = '::ffff:';
|
||||
|
||||
export function normalizeClientIp(value?: string): string {
|
||||
let ip = (value || '').trim().toLowerCase();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.startsWith('[') && ip.endsWith(']')) {
|
||||
ip = ip.slice(1, -1);
|
||||
}
|
||||
|
||||
const zoneIndex = ip.indexOf('%');
|
||||
if (zoneIndex !== -1) {
|
||||
ip = ip.slice(0, zoneIndex);
|
||||
}
|
||||
|
||||
if (ip.startsWith(IPV4_MAPPED_PREFIX)) {
|
||||
return ip.slice(IPV4_MAPPED_PREFIX.length);
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
export function getClientIp(req: Request): string {
|
||||
return normalizeClientIp(req.ip || req.socket.remoteAddress);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import cronParser from 'cron-parser-v4';
|
||||
import { ScheduleType } from '../interface/schedule';
|
||||
|
||||
// Keep validation aligned with node-schedule's locked cron-parser version.
|
||||
// cron-parser 5 accepts syntax (e.g. H) that the scheduler cannot execute.
|
||||
export function isValidCronSchedule(schedule: unknown): schedule is string {
|
||||
if (typeof schedule !== 'string' || !schedule.trim()) return false;
|
||||
try {
|
||||
return cronParser.parseExpression(schedule).hasNext();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getInvalidCronSchedules(cron: {
|
||||
schedule?: string;
|
||||
extra_schedules?: Array<{ schedule: string }>;
|
||||
}): unknown[] {
|
||||
if (
|
||||
cron.schedule?.startsWith(ScheduleType.ONCE) ||
|
||||
cron.schedule?.startsWith(ScheduleType.BOOT)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [cron.schedule, ...(cron.extra_schedules || []).map((x) => x.schedule)]
|
||||
.filter((schedule) => !isValidCronSchedule(schedule));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Keep error formatting independent of database, HTTP and gRPC dependencies.
|
||||
export function errStack(error: unknown): string {
|
||||
return error instanceof Error && error.stack ? error.stack : String(error);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function isWithin(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== '..' &&
|
||||
!path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve existing files and not-yet-created children without following an
|
||||
* existing symlink outside the root. Blacklisted directories cover descendants. */
|
||||
export function resolveFileAccess(
|
||||
root: string,
|
||||
parts: string[],
|
||||
blacklist: string[] = [],
|
||||
): string {
|
||||
if (parts.some((part) => typeof part !== 'string' || part.includes('\0'))) {
|
||||
return '';
|
||||
}
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const target = path.resolve(resolvedRoot, ...parts);
|
||||
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
|
||||
const isBlocked = (relative: string) =>
|
||||
relative.split(path.sep).some((part) => blacklist.includes(part));
|
||||
if (isBlocked(path.relative(resolvedRoot, target))) return '';
|
||||
try {
|
||||
const realRoot = fs.realpathSync(resolvedRoot);
|
||||
let existing = target;
|
||||
const missing: string[] = [];
|
||||
while (!fs.existsSync(existing)) {
|
||||
// existsSync is false for a dangling symlink; never treat one as absent.
|
||||
try {
|
||||
fs.lstatSync(existing);
|
||||
return '';
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') return '';
|
||||
}
|
||||
if (existing === resolvedRoot) return '';
|
||||
missing.unshift(path.basename(existing));
|
||||
existing = path.dirname(existing);
|
||||
}
|
||||
const realTarget = path.resolve(fs.realpathSync(existing), ...missing);
|
||||
if (
|
||||
!isWithin(realRoot, realTarget) ||
|
||||
isBlocked(path.relative(realRoot, realTarget))
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return target;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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 Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
|
||||
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
|
||||
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
|
||||
'该 IP 已被列入黑名单': 'This IP address has been blocked',
|
||||
'已加入 IP 黑名单': 'IP address added to the blacklist',
|
||||
'已移出 IP 黑名单': 'IP address removed from the blacklist',
|
||||
'青龙快讯': '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"':
|
||||
'Task ID %s: invalid cron expression "%s"',
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"':
|
||||
'Task ID %s (extra_schedule): invalid cron expression "%s"',
|
||||
},
|
||||
};
|
||||
|
||||
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,109 @@
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export const DEFAULT_LOG_CHUNK_BYTES = 256 * 1024;
|
||||
export const MAX_LOG_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
export interface LogReadOptions {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
tail?: boolean;
|
||||
}
|
||||
|
||||
export interface LogChunk {
|
||||
content: string;
|
||||
offset: number;
|
||||
nextOffset: number;
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function normalizeLimit(limit?: number) {
|
||||
if (!Number.isFinite(limit)) return DEFAULT_LOG_CHUNK_BYTES;
|
||||
return Math.min(Math.max(Math.trunc(limit!), 4), MAX_LOG_CHUNK_BYTES);
|
||||
}
|
||||
|
||||
function isUtf8ContinuationByte(byte: number) {
|
||||
return (byte & 0xc0) === 0x80;
|
||||
}
|
||||
|
||||
function completeUtf8End(buffer: Buffer, start: number, end: number) {
|
||||
if (end <= start) return start;
|
||||
|
||||
let sequenceStart = end - 1;
|
||||
while (
|
||||
sequenceStart > start &&
|
||||
isUtf8ContinuationByte(buffer[sequenceStart])
|
||||
) {
|
||||
sequenceStart--;
|
||||
}
|
||||
|
||||
const firstByte = buffer[sequenceStart];
|
||||
const expectedLength =
|
||||
firstByte < 0x80
|
||||
? 1
|
||||
: firstByte < 0xe0
|
||||
? 2
|
||||
: firstByte < 0xf0
|
||||
? 3
|
||||
: 4;
|
||||
return end - sequenceStart < expectedLength ? sequenceStart : end;
|
||||
}
|
||||
|
||||
export async function readLogChunk(
|
||||
filePath: string,
|
||||
options: LogReadOptions = {},
|
||||
): Promise<LogChunk> {
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(filePath, 'r');
|
||||
const { size: total } = await handle.stat();
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const requestedOffset = Number.isFinite(options.offset)
|
||||
? Math.trunc(options.offset!)
|
||||
: undefined;
|
||||
const requestedStart =
|
||||
options.tail || requestedOffset === undefined
|
||||
? Math.max(total - limit, 0)
|
||||
: Math.min(Math.max(requestedOffset, 0), total);
|
||||
const length = Math.min(limit + 3, total - requestedStart);
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
length,
|
||||
requestedStart,
|
||||
);
|
||||
let leadingBytes = 0;
|
||||
while (
|
||||
leadingBytes < bytesRead &&
|
||||
isUtf8ContinuationByte(buffer[leadingBytes])
|
||||
) {
|
||||
leadingBytes++;
|
||||
}
|
||||
const offset = requestedStart + leadingBytes;
|
||||
const candidateEnd = Math.min(leadingBytes + limit, bytesRead);
|
||||
const contentEnd = completeUtf8End(buffer, leadingBytes, candidateEnd);
|
||||
const nextOffset = requestedStart + contentEnd;
|
||||
|
||||
return {
|
||||
content: buffer.subarray(leadingBytes, contentEnd).toString('utf8'),
|
||||
offset,
|
||||
nextOffset,
|
||||
total,
|
||||
truncated: offset > 0 || nextOffset < total,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return {
|
||||
content: '',
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
total: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createWriteStream, WriteStream } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import config from '../config';
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
|
||||
/**
|
||||
* Manages write streams for log files to improve performance by avoiding repeated file opens
|
||||
@@ -8,83 +11,90 @@ 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;
|
||||
}
|
||||
private closingStreams = new Map<string, Promise<void>>();
|
||||
private closedStreams = new WeakSet<WriteStream>();
|
||||
private streamErrors = new Map<string, Error>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
constructor(private readonly logRoot = config.logPath) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
/** Register each write synchronously, so concurrent callers cannot lose the tail. */
|
||||
async write(filePath: string, data: string): Promise<void> {
|
||||
if (this.closingStreams.has(filePath)) {
|
||||
throw new Error(`Log stream is closing: ${filePath}`);
|
||||
}
|
||||
const previous = this.pendingWrites.get(filePath) || Promise.resolve();
|
||||
const pending = previous.then(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const failure = this.streamErrors.get(filePath);
|
||||
if (failure) return reject(failure);
|
||||
let stream = this.streams.get(filePath);
|
||||
if (!stream) {
|
||||
// Validate only when opening: subsequent chunks reuse the same descriptor.
|
||||
const root = path.resolve(this.logRoot);
|
||||
const target = path.resolve(filePath);
|
||||
if (
|
||||
!target.startsWith(root + path.sep) ||
|
||||
!resolveFileAccess(root, [target])
|
||||
) {
|
||||
return reject(new Error('Log path is outside the log directory'));
|
||||
}
|
||||
stream = createWriteStream(target, { flags: 'a' });
|
||||
this.streams.set(filePath, stream);
|
||||
const current = stream;
|
||||
stream.once('close', () => this.closedStreams.add(current));
|
||||
stream.on('error', (error) => {
|
||||
this.streamErrors.set(filePath, error);
|
||||
// EventEmitter's unobserved "error" event would crash the caller.
|
||||
if (this.listenerCount('error') > 0)
|
||||
this.emit('error', { filePath, error });
|
||||
});
|
||||
}
|
||||
stream.write(data, 'utf8', (error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
this.pendingWrites.set(filePath, pending);
|
||||
// Keep the tail until close, including failures; never reopen a failed log mid-run.
|
||||
return pending;
|
||||
}
|
||||
|
||||
const stream = this.streams.get(filePath);
|
||||
if (stream) {
|
||||
return new Promise<void>((resolve) => {
|
||||
stream.end(() => {
|
||||
this.streams.delete(filePath);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
async closeStream(filePath: string): Promise<void> {
|
||||
const closing = this.closingStreams.get(filePath);
|
||||
if (closing) return closing;
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
const result = (async () => {
|
||||
let failure: unknown;
|
||||
try {
|
||||
await pending;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
const stream = this.streams.get(filePath);
|
||||
try {
|
||||
if (stream && !this.closedStreams.has(stream)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.once('close', resolve);
|
||||
if (failure || stream.destroyed) stream.destroy();
|
||||
else stream.end();
|
||||
});
|
||||
}
|
||||
failure ||= this.streamErrors.get(filePath);
|
||||
if (failure) throw failure;
|
||||
} finally {
|
||||
this.streams.delete(filePath);
|
||||
this.pendingWrites.delete(filePath);
|
||||
this.streamErrors.delete(filePath);
|
||||
}
|
||||
})();
|
||||
this.closingStreams.set(filePath, result);
|
||||
try {
|
||||
await result;
|
||||
} finally {
|
||||
this.closingStreams.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +102,11 @@ export class LogStreamManager extends EventEmitter {
|
||||
* Close all open streams
|
||||
*/
|
||||
async closeAll(): Promise<void> {
|
||||
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
|
||||
const paths = new Set([
|
||||
...this.streams.keys(),
|
||||
...this.pendingWrites.keys(),
|
||||
]);
|
||||
const closePromises = Array.from(paths).map((filePath) =>
|
||||
this.closeStream(filePath),
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
|
||||
+30
-7
@@ -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.grpcPort}`,
|
||||
credentials.createInsecure(),
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const deriveKey = promisify(scrypt);
|
||||
const HASH_PREFIX = 'scrypt$';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const key = (await deriveKey(password, salt, 64)) as Buffer;
|
||||
return `${HASH_PREFIX}${salt}$${key.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function isPasswordHash(password: string): boolean {
|
||||
return /^scrypt\$[a-f0-9]{32}\$[a-f0-9]{128}$/.test(password);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
if (!isPasswordHash(stored)) {
|
||||
// Existing installations migrate after a successful password check.
|
||||
const input = Buffer.from(password);
|
||||
const expected = Buffer.from(stored);
|
||||
return input.length === expected.length && timingSafeEqual(input, expected);
|
||||
}
|
||||
const [, salt, hash] = stored.split('$');
|
||||
const key = (await deriveKey(password, salt, 64)) as Buffer;
|
||||
return timingSafeEqual(key, Buffer.from(hash, 'hex'));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs/promises';
|
||||
import { Dirent } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const MAX_RETENTION_DAYS = 3650;
|
||||
export const DEPENDENCE_CACHE_TYPES = ['node', 'python3'] as const;
|
||||
|
||||
export type DependenceCacheType = (typeof DEPENDENCE_CACHE_TYPES)[number];
|
||||
|
||||
export interface RetentionPolicy {
|
||||
runningInstanceRetentionDays: number;
|
||||
cronStatRetentionDays: number;
|
||||
}
|
||||
|
||||
export function normalizeRetentionDays(value: unknown) {
|
||||
const days = Number(value);
|
||||
if (!Number.isFinite(days)) return 0;
|
||||
return Math.min(Math.max(Math.trunc(days), 0), MAX_RETENTION_DAYS);
|
||||
}
|
||||
|
||||
export function normalizeRetentionPolicy(
|
||||
policy: Partial<RetentionPolicy>,
|
||||
): RetentionPolicy {
|
||||
return {
|
||||
runningInstanceRetentionDays: normalizeRetentionDays(
|
||||
policy.runningInstanceRetentionDays,
|
||||
),
|
||||
cronStatRetentionDays: normalizeRetentionDays(policy.cronStatRetentionDays),
|
||||
};
|
||||
}
|
||||
|
||||
export function isDependenceCacheType(
|
||||
value: string,
|
||||
): value is DependenceCacheType {
|
||||
return DEPENDENCE_CACHE_TYPES.includes(value as DependenceCacheType);
|
||||
}
|
||||
|
||||
export async function getDirectorySize(rootPath: string): Promise<number> {
|
||||
const pending = [rootPath];
|
||||
let total = 0;
|
||||
|
||||
while (pending.length > 0) {
|
||||
const currentPath = pending.pop()!;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') continue;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
const entryPath = path.join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
total += (await fs.stat(entryPath)).size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
+46
-27
@@ -4,10 +4,13 @@ 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';
|
||||
import { observeChildProcess, asError } from './childProcess';
|
||||
|
||||
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
return taskLimit.runWithCronLimit(cron, () => {
|
||||
return new Promise(async (resolve: any) => {
|
||||
return taskLimit.runWithCronLimit(cron, async () => {
|
||||
try {
|
||||
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
|
||||
try {
|
||||
const existingCron = await CrontabModel.findOne({
|
||||
@@ -28,7 +31,18 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
Logger.info(
|
||||
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
|
||||
);
|
||||
await killTask(existingCron.pid);
|
||||
await killTask(existingCron.pid, true);
|
||||
// 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 },
|
||||
@@ -39,6 +53,7 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
Logger.error(
|
||||
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
@@ -49,33 +64,37 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
);
|
||||
const cp = spawn(cmd, { shell: '/bin/bash' });
|
||||
|
||||
cp.stderr.on('data', (data) => {
|
||||
Logger.info(
|
||||
'[schedule][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
cmd,
|
||||
data.toString(),
|
||||
);
|
||||
const { completed } = observeChildProcess(cp, {
|
||||
onStderr: async (message) => {
|
||||
Logger.info(
|
||||
'[schedule][任务标准错误] 命令: %s, 信息: %s',
|
||||
cmd,
|
||||
message,
|
||||
);
|
||||
},
|
||||
});
|
||||
cp.on('error', (err) => {
|
||||
const result = await completed;
|
||||
if (result.error) {
|
||||
Logger.error(
|
||||
'[schedule][创建任务失败] 命令: %s, 错误信息: %j',
|
||||
'[schedule][执行任务失败] 命令: %s, 错误: %s',
|
||||
cmd,
|
||||
err,
|
||||
result.error.message,
|
||||
);
|
||||
});
|
||||
|
||||
cp.on('exit', async (code) => {
|
||||
taskLimit.removeQueuedCron(cron.id);
|
||||
Logger.info(
|
||||
'[schedule][执行任务结束] 参数: %s, 退出码: %j',
|
||||
JSON.stringify({
|
||||
...cron,
|
||||
command: cmd,
|
||||
}),
|
||||
code,
|
||||
);
|
||||
resolve({ ...cron, command: cmd, pid: cp.pid, code });
|
||||
});
|
||||
});
|
||||
}
|
||||
Logger.info(
|
||||
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
cron.id,
|
||||
result.code,
|
||||
);
|
||||
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
'[schedule][创建任务失败] 命令: %s, 错误: %s',
|
||||
cmd,
|
||||
asError(error).message,
|
||||
);
|
||||
} finally {
|
||||
taskLimit.removeQueuedCron(cron.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import lockfile from 'proper-lockfile';
|
||||
import config from '../config';
|
||||
|
||||
// HTTP and gRPC both mutate cron definitions. Hold one shared lock from the
|
||||
// initial DB read/write through scheduler registration (including rollback).
|
||||
// Recovery takes the same lock before reading its replacement snapshot.
|
||||
export async function withSchedulerMutation<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
let release: () => Promise<void>;
|
||||
try {
|
||||
// proper-lockfile indexes held locks by target, not lockfilePath. Keep
|
||||
// this identity separate from nested writeFileWithLock(crontabFile).
|
||||
release = await lockfile.lock(`${config.crontabFile}.scheduler`, {
|
||||
realpath: false,
|
||||
lockfilePath: `${config.crontabFile}.scheduler.lock`,
|
||||
stale: 30000,
|
||||
update: 10000,
|
||||
retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
||||
});
|
||||
} catch (cause) {
|
||||
throw Object.assign(
|
||||
new Error('Scheduler configuration is busy', { cause }),
|
||||
{
|
||||
status: 503,
|
||||
},
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export function schedulerRegistrationError(message: string, cause: any): Error {
|
||||
return Object.assign(new Error(message, { cause }), {
|
||||
status: cause?.status === 503 ? 503 : 500,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Recovery is single-flight and retried only while unavailable, never at idle.
|
||||
export class SchedulerReadiness {
|
||||
private ready = false;
|
||||
private generation = 0;
|
||||
private restore?: () => Promise<void>;
|
||||
private pending?: Promise<boolean>;
|
||||
private retry?: NodeJS.Timeout;
|
||||
|
||||
constructor(private probe: () => Promise<void>, private retryMs = 1000) {}
|
||||
|
||||
configure(restore: () => Promise<void>) {
|
||||
this.restore = restore;
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.ready = false;
|
||||
this.generation++;
|
||||
void this.recover();
|
||||
}
|
||||
|
||||
recover(): Promise<boolean> {
|
||||
if (this.pending) return this.pending;
|
||||
if (!this.restore) return Promise.resolve(false);
|
||||
clearTimeout(this.retry);
|
||||
const generation = this.generation;
|
||||
this.ready = false;
|
||||
this.pending = (async () => {
|
||||
try {
|
||||
await this.probe();
|
||||
await this.restore!();
|
||||
await this.probe();
|
||||
this.ready = generation === this.generation;
|
||||
} catch {
|
||||
this.ready = false;
|
||||
}
|
||||
return this.ready;
|
||||
})().finally(() => {
|
||||
this.pending = undefined;
|
||||
if (!this.ready) {
|
||||
this.retry = setTimeout(() => void this.recover(), this.retryMs);
|
||||
this.retry.unref();
|
||||
}
|
||||
});
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
async check(): Promise<boolean> {
|
||||
if (!this.ready) return false;
|
||||
const generation = this.generation;
|
||||
try {
|
||||
await this.probe();
|
||||
return this.ready && generation === this.generation;
|
||||
} catch {
|
||||
this.invalidate();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureReady(timeoutMs = 2000): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const available = await Promise.race([
|
||||
(async () => (await this.check()) || (await this.recover()))(),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (!available) {
|
||||
throw Object.assign(new Error('Scheduler is recovering; try again later'), {
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { QueryTypes, Sequelize } from 'sequelize';
|
||||
|
||||
// Append new entries; IDs are persisted and must not be renumbered or reused.
|
||||
const columns = [
|
||||
{
|
||||
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' },
|
||||
{ table: 'Crontabs', column: 'queued_token', type: 'VARCHAR(255)' },
|
||||
];
|
||||
|
||||
export async function migrateSchema(database: Sequelize): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
await database.query(
|
||||
'CREATE TABLE IF NOT EXISTS "SchemaMigrations" ("id" TEXT PRIMARY KEY, "applied_at" TEXT NOT NULL)',
|
||||
{ transaction },
|
||||
);
|
||||
const applied = await database.query<{ id: string }>(
|
||||
'SELECT "id" FROM "SchemaMigrations"',
|
||||
{ type: QueryTypes.SELECT, transaction },
|
||||
);
|
||||
const appliedIds = new Set(applied.map(({ id }) => id));
|
||||
for (const { table, column, type } of columns) {
|
||||
const id = `add-${table}-${column}`;
|
||||
const fields = await database.query<{ name: string }>(
|
||||
`PRAGMA table_info("${table}")`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
if (fields.length === 0)
|
||||
throw new Error(`Migration table is missing: ${table}`);
|
||||
if (!fields.some((field) => field.name === column)) {
|
||||
// table/column/type come only from the static migration manifest above.
|
||||
await database.query(
|
||||
`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`,
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
if (!appliedIds.has(id)) {
|
||||
await database.query(
|
||||
'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (:id, :appliedAt)',
|
||||
{
|
||||
replacements: { id, appliedAt: new Date().toISOString() },
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import path from 'path';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
import { brotliCompress, brotliDecompress, constants } from 'zlib';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
import { getUniqueLockPath } from './utils';
|
||||
|
||||
const compress = promisify(brotliCompress);
|
||||
const decompress = promisify(brotliDecompress);
|
||||
const hash = (content: string) =>
|
||||
createHash('sha256').update(content).digest('hex');
|
||||
|
||||
export const SCRIPT_HISTORY_LIMITS = {
|
||||
versions: 20,
|
||||
fileBytes: 1024 * 1024,
|
||||
unpackedBytes: 8 * 1024 * 1024,
|
||||
archiveBytes: 2 * 1024 * 1024,
|
||||
totalBytes: 32 * 1024 * 1024,
|
||||
files: 1000,
|
||||
};
|
||||
|
||||
type Source = 'baseline' | 'save' | 'restore' | 'external';
|
||||
interface Version {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
source: Source;
|
||||
hash: string;
|
||||
content: string;
|
||||
}
|
||||
interface Archive {
|
||||
format: 1;
|
||||
path: string;
|
||||
versions: Version[];
|
||||
head?: { hash: string; createdAt: string; source: Source };
|
||||
}
|
||||
|
||||
export class ScriptHistoryError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class HistoryUnavailableError extends ScriptHistoryError {
|
||||
constructor(public currentHash: string) {
|
||||
super(413, '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本');
|
||||
}
|
||||
}
|
||||
interface SaveOptions {
|
||||
skipHistory?: boolean;
|
||||
expectedHash?: string;
|
||||
}
|
||||
|
||||
/** Lazy, bounded storage: no watcher, database, daemon, or in-memory cache.
|
||||
* Full snapshots share a Brotli window, so similar revisions compress together
|
||||
* without a patch chain. All mutations use the existing script lock and a
|
||||
* cross-process archive lock. History is durable before overwriting the script. */
|
||||
export class ScriptHistory {
|
||||
constructor(
|
||||
private scriptRoot: string,
|
||||
private historyRoot: string,
|
||||
private blacklist: string[] = [],
|
||||
private limits = SCRIPT_HISTORY_LIMITS,
|
||||
) {}
|
||||
|
||||
private async target(directory: string, filename: string) {
|
||||
const candidate = resolveFileAccess(
|
||||
this.scriptRoot,
|
||||
[directory, filename],
|
||||
this.blacklist,
|
||||
);
|
||||
if (!candidate) throw new ScriptHistoryError(403, '暂无权限');
|
||||
try {
|
||||
const file = await fs.realpath(candidate);
|
||||
const root = await fs.realpath(this.scriptRoot);
|
||||
const relative = path.relative(root, file);
|
||||
const key = hash(relative);
|
||||
return {
|
||||
file,
|
||||
relative,
|
||||
archive: path.join(this.historyRoot, key + '.br'),
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT')
|
||||
throw new ScriptHistoryError(404, '脚本不存在');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async readText(file: string) {
|
||||
const handle = await fs.open(file, 'r');
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) throw new ScriptHistoryError(400, '请选择脚本文件');
|
||||
if (stat.size > this.limits.fileBytes)
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
// Bounded read even if an external process grows the file after stat.
|
||||
const bytes = Buffer.alloc(this.limits.fileBytes + 1);
|
||||
let size = 0;
|
||||
while (size < bytes.length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
size,
|
||||
bytes.length - size,
|
||||
null,
|
||||
);
|
||||
if (!bytesRead) break;
|
||||
size += bytesRead;
|
||||
}
|
||||
const buffer = bytes.subarray(0, size);
|
||||
const content = buffer.toString('utf8');
|
||||
if (
|
||||
size > this.limits.fileBytes ||
|
||||
content.includes('\0') ||
|
||||
!Buffer.from(content).equals(buffer)
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async readArchive(file: string, relative: string): Promise<Archive> {
|
||||
try {
|
||||
const stat = await fs.lstat(file);
|
||||
if (!stat.isFile() || stat.size > this.limits.archiveBytes)
|
||||
throw new Error('Invalid archive');
|
||||
const bytes = await decompress(await fs.readFile(file), {
|
||||
maxOutputLength: this.limits.unpackedBytes,
|
||||
});
|
||||
const archive: Archive = JSON.parse(bytes.toString('utf8'));
|
||||
if (
|
||||
archive.format !== 1 ||
|
||||
archive.path !== relative ||
|
||||
!Array.isArray(archive.versions) ||
|
||||
archive.versions.length > this.limits.versions ||
|
||||
archive.versions.some(
|
||||
(v) =>
|
||||
typeof v.content !== 'string' ||
|
||||
typeof v.id !== 'string' ||
|
||||
typeof v.createdAt !== 'string' ||
|
||||
!['baseline', 'save', 'restore', 'external'].includes(v.source) ||
|
||||
Buffer.byteLength(v.content) > this.limits.fileBytes ||
|
||||
hash(v.content) !== v.hash,
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid archive');
|
||||
}
|
||||
return archive;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT')
|
||||
return { format: 1, path: relative, versions: [] };
|
||||
throw new ScriptHistoryError(500, '历史版本读取失败,请检查历史文件');
|
||||
}
|
||||
}
|
||||
|
||||
async list(directory: string, filename: string) {
|
||||
const target = await this.target(directory, filename);
|
||||
const [current, archive] = await Promise.all([
|
||||
this.readText(target.file),
|
||||
this.readArchive(target.archive, target.relative),
|
||||
]);
|
||||
const currentHash = hash(current);
|
||||
const versions = archive.versions
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(({ content, ...v }) => ({
|
||||
...v,
|
||||
size: Buffer.byteLength(content),
|
||||
identical: v.hash === currentHash,
|
||||
}));
|
||||
return { versions, currentHash, limit: this.limits.versions };
|
||||
}
|
||||
|
||||
async detail(directory: string, filename: string, id: string) {
|
||||
const target = await this.target(directory, filename);
|
||||
const [current, archive] = await Promise.all([
|
||||
this.readText(target.file),
|
||||
this.readArchive(target.archive, target.relative),
|
||||
]);
|
||||
const version = archive.versions.find((v) => v.id === id);
|
||||
if (!version) throw new ScriptHistoryError(404, '历史版本不存在或已清理');
|
||||
return { version, current, currentHash: hash(current) };
|
||||
}
|
||||
|
||||
async save(
|
||||
directory: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
options: SaveOptions = {},
|
||||
) {
|
||||
return this.mutate(directory, filename, { content, ...options });
|
||||
}
|
||||
|
||||
async restore(
|
||||
directory: string,
|
||||
filename: string,
|
||||
id: string,
|
||||
expectedHash: string,
|
||||
) {
|
||||
return this.mutate(directory, filename, { id, expectedHash });
|
||||
}
|
||||
|
||||
private async mutate(
|
||||
directory: string,
|
||||
filename: string,
|
||||
change:
|
||||
| ({ content: string } & SaveOptions)
|
||||
| { id: string; expectedHash: string },
|
||||
) {
|
||||
const target = await this.target(directory, filename);
|
||||
const releaseFile = await lock(target.file, {
|
||||
lockfilePath: getUniqueLockPath(target.file),
|
||||
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||
});
|
||||
try {
|
||||
let current: string;
|
||||
try {
|
||||
current = await this.readText(target.file);
|
||||
if (
|
||||
'content' in change &&
|
||||
(Buffer.byteLength(change.content) > this.limits.fileBytes ||
|
||||
change.content.includes('\0'))
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!('content' in change) ||
|
||||
!(error instanceof ScriptHistoryError) ||
|
||||
error.status !== 413
|
||||
)
|
||||
throw error;
|
||||
// Hash oversized or non-UTF-8 files without reading them all into memory.
|
||||
const digest = createHash('sha256');
|
||||
for await (const chunk of createReadStream(target.file))
|
||||
digest.update(chunk);
|
||||
const currentHash = digest.digest('hex');
|
||||
if (!change.skipHistory) throw new HistoryUnavailableError(currentHash);
|
||||
if (change.expectedHash !== currentHash)
|
||||
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
|
||||
await this.preparedWrite(target.file, change.content, (commit) =>
|
||||
commit(),
|
||||
);
|
||||
return {
|
||||
content: change.content,
|
||||
changed: true,
|
||||
historyRecorded: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
('id' in change || change.skipHistory) &&
|
||||
hash(current) !== change.expectedHash
|
||||
) {
|
||||
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
|
||||
}
|
||||
if ('content' in change && change.content === current)
|
||||
return { content: current, changed: false };
|
||||
await fs.mkdir(this.historyRoot, { recursive: true, mode: 0o700 });
|
||||
const releaseHistory = await lock(this.historyRoot, {
|
||||
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||
});
|
||||
try {
|
||||
const archive = await this.readArchive(target.archive, target.relative);
|
||||
const restored =
|
||||
'id' in change
|
||||
? archive.versions.find((v) => v.id === change.id)
|
||||
: undefined;
|
||||
if ('id' in change && !restored)
|
||||
throw new ScriptHistoryError(404, '历史版本不存在或已清理');
|
||||
const content =
|
||||
'content' in change ? change.content : restored!.content;
|
||||
if (content === current) return { content, changed: false };
|
||||
if (
|
||||
Buffer.byteLength(content) > this.limits.fileBytes ||
|
||||
content.includes('\0')
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
const currentHash = hash(current);
|
||||
if (
|
||||
archive.versions[archive.versions.length - 1]?.hash !== currentHash
|
||||
) {
|
||||
const head =
|
||||
archive.head?.hash === currentHash ? archive.head : undefined;
|
||||
archive.versions.push({
|
||||
id: randomUUID(),
|
||||
createdAt: head?.createdAt || new Date().toISOString(),
|
||||
source:
|
||||
head?.source ||
|
||||
(archive.versions.length ? 'external' : 'baseline'),
|
||||
hash: currentHash,
|
||||
content: current,
|
||||
});
|
||||
}
|
||||
// Only prior contents are stored. Head metadata labels the next snapshot,
|
||||
// but is used only if its hash matches the file. A failed file write can
|
||||
// never introduce a phantom content version or lose the old snapshot.
|
||||
archive.head = {
|
||||
hash: hash(content),
|
||||
createdAt: new Date().toISOString(),
|
||||
source: 'id' in change ? 'restore' : 'save',
|
||||
};
|
||||
const packed = await this.pack(archive);
|
||||
const cleanupPending = await this.preparedWrite(
|
||||
target.file,
|
||||
content,
|
||||
(commit) => this.store(target.archive, packed, commit),
|
||||
);
|
||||
return {
|
||||
content,
|
||||
changed: true,
|
||||
historyRecorded: true,
|
||||
cleanupPending,
|
||||
};
|
||||
} finally {
|
||||
await releaseHistory();
|
||||
}
|
||||
} finally {
|
||||
await releaseFile();
|
||||
}
|
||||
}
|
||||
|
||||
private async pack(archive: Archive) {
|
||||
archive.versions = archive.versions.slice(-this.limits.versions);
|
||||
while (true) {
|
||||
const serialized = Buffer.from(JSON.stringify(archive));
|
||||
if (serialized.length <= this.limits.unpackedBytes) {
|
||||
const compressed = await compress(serialized, {
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: 4,
|
||||
[constants.BROTLI_PARAM_LGWIN]: 22,
|
||||
},
|
||||
});
|
||||
if (compressed.length <= this.limits.archiveBytes) return compressed;
|
||||
}
|
||||
if (archive.versions.length <= 1)
|
||||
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
|
||||
archive.versions.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage the script before publishing history. A failed write/fsync never
|
||||
* truncates the original. Preserve the existing file's ownership and mode. */
|
||||
private async preparedWrite<T>(
|
||||
file: string,
|
||||
content: string,
|
||||
run: (commit: () => Promise<void>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const stat = await fs.stat(file);
|
||||
const temporary = path.join(
|
||||
path.dirname(file),
|
||||
`.ql-save-${randomUUID()}.tmp`,
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(temporary, content, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
if (
|
||||
typeof process.getuid === 'function' &&
|
||||
typeof process.getgid === 'function' &&
|
||||
(stat.uid !== process.getuid() || stat.gid !== process.getgid())
|
||||
) {
|
||||
await fs.chown(temporary, stat.uid, stat.gid);
|
||||
}
|
||||
await fs.chmod(temporary, stat.mode & 0o7777);
|
||||
const handle = await fs.open(temporary, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return await run(() => fs.rename(temporary, file));
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async store(
|
||||
file: string,
|
||||
content: Buffer,
|
||||
commit: () => Promise<void>,
|
||||
) {
|
||||
const entries = [];
|
||||
for (const name of await fs.readdir(this.historyRoot)) {
|
||||
if (!/^[a-f0-9]{64}\.br$/.test(name)) continue;
|
||||
const fullPath = path.join(this.historyRoot, name);
|
||||
if (fullPath === file) continue;
|
||||
const stat = await fs.lstat(fullPath);
|
||||
entries.push({ file: fullPath, size: stat.size, time: stat.mtimeMs });
|
||||
}
|
||||
entries.sort((a, b) => a.time - b.time);
|
||||
let total = entries.reduce((sum, v) => sum + v.size, content.length);
|
||||
const victims: string[] = [];
|
||||
while (
|
||||
entries.length &&
|
||||
(total > this.limits.totalBytes || entries.length >= this.limits.files)
|
||||
) {
|
||||
const oldest = entries.shift()!;
|
||||
victims.push(oldest.file);
|
||||
total -= oldest.size;
|
||||
}
|
||||
if (total > this.limits.totalBytes)
|
||||
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
|
||||
const previous = await fs.readFile(file).catch((error) => {
|
||||
if (error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
const temporary = path.join(this.historyRoot, '.pending.tmp');
|
||||
const publish = async (bytes: Buffer) => {
|
||||
await fs.unlink(temporary).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
||||
const handle = await fs.open(temporary, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fs.rename(temporary, file);
|
||||
};
|
||||
try {
|
||||
await publish(content);
|
||||
try {
|
||||
await commit();
|
||||
} catch (error) {
|
||||
// The original script is still intact. Restore its exact history before
|
||||
// returning an error; no unrelated archive has been removed yet.
|
||||
if (previous) await publish(previous);
|
||||
else await fs.unlink(file);
|
||||
throw error;
|
||||
}
|
||||
// Quota eviction is post-commit maintenance. Report cleanup failures as a
|
||||
// warning, not a failed save (which would encourage a misleading retry).
|
||||
let cleanupPending = false;
|
||||
for (const victim of victims) {
|
||||
try {
|
||||
await fs.unlink(victim);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') cleanupPending = true;
|
||||
}
|
||||
}
|
||||
return cleanupPending;
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { fork } from 'child_process';
|
||||
|
||||
// Keep one-off startup dependencies out of the long-lived cluster primary.
|
||||
export function runStartupProcess(entrypoint: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = fork(entrypoint, [], { stdio: 'inherit' });
|
||||
let interrupted: NodeJS.Signals | undefined;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const terminate = (signal: NodeJS.Signals) => {
|
||||
if (interrupted) return;
|
||||
interrupted = signal;
|
||||
child.kill(signal);
|
||||
killTimer = setTimeout(() => child.kill('SIGKILL'), 8000);
|
||||
killTimer.unref();
|
||||
};
|
||||
const onSigterm = () => terminate('SIGTERM');
|
||||
const onSigint = () => terminate('SIGINT');
|
||||
const onExit = () => child.kill('SIGKILL');
|
||||
const cleanup = () => {
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
process.removeListener('SIGTERM', onSigterm);
|
||||
process.removeListener('SIGINT', onSigint);
|
||||
process.removeListener('exit', onExit);
|
||||
};
|
||||
|
||||
process.once('SIGTERM', onSigterm);
|
||||
process.once('SIGINT', onSigint);
|
||||
process.once('exit', onExit);
|
||||
child.once('error', (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
cleanup();
|
||||
if (code === 0 && !interrupted) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Startup process failed (${interrupted || signal || code})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user