mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
fix(dependencies): quote and validate Python source installs
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -2,152 +2,237 @@ name: Build And Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
paths:
|
||||
- "**"
|
||||
- "!*.md"
|
||||
# CLI is verified separately; its shared Bash upgrade engine ships in images.
|
||||
- "!cli/**"
|
||||
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
|
||||
- "!.github/workflows/cli-package.yml"
|
||||
branches:
|
||||
- "master"
|
||||
- "develop"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
static_sync_only:
|
||||
description: Synchronize an existing static snapshot to GitLab and verify Gitee
|
||||
type: boolean
|
||||
default: false
|
||||
static_ref:
|
||||
description: Static branch or version name, for example v2.22.0
|
||||
type: string
|
||||
default: ''
|
||||
static_ref_type:
|
||||
description: Synchronize a branch, or a version branch and Git tag
|
||||
type: choice
|
||||
options: [branch, tag]
|
||||
default: branch
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
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
|
||||
validate:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
uses: ./.github/workflows/validate.yml
|
||||
|
||||
code_gitee:
|
||||
code_gitlab:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
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"
|
||||
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"
|
||||
|
||||
build-static:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
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 }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
mkdir -p tmp
|
||||
cd ./tmp
|
||||
cp -rf ../static/* ./
|
||||
git init -b ${GITHUB_BRANCH} && git add .
|
||||
git config --local user.name 'github-actions[bot]'
|
||||
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
|
||||
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
||||
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
|
||||
static_gitlab:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: static-gitlab-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
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
|
||||
env:
|
||||
STATIC_BRANCH: ${{ github.ref_name }}
|
||||
STATIC_REF_TYPE: ${{ github.ref_type }}
|
||||
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
|
||||
|
||||
static_gitee:
|
||||
verify-gitee-mirrors:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 14
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
repository: [qinglong, qinglong-static]
|
||||
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
|
||||
# Gitee Pull mirrors and GitHub push webhooks own synchronization.
|
||||
# Verification runs alongside Docker builds and needs no Gitee credentials.
|
||||
- name: Wait for Gitee Pull mirror
|
||||
if: ${{ matrix.repository == 'qinglong' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong
|
||||
MIRROR_REPOSITORY: ${{ matrix.repository }}
|
||||
MIRROR_REF: ${{ github.ref_name }}
|
||||
MIRROR_REF_TYPE: ${{ github.ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }}
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false'
|
||||
run: |
|
||||
bash scripts/wait-for-mirror.sh "https://github.com/whyour/$MIRROR_REPOSITORY.git" "https://gitee.com/whyour/$MIRROR_REPOSITORY.git" "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
- name: Notify and wait for Gitee static mirror
|
||||
if: ${{ matrix.repository == 'qinglong-static' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||
MIRROR_REF: ${{ github.ref_name }}
|
||||
MIRROR_REF_TYPE: ${{ github.ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
|
||||
run: |
|
||||
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
|
||||
build:
|
||||
sync-existing-static:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mirror: [gitlab]
|
||||
concurrency:
|
||||
group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Synchronize existing static snapshot
|
||||
env:
|
||||
STATIC_REF: ${{ inputs.static_ref }}
|
||||
STATIC_REF_TYPE: ${{ inputs.static_ref_type }}
|
||||
MIRROR_HOST: ${{ matrix.mirror }}.com
|
||||
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE"
|
||||
|
||||
verify-existing-static-gitee:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Wait for existing Gitee static snapshot
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||
MIRROR_REF: ${{ inputs.static_ref }}
|
||||
MIRROR_REF_TYPE: ${{ inputs.static_ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
|
||||
run: |
|
||||
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
|
||||
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 +240,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 +254,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,161 @@
|
||||
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 stable / develop prerelease)
|
||||
type: boolean
|
||||
default: false
|
||||
prerelease_version:
|
||||
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Keep validation runs separate from releases; never interrupt a running release.
|
||||
# Master push releases share the same group as manually requested releases.
|
||||
group: >-
|
||||
cli-package-${{ github.workflow }}-${{ github.ref }}-${{
|
||||
((github.event_name == 'workflow_dispatch' && inputs.publish) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) && 'publish' || 'check' }}
|
||||
cancel-in-progress: >-
|
||||
${{ github.ref != 'refs/heads/master' &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.publish) }}
|
||||
|
||||
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
|
||||
- name: Set requested prerelease version before verification
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.prerelease_version != ''
|
||||
working-directory: cli
|
||||
env:
|
||||
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$GITHUB_REF" != refs/heads/develop ||
|
||||
! "$PRERELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
|
||||
echo '::error::Prereleases require develop and an explicit alpha/beta/rc version.'
|
||||
exit 1
|
||||
fi
|
||||
npm version "$PRERELEASE_VERSION" --no-git-tag-version --ignore-scripts
|
||||
- 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))) ||
|
||||
(github.ref == 'refs/heads/develop' && github.event_name == 'workflow_dispatch' &&
|
||||
inputs.publish && inputs.prerelease_version != ''))
|
||||
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
|
||||
env:
|
||||
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||
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")' <<< "$metadata")
|
||||
if [[ "$GITHUB_REF" == refs/heads/develop ]]; then
|
||||
if [[ "$version" != "$PRERELEASE_VERSION" ||
|
||||
! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
|
||||
echo '::error::Archive version does not match the requested prerelease.'
|
||||
exit 1
|
||||
fi
|
||||
tag="${BASH_REMATCH[1]}"
|
||||
elif [[ "$GITHUB_REF" == refs/heads/master && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
tag=latest
|
||||
else
|
||||
echo '::error::Refusing to publish this branch/version combination.'
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
if [[ "$tag" != latest ]]; then
|
||||
echo '::error::Prerelease version already exists; choose a new version to test this build.'
|
||||
exit 1
|
||||
fi
|
||||
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 "$tag" --ignore-scripts --registry=https://registry.npmjs.org
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Release readiness
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/release-readiness.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-readiness-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
regression:
|
||||
uses: ./.github/workflows/validate.yml
|
||||
|
||||
static:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- 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: pnpm build:front && pnpm build:back && pnpm build:info
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release-readiness-static
|
||||
path: static/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
|
||||
images:
|
||||
needs: [regression, static]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: alpine
|
||||
file: docker/Dockerfile
|
||||
cache: alpine
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
- image: debian
|
||||
file: docker/Dockerfile.debian
|
||||
cache: debian
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
- image: alpine-python3.10
|
||||
file: docker/Dockerfile.310
|
||||
cache: alpine-python3.10
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
- image: debian-python3.10
|
||||
file: docker/Dockerfile.debian310
|
||||
cache: debian-python3.10
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Record source
|
||||
id: source
|
||||
env:
|
||||
SOURCE_URL: ${{ github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}
|
||||
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: release-readiness-static
|
||||
path: static/
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: false
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Build every release architecture
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
build-args: |
|
||||
QL_URL=${{ steps.source.outputs.url }}
|
||||
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.cache }}
|
||||
- name: Load native image for runtime verification
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
QL_URL=${{ steps.source.outputs.url }}
|
||||
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||
tags: qinglong-readiness:${{ matrix.image }}
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
- name: Verify native production runtime without external networking
|
||||
run: |
|
||||
docker run --rm --network none --entrypoint node \
|
||||
qinglong-readiness:${{ matrix.image }} \
|
||||
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.3.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||
- name: Export native Debian image for isolated dev validation
|
||||
if: matrix.image == 'debian'
|
||||
run: |
|
||||
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
|
||||
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
|
||||
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: matrix.image == 'debian'
|
||||
with:
|
||||
name: release-readiness-debian-image
|
||||
path: release-readiness-debian-image.*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+20
-20
@@ -4,17 +4,18 @@ import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { join } 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 +72,29 @@ 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);
|
||||
if (name.startsWith('data/scripts/')) {
|
||||
path = join(config.rootPath, name);
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
await writeFileWithLock(path, content);
|
||||
res.send({ code: 200, message: '保存成功' });
|
||||
const normalized = resolveFileAccess(
|
||||
config.configPath,
|
||||
[name],
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+65
-16
@@ -1,23 +1,20 @@
|
||||
import { Joi, celebrate } from 'celebrate';
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import multer from 'multer';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { safeJSONParse } from '../config/util';
|
||||
import { t } from '../shared/i18n';
|
||||
import EnvService from '../services/env';
|
||||
const route = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.scriptPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, file.originalname);
|
||||
},
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||
});
|
||||
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 +41,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 +50,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 +68,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 +229,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'),
|
||||
@@ -237,24 +274,36 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
|
||||
const parseContent = safeJSONParse(fileContent);
|
||||
let parseContent: unknown;
|
||||
try {
|
||||
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
|
||||
} catch {
|
||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||
}
|
||||
const data = Array.isArray(parseContent)
|
||||
? parseContent
|
||||
: [parseContent];
|
||||
if (data.every((x) => x.name && x.value)) {
|
||||
if (
|
||||
data.length > 0 &&
|
||||
data.every((x) =>
|
||||
x && typeof x.name === 'string' &&
|
||||
/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(x.name) &&
|
||||
typeof x.value === 'string' && x.value.length > 0,
|
||||
)
|
||||
) {
|
||||
const result = await envService.create(
|
||||
data.map((x) => ({
|
||||
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;
|
||||
};
|
||||
|
||||
+42
-22
@@ -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)) {
|
||||
(req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
code: 410,
|
||||
message: t('接口已下线,请使用 /logs/detail 接口'),
|
||||
});
|
||||
}
|
||||
const content = await getFileContentByName(finalPath);
|
||||
res.send({ code: 200, data: content });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
|
||||
import OpenService from '../services/open';
|
||||
import { Logger } from 'winston';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -100,6 +101,7 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/auth/token',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
|
||||
celebrate({
|
||||
query: {
|
||||
client_id: Joi.string().required(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+249
-37
@@ -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';
|
||||
@@ -5,18 +7,68 @@ import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import path, { join, parse } from 'path';
|
||||
import path, { dirname, join, parse, resolve, sep } 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],
|
||||
// Panel configuration secrets must not restrict user script filenames.
|
||||
path.resolve(x) === path.resolve(config.configPath)
|
||||
? config.blackFileList
|
||||
: [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function existingWritableRealPath(targetPath: string): Promise<string> {
|
||||
if (!isPathAllowed(targetPath)) {
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
const resolved = resolve(targetPath);
|
||||
for (const writableRoot of config.writePathList) {
|
||||
const root = resolve(writableRoot);
|
||||
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
||||
// Keep an explicit normalized boundary check at the filesystem operation.
|
||||
if (!resolved.startsWith(rootPrefix)) continue;
|
||||
const realPath = await fs.realpath(resolved);
|
||||
const realRoot = await fs.realpath(root);
|
||||
const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
|
||||
if (
|
||||
!realPath.startsWith(realRootPrefix) ||
|
||||
!resolveFileAccess(
|
||||
realRoot,
|
||||
[realPath],
|
||||
root === resolve(config.configPath) ? config.blackFileList : [],
|
||||
)
|
||||
) {
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
return realPath;
|
||||
}
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
|
||||
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 +96,11 @@ export default (app: Router) => {
|
||||
'package-lock.json',
|
||||
];
|
||||
if (req.query.path) {
|
||||
if (
|
||||
!resolveFileAccess(config.scriptPath, [req.query.path as string])
|
||||
) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
result = await readDir(
|
||||
req.query.path as string,
|
||||
config.scriptPath,
|
||||
@@ -71,7 +128,8 @@ export default (app: Router) => {
|
||||
logger.error('🔥 error: %o', e);
|
||||
return next(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/detail',
|
||||
@@ -85,7 +143,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 +153,108 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
route.get(
|
||||
'/:file',
|
||||
celebrate({
|
||||
params: Joi.object({
|
||||
file: Joi.string().required(),
|
||||
}),
|
||||
query: Joi.object({
|
||||
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(
|
||||
'/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({
|
||||
query: Joi.object({
|
||||
...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 +283,87 @@ 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, '')}`,
|
||||
);
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
const filePath = join(path, `${filename.replace(/\//g, '')}`);
|
||||
const originFilePath = join(path, originFilename);
|
||||
const filePath = join(path, filename);
|
||||
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
// Check the canonical parent directly at the directory creation boundary.
|
||||
const parentPath = resolve(dirname(filePath));
|
||||
let parentCreated = false;
|
||||
for (const writableRoot of config.writePathList) {
|
||||
const root = resolve(writableRoot);
|
||||
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
||||
if (parentPath === root) {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
} else if (parentPath.startsWith(rootPrefix)) {
|
||||
await fs.mkdir(parentPath, { recursive: true });
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
parentCreated = true;
|
||||
break;
|
||||
}
|
||||
if (!parentCreated) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
const fileExists = await fileExist(filePath);
|
||||
if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) {
|
||||
let removeSource = filename !== originFilename;
|
||||
let backupSource = '';
|
||||
if (removeSource) {
|
||||
const [originRealPath, targetRealPath] = await Promise.all([
|
||||
existingWritableRealPath(originFilePath),
|
||||
existingWritableRealPath(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;
|
||||
backupSource = originRealPath;
|
||||
}
|
||||
// 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(
|
||||
backupSource,
|
||||
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 +376,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 +388,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 +407,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 +445,7 @@ export default (app: Router) => {
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await rmPath(filePath);
|
||||
@@ -284,7 +478,7 @@ export default (app: Router) => {
|
||||
if (!filePath) {
|
||||
return res.send({
|
||||
code: 403,
|
||||
message: '暂无权限',
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
return res.download(filePath, filename, (err) => {
|
||||
@@ -316,6 +510,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,12 +541,24 @@ export default (app: Router) => {
|
||||
}
|
||||
const { name, ext } = parse(filename);
|
||||
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
|
||||
const logPath = join(config.logPath, path, `${name}.swap`);
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
const logPath = resolveFileAccess(config.logPath, [
|
||||
path,
|
||||
`${name}.swap`,
|
||||
]);
|
||||
if (!logPath) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const result = await scriptService.stopScript(filePath, pid);
|
||||
setTimeout(() => {
|
||||
rmPath(logPath);
|
||||
const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (cleanupPath) {
|
||||
void rmPath(cleanupPath);
|
||||
}
|
||||
}, 3000);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
@@ -379,6 +588,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) {
|
||||
|
||||
+19
-12
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import SubscriptionService from '../services/subscription';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -58,10 +58,7 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.create(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
@@ -148,13 +145,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);
|
||||
}
|
||||
@@ -190,11 +201,7 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
typeof req.body.schedule === 'object' ||
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.update(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
|
||||
+67
-19
@@ -6,15 +6,14 @@ import config from '../config';
|
||||
import SystemService from '../services/system';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import UserService from '../services/user';
|
||||
import {
|
||||
getUniqPath,
|
||||
handleLogPath,
|
||||
parseVersion,
|
||||
promiseExec,
|
||||
} from '../config/util';
|
||||
import { t } from '../shared/i18n';
|
||||
import { isDefaultAuthInfo } from '../shared/auth';
|
||||
import { getUniqPath, handleLogPath, parseVersion } from '../config/util';
|
||||
import { findCronId } from '../shared/cronCommand';
|
||||
import dayjs from 'dayjs';
|
||||
import multer from 'multer';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
const route = Router();
|
||||
const storage = multer.diskStorage({
|
||||
@@ -38,14 +37,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: {
|
||||
@@ -254,6 +246,7 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/command-run',
|
||||
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
command: Joi.string().required(),
|
||||
@@ -263,8 +256,13 @@ export default (app: Router) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const command = req.body.command;
|
||||
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
|
||||
let id = await promiseExec(idStr);
|
||||
const crontab = await fs
|
||||
.readFile(config.crontabFile, 'utf8')
|
||||
.catch((e) => {
|
||||
if (e.code === 'ENOENT') return '';
|
||||
throw e;
|
||||
});
|
||||
const id = findCronId(crontab, command);
|
||||
const uniqPath = await getUniqPath(command, id);
|
||||
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
||||
const logPath = `${uniqPath}/${logTime}.log`;
|
||||
@@ -278,8 +276,13 @@ export default (app: Router) => {
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
try {
|
||||
await logStreamManager.closeStream(
|
||||
await handleLogPath(logPath),
|
||||
);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
res.write(message);
|
||||
@@ -355,6 +358,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 +374,7 @@ export default (app: Router) => {
|
||||
req.query as {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -400,8 +409,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 +438,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,198 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
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(args: string[]): string {
|
||||
return execFileSync('openssl', args, {
|
||||
stdio: 'pipe',
|
||||
timeout: 30000,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
||||
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
||||
await fs.mkdir(certDir, { recursive: true, mode: 0o700 });
|
||||
// Atomically reserve a private workspace for keys, CSRs and OpenSSL side files.
|
||||
const workspace = await fs.mkdtemp(path.join(certDir, '.generate-'));
|
||||
const caKeyTmp = path.join(workspace, 'ca.key');
|
||||
const caCertTmp = path.join(workspace, 'ca.crt');
|
||||
const serverKeyTmp = path.join(workspace, 'server.key');
|
||||
const serverCsrTmp = path.join(workspace, 'server.csr');
|
||||
const serverExtTmp = path.join(workspace, 'server.ext');
|
||||
const clientKeyTmp = path.join(workspace, 'client.key');
|
||||
const clientCsrTmp = path.join(workspace, 'client.csr');
|
||||
const clientExtTmp = path.join(workspace, 'client.ext');
|
||||
|
||||
const cleanup = async () => {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
try {
|
||||
run(['genrsa', '-out', caKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-x509',
|
||||
'-days',
|
||||
'3650',
|
||||
'-key',
|
||||
caKeyTmp,
|
||||
'-out',
|
||||
caCertTmp,
|
||||
'-subj',
|
||||
'/CN=qinglong-ca/O=qinglong/C=CN',
|
||||
]);
|
||||
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
|
||||
const caCert = await fs.readFile(caCertTmp, 'utf-8');
|
||||
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
|
||||
|
||||
run(['genrsa', '-out', serverKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-key',
|
||||
serverKeyTmp,
|
||||
'-out',
|
||||
serverCsrTmp,
|
||||
'-subj',
|
||||
'/CN=grpc-server',
|
||||
]);
|
||||
await fs.writeFile(
|
||||
serverExtTmp,
|
||||
'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n',
|
||||
);
|
||||
const serverCert = run([
|
||||
'x509',
|
||||
'-req',
|
||||
'-days',
|
||||
'3650',
|
||||
'-in',
|
||||
serverCsrTmp,
|
||||
'-CA',
|
||||
caCertTmp,
|
||||
'-CAkey',
|
||||
caKeyTmp,
|
||||
'-CAserial',
|
||||
path.join(workspace, 'ca.srl'),
|
||||
'-CAcreateserial',
|
||||
'-extfile',
|
||||
serverExtTmp,
|
||||
]);
|
||||
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
|
||||
|
||||
run(['genrsa', '-out', clientKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-key',
|
||||
clientKeyTmp,
|
||||
'-out',
|
||||
clientCsrTmp,
|
||||
'-subj',
|
||||
'/CN=grpc-client',
|
||||
]);
|
||||
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
|
||||
const clientCert = run([
|
||||
'x509',
|
||||
'-req',
|
||||
'-days',
|
||||
'3650',
|
||||
'-in',
|
||||
clientCsrTmp,
|
||||
'-CA',
|
||||
caCertTmp,
|
||||
'-CAkey',
|
||||
caKeyTmp,
|
||||
'-CAserial',
|
||||
path.join(workspace, 'ca.srl'),
|
||||
'-CAcreateserial',
|
||||
'-extfile',
|
||||
clientExtTmp,
|
||||
]);
|
||||
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
||||
|
||||
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
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,19 +67,19 @@ 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('');
|
||||
|
||||
+23
-26
@@ -1,5 +1,7 @@
|
||||
import { Subscription } from '../data/subscription';
|
||||
import isNil from 'lodash/isNil';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
|
||||
|
||||
export function formatUrl(doc: Subscription) {
|
||||
let url = doc.url;
|
||||
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
|
||||
if (doc.type === 'private-repo') {
|
||||
if (doc.pull_type === 'ssh-key') {
|
||||
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
|
||||
url = doc.url!.replace(host, doc.alias);
|
||||
url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
|
||||
} else {
|
||||
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
|
||||
const { username, password } = doc.pull_option as any;
|
||||
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
|
||||
}
|
||||
|
||||
export function formatCommand(doc: Subscription, url?: string) {
|
||||
let command = `SUB_ID=${doc.id} ql `;
|
||||
let _url = url || formatUrl(doc).url;
|
||||
const {
|
||||
type,
|
||||
whitelist,
|
||||
blacklist,
|
||||
dependences,
|
||||
branch,
|
||||
extensions,
|
||||
proxy,
|
||||
autoAddCron,
|
||||
autoDelCron,
|
||||
} = doc;
|
||||
if (type === 'file') {
|
||||
command += `raw "${_url}" "${proxy || ''}" "${
|
||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
||||
} else {
|
||||
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
|
||||
dependences || ''
|
||||
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
|
||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
||||
}
|
||||
return command;
|
||||
const args =
|
||||
doc.type === 'file'
|
||||
? ['raw', url || formatUrl(doc).url, doc.proxy || '']
|
||||
: [
|
||||
'repo',
|
||||
url || formatUrl(doc).url,
|
||||
doc.whitelist || '',
|
||||
doc.blacklist || '',
|
||||
doc.dependences || '',
|
||||
doc.branch || '',
|
||||
doc.extensions || '',
|
||||
doc.proxy || '',
|
||||
];
|
||||
args.push(
|
||||
String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
|
||||
String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
|
||||
);
|
||||
return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
|
||||
.map((arg) => shellQuote(arg!))
|
||||
.join(' ')}`;
|
||||
}
|
||||
|
||||
+432
-110
@@ -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,41 @@ 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 { shellQuote } from '../shared/shellQuote';
|
||||
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 +146,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 +217,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 +323,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 +490,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 +647,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 +661,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 +671,14 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -558,9 +706,11 @@ export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||
// For Python dependencies installed from GitHub or requirements files,
|
||||
// we can't reliably check if they're installed, so skip the check
|
||||
if (type === DependenceTypes.python3) {
|
||||
if (isGitHubUrl(trimmedName) ||
|
||||
if (
|
||||
isGitHubUrl(trimmedName) ||
|
||||
isRequirementsFile(trimmedName) ||
|
||||
isPyprojectToml(trimmedName)) {
|
||||
isPyprojectToml(trimmedName)
|
||||
) {
|
||||
// Return a command that will always indicate not installed
|
||||
// This ensures GitHub URLs and requirements files are always installed
|
||||
return 'echo ""';
|
||||
@@ -569,81 +719,253 @@ export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||
|
||||
const baseCommands = {
|
||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
|
||||
[DependenceTypes.python3]: `
|
||||
python3 -c "exec('''
|
||||
name='${trimmedName}'
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
print(version(name))
|
||||
except:
|
||||
import importlib.util as u
|
||||
import importlib.metadata as m
|
||||
spec=u.find_spec(name)
|
||||
print(name if spec else '')
|
||||
''')"`,
|
||||
[DependenceTypes.linux]: `apk info -es ${trimmedName}`,
|
||||
[DependenceTypes.python3]: `python3 -c ${shellQuote(
|
||||
`from importlib.metadata import version\nimport importlib.util as u\nname=${JSON.stringify(
|
||||
trimmedName,
|
||||
)}\ntry:\n print(version(name))\nexcept Exception:\n print(name if u.find_spec(name) else '')`,
|
||||
)}`,
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${trimmedName}`
|
||||
: maybeSudo(`dpkg-query -s ${trimmedName}`),
|
||||
};
|
||||
|
||||
return baseCommands[type];
|
||||
}
|
||||
|
||||
export function getInstallCommand(type: DependenceTypes, name: string): string {
|
||||
if (type === DependenceTypes.python3) {
|
||||
const prefix = PYTHON_INSTALL_DIR
|
||||
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
|
||||
: '';
|
||||
const pip = `pip3 install --disable-pip-version-check --root-user-action=ignore${prefix}`;
|
||||
name = name.trim();
|
||||
if (isRequirementsFile(name) || isPyprojectToml(name)) {
|
||||
const file = [config.scriptPath, config.repoPath]
|
||||
.filter(Boolean)
|
||||
.map((root) => resolveFileAccess(root, [name]))
|
||||
.find(Boolean);
|
||||
if (!file)
|
||||
throw new Error(
|
||||
'Python dependency file must be inside scripts or repo',
|
||||
);
|
||||
return isRequirementsFile(name)
|
||||
? `${pip} -r ${shellQuote(file)}`
|
||||
: `${pip} ${shellQuote(path.dirname(file))}`;
|
||||
}
|
||||
return `${pip} -- ${shellQuote(name)}`;
|
||||
}
|
||||
const baseCommands = {
|
||||
[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];
|
||||
|
||||
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
|
||||
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// Handle different installation methods for Python
|
||||
if (type === DependenceTypes.python3) {
|
||||
// Check if it's a GitHub URL (support both git+ and direct URLs)
|
||||
if (isGitHubUrl(trimmedName)) {
|
||||
return `${command} ${trimmedName}`;
|
||||
}
|
||||
// Check if it's a requirements file path
|
||||
if (isRequirementsFile(trimmedName)) {
|
||||
return `${command} -r ${trimmedName}`;
|
||||
}
|
||||
// Check if it's a pyproject.toml file
|
||||
if (isPyprojectToml(trimmedName)) {
|
||||
// For pyproject.toml, install from the directory containing it
|
||||
const pathMatch = trimmedName.match(/^(.+)\/pyproject\.toml$/);
|
||||
if (pathMatch) {
|
||||
// Has a path prefix, use the directory
|
||||
return `${command} ${pathMatch[1]}`;
|
||||
} else {
|
||||
// Just "pyproject.toml", install current directory
|
||||
return `${command} .`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${command} ${trimmedName}`;
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
}
|
||||
|
||||
export function getUninstallCommand(
|
||||
type: DependenceTypes,
|
||||
name: string,
|
||||
): string {
|
||||
if (type === DependenceTypes.python3) {
|
||||
name = name.trim();
|
||||
if (isRequirementsFile(name)) {
|
||||
const file = [config.scriptPath, config.repoPath]
|
||||
.filter(Boolean)
|
||||
.map((root) => resolveFileAccess(root, [name]))
|
||||
.find(Boolean);
|
||||
if (!file)
|
||||
throw new Error(
|
||||
'Python dependency file must be inside scripts or repo',
|
||||
);
|
||||
return `pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y -r ${shellQuote(
|
||||
file,
|
||||
)}`;
|
||||
}
|
||||
if (isPyprojectToml(name) || isGitHubUrl(name)) {
|
||||
// A source URL/project can install several distributions and has no safe
|
||||
// inverse. Removing its panel entry leaves installed packages intact.
|
||||
return `printf '%s\\n' ${shellQuote(
|
||||
'Remove installed Python distributions separately by package name.',
|
||||
)}`;
|
||||
}
|
||||
return `pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y -- ${shellQuote(
|
||||
name,
|
||||
)}`;
|
||||
}
|
||||
const baseCommands = {
|
||||
[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()}`;
|
||||
}
|
||||
|
||||
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 ' : '';
|
||||
// APT otherwise returns zero when index downloads fail with transient errors.
|
||||
const aptUpdate = `${S}apt-get update --error-on=any`;
|
||||
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}) && ${aptUpdate}`;
|
||||
} 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} && ${aptUpdate}`;
|
||||
}
|
||||
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}) && ${aptUpdate}`;
|
||||
} 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} && ${aptUpdate}`;
|
||||
}
|
||||
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}`,
|
||||
);
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
// Column already exists or other error, continue
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info('✌️ DB loaded');
|
||||
} catch (error) {
|
||||
Logger.error('✌️ DB load failed', error);
|
||||
Logger.error('[boot] DB load failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
+45
-25
@@ -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',
|
||||
@@ -59,17 +90,6 @@ async function linkCommandToDir(commandDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
+86
-29
@@ -8,14 +8,40 @@ 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 uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||
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 }) => {
|
||||
app.set('trust proxy', 'loopback');
|
||||
// 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', resolveTrustProxy());
|
||||
app.use(cors());
|
||||
|
||||
// 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) {
|
||||
@@ -23,7 +49,26 @@ export default ({ app }: { app: Application }) => {
|
||||
}
|
||||
|
||||
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 }));
|
||||
@@ -36,7 +81,7 @@ export default ({ app }: { app: Application }) => {
|
||||
secret: config.jwt.secret,
|
||||
algorithms: ['HS384'],
|
||||
}).unless({
|
||||
path: [...config.apiWhiteList, /^\/(?!api\/).*/],
|
||||
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -51,31 +96,44 @@ export default ({ app }: { app: Application }) => {
|
||||
});
|
||||
|
||||
app.use(async (req: Request, res, next) => {
|
||||
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
|
||||
const pathLower = req.path.toLowerCase();
|
||||
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const headerToken = getToken(req);
|
||||
if (req.path.startsWith('/open/')) {
|
||||
if (pathLower.startsWith('/open/')) {
|
||||
const apps = await shareStore.getApps();
|
||||
const doc = apps?.filter((x) =>
|
||||
x.tokens?.find((y) => y.value === headerToken),
|
||||
)?.[0];
|
||||
if (doc && doc.tokens && doc.tokens.length > 0) {
|
||||
const currentToken = doc.tokens.find((x) => x.value === headerToken);
|
||||
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
|
||||
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
|
||||
const key = keyMatch && keyMatch[1];
|
||||
|
||||
if (!doc.scopes.includes(key as AppScope)) {
|
||||
const err = new UnauthorizedError('credentials_bad_scheme', {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (
|
||||
doc.scopes.includes(key as any) &&
|
||||
currentToken &&
|
||||
currentToken.expiration >= Math.round(Date.now() / 1000)
|
||||
!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 &&
|
||||
@@ -85,36 +143,34 @@ 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);
|
||||
});
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
|
||||
const pathLower = req.path.toLowerCase();
|
||||
if (
|
||||
![
|
||||
'/api/user/init',
|
||||
'/api/user/notification/init',
|
||||
'/open/user/init',
|
||||
'/open/user/notification/init',
|
||||
].includes(pathLower)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
const authInfo =
|
||||
(await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']);
|
||||
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();
|
||||
}
|
||||
@@ -135,6 +191,7 @@ export default ({ app }: { app: Application }) => {
|
||||
});
|
||||
|
||||
app.use(errors());
|
||||
app.use(uniqueConstraintError);
|
||||
|
||||
app.use(
|
||||
(
|
||||
|
||||
+35
-21
@@ -13,11 +13,13 @@ import { AuthDataType, SystemModel } from '../data/system';
|
||||
import SystemService from '../services/system';
|
||||
import UserService from '../services/user';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { createRandomString, fileExist, safeJSONParse } from '../config/util';
|
||||
import { fileExist, isDemoEnv, safeJSONParse } from '../config/util';
|
||||
import OpenService from '../services/open';
|
||||
import { shareStore } from '../shared/store';
|
||||
import Logger from './logger';
|
||||
import { AppModel } from '../data/open';
|
||||
import cronClient from '../schedule/client';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { setLang, systemLang } from '../shared/i18n';
|
||||
|
||||
export default async () => {
|
||||
const cronService = Container.get(CronService);
|
||||
@@ -28,19 +30,7 @@ export default async () => {
|
||||
const openService = Container.get(OpenService);
|
||||
|
||||
// 初始化增加系统配置
|
||||
let systemApp = (
|
||||
await AppModel.findOne({
|
||||
where: { name: 'system' },
|
||||
})
|
||||
)?.get({ plain: true });
|
||||
if (!systemApp) {
|
||||
systemApp = await AppModel.create({
|
||||
name: 'system',
|
||||
scopes: ['crons', 'system'],
|
||||
client_id: createRandomString(12, 12),
|
||||
client_secret: createRandomString(24, 24),
|
||||
});
|
||||
}
|
||||
await openService.initializeSystemApp();
|
||||
const [systemConfig] = await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.systemConfig },
|
||||
});
|
||||
@@ -50,7 +40,7 @@ export default async () => {
|
||||
const [authConfig] = await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.authConfig },
|
||||
});
|
||||
if (!authConfig?.info) {
|
||||
if (!authConfig?.info || isDemoEnv()) {
|
||||
let authInfo = {
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
@@ -134,6 +124,16 @@ export default async () => {
|
||||
// 初始化更新所有任务状态为空闲
|
||||
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
|
||||
|
||||
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
||||
await RunningInstanceModel.update(
|
||||
{
|
||||
status: InstanceStatus.stopped,
|
||||
finished_at: Math.floor(Date.now() / 1000),
|
||||
// The previous process's exit code cannot be recovered after a restart.
|
||||
},
|
||||
{ where: { status: InstanceStatus.running } },
|
||||
);
|
||||
|
||||
// 初始化时执行一次所有的 ql repo 任务
|
||||
CrontabModel.findAll({
|
||||
where: {
|
||||
@@ -210,14 +210,28 @@ 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(async () => {
|
||||
await cronService.autosave_crontab(true);
|
||||
await systemService.restoreCronConcurrency();
|
||||
});
|
||||
await cronClient.readiness.recover();
|
||||
|
||||
await envService.set_envs();
|
||||
|
||||
const authInfo = await userService.getAuthInfo();
|
||||
const apps = await openService.findApps();
|
||||
await shareStore.updateAuthInfo(authInfo);
|
||||
if (apps?.length) {
|
||||
await shareStore.updateApps(apps);
|
||||
}
|
||||
await openService.refreshApps();
|
||||
};
|
||||
|
||||
@@ -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,9 @@ import SshKeyService from '../services/sshKey';
|
||||
import config from '../config';
|
||||
import { fileExist } from '../config/util';
|
||||
import { join } from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import Logger from './logger';
|
||||
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
|
||||
|
||||
export default async () => {
|
||||
const systemService = Container.get(SystemService);
|
||||
@@ -23,9 +26,10 @@ export default async () => {
|
||||
if (await fileExist(tokenFile)) {
|
||||
tokenCommand = `node ${tokenFile}`;
|
||||
}
|
||||
tokenCommand += ' --renew';
|
||||
const cron = {
|
||||
id: NaN,
|
||||
name: '生成token',
|
||||
name: t('生成token'),
|
||||
command: tokenCommand,
|
||||
runOrigin: 'system',
|
||||
} as ScheduleTaskType;
|
||||
@@ -44,7 +48,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,
|
||||
};
|
||||
@@ -69,6 +73,12 @@ export default async () => {
|
||||
await subscriptionService.setSshConfig();
|
||||
const subs = await subscriptionService.list();
|
||||
for (const sub of subs) {
|
||||
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
|
||||
try {
|
||||
const doc = sub.get({ plain: true });
|
||||
assertSubscriptionAlias(doc.alias);
|
||||
await subscriptionService.handleTask(doc, !sub.is_disabled);
|
||||
} catch (error) {
|
||||
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
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 (requestMetrics.length > 1000) {
|
||||
requestMetrics.shift();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { UniqueConstraintError } from 'sequelize';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
// Handle the database constraint itself so concurrent writes are covered too.
|
||||
const uniqueConstraintError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
if (!(err instanceof UniqueConstraintError)) return next(err);
|
||||
// Constraint details can contain environment values or application credentials.
|
||||
res
|
||||
.status(409)
|
||||
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
|
||||
};
|
||||
|
||||
export default uniqueConstraintError;
|
||||
@@ -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 {
|
||||
|
||||
+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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.ql.cron;
|
||||
service Cron {
|
||||
rpc addCron(AddCronRequest) returns (AddCronResponse);
|
||||
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
|
||||
rpc setConcurrency(SetConcurrencyRequest) returns (AddCronResponse);
|
||||
}
|
||||
|
||||
message ISchedule { string schedule = 1; }
|
||||
@@ -17,10 +18,16 @@ message ICron {
|
||||
string name = 5;
|
||||
}
|
||||
|
||||
message AddCronRequest { repeated ICron crons = 1; }
|
||||
message AddCronRequest {
|
||||
repeated ICron crons = 1;
|
||||
bool replace = 2;
|
||||
}
|
||||
|
||||
message AddCronResponse {}
|
||||
|
||||
message DeleteCronRequest { repeated string ids = 1; }
|
||||
|
||||
message DeleteCronResponse {}
|
||||
|
||||
// Zero restores the default limit.
|
||||
message SetConcurrencyRequest { int32 concurrency = 1; }
|
||||
|
||||
+110
-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 v5.28.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 {
|
||||
@@ -47,6 +48,11 @@ export interface DeleteCronRequest {
|
||||
export interface DeleteCronResponse {
|
||||
}
|
||||
|
||||
/** Zero restores the default limit. */
|
||||
export interface SetConcurrencyRequest {
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
function createBaseISchedule(): ISchedule {
|
||||
return { schedule: "" };
|
||||
}
|
||||
@@ -232,7 +238,7 @@ export const ICron: MessageFns<ICron> = {
|
||||
};
|
||||
|
||||
function createBaseAddCronRequest(): AddCronRequest {
|
||||
return { crons: [] };
|
||||
return { crons: [], replace: false };
|
||||
}
|
||||
|
||||
export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
@@ -240,6 +246,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 +267,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 +285,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 +296,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 +308,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;
|
||||
},
|
||||
};
|
||||
@@ -433,6 +457,64 @@ export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseSetConcurrencyRequest(): SetConcurrencyRequest {
|
||||
return { concurrency: 0 };
|
||||
}
|
||||
|
||||
export const SetConcurrencyRequest: MessageFns<SetConcurrencyRequest> = {
|
||||
encode(message: SetConcurrencyRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.concurrency !== 0) {
|
||||
writer.uint32(8).int32(message.concurrency);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): SetConcurrencyRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
let end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseSetConcurrencyRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 8) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.concurrency = reader.int32();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): SetConcurrencyRequest {
|
||||
return { concurrency: isSet(object.concurrency) ? globalThis.Number(object.concurrency) : 0 };
|
||||
},
|
||||
|
||||
toJSON(message: SetConcurrencyRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.concurrency !== 0) {
|
||||
obj.concurrency = Math.round(message.concurrency);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(base?: I): SetConcurrencyRequest {
|
||||
return SetConcurrencyRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(object: I): SetConcurrencyRequest {
|
||||
const message = createBaseSetConcurrencyRequest();
|
||||
message.concurrency = object.concurrency ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
export type CronService = typeof CronService;
|
||||
export const CronService = {
|
||||
addCron: {
|
||||
@@ -453,11 +535,21 @@ export const CronService = {
|
||||
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
|
||||
},
|
||||
setConcurrency: {
|
||||
path: "/com.ql.cron.Cron/setConcurrency",
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (value: SetConcurrencyRequest) => Buffer.from(SetConcurrencyRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer) => SetConcurrencyRequest.decode(value),
|
||||
responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface CronServer extends UntypedServiceImplementation {
|
||||
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
|
||||
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
|
||||
setConcurrency: handleUnaryCall<SetConcurrencyRequest, AddCronResponse>;
|
||||
}
|
||||
|
||||
export interface CronClient extends Client {
|
||||
@@ -491,6 +583,21 @@ export interface CronClient extends Client {
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
setConcurrency(
|
||||
request: SetConcurrencyRequest,
|
||||
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
setConcurrency(
|
||||
request: SetConcurrencyRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
setConcurrency(
|
||||
request: SetConcurrencyRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
}
|
||||
|
||||
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+91
-32
@@ -1,54 +1,113 @@
|
||||
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 { createCronJob, CronJob } from '../shared/cronScheduler';
|
||||
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>,
|
||||
) => {
|
||||
for (const item of call.request.crons) {
|
||||
const { id, schedule, command, extra_schedules, name } = item;
|
||||
if (scheduleStacks.has(id)) {
|
||||
scheduleStacks.get(id)?.forEach((x) => x.cancel());
|
||||
}
|
||||
// ===== 第一遍:预校验所有 cron 表达式 =====
|
||||
const validationErrors: string[] = [];
|
||||
|
||||
Logger.info(
|
||||
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||
id,
|
||||
name,
|
||||
schedule,
|
||||
command,
|
||||
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) => {
|
||||
Logger.info(
|
||||
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||
id,
|
||||
name,
|
||||
if (!isValidCronField(x.schedule)) {
|
||||
validationErrors.push(
|
||||
tf(
|
||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"',
|
||||
String(id),
|
||||
x.schedule,
|
||||
command,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
]);
|
||||
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;
|
||||
}
|
||||
|
||||
// Prepare stopped jobs first: construction errors must preserve the old snapshot.
|
||||
const prepared = new Map<string, CronJob[]>();
|
||||
try {
|
||||
for (const item of call.request.crons) {
|
||||
const jobs: CronJob[] = [];
|
||||
prepared.get(item.id)?.forEach((job) => job.cancel());
|
||||
prepared.set(item.id, jobs);
|
||||
for (const schedule of [
|
||||
item.schedule,
|
||||
...(item.extra_schedules || []).map((x) => x.schedule),
|
||||
]) {
|
||||
jobs.push(
|
||||
createCronJob(
|
||||
schedule,
|
||||
async () => {
|
||||
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
|
||||
await runCron(
|
||||
item.command,
|
||||
item,
|
||||
() => scheduleStacks.get(item.id) === jobs,
|
||||
);
|
||||
},
|
||||
{
|
||||
name: `${item.id}: ${item.name || ''}`,
|
||||
logger: Logger,
|
||||
start: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
for (const jobs of prepared.values()) jobs.forEach((job) => job.cancel());
|
||||
const err: any = new Error(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
err.code = status.INVALID_ARGUMENT;
|
||||
err.details = err.message;
|
||||
callback(err, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (call.request.replace) {
|
||||
for (const jobs of scheduleStacks.values())
|
||||
jobs.forEach((job) => job?.cancel());
|
||||
scheduleStacks.clear();
|
||||
}
|
||||
for (const [id, jobs] of prepared) {
|
||||
scheduleStacks.get(id)?.forEach((job) => job.cancel());
|
||||
scheduleStacks.set(id, jobs);
|
||||
jobs.forEach((job) => job.start());
|
||||
Logger.info(
|
||||
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
|
||||
id,
|
||||
jobs.length,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+103
-11
@@ -1,4 +1,4 @@
|
||||
import { credentials } from '@grpc/grpc-js';
|
||||
import { credentials, status, Metadata } from '@grpc/grpc-js';
|
||||
import {
|
||||
AddCronRequest,
|
||||
AddCronResponse,
|
||||
@@ -7,30 +7,122 @@ 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(),
|
||||
readonly readiness = new SchedulerReadiness(() => this.probe());
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
|
||||
async setConcurrency(concurrency: number): Promise<void> {
|
||||
await this.waitForReady(2000);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.addCron({ crons: request }, (err, res) => {
|
||||
this.client.setConcurrency(
|
||||
{ concurrency }, new Metadata(), { deadline: Date.now() + 5000 },
|
||||
(err) => {
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async addCron(
|
||||
request: AddCronRequest['crons'],
|
||||
replace = false
|
||||
): Promise<AddCronResponse> {
|
||||
await this.waitForReady(2000);
|
||||
// Include every rule in a recovery snapshot, but bound a stuck worker.
|
||||
const ruleCount = request.reduce(
|
||||
(count, item) => count + 1 + (item.extra_schedules?.length || 0),
|
||||
0,
|
||||
);
|
||||
const registrationTimeoutMs = Math.min(120000, 5000 + ruleCount * 5);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.addCron(
|
||||
{ crons: request, replace },
|
||||
new Metadata(),
|
||||
{ deadline: Date.now() + registrationTimeoutMs },
|
||||
(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);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import nodeSchedule from 'node-schedule';
|
||||
import { CronJob } from '../shared/cronScheduler';
|
||||
import { ToadScheduler } from 'toad-scheduler';
|
||||
|
||||
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>();
|
||||
export const scheduleStacks = new Map<string, CronJob[]>();
|
||||
|
||||
export const intervalSchedule = new ToadScheduler();
|
||||
|
||||
@@ -13,7 +13,20 @@ const delCron = (
|
||||
'[schedule][取消定时任务] 任务ID: %s',
|
||||
id,
|
||||
);
|
||||
scheduleStacks.get(id)?.forEach(x => x.cancel());
|
||||
// 防御性过滤历史调度栈中的空任务,
|
||||
// 否则对 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);
|
||||
}
|
||||
}
|
||||
|
||||
+90
-11
@@ -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')) {
|
||||
// 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 });
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||
import os from 'os';
|
||||
import { AddCronResponse, SetConcurrencyRequest } from '../protos/cron';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
|
||||
export async function setConcurrency(
|
||||
call: ServerUnaryCall<SetConcurrencyRequest, AddCronResponse>,
|
||||
callback: sendUnaryData<AddCronResponse>,
|
||||
) {
|
||||
const limit = call.request.concurrency;
|
||||
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
|
||||
callback(
|
||||
Object.assign(new Error('Invalid concurrency'), {
|
||||
code: status.INVALID_ARGUMENT,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||
callback(null, {});
|
||||
} catch (error) {
|
||||
callback(error as Error, null);
|
||||
}
|
||||
}
|
||||
+13
-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,16 @@ 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 || filePath.startsWith('data/scripts/')) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
const resolved = resolveFileAccess(
|
||||
config.configPath,
|
||||
[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 +29,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 });
|
||||
|
||||
+485
-114
@@ -1,15 +1,24 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getInvalidCronSchedules, isValidCronSchedule } from '../shared/cronSchedule';
|
||||
import {
|
||||
withSchedulerMutation,
|
||||
schedulerRegistrationError,
|
||||
} from '../shared/schedulerMutationLock';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
import { resolveLogPath } from '../shared/logPath';
|
||||
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';
|
||||
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,40 @@ export default class CronService {
|
||||
|
||||
private isNodeCron(cron: Crontab) {
|
||||
const { schedule, extra_schedules } = cron;
|
||||
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
|
||||
return true;
|
||||
const fields = schedule?.trim().split(/\s+/) || [];
|
||||
// System crontab only receives portable numeric five-field expressions.
|
||||
// Extended syntax, macros and legacy shorthand use the Node scheduler.
|
||||
return (
|
||||
fields.length !== 5 ||
|
||||
/[^\d\s*,/\-]/.test(schedule || '') ||
|
||||
Boolean(extra_schedules?.length) ||
|
||||
// BusyBox treats N/step as a single value and doesn't support Sunday=7.
|
||||
fields.some((field) => field.split(',').some((part) => /^\d+\//.test(part))) ||
|
||||
fields[4].includes('7') ||
|
||||
// BusyBox steps DOM from zero; its full-range day/week wildcard
|
||||
// handling also differs from the legacy Node parser's OR semantics.
|
||||
fields[2].includes('/') ||
|
||||
(fields[2] !== '*' && fields[4] !== '*')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
|
||||
private get schedulerMode(): 'system' | 'node' {
|
||||
const env = process.env.QL_SCHEDULER;
|
||||
if (env === 'system') return 'system';
|
||||
if (env === 'node') return 'node';
|
||||
try {
|
||||
execSync('which crond', { stdio: 'ignore' });
|
||||
return 'system';
|
||||
} catch {
|
||||
return 'node';
|
||||
}
|
||||
}
|
||||
|
||||
private shouldUseCronClient(cron: Crontab): boolean {
|
||||
if (this.schedulerMode === 'node') {
|
||||
return !this.isSpecialSchedule(cron.schedule);
|
||||
}
|
||||
return this.isNodeCron(cron) && !this.isSpecialSchedule(cron.schedule);
|
||||
}
|
||||
|
||||
private isOnceSchedule(schedule?: string) {
|
||||
@@ -71,6 +115,7 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async create(payload: Crontab): Promise<Crontab> {
|
||||
return withSchedulerMutation(async () => {
|
||||
const tab = new Crontab(payload);
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
@@ -80,7 +125,8 @@ export default class CronService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
|
||||
if (this.shouldUseCronClient(doc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
@@ -90,10 +136,24 @@ export default class CronService {
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
|
||||
// (DB 和 crontab.list 有记录但调度器永远不会执行)
|
||||
await CrontabModel.destroy({ where: { id: doc.id } });
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw schedulerRegistrationError(
|
||||
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
public async insert(payload: Crontab): Promise<Crontab> {
|
||||
@@ -101,21 +161,22 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async update(payload: Partial<Crontab>): Promise<Crontab> {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 (doc.isDisabled === 1 || isDemoEnv()) {
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
if (this.isNodeCron(doc)) {
|
||||
await cronClient.delCron([String(doc.id)]);
|
||||
}
|
||||
|
||||
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) {
|
||||
if (this.shouldUseCronClient(newDoc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
@@ -125,10 +186,43 @@ export default class CronService {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
public async updateDb(payload: Crontab): Promise<Crontab> {
|
||||
@@ -143,6 +237,7 @@ export default class CronService {
|
||||
log_path,
|
||||
last_running_time = 0,
|
||||
last_execution_time = 0,
|
||||
exit_code,
|
||||
}: {
|
||||
ids: number[];
|
||||
status: CrontabStatus;
|
||||
@@ -150,7 +245,11 @@ export default class CronService {
|
||||
log_path: string;
|
||||
last_running_time: number;
|
||||
last_execution_time: number;
|
||||
exit_code?: number;
|
||||
}) {
|
||||
if (log_path && log_path !== '/dev/null') {
|
||||
resolveLogPath(config.logPath, log_path);
|
||||
}
|
||||
let options: any = {
|
||||
status,
|
||||
pid,
|
||||
@@ -172,6 +271,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 +313,11 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async remove(ids: number[]) {
|
||||
await CrontabModel.destroy({ where: { id: ids } });
|
||||
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 +485,7 @@ export default class CronService {
|
||||
}
|
||||
|
||||
private formatFilterQuery(query: any, filterQuery: any) {
|
||||
if (filterQuery) {
|
||||
if (!isEmpty(filterQuery)) {
|
||||
if (!query[Op.and]) {
|
||||
query[Op.and] = [];
|
||||
}
|
||||
@@ -453,132 +588,285 @@ 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);
|
||||
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,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}
|
||||
const command = doc.command.replace(/\s+/g, ' ').trim();
|
||||
await killAllTasks(command);
|
||||
this.logger.info(
|
||||
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
|
||||
);
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
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 }),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async stopInstance(instanceId: number) {
|
||||
const instance = await RunningInstanceModel.findOne({
|
||||
where: { id: instanceId, status: InstanceStatus.running },
|
||||
});
|
||||
if (!instance) {
|
||||
return { code: 400, message: t('实例不存在或已停止') };
|
||||
}
|
||||
if (instance.pid) {
|
||||
try {
|
||||
await killTask(instance.pid);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: dayjs().unix(), exit_code: 143 },
|
||||
{ where: { id: instanceId } },
|
||||
);
|
||||
|
||||
// Check if there are still other running instances for this cron
|
||||
const otherRunning = await RunningInstanceModel.count({
|
||||
where: { cron_id: instance.cron_id, status: InstanceStatus.running },
|
||||
});
|
||||
if (otherRunning === 0) {
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id: ids } },
|
||||
{ where: { id: instance.cron_id } },
|
||||
);
|
||||
}
|
||||
return { code: 200, message: t('实例已停止') };
|
||||
}
|
||||
|
||||
private async runSingle(cronId: number): Promise<number | void> {
|
||||
return taskLimit.manualRunWithCronLimit(() => {
|
||||
return new Promise(async (resolve: any) => {
|
||||
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 });
|
||||
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)}`,
|
||||
);
|
||||
|
||||
let { id, command, log_name } = cron;
|
||||
|
||||
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 } },
|
||||
// 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 }),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
cp.stdout.on('data', async (data) => {
|
||||
await logStreamManager.write(absolutePath, data.toString());
|
||||
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,
|
||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
id,
|
||||
result.code
|
||||
);
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(absolutePath);
|
||||
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
|
||||
);
|
||||
}
|
||||
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 } });
|
||||
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[]) {
|
||||
return withSchedulerMutation(async () => {
|
||||
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
|
||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||
const sixCron = docs
|
||||
.filter((x) => this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule))
|
||||
const crons = docs
|
||||
.filter((x) => this.shouldUseCronClient(x))
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
@@ -590,38 +878,95 @@ export default class CronService {
|
||||
if (isDemoEnv()) {
|
||||
return;
|
||||
}
|
||||
await cronClient.addCron(sixCron);
|
||||
|
||||
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 absolutePath = doc.log_path
|
||||
? resolveLogPath(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async logs(id: number) {
|
||||
const doc = await this.getDb({ id });
|
||||
if (!doc || !doc.log_path) {
|
||||
if (!doc || !doc.log_path || doc.log_path === '/dev/null') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relativeDir = path.dirname(`${doc.log_path}`);
|
||||
const dir = path.resolve(config.logPath, relativeDir);
|
||||
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
|
||||
const dirExist = await fileExist(dir);
|
||||
if (dirExist) {
|
||||
let files = await fs.readdir(dir);
|
||||
@@ -661,6 +1006,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,12 +1038,14 @@ export default class CronService {
|
||||
|
||||
await writeFileWithLock(config.crontabFile, crontab_string);
|
||||
|
||||
if (this.schedulerMode === 'system') {
|
||||
try {
|
||||
execSync(`crontab ${config.crontabFile}`);
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || String(error);
|
||||
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
await CrontabModel.update({ saved: true }, { where: {} });
|
||||
}
|
||||
@@ -720,7 +1070,7 @@ export default class CronService {
|
||||
if (
|
||||
command &&
|
||||
schedule &&
|
||||
CronExpressionParser.parse(schedule).hasNext()
|
||||
isValidCronSchedule(schedule)
|
||||
) {
|
||||
const name = namePrefix + '_' + index;
|
||||
|
||||
@@ -739,14 +1089,28 @@ export default class CronService {
|
||||
});
|
||||
}
|
||||
|
||||
public async autosave_crontab() {
|
||||
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.isNodeCron(x) &&
|
||||
!this.isSpecialSchedule(x.schedule),
|
||||
this.shouldUseCronClient(x),
|
||||
)
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
@@ -760,8 +1124,21 @@ export default class CronService {
|
||||
await writeFileWithLock(config.crontabFile, '');
|
||||
return;
|
||||
}
|
||||
await cronClient.addCron(regularCrons);
|
||||
this.setCrontab(tabs);
|
||||
|
||||
// 先同步 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 +1147,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!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+193
-40
@@ -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 {
|
||||
@@ -32,6 +36,7 @@ export default class DependenceService {
|
||||
|
||||
public async create(payloads: Dependence[]): Promise<Dependence[]> {
|
||||
const tabs = payloads.map((x) => {
|
||||
getInstallCommand(x.type, x.name);
|
||||
const tab = new Dependence({ ...x, status: DependenceStatus.queued });
|
||||
return tab;
|
||||
});
|
||||
@@ -55,6 +60,7 @@ export default class DependenceService {
|
||||
...other,
|
||||
status: DependenceStatus.queued,
|
||||
});
|
||||
getInstallCommand(tab.type, tab.name);
|
||||
const newDoc = await this.updateDb(tab);
|
||||
this.installDependenceOneByOne([newDoc]);
|
||||
return newDoc;
|
||||
@@ -107,6 +113,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 +140,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 +233,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),
|
||||
@@ -213,43 +298,102 @@ export default class DependenceService {
|
||||
force: boolean = false,
|
||||
) {
|
||||
return taskLimit.runDependeny(dependency, () => {
|
||||
let validatedCommand: string;
|
||||
try {
|
||||
validatedCommand = isInstall
|
||||
? getInstallCommand(dependency.type, dependency.name.trim())
|
||||
: getUninstallCommand(dependency.type, dependency.name.trim());
|
||||
} catch (error) {
|
||||
taskLimit.removeQueuedDependency(dependency);
|
||||
return DependenceModel.update(
|
||||
{
|
||||
status: isInstall
|
||||
? DependenceStatus.installFailed
|
||||
: DependenceStatus.removeFailed,
|
||||
log: [String(error)],
|
||||
},
|
||||
{ where: { id: dependency.id } },
|
||||
).then(() => null);
|
||||
}
|
||||
return new Promise(async (resolve) => {
|
||||
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
|
||||
? getInstallCommand(dependency.type, depName)
|
||||
: getUninstallCommand(dependency.type, depName);
|
||||
const actionText = isInstall ? '安装' : '删除';
|
||||
let command = validatedCommand;
|
||||
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 +405,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 +413,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 +455,7 @@ export default class DependenceService {
|
||||
type: socketMessageType,
|
||||
message: data.toString(),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, data.toString());
|
||||
});
|
||||
@@ -318,6 +465,7 @@ export default class DependenceService {
|
||||
type: socketMessageType,
|
||||
message: data.toString(),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, data.toString());
|
||||
});
|
||||
@@ -327,6 +475,7 @@ export default class DependenceService {
|
||||
type: socketMessageType,
|
||||
message: JSON.stringify(err),
|
||||
references: depIds,
|
||||
status,
|
||||
});
|
||||
this.updateLog(depIds, JSON.stringify(err));
|
||||
});
|
||||
@@ -334,28 +483,32 @@ 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 +516,7 @@ export default class DependenceService {
|
||||
|
||||
if (_docIds.length > 0) {
|
||||
await DependenceModel.update(
|
||||
{ status },
|
||||
{ status: exitStatus },
|
||||
{ where: { id: _docIds } },
|
||||
);
|
||||
}
|
||||
|
||||
+44
-7
@@ -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 },
|
||||
@@ -224,10 +262,9 @@ export default class EnvService {
|
||||
.map((x) => x.value)
|
||||
.join('&')
|
||||
.replace(/\\/g, '\\\\')}`;
|
||||
js_env_string += `process.env.${key}=\`${_env_value.replace(
|
||||
/\`/g,
|
||||
'\\`',
|
||||
)}\`;\n`;
|
||||
js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify(
|
||||
group.map((x) => x.value).join('&'),
|
||||
)};\n`;
|
||||
py_env_string += `os.environ['${key}']='''${_env_value.replace(
|
||||
/\'/g,
|
||||
"\\'",
|
||||
|
||||
+38
-8
@@ -4,6 +4,7 @@ import { HealthService } from '../protos/health';
|
||||
import { ApiService } from '../protos/api';
|
||||
import { addCron } from '../schedule/addCron';
|
||||
import { delCron } from '../schedule/delCron';
|
||||
import { setConcurrency } from '../schedule/setConcurrency';
|
||||
import { check } from '../schedule/health';
|
||||
import * as Api from '../schedule/api';
|
||||
import Logger from '../loaders/logger';
|
||||
@@ -11,30 +12,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(CronService, { addCron, delCron, setConcurrency });
|
||||
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`);
|
||||
|
||||
const grpcPort = config.grpcPort;
|
||||
const hostsToTry = [
|
||||
config.bindHostGrpc,
|
||||
...(config.bindHostGrpc === '::' ? ['0.0.0.0'] : [])
|
||||
];
|
||||
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const host of hostsToTry) {
|
||||
try {
|
||||
const address = this.formatGrpcAddress(host, grpcPort);
|
||||
await bindAsync(address, credentials);
|
||||
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
|
||||
metricsService.record('grpc_service_start', 1, {
|
||||
port: grpcPort.toString(),
|
||||
host
|
||||
});
|
||||
|
||||
return grpcPort;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error('Failed to start gRPC service on all hosts');
|
||||
throw lastError || new Error('Failed to start gRPC service');
|
||||
} catch (err) {
|
||||
Logger.error('Failed to start gRPC service:', err);
|
||||
throw err;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
+36
-10
@@ -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) {
|
||||
const hostsToTry = [
|
||||
config.bindHost,
|
||||
...(config.bindHost === '::' ? ['0.0.0.0'] : [])
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const host of hostsToTry) {
|
||||
try {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = expressApp.listen(port, '0.0.0.0', () => {
|
||||
Logger.debug(`✌️ HTTP service started successfully`);
|
||||
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
|
||||
});
|
||||
resolve(this.server);
|
||||
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);
|
||||
});
|
||||
|
||||
this.server?.on('error', (err: Error) => {
|
||||
Logger.error('Failed to start HTTP service:', err);
|
||||
server.on('error', (err: Error) => {
|
||||
server.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error('Failed to start HTTP service:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from 'path';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
@@ -8,7 +8,6 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
+58
-16
@@ -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(),
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+101
-18
@@ -5,17 +5,67 @@ 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';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { writeFile, rename, rm, stat, chmod } from 'fs/promises';
|
||||
import config from '../config';
|
||||
|
||||
@Service()
|
||||
export default class OpenService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
// Every database mutation and full authentication-cache publication shares
|
||||
// this cross-process lock. Lock a separate marker, not token.json itself.
|
||||
private async withAppsLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const target = path.join(config.configPath, '.apps-state');
|
||||
const release = await lock(target, {
|
||||
realpath: false,
|
||||
stale: 10_000,
|
||||
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 3000 },
|
||||
});
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
public async initializeSystemApp(): Promise<void> {
|
||||
await this.withAppsLock(async () => {
|
||||
const doc = await AppModel.findOne({ where: { name: 'system' } });
|
||||
const app = doc?.get({ plain: true });
|
||||
if (!app) {
|
||||
await this.insert({
|
||||
name: 'system',
|
||||
scopes: ['crons', 'system', 'dashboard'],
|
||||
client_id: createRandomString(12, 12),
|
||||
client_secret: createRandomString(24, 24),
|
||||
});
|
||||
} else if (!app.scopes.includes('dashboard')) {
|
||||
await AppModel.update(
|
||||
{ scopes: [...app.scopes, 'dashboard'] },
|
||||
{ where: { id: app.id } },
|
||||
);
|
||||
}
|
||||
await shareStore.updateApps(await this.find({}));
|
||||
});
|
||||
}
|
||||
|
||||
public async refreshApps(): Promise<void> {
|
||||
await this.withAppsLock(async () => {
|
||||
await shareStore.updateApps(await this.find({}));
|
||||
});
|
||||
}
|
||||
|
||||
public async findApps(): Promise<App[] | null> {
|
||||
const docs = await this.find({});
|
||||
return docs;
|
||||
}
|
||||
|
||||
public async create(payload: App): Promise<App> {
|
||||
return this.withAppsLock(async () => {
|
||||
const tab = { ...payload };
|
||||
tab.client_id = createRandomString(12, 12);
|
||||
tab.client_secret = createRandomString(24, 24);
|
||||
@@ -23,9 +73,10 @@ export default class OpenService {
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
return { ...doc, tokens: [] };
|
||||
});
|
||||
}
|
||||
|
||||
public async insert(payload: App): Promise<App> {
|
||||
private async insert(payload: App): Promise<App> {
|
||||
const doc = await AppModel.create(payload, { returning: true });
|
||||
return doc.get({ plain: true });
|
||||
}
|
||||
@@ -40,10 +91,12 @@ export default class OpenService {
|
||||
}
|
||||
|
||||
private async updateDb(payload: Partial<App>): Promise<App> {
|
||||
return this.withAppsLock(async () => {
|
||||
await AppModel.update(payload, { where: { id: payload.id } });
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
return apps?.find((x) => x.id === payload.id) as App;
|
||||
});
|
||||
}
|
||||
|
||||
public async getDb(query: Record<string, any>): Promise<App> {
|
||||
@@ -55,9 +108,11 @@ export default class OpenService {
|
||||
}
|
||||
|
||||
public async remove(ids: number[]) {
|
||||
return this.withAppsLock(async () => {
|
||||
await AppModel.destroy({ where: { id: ids } });
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
});
|
||||
}
|
||||
|
||||
public async resetSecret(id: number): Promise<App> {
|
||||
@@ -111,7 +166,15 @@ export default class OpenService {
|
||||
return docs.map((x) => x.get({ plain: true }));
|
||||
}
|
||||
|
||||
public async authToken({
|
||||
public async authToken(credentials: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}): Promise<any> {
|
||||
return this.withAppsLock(() => this.issueToken(credentials));
|
||||
}
|
||||
|
||||
// Caller holds withAppsLock through database update and cache publication.
|
||||
private async issueToken({
|
||||
client_id,
|
||||
client_secret,
|
||||
}: {
|
||||
@@ -151,31 +214,51 @@ export default class OpenService {
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return { code: 400, message: 'client_id 或 client_seret 有误' };
|
||||
return { code: 400, message: t('client_id 或 client_seret 有误') };
|
||||
}
|
||||
}
|
||||
|
||||
public async generateSystemToken(): Promise<{
|
||||
public async generateSystemToken(renew = false): Promise<{
|
||||
value: string;
|
||||
expiration: number;
|
||||
}> {
|
||||
const apps = await shareStore.getApps();
|
||||
const systemApp = apps?.find((x) => x.name === 'system');
|
||||
if (!systemApp) {
|
||||
throw new Error('system app not found');
|
||||
}
|
||||
return this.withAppsLock(async () => {
|
||||
// Read after acquiring the lock; a concurrent issuer may have renewed.
|
||||
const systemApp = await this.getDb({ name: 'system' });
|
||||
const now = Math.round(Date.now() / 1000);
|
||||
const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
|
||||
if (currentToken) {
|
||||
return currentToken;
|
||||
}
|
||||
const { data } = await this.authToken({
|
||||
let appToken = systemApp.tokens
|
||||
?.filter((token) => token.expiration > now)
|
||||
.sort((left, right) => right.expiration - left.expiration)[0];
|
||||
if (renew || !appToken) {
|
||||
const { data } = await this.issueToken({
|
||||
client_id: systemApp.client_id,
|
||||
client_secret: systemApp.client_secret,
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
value: data.token,
|
||||
};
|
||||
appToken = { value: data.token, expiration: data.expiration };
|
||||
} else {
|
||||
// Recover a prior database commit whose cache publication failed.
|
||||
await shareStore.updateApps(await this.find({}));
|
||||
}
|
||||
const tokenFile = path.join(config.configPath, 'token.json');
|
||||
const temporaryFile = `${tokenFile}.${process.pid}.tmp`;
|
||||
const mode = await stat(tokenFile).then(
|
||||
(file) => file.mode & 0o777,
|
||||
(error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return 0o600;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await writeFile(temporaryFile, `${JSON.stringify(appToken)}${os.EOL}`, {
|
||||
mode,
|
||||
});
|
||||
await chmod(temporaryFile, mode);
|
||||
// Shell readers see a complete old or new document, never a partial one.
|
||||
await rename(temporaryFile, tokenFile);
|
||||
} finally {
|
||||
await rm(temporaryFile, { force: true });
|
||||
}
|
||||
return appToken;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
+88
-56
@@ -1,6 +1,6 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import nodeSchedule from 'node-schedule';
|
||||
import { createCronJob, CronJob } from '../shared/cronScheduler';
|
||||
import { ChildProcessWithoutNullStreams } from 'child_process';
|
||||
import {
|
||||
ToadScheduler,
|
||||
@@ -11,6 +11,11 @@ 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 +32,7 @@ export interface TaskCallbacks {
|
||||
startTime: dayjs.Dayjs,
|
||||
) => Promise<void>;
|
||||
onEnd?: (
|
||||
cp: ChildProcessWithoutNullStreams,
|
||||
cp: ChildProcessWithoutNullStreams | undefined,
|
||||
endTime: dayjs.Dayjs,
|
||||
diff: number,
|
||||
) => Promise<void>;
|
||||
@@ -37,7 +42,7 @@ export interface TaskCallbacks {
|
||||
|
||||
@Service()
|
||||
export default class ScheduleService {
|
||||
private scheduleStacks = new Map<string, nodeSchedule.Job>();
|
||||
private scheduleStacks = new Map<string, CronJob>();
|
||||
|
||||
private intervalSchedule = new ToadScheduler();
|
||||
|
||||
@@ -63,73 +68,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 {
|
||||
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);
|
||||
|
||||
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 = 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);
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (result.error) {
|
||||
this.logger.error(
|
||||
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
err,
|
||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
||||
others.id,
|
||||
result.error.message,
|
||||
);
|
||||
await callbacks.onError?.(JSON.stringify(err));
|
||||
});
|
||||
|
||||
cp.on('exit', async (code) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 参数: %s, 退出码: %j',
|
||||
JSON.stringify({
|
||||
...others,
|
||||
command,
|
||||
}),
|
||||
code,
|
||||
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'),
|
||||
);
|
||||
resolve({ ...others, pid: cp.pid, code });
|
||||
});
|
||||
} catch (error) {
|
||||
result.error ??= asError(error);
|
||||
this.logger.error(
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
error,
|
||||
'[panel][任务结束回调失败] %s',
|
||||
asError(error).message,
|
||||
);
|
||||
await callbacks.onError?.(JSON.stringify(error));
|
||||
}
|
||||
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(
|
||||
@@ -146,18 +163,33 @@ export default class ScheduleService {
|
||||
command,
|
||||
);
|
||||
|
||||
this.scheduleStacks.set(
|
||||
_id,
|
||||
nodeSchedule.scheduleJob(_id, schedule, async () => {
|
||||
if (schedule) {
|
||||
try {
|
||||
const job = createCronJob(
|
||||
schedule,
|
||||
() =>
|
||||
this.runTask(command, callbacks, {
|
||||
name,
|
||||
schedule,
|
||||
command,
|
||||
id: _id,
|
||||
runOrigin,
|
||||
});
|
||||
}),
|
||||
{ name: `${_id}: ${name || ''}`, logger: this.logger, start: false },
|
||||
);
|
||||
this.scheduleStacks.get(_id)?.cancel();
|
||||
this.scheduleStacks.set(_id, job);
|
||||
job.start();
|
||||
} catch (error) {
|
||||
// A persisted invalid subscription must not become an unhandled startup rejection.
|
||||
this.logger.warn(
|
||||
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
|
||||
_id,
|
||||
schedule,
|
||||
asError(error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (runImmediately) {
|
||||
this.runTask(command, callbacks, {
|
||||
|
||||
@@ -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,15 @@ 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]);
|
||||
}
|
||||
|
||||
public async getFile(filePath: string, fileName: string) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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'));
|
||||
}
|
||||
}
|
||||
+40
-22
@@ -8,10 +8,22 @@ import { formatUrl } from '../config/subscription';
|
||||
import config from '../config';
|
||||
import { fileExist, rmPath } from '../config/util';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import {
|
||||
getSubscriptionSshAlias,
|
||||
resolveSubscriptionPath,
|
||||
} from '../shared/subscriptionPath';
|
||||
|
||||
@Service()
|
||||
export default class SshKeyService {
|
||||
private homedir = os.homedir();
|
||||
// OpenSSH uses the effective user's passwd entry, not the HOME override.
|
||||
private get homedir(): string {
|
||||
try {
|
||||
return os.userInfo().homedir;
|
||||
} catch {
|
||||
// Custom container UIDs may have no passwd entry. Keep startup working.
|
||||
return os.homedir();
|
||||
}
|
||||
}
|
||||
private sshPath = config.sshdPath;
|
||||
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
|
||||
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
|
||||
@@ -21,6 +33,10 @@ export default class SshKeyService {
|
||||
}
|
||||
|
||||
private async initSshConfigFile() {
|
||||
await fs.mkdir(path.dirname(this.sshConfigFilePath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
let config = '';
|
||||
const _exist = await fileExist(this.sshConfigFilePath);
|
||||
if (_exist) {
|
||||
@@ -42,13 +58,11 @@ export default class SshKeyService {
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await writeFileWithLock(
|
||||
path.join(this.sshPath, alias),
|
||||
`${key}${os.EOL}`,
|
||||
{
|
||||
mode: '400',
|
||||
},
|
||||
);
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||
try {
|
||||
await rmPath(filePath);
|
||||
} catch { }
|
||||
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
|
||||
} catch (error) {
|
||||
this.logger.error('生成私钥文件失败', error);
|
||||
}
|
||||
@@ -56,7 +70,7 @@ export default class SshKeyService {
|
||||
|
||||
private async removePrivateKeyFile(alias: string): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.sshPath, alias);
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||
await rmPath(filePath);
|
||||
} catch (error) {
|
||||
this.logger.error('删除私钥文件失败', error);
|
||||
@@ -68,18 +82,19 @@ export default class SshKeyService {
|
||||
host: string,
|
||||
proxy?: string,
|
||||
) {
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
|
||||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
|
||||
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
|
||||
}
|
||||
if (host === 'github.com') {
|
||||
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
|
||||
}
|
||||
const proxyStr = proxy
|
||||
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
|
||||
: '';
|
||||
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join(
|
||||
this.sshPath,
|
||||
alias,
|
||||
)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
||||
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
||||
await writeFileWithLock(
|
||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
||||
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||
config,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
@@ -90,7 +105,7 @@ export default class SshKeyService {
|
||||
|
||||
private async removeSshConfig(alias: string) {
|
||||
try {
|
||||
const filePath = path.join(this.sshPath, `${alias}.config`);
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
|
||||
await rmPath(filePath);
|
||||
} catch (error) {
|
||||
this.logger.error(`删除ssh配置文件${alias}失败`, error);
|
||||
@@ -103,6 +118,7 @@ export default class SshKeyService {
|
||||
host: string,
|
||||
proxy?: string,
|
||||
): Promise<void> {
|
||||
alias = getSubscriptionSshAlias(alias);
|
||||
await this.generatePrivateKeyFile(alias, key);
|
||||
await this.generateSingleSshConfig(alias, host, proxy);
|
||||
}
|
||||
@@ -112,6 +128,7 @@ export default class SshKeyService {
|
||||
host: string,
|
||||
proxy?: string,
|
||||
): Promise<void> {
|
||||
alias = getSubscriptionSshAlias(alias);
|
||||
await this.removePrivateKeyFile(alias);
|
||||
await this.removeSshConfig(alias);
|
||||
}
|
||||
@@ -119,7 +136,8 @@ export default class SshKeyService {
|
||||
public async setSshConfig(docs: Subscription[]) {
|
||||
for (const doc of docs) {
|
||||
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
|
||||
const { alias, proxy } = doc;
|
||||
try {
|
||||
const alias = getSubscriptionSshAlias(doc.alias);
|
||||
const { host } = formatUrl(doc);
|
||||
await this.removePrivateKeyFile(alias);
|
||||
await this.removeSshConfig(alias);
|
||||
@@ -127,7 +145,10 @@ export default class SshKeyService {
|
||||
alias,
|
||||
(doc.pull_option as any).private_key,
|
||||
);
|
||||
await this.generateSingleSshConfig(alias, host, proxy);
|
||||
await this.generateSingleSshConfig(alias, host, doc.proxy);
|
||||
} catch (error) {
|
||||
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,12 +167,9 @@ export default class SshKeyService {
|
||||
|
||||
private async generateGlobalSshConfig(alias: string) {
|
||||
// Create a config that matches all hosts, making this key globally available
|
||||
const config = `Host *\n IdentityFile ${path.join(
|
||||
this.sshPath,
|
||||
alias,
|
||||
)}\n StrictHostKeyChecking no\n`;
|
||||
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
|
||||
await writeFileWithLock(
|
||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
||||
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||
config,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
|
||||
+129
-34
@@ -20,18 +20,28 @@ import {
|
||||
} from '../config/util';
|
||||
import fs from 'fs/promises';
|
||||
import { FindOptions, Op } from 'sequelize';
|
||||
import path, { join } from 'path';
|
||||
import path 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';
|
||||
import { formatCommand, formatUrl } from '../config/subscription';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
|
||||
import { resolveLogPath } from '../shared/logPath';
|
||||
import {
|
||||
getSubscriptionStorageCandidates,
|
||||
isSubscriptionStorageName,
|
||||
resolveSubscriptionStoragePath,
|
||||
} from '../shared/subscriptionStorage';
|
||||
import { withSubscriptionMutation } from '../shared/subscriptionMutationLock';
|
||||
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 {
|
||||
@@ -86,9 +96,11 @@ export default class SubscriptionService {
|
||||
needCreate = true,
|
||||
runImmediately = false,
|
||||
) {
|
||||
// Cancelling by ID must also work for invalid historical subscriptions.
|
||||
if (needCreate) {
|
||||
const { url } = formatUrl(doc);
|
||||
|
||||
doc.command = formatCommand(doc, url as string);
|
||||
}
|
||||
|
||||
if (doc.schedule_type === 'crontab') {
|
||||
this.scheduleService.cancelCronTask(doc as any);
|
||||
@@ -130,14 +142,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,14 +169,19 @@ export default class SubscriptionService {
|
||||
);
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
let absolutePath: string | undefined;
|
||||
try {
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
const absolutePath = await handleLogPath(sub.log_path as string);
|
||||
absolutePath = await handleLogPath(sub.log_path as string);
|
||||
|
||||
// 执行 sub_after
|
||||
let afterStr = '';
|
||||
try {
|
||||
if (sub.sub_after) {
|
||||
await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`);
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
`\n\n## ${t('执行after命令...')}\n\n`,
|
||||
);
|
||||
afterStr = await promiseExec(sub.sub_after);
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -177,24 +194,29 @@ export default class SubscriptionService {
|
||||
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
`\n## 执行结束... ${endTime.format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)} 耗时 ${diff} 秒${LOG_END_SYMBOL}`,
|
||||
'\n' +
|
||||
tf(
|
||||
'## 执行结束... %s 耗时 %s 秒',
|
||||
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
String(diff),
|
||||
) +
|
||||
LOG_END_SYMBOL,
|
||||
);
|
||||
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(absolutePath);
|
||||
|
||||
} finally {
|
||||
try {
|
||||
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
||||
} finally {
|
||||
await SubscriptionModel.update(
|
||||
{ status: SubscriptionStatus.idle, pid: undefined },
|
||||
{ where: { id: sub.id } },
|
||||
{ status: SubscriptionStatus.idle, pid: null } as any,
|
||||
{ where: { id: doc.id } },
|
||||
);
|
||||
|
||||
this.sockService.sendMessage({
|
||||
type: 'runSubscriptionEnd',
|
||||
message: '订阅执行完成',
|
||||
message: t('订阅执行完成'),
|
||||
references: [doc.id as number],
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
@@ -210,11 +232,14 @@ export default class SubscriptionService {
|
||||
}
|
||||
|
||||
public async create(payload: Subscription): Promise<Subscription> {
|
||||
assertSubscriptionAlias(payload.alias);
|
||||
return withSubscriptionMutation(async () => {
|
||||
const tab = new Subscription(payload);
|
||||
const doc = await this.insert(tab);
|
||||
await this.handleTask(doc.get({ plain: true }));
|
||||
await this.setSshConfig();
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
public async insert(payload: Subscription): Promise<SubscriptionInstance> {
|
||||
@@ -222,12 +247,27 @@ export default class SubscriptionService {
|
||||
}
|
||||
|
||||
public async update(payload: Subscription): Promise<Subscription> {
|
||||
assertSubscriptionAlias(payload.alias);
|
||||
return withSubscriptionMutation(async () => {
|
||||
const doc = await this.getDb({ id: payload.id });
|
||||
const tab = new Subscription({ ...doc, ...payload });
|
||||
const newDoc = await this.updateDb(tab);
|
||||
await this.handleTask(newDoc, !newDoc.is_disabled);
|
||||
await this.removeSshConfigForSubscription(doc);
|
||||
await this.setSshConfig();
|
||||
return newDoc;
|
||||
});
|
||||
}
|
||||
|
||||
private async removeSshConfigForSubscription(doc: Subscription) {
|
||||
if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return;
|
||||
// Invalid historical rows must remain deletable without deriving unsafe paths.
|
||||
try {
|
||||
assertSubscriptionAlias(doc.alias);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await this.sshKeyService.removeSSHKey(doc.alias, '');
|
||||
}
|
||||
|
||||
public async updateDb(payload: Subscription): Promise<Subscription> {
|
||||
@@ -250,6 +290,7 @@ export default class SubscriptionService {
|
||||
last_running_time: number;
|
||||
last_execution_time: number;
|
||||
}) {
|
||||
if (log_path) resolveLogPath(config.logPath, log_path);
|
||||
const options: any = {
|
||||
status,
|
||||
pid,
|
||||
@@ -267,11 +308,52 @@ export default class SubscriptionService {
|
||||
}
|
||||
|
||||
public async remove(ids: number[], query: { force?: boolean }) {
|
||||
return withSubscriptionMutation(async () => {
|
||||
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
|
||||
const storagePaths: string[] = [];
|
||||
const retainedPaths: string[] = [];
|
||||
const collectPaths = (
|
||||
doc: Subscription,
|
||||
targets: string[],
|
||||
deleting = false,
|
||||
) => {
|
||||
for (const names of getSubscriptionStorageCandidates(doc)) {
|
||||
// A URL accepted by one engine can produce an invalid name in another.
|
||||
// Validate each candidate independently, retaining the safe candidate.
|
||||
if (!Object.values(names).every(isSubscriptionStorageName)) continue;
|
||||
const resolve = deleting ? resolveSubscriptionStoragePath : path.resolve;
|
||||
const scriptPath = resolve(config.scriptPath, names.script);
|
||||
if (scriptPath) targets.push(scriptPath);
|
||||
if (names.repo) {
|
||||
const repoPath = resolve(config.repoPath, names.repo);
|
||||
if (repoPath) targets.push(repoPath);
|
||||
}
|
||||
if (names.raw) {
|
||||
const rawPath = resolve(path.join(config.dataPath, 'raw'), names.raw);
|
||||
if (rawPath) targets.push(rawPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Validate all deletion targets before changing rows or removing any files.
|
||||
if (query?.force === true) {
|
||||
for (const doc of docs) {
|
||||
assertSubscriptionAlias(doc.alias);
|
||||
resolveSubscriptionStoragePath(config.scriptPath, doc.alias);
|
||||
resolveSubscriptionStoragePath(config.repoPath, doc.alias);
|
||||
collectPaths(doc, storagePaths, true);
|
||||
}
|
||||
const remaining = await SubscriptionModel.findAll({ where: {} });
|
||||
for (const doc of remaining) {
|
||||
if (!ids.includes(doc.id!)) collectPaths(doc, retainedPaths);
|
||||
}
|
||||
}
|
||||
for (const doc of docs) {
|
||||
await this.handleTask(doc.get({ plain: true }), false);
|
||||
}
|
||||
await SubscriptionModel.destroy({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
await this.removeSshConfigForSubscription(doc);
|
||||
}
|
||||
await this.setSshConfig();
|
||||
|
||||
if (query?.force === true) {
|
||||
@@ -279,13 +361,18 @@ export default class SubscriptionService {
|
||||
if (crons?.length) {
|
||||
await this.crontabService.remove(crons.map((x) => x.id!));
|
||||
}
|
||||
for (const doc of docs) {
|
||||
const filePath = join(config.scriptPath, doc.alias);
|
||||
const repoPath = join(config.repoPath, doc.alias);
|
||||
await rmPath(filePath);
|
||||
await rmPath(repoPath);
|
||||
for (const storagePath of new Set(storagePaths)) {
|
||||
// rm is recursive: an ancestor must also be retained if a surviving
|
||||
// subscription uses a nested branch, and vice versa.
|
||||
const shared = retainedPaths.some((retained) =>
|
||||
retained === storagePath ||
|
||||
retained.startsWith(`${storagePath}${path.sep}`) ||
|
||||
storagePath.startsWith(`${retained}${path.sep}`),
|
||||
);
|
||||
if (!shared) await rmPath(storagePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getDb(
|
||||
@@ -310,20 +397,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);
|
||||
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 +450,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) {
|
||||
@@ -379,7 +474,7 @@ export default class SubscriptionService {
|
||||
|
||||
if (doc.log_path) {
|
||||
const relativeDir = path.dirname(`${doc.log_path}`);
|
||||
const dir = path.resolve(config.logPath, relativeDir);
|
||||
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
|
||||
const _exist = await fileExist(dir);
|
||||
if (_exist) {
|
||||
let files = await fs.readdir(dir);
|
||||
|
||||
+146
-54
@@ -1,8 +1,10 @@
|
||||
import { spawn } from 'cross-spawn';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
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';
|
||||
@@ -32,11 +34,15 @@ import {
|
||||
SystemModelInfo,
|
||||
} from '../data/system';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import cronClient from '../schedule/client';
|
||||
import { withSchedulerMutation } from '../shared/schedulerMutationLock';
|
||||
import NotificationService from './notify';
|
||||
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 +82,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 +92,7 @@ export default class SystemService {
|
||||
});
|
||||
return { code: 200, data: { ...result, code } };
|
||||
} else {
|
||||
return { code: 400, message: '通知发送失败,请检查参数' };
|
||||
return { code: 400, message: t('通知发送失败,请检查参数') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +104,7 @@ export default class SystemService {
|
||||
});
|
||||
const cron = {
|
||||
id: result.id as number,
|
||||
name: '删除日志',
|
||||
name: t('删除日志'),
|
||||
command: `ql rmlog ${info.logRemoveFrequency}`,
|
||||
runOrigin: 'system' as const,
|
||||
};
|
||||
@@ -118,15 +124,29 @@ export default class SystemService {
|
||||
}
|
||||
|
||||
public async updateCronConcurrency(info: SystemModelInfo) {
|
||||
const limit = info.cronConcurrency ?? 0;
|
||||
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
|
||||
throw Object.assign(new Error(t('参数错误')), { status: 400 });
|
||||
}
|
||||
return withSchedulerMutation(async () => {
|
||||
const oDoc = await this.getSystemConfig();
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, ...info },
|
||||
info: { ...oDoc.info, ...info, cronConcurrency: limit },
|
||||
});
|
||||
if (info.cronConcurrency) {
|
||||
await taskLimit.setCustomLimit(info.cronConcurrency);
|
||||
}
|
||||
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||
await cronClient.setConcurrency(limit);
|
||||
return { code: 200, data: info };
|
||||
});
|
||||
}
|
||||
|
||||
public async restoreCronConcurrency() {
|
||||
await withSchedulerMutation(async () => {
|
||||
const doc = await this.getSystemConfig();
|
||||
const limit = doc.info?.cronConcurrency ?? 0;
|
||||
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||
await cronClient.setConcurrency(limit);
|
||||
});
|
||||
}
|
||||
|
||||
public async updateDependenceProxy(info: SystemModelInfo) {
|
||||
@@ -138,7 +158,7 @@ export default class SystemService {
|
||||
if (info.dependenceProxy) {
|
||||
await fs.promises.writeFile(
|
||||
config.dependenceProxyFile,
|
||||
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
|
||||
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
|
||||
);
|
||||
} else {
|
||||
await fs.promises.rm(config.dependenceProxyFile);
|
||||
@@ -154,7 +174,7 @@ export default class SystemService {
|
||||
});
|
||||
let cmd = 'pnpm config delete registry';
|
||||
if (info.nodeMirror) {
|
||||
cmd = `pnpm config set registry ${info.nodeMirror}`;
|
||||
cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
|
||||
}
|
||||
let command = `cd && ${cmd}`;
|
||||
const docs = await DependenceModel.findAll({
|
||||
@@ -177,6 +197,7 @@ export default class SystemService {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateNodeMirror',
|
||||
message: 'update node mirror end',
|
||||
status: 'completed',
|
||||
});
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
@@ -202,7 +223,7 @@ export default class SystemService {
|
||||
});
|
||||
let cmd = 'pip config unset global.index-url';
|
||||
if (info.pythonMirror) {
|
||||
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
|
||||
cmd = `pip3 config set global.index-url ${shellQuote(info.pythonMirror)}`;
|
||||
}
|
||||
await promiseExec(cmd);
|
||||
return { code: 200, data: info };
|
||||
@@ -214,33 +235,10 @@ 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 || '');
|
||||
this.scheduleService.runTask(
|
||||
command,
|
||||
{
|
||||
@@ -248,12 +246,20 @@ export default class SystemService {
|
||||
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
|
||||
res?.end();
|
||||
},
|
||||
onEnd: async () => {
|
||||
onEnd: async (cp) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateLinuxMirror',
|
||||
message: 'update linux mirror end',
|
||||
status: 'completed',
|
||||
});
|
||||
onEnd?.();
|
||||
// Tools such as sudo and apt may write warnings to stderr on success.
|
||||
if (cp?.exitCode === 0 && cp.signalCode === null) {
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, ...info },
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
|
||||
@@ -353,6 +359,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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,7 +375,10 @@ export default class SystemService {
|
||||
}
|
||||
|
||||
public async reloadSystem(target?: 'system' | 'data') {
|
||||
const cmd = `real_time=true ql reload ${target || ''}`;
|
||||
if (target && !['system', 'data'].includes(target)) {
|
||||
return { code: 400, message: t('参数错误') };
|
||||
}
|
||||
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
|
||||
const cp = spawn(cmd, {
|
||||
shell: '/bin/bash',
|
||||
detached: true,
|
||||
@@ -395,9 +413,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 +433,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 +449,7 @@ export default class SystemService {
|
||||
await killTask(_pid);
|
||||
return { code: 200 };
|
||||
} else {
|
||||
return { code: 400, message: '任务未找到' };
|
||||
return { code: 400, message: t('任务未找到') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,11 +459,20 @@ export default class SystemService {
|
||||
if (type && type.length) {
|
||||
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
||||
}
|
||||
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
|
||||
await promiseExec(
|
||||
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
|
||||
} ${dataPaths.join(' ')}`,
|
||||
);
|
||||
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
|
||||
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
|
||||
if (dataDirs.some((dir) => !allowed.has(dir))) {
|
||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||
}
|
||||
if (dataDirs.includes('scripts')) dataDirs.push('script-history');
|
||||
// History is created lazily; old/fresh installations must still export.
|
||||
if (!fs.existsSync(path.join(config.dataPath, 'script-history'))) {
|
||||
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
|
||||
}
|
||||
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
|
||||
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
|
||||
cwd: path.dirname(config.dataPath),
|
||||
});
|
||||
res.download(config.dataTgzFile);
|
||||
} catch (error: any) {
|
||||
return res.send({ code: 400, message: error.message });
|
||||
@@ -469,6 +496,7 @@ export default class SystemService {
|
||||
query: {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
},
|
||||
) {
|
||||
const startTime = dayjs(query.startTime || undefined)
|
||||
@@ -483,8 +511,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 +546,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,10 +580,40 @@ 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=${shellQuote(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({
|
||||
@@ -553,12 +637,20 @@ export default class SystemService {
|
||||
|
||||
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) { }
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
+236
-53
@@ -20,14 +20,24 @@ 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';
|
||||
import { LoginThrottle } from '../shared/loginThrottle';
|
||||
|
||||
@Service()
|
||||
export default class UserService {
|
||||
private loginThrottle = new LoginThrottle();
|
||||
@Inject((type) => NotificationService)
|
||||
private notificationService!: NotificationService;
|
||||
|
||||
@@ -37,7 +47,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 +65,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,27 +87,43 @@ export default class UserService {
|
||||
twoFactorActivated,
|
||||
tokens = {},
|
||||
platform,
|
||||
blockedIps = [],
|
||||
} = content;
|
||||
const retriesTime = Math.pow(3, retries) * 1000;
|
||||
if (retries > 2 && timestamp - lastlogon < retriesTime) {
|
||||
const waitTime = Math.ceil(
|
||||
(retriesTime - (timestamp - lastlogon)) / 1000,
|
||||
);
|
||||
|
||||
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 waitTime = this.loginThrottle.retryAfter(ip, timestamp);
|
||||
if (waitTime > 0) {
|
||||
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 +131,8 @@ 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) {
|
||||
this.loginThrottle.reset(ip);
|
||||
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,
|
||||
@@ -155,18 +206,28 @@ export default class UserService {
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const failures = this.loginThrottle.fail(ip, timestamp);
|
||||
await this.updateAuthInfo(content, {
|
||||
retries: retries + 1,
|
||||
retries: failures,
|
||||
lastlogon: timestamp,
|
||||
lastip: ip,
|
||||
lastaddr: address,
|
||||
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,
|
||||
@@ -179,19 +240,20 @@ export default class UserService {
|
||||
},
|
||||
});
|
||||
this.getLoginLog();
|
||||
if (retries > 2) {
|
||||
const waitTime = Math.round(Math.pow(3, retries + 1));
|
||||
if (failures > 3) {
|
||||
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
|
||||
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 +307,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 +376,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 +417,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 +439,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 source = getClientIp(req);
|
||||
if (this.loginThrottle.retryAfter(source, now) > 0) {
|
||||
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 +473,26 @@ export default class UserService {
|
||||
.join(' ');
|
||||
}
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
retries: this.loginThrottle.fail(source, now),
|
||||
lastlogon: now,
|
||||
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,24 @@ 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);
|
||||
if (retries === 0 || password !== undefined || username !== undefined) {
|
||||
this.loginThrottle.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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,11 @@
|
||||
// Match command text literally: looking up a task must never execute its input.
|
||||
export function findCronId(crontab: string, command: string): string {
|
||||
if (!command) return '';
|
||||
for (const line of crontab.split('\n')) {
|
||||
const id = /\bID=(\d+)\s/.exec(line);
|
||||
if (id && line.includes(command, id.index + id[0].length)) {
|
||||
return id[1];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { validate } from 'node-cron';
|
||||
import { ScheduleType } from '../interface/schedule';
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
'@yearly': '0 0 0 1 1 *',
|
||||
'@annually': '0 0 0 1 1 *',
|
||||
'@monthly': '0 0 0 1 * *',
|
||||
'@weekly': '0 0 0 * * 0',
|
||||
'@daily': '0 0 0 * * *',
|
||||
'@midnight': '0 0 0 * * *',
|
||||
'@hourly': '0 0 * * * *',
|
||||
'@minutely': '0 * * * * *',
|
||||
};
|
||||
|
||||
export interface CronSchedule {
|
||||
patterns: string[];
|
||||
dayCount: number;
|
||||
nthDay: number;
|
||||
}
|
||||
|
||||
// Validation and registration repeatedly see identical rules in large snapshots.
|
||||
// Cache only immutable syntax, never dates or running tasks, and bound user input.
|
||||
const scheduleCache = new Map<string, CronSchedule>();
|
||||
|
||||
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
|
||||
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
|
||||
export function parseCronSchedule(schedule: unknown): CronSchedule {
|
||||
if (typeof schedule !== 'string' || !schedule.trim()) {
|
||||
throw new Error('Invalid cron schedule');
|
||||
}
|
||||
let source = schedule.trim();
|
||||
const cacheKey = source;
|
||||
const cached = scheduleCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
if (source.startsWith('@')) {
|
||||
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
|
||||
source = aliases[source];
|
||||
}
|
||||
let parts = source.split(/\s+/);
|
||||
if (
|
||||
parts.length > 6 ||
|
||||
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
|
||||
) {
|
||||
throw new Error('Unsupported cron syntax');
|
||||
}
|
||||
parts = [
|
||||
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
|
||||
...parts,
|
||||
];
|
||||
if (
|
||||
parts.some(
|
||||
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
|
||||
)
|
||||
) {
|
||||
throw new Error('Question mark is only valid in day fields');
|
||||
}
|
||||
const expression = CronExpressionParser.parse(parts.join(' '));
|
||||
if (!expression.hasNext())
|
||||
throw new Error('Cron schedule has no next execution');
|
||||
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
|
||||
const dayCount = expression.fields.dayOfMonth.values.length;
|
||||
const weekCount = expression.fields.dayOfWeek.values.length;
|
||||
const patterns =
|
||||
dayCount < 31 && weekCount < 8
|
||||
? [
|
||||
[...normalized.slice(0, 5), '*'].join(' '),
|
||||
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
|
||||
]
|
||||
: [normalized.join(' ')];
|
||||
if (!patterns.every((pattern) => validate(pattern))) {
|
||||
throw new Error('Unsupported cron schedule');
|
||||
}
|
||||
const parsed = {
|
||||
patterns,
|
||||
dayCount,
|
||||
nthDay: expression.fields.dayOfWeek.nthDay,
|
||||
};
|
||||
Object.freeze(patterns);
|
||||
Object.freeze(parsed);
|
||||
if (scheduleCache.size >= 512)
|
||||
scheduleCache.delete(scheduleCache.keys().next().value);
|
||||
scheduleCache.set(cacheKey, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function isValidCronSchedule(schedule: unknown): schedule is string {
|
||||
try {
|
||||
parseCronSchedule(schedule);
|
||||
return true;
|
||||
} 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,89 @@
|
||||
import { createTask, ScheduledTask } from 'node-cron';
|
||||
import { parseCronSchedule } from './cronSchedule';
|
||||
|
||||
interface SchedulerLogger {
|
||||
warn(message: string, ...args: unknown[]): unknown;
|
||||
error(message: string, ...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
start(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
// node-cron owns timers and calendar calculation; Qinglong owns execution
|
||||
// concurrency and process lifetime. Missed in-process slots join that same queue.
|
||||
export function createCronJob(
|
||||
schedule: string,
|
||||
callback: (date: Date) => unknown | Promise<unknown>,
|
||||
options: { name: string; logger: SchedulerLogger; start?: boolean },
|
||||
): CronJob {
|
||||
const parsed = parseCronSchedule(schedule);
|
||||
const tasks: ScheduledTask[] = [];
|
||||
let cancelled = false;
|
||||
let started = false;
|
||||
const accepts = (index: number, date: Date) => {
|
||||
if (tasks.length === 1) return true;
|
||||
// Preserve cron-parser 4's day/weekday OR semantics, including its
|
||||
// month-dependent full-day range and the global nth-week constraint.
|
||||
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
|
||||
return false;
|
||||
if (index === 1) return !tasks[0].match(date);
|
||||
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
|
||||
};
|
||||
const dispatch = async (index: number, date: Date, missed: boolean) => {
|
||||
if (cancelled || !started || !accepts(index, date)) return;
|
||||
if (missed) {
|
||||
options.logger.warn(
|
||||
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
|
||||
options.name,
|
||||
date.toISOString(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await callback(date);
|
||||
} catch (error) {
|
||||
options.logger.error(
|
||||
'[schedule][定时回调失败] 任务: %s, 错误: %s',
|
||||
options.name,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (const [index, pattern] of parsed.patterns.entries()) {
|
||||
const task = createTask(
|
||||
pattern,
|
||||
(context) => dispatch(index, context.date, false),
|
||||
{
|
||||
noOverlap: false,
|
||||
missedExecutionTolerance: 5000,
|
||||
},
|
||||
);
|
||||
tasks.push(task);
|
||||
task.on('execution:missed', (context) =>
|
||||
dispatch(index, context.date, true),
|
||||
);
|
||||
// Fail before an existing schedule is cancelled, including impossible dates.
|
||||
task.getNextRuns(1);
|
||||
}
|
||||
} catch (error) {
|
||||
tasks.forEach((task) => task.destroy());
|
||||
throw error;
|
||||
}
|
||||
const job: CronJob = {
|
||||
start() {
|
||||
if (started || cancelled) return;
|
||||
started = true;
|
||||
tasks.forEach((task) => task.start());
|
||||
},
|
||||
cancel() {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
tasks.forEach((task) => task.destroy());
|
||||
},
|
||||
};
|
||||
if (options.start !== false) job.start();
|
||||
return job;
|
||||
}
|
||||
@@ -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,179 @@
|
||||
import { shareStore } from './store';
|
||||
|
||||
const messages: Record<string, Record<string, string>> = {
|
||||
zh: {},
|
||||
en: {
|
||||
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
|
||||
'暂无权限': '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),
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface IDependencyFn<T> {
|
||||
export interface ICronFn<T> {
|
||||
(): Promise<T>;
|
||||
cron?: TCron;
|
||||
isCurrent?: () => boolean;
|
||||
}
|
||||
|
||||
export interface ISchedule {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
|
||||
/** Check stored paths too: old database rows are not trusted input. */
|
||||
export function resolveLogPath(root: string, value: string): string {
|
||||
const target = resolveFileAccess(root, [value]);
|
||||
if (!target)
|
||||
throw Object.assign(new Error('Log path is outside the log directory'), {
|
||||
status: 400,
|
||||
});
|
||||
return target;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
private closingStreams = new Map<string, Promise<void>>();
|
||||
private closedStreams = new WeakSet<WriteStream>();
|
||||
private streamErrors = new Map<string, Error>();
|
||||
|
||||
constructor(private readonly logRoot = config.logPath) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** Register each write synchronously, so concurrent callers cannot lose the tail. */
|
||||
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;
|
||||
if (this.closingStreams.has(filePath)) {
|
||||
throw new Error(`Log stream is closing: ${filePath}`);
|
||||
}
|
||||
|
||||
// Create a new promise for this write operation
|
||||
const writePromise = new Promise<void>((resolve, reject) => {
|
||||
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) {
|
||||
// Create a new write stream if one doesn't exist
|
||||
stream = createWriteStream(filePath, { flags: 'a' });
|
||||
// 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);
|
||||
|
||||
// Handle stream errors
|
||||
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 });
|
||||
// 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 closing = this.closingStreams.get(filePath);
|
||||
if (closing) return closing;
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
if (pending) {
|
||||
await pending.catch(() => {
|
||||
// Ignore errors on pending writes during close
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
const stream = this.streams.get(filePath);
|
||||
if (stream) {
|
||||
return new Promise<void>((resolve) => {
|
||||
stream.end(() => {
|
||||
failure ||= this.streamErrors.get(filePath);
|
||||
if (failure) throw failure;
|
||||
} finally {
|
||||
this.streams.delete(filePath);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user