Compare commits

..
1 Commits
Author SHA1 Message Date
whyour a3cbf8cdf4 迁移文件 2023-01-04 17:04:02 +08:00
330 changed files with 23534 additions and 43818 deletions
+9 -13
View File
@@ -1,16 +1,12 @@
GRPC_PORT=5500
BACK_PORT=5700
PUBLIC_PORT=5400
CRON_PORT=5500
BACK_PORT=5600
PORT=5700
# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6)
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
# BIND_HOST=0.0.0.0
# BIND_HOST_GRPC=0.0.0.0
LOG_LEVEL='debug'
LOG_LEVEL='info'
SECRET='whyour'
JWT_SECRET=
JWT_EXPIRES_IN=
QINIU_AK=
QINIU_SK=
QINIU_SCOPE=
QINIU_AK=''
QINIU_SK=''
QINIU_SCOPE=''
-4
View File
@@ -1,4 +0,0 @@
---
name: Bug Fixer
description: Fix this issue following our error handling pattern.
---
-465
View File
@@ -1,465 +0,0 @@
name: Build And Push Docker Image
on:
push:
paths-ignore:
- "*.md"
branches:
- "master"
- "develop"
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
jobs:
code_gitlab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Push to GitLab
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitlab git@gitlab.com:whyour/qinglong.git 2>/dev/null
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
code_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Push to Gitee
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee push --all succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
run: |
mkdir -p tmp
cd ./tmp
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add .
git config --local user.name 'github-actions[bot]'
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
static_gitlab:
needs: build-static
runs-on: ubuntu-latest
steps:
- name: Push qinglong-static to GitLab
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
static_gitee:
needs: build-static
runs-on: ubuntu-latest
steps:
- name: Push qinglong-static to Gitee
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee static push succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong-static","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian
push: true
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: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile.310
push: true
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-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
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.ref_name == 'master' }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: publish npm package
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
npm publish
+166
View File
@@ -0,0 +1,166 @@
name: Build And Push Docker Image
on:
push:
branches:
- 'master'
- 'develop'
# Sequence of patterns matched against refs/tags
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
schedule:
# 参考 https://jasonet.co/posts/scheduled-actions/
# note: 这里是GMT时间,北京时间减去八小时即可。如北京时间 22:30 => GMT 14:30
# minute hour day month dayOfWeek
- cron: '00 14 * * *' # GMT 14:00 => 北京时间 22:00
#- cron: '30 16 * * *' # GMT 16:30(前一天) => 北京时间 00:30
workflow_dispatch:
jobs:
to_gitlab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@gitlab.com:whyour/qinglong.git
ssh_private_key:
${{ secrets.GITLAB_SSH_PK }}
to_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@gitee.com:whyour/qinglong.git
ssh_private_key:
${{ secrets.GITLAB_SSH_PK }}
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: latest
- uses: actions/setup-node@v3
with:
cache: 'pnpm'
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
REPO_GITEE: git@gitee.com:whyour/qinglong-static.git
REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git
PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: |
mkdir -p tmp
cd ./tmp
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add .
git config --local user.name 'github-actions[bot]'
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
mkdir -p ~/.ssh
echo "${PRIVATE_KEY}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
export GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -l git"
git remote add gitee "${REPO_GITEE}"
git remote add gitlab "${REPO_GITLAB}"
git push --force --quiet gitee ${GITHUB_BRANCH}:${GITHUB_BRANCH}
git push --force --quiet gitlab ${GITHUB_BRANCH}:${GITHUB_BRANCH}
build:
needs: build-static
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: latest
- uses: actions/setup-node@v3
with:
cache: 'pnpm'
- name: Setup timezone
uses: zcong1993/setup-timezone@master
with:
timezone: Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v2
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@v4
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
tags: |
type=schedule,pattern=nightly
type=edge
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
id: docker_build
uses: docker/build-push-action@v3
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
context: docker/
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
+7 -12
View File
@@ -22,16 +22,11 @@
.env
.history
.version.ts
/.tmp
__pycache__
/shell/preload/env.*
/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/
/config
/log
/db
/manual_log
/scripts
/bak
node_modules
-22
View File
@@ -1,22 +0,0 @@
/.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
+1
View File
@@ -1 +1,2 @@
sentrycli_cdnurl=https://npmmirror.com/mirrors/sentry-cli/
strict-peer-dependencies=false
+4 -20
View File
@@ -2,23 +2,7 @@
**/*.svg
**/*.ejs
**/*.html
/.umi
/.umi-production
/.umi-test
/.history
/.tmp
/node_modules
npm-debug.log*
yarn-error.log
yarn.lock
package-lock.json
/static
/data
DS_Store
/src/.umi
/src/.umi-production
/src/.umi-test
.env.local
.env
version.ts
/.tmp
package.json
.umi
.umi-production
.umi-test
-43
View File
@@ -1,43 +0,0 @@
<!-- 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 -->
+172 -66
View File
@@ -1,29 +1,30 @@
<p align="center">
<a href="https://github.com/whyour/qinglong">
<img width="150" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
</a>
</p>
<h1 align="center">Green Dragon</h1>
<div align="center">
<img width="100" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
<h1 align="center">Qinglong</h1>
Timed task management panel with python3, javaScript, shell, typescript support
[简体中文](./README.md) | English
[![docker version][docker-version-image]][docker-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[![npm version][npm-version-image]][npm-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
[npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat
[npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme
[docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat
[docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong
[docker-version-image]: https://img.shields.io/docker/v/whyour/qinglong?style=flat
[docker-version-url]: https://hub.docker.com/r/whyour/qinglong/tags?page=1&ordering=last_updated
[docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat
[docker-stars-url]: https://hub.docker.com/r/whyour/qinglong
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
[![](https://user-images.githubusercontent.com/22700758/203243067-1a8a570d-b1b4-4837-9f12-d78d83e31f35.jpg)](https://whyour.cn)
[简体中文](./README.md) | English
## Features
@@ -35,63 +36,150 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- Support dark mode
- Support cell phone operation
## Version
### docker
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
When running the `debian` image as a non-root user, specify `--user qinglong`:
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
```bash
npm i @whyour/qinglong
```
## Deployment
[View Documentation](https://qinglong.online/guide/getting-started/installation-guide)
## Built-in API
[View Documentation](https://qinglong.online/guide/user-guide/built-in-api)
## Built-in commands
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
## Development
### Local Deployment
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
pnpm start
# To be refined, see the development steps first (not supported on windows yet)
```
Open your browser and visit <http://127.0.0.1:5700>
### Podman Deployment
1. podman installation
```bash
https://podman.io/getting-started/installation
```
2. start the container
```bash
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### Docker Deployment
1. docker installation
```bash
sudo curl -sSL get.docker.com | sh
```
2. configure domestic mirror sources
```bash
mkdir -p /etc/docker
tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://0b27f0a81a00f3560fbdc00ddd2f99e0.mirror.swr.myhuaweicloud.com",
"https://ypzju6vq.mirror.aliyuncs.com",
"https://registry.docker-cn.com",
"http://hub-mirror.c.163.com",
"https://docker.mirrors.ustc.edu.cn"
]
}
EOF
systemctl daemon-reload
systemctl restart docker
```
3. start the container
```bash
docker run -dit \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
### Docker-compose Deployment
1. docker-compose installation
```bash
sudo curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
```
2. start the container
```bash
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# start
docker-compose up -d
# stop
docker-compose down
```
3. access
Open your browser and visit http://{ip}:5700
## Use
1. built-in commands
```bash
# Update and restart Green Dragon
ql update
# Run custom scripts extra.sh
ql extra
# Adding a single script file
ql raw <file_url>
# Add a specific script for a single repository
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch>
# Delete old logs
ql rmlog <days>
# Start bot
ql bot
# Detecting the Green Dragon environment and repairing it
ql check
# Reset the number of login errors
ql resetlet
# Disable two-step login
ql resettfa
# Execute in sequence, if a random delay is set, it will be randomly delayed by a certain number of seconds
task <file_path>
# Execute in sequence, regardless of whether a random delay is set, all run immediately,
# and the foreground will output the day, while recorded in the log file
task <file_path> now
# Concurrent execution, regardless of whether a random delay is set, are run immediately,
# the foreground does not generate the day, directly recorded in the log file, and can be specified account execution
task <file_path> conc <env_name> <account_number>(Optional)
# Specify the account to execute and run immediately regardless of whether a random delay is set
task <file_path> desi <env_name> <account_number>
# Set task timeout
task -m <max_time> <file_path>
# Print task log in real time, no need to carry this parameter when creating timed tasks
task -l <file_path>
```
2. parameter description
* file_url: Script address
* repo_url: Repository address
* whitelist: The whitelist when pulling the repository, i.e., the string contained in the path of the script to be pulled
* blacklist: Blacklisting when pulling repositories, i.e. strings that are not included in the path of the script to be pulled
* dependence: Pulling the dependencies needed for the repository will be copied directly from the repository to the repository directory under scripts, regardless of the blacklist
* branch: Pull the branch of the repository
* days: Number of days of logs to be kept
* file_path: File path for task execution
* env_name: The name of the environment variable that needs to be concurrent or specified at the time of task execution
* account_number: Specify the account number of an environment variable to be executed when the task is executed
* max_time: Timeout, suffix "s" for seconds (default), "m" for minutes, "h" for hours, "d" for days
## Links
@@ -103,10 +191,28 @@ Open your browser and visit <http://127.0.0.1:5700>
- [darkreader](https://github.com/darkreader/darkreader)
- [admin-server](https://github.com/sunpu007/admin-server)
## Development
```bash
$ git clone git@github.com:whyour/qinglong.git
$ cd qinglong
$ cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
$ npm install -g pnpm
$ pnpm install
$ pnpm start
```
Open your browser and visit http://127.0.0.1:5700
## Communication
[telegram channel](https://t.me/jiao_long)
## Name Origin
The Green Dragon, also known as the Canglong, is one of the four elephants and one of the [four spirits of the heavens](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5) in traditional Chinese culture. According to the Five Elements, it is a spirit animal representing the East as a green dragon, the five elements are wood, and the season represented is spring, with the eight trigrams dominating vibration. Like the Ying Long, the Cang Long has feathered wings. According to the Zhang Guo Xing Zong (Zhang Guo Xing Zong), "a true dragon is one that has complementary wings".
In the Book of the Later Han Dynasty (後漢書-律曆志下), it is written: "The sun is in the sky, a cold and a summer, the four seasons are ready, all things are changed, the regency moves, and the green dragon moves to the star, which is called the year. (The Year of the Star)
Among the [twenty-eight Chinese constellations](https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%8D%81%E5%85%AB%E5%AE%BF), the Green Dragon is the generic name for the seven eastern constellations (Horn, Hyper, Diao, Fang, Heart, Tail and Minchi). It is known in Taoism as "Mengzhang" and in different Taoist scriptures as "Dijun", "Shengjian", "Shenjian" and He is also known in different Daoist scriptures as "Dijun", "Shengjun", "Shenjun" and "Ghost Catcher"[1], and is the guardian deity of Daoism, together with the White Tiger Supervisor of Soldiers.
Among the [twenty-eight Chinese constellations](https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%8D%81%E5%85%AB%E5%AE%BF), the Green Dragon is the generic name for the seven eastern constellations (Horn, Hyper, Diao, Fang, Heart, Tail and Minchi). It is known in Taoism as "Mengzhang" and in different Taoist scriptures as "Dijun", "Shengjian", "Shenjian" and He is also known in different Daoist scriptures as "Dijun", "Shengjun", "Shenjun" and "Ghost Catcher"[1], and is the guardian deity of Daoism, together with the White Tiger Supervisor of Soldiers.
+169 -65
View File
@@ -1,31 +1,30 @@
<div align="center">
<img width="100" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
<p align="center">
<a href="https://github.com/whyour/qinglong">
<img width="150" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
</a>
</p>
<h1 align="center">青龙</h1>
简体中文 | [English](./README-en.md)
<div align="center">
支持 Python3、JavaScript、Shell、Typescript 的定时任务管理平台
支持python3、javaScript、shell、typescript 的定时任务管理面板
Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[![docker version][docker-version-image]][docker-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
[![npm version][npm-version-image]][npm-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
[npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat
[npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme
[docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat
[docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong
[docker-version-image]: https://img.shields.io/docker/v/whyour/qinglong?style=flat
[docker-version-url]: https://hub.docker.com/r/whyour/qinglong/tags?page=1&ordering=last_updated
[docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat
[docker-stars-url]: https://hub.docker.com/r/whyour/qinglong
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
[![](https://user-images.githubusercontent.com/22700758/203243067-1a8a570d-b1b4-4837-9f12-d78d83e31f35.jpg)](https://whyour.cn)
简体中文 | [English](./README-en.md)
## 功能
@@ -37,63 +36,150 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- 支持暗黑模式
- 支持手机端操作
## 版本
### docker
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
```bash
npm i @whyour/qinglong
```
## 部署
[查看文档](https://qinglong.online/guide/getting-started/installation-guide)
## 内置 API
[查看文档](https://qinglong.online/guide/user-guide/built-in-api)
## 内置命令
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
## 开发
### 本机部署
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# 推荐使用 pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
pnpm start
# 待完善,可先参考开发步骤 (windows暂时不支持)
```
打开你的浏览器,访问 <http://127.0.0.1:5700>
### podman 部署
1. podman 安装
```bash
https://podman.io/getting-started/installation
```
2. 启动容器
```bash
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### docker 部署
1. docker 安装
```bash
sudo curl -sSL get.docker.com | sh
```
2. 配置国内镜像源
Configure domestic mirror sources
```bash
mkdir -p /etc/docker
tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://0b27f0a81a00f3560fbdc00ddd2f99e0.mirror.swr.myhuaweicloud.com",
"https://ypzju6vq.mirror.aliyuncs.com",
"https://registry.docker-cn.com",
"http://hub-mirror.c.163.com",
"https://docker.mirrors.ustc.edu.cn"
]
}
EOF
systemctl daemon-reload
systemctl restart docker
```
3. 启动容器
```bash
docker run -dit \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
### docker-compose 部署
1. docker-compose 安装
```bash
sudo curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
```
2. 启动容器
```bash
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# 启动
docker-compose up -d
# 停止
docker-compose down
```
3. 访问
打开你的浏览器,访问 http://{ip}:5700
## 使用
1. 内置命令
```bash
# 更新并重启青龙
ql update
# 运行自定义脚本extra.sh
ql extra
# 添加单个脚本文件
ql raw <file_url>
# 添加单个仓库的指定脚本
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> <extensions>
# 删除旧日志
ql rmlog <days>
# 启动tg-bot
ql bot
# 检测青龙环境并修复
ql check
# 重置登录错误次数
ql resetlet
# 禁用两步登录
ql resettfa
# 依次执行,如果设置了随机延迟,将随机延迟一定秒数
task <file_path>
# 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日,同时记录在日志文件中
task <file_path> now
# 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日,直接记录在日志文件中,且可指定账号执行
task <file_path> conc <env_name> <account_number>(可选的)
# 指定账号执行,无论是否设置了随机延迟,均立即运行
task <file_path> desi <env_name> <account_number>
# 设置任务超时时间
task -m <max_time> <file_path>
# 实时打印任务日志,创建定时任务时,不用携带此参数
task -l <file_path>
```
2. 参数说明
* file_url: 脚本地址
* repo_url: 仓库地址
* whitelist: 拉取仓库时的白名单,即就是需要拉取的脚本的路径包含的字符串,多个竖线分割
* blacklist: 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串,多个竖线分割
* dependence: 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响,多个竖线分割
* extensions: 拉取仓库的文件后缀,多个竖线分割
* branch: 拉取仓库的分支
* days: 需要保留的日志的天数
* file_path: 任务执行时的文件路径
* env_name: 任务执行时需要并发或者指定时的环境变量名称
* account_number: 任务执行时指定某个环境变量需要执行的账号序号
* max_time: 超时时间,后缀"s"代表秒(默认值), "m"代表分, "h"代表小时, "d"代表天
## 链接
@@ -105,6 +191,24 @@ pnpm start
- [darkreader](https://github.com/darkreader/darkreader)
- [admin-server](https://github.com/sunpu007/admin-server)
## 开发
```bash
$ git clone git@github.com:whyour/qinglong.git
$ cd qinglong
$ cp .env.example .env
# 推荐使用 pnpm https://pnpm.io/zh/installation
$ npm install -g pnpm
$ pnpm install
$ pnpm start
```
打开你的浏览器,访问 http://127.0.0.1:5700
## 交流
[telegram频道](https://t.me/jiao_long)
## 名称来源
青龙,又名苍龙,在中国传统文化中是四象之一、[天之四灵](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5)之一,根据五行学说,它是代表东方的灵兽,为青色的龙,五行属木,代表的季节是春季,八卦主震。苍龙与应龙一样,都是身具羽翼。《张果星宗》称“又有辅翼,方为真龙”。
-5
View File
@@ -1,5 +0,0 @@
## Reporting a Vulnerability
To report a vulnerability, please open a private vulnerability report at <https://github.com/whyour/qinglong/security>.
While the discovery of new vulnerabilities is rare, we also recommend always using the latest versions of Qinglong to ensure your application remains as secure as possible.
-110
View File
@@ -1,110 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate';
import { join, basename } from 'path';
import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils';
const route = Router();
export default (app: Router) => {
app.use('/configs', route);
route.get(
'/samples',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({
code: 200,
data: SAMPLE_FILES,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/files',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const fileList = await fs.readdir(config.configPath, 'utf-8');
res.send({
code: 200,
data: fileList
.filter((x) => !config.blackFileList.includes(x))
.map((x) => {
return { title: x, value: x };
}),
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
try {
const configService = Container.get(ConfigService);
await configService.getFile(req.query.path as string, res);
} catch (e) {
return next(e);
}
},
);
route.post(
'/save',
celebrate({
body: Joi.object({
name: Joi.string().required(),
content: Joi.string().allow('').optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const { name, content } = req.body;
// Resolve path first to prevent traversal attacks
let basePath = config.configPath;
if (name.startsWith('data/scripts/')) {
basePath = join(config.rootPath, 'data/scripts');
}
const cleanName = name.replace(/^data\/scripts\//, '');
const resolvedPath = join(basePath, cleanName);
const normalized = join(resolvedPath);
// Verify the resolved path stays within allowed directory
if (!normalized.startsWith(basePath)) {
return res.send({ code: 403, message: t('文件路径无效') });
}
// Check blacklist on actual filename (not user input)
if (config.blackFileList.includes(basename(normalized))) {
return res.send({ code: 403, message: t('文件无法访问') });
}
await writeFileWithLock(normalized, content);
res.send({ code: 200, message: t('保存成功') });
} catch (e) {
return next(e);
}
},
);
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);
}
},
);
};
-397
View File
@@ -1,397 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { fn, col, where, Op } from 'sequelize';
import { CrontabModel } from '../data/cron';
import { CrontabStatModel } from '../data/cronStats';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs';
import os from 'os';
import { isEmpty } from 'lodash';
import { t, tf } from '../shared/i18n';
const route = Router();
export default (app: Router) => {
app.use('/dashboard', route);
route.post('/record', async (req: Request, res: Response) => {
try {
const { ref_id, code, elapsed } = req.body;
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
const today = dayjs().format('YYYY-MM-DD');
const isSuccess = code === 0 ? 1 : 0;
const isFail = code !== 0 ? 1 : 0;
const elapsedMs = (Number(elapsed) || 0) * 1000;
const existing = await CrontabStatModel.findOne({
where: { ref_id: Number(ref_id), date: today },
});
if (existing) {
await CrontabStatModel.update(
{
run_count: (existing.run_count || 0) + 1,
success_count: (existing.success_count || 0) + isSuccess,
fail_count: (existing.fail_count || 0) + isFail,
total_time: (existing.total_time || 0) + elapsedMs,
max_time: Math.max(existing.max_time || 0, elapsedMs),
},
{ where: { id: existing.id } },
);
} else {
await CrontabStatModel.create({
ref_id: Number(ref_id),
date: today,
run_count: 1,
success_count: isSuccess,
fail_count: isFail,
total_time: elapsedMs,
max_time: elapsedMs,
});
}
res.send({ code: 200 });
} catch (e) {
res.send({ code: 500 });
}
});
route.get(
'/overview',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [total, enabled, disabled, stats] = await Promise.all([
CrontabModel.count(),
CrontabModel.count({ where: { isDisabled: 0 } }),
CrontabModel.count({ where: { isDisabled: 1 } }),
CrontabStatModel.findOne({
attributes: [
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
[fn('SUM', col('total_time')), 'total_time'],
],
where: { date: today },
raw: true,
}),
]);
const row = stats as any;
const totalRuns = Number(row?.total_runs) || 0;
const totalSuccess = Number(row?.total_success) || 0;
const totalFail = Number(row?.total_fail) || 0;
const totalTime = Number(row?.total_time) || 0;
res.send({
code: 200,
data: {
total,
enabled,
disabled,
todayRuns: totalRuns,
todaySuccess: totalSuccess,
todayFail: totalFail,
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
},
});
} catch (e) {
next(e);
}
},
);
route.get(
'/trend',
async (req: Request, res: Response, next: NextFunction) => {
try {
const days = parseInt(req.query.days as string) || 7;
const dates: string[] = [];
for (let i = days - 1; i >= 0; i--) {
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
}
const rows = (await CrontabStatModel.findAll({
attributes: [
'date',
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
],
where: {
date: { [Op.in]: dates },
},
group: ['date'],
order: [['date', 'ASC']],
raw: true,
})) as any[];
const dataMap: Record<string, any> = {};
rows.forEach((r: any) => {
dataMap[r.date] = {
total: Number(r.total_runs) || 0,
success: Number(r.total_success) || 0,
fail: Number(r.total_fail) || 0,
};
});
const data = dates.map((d) => ({
date: dayjs(d).format('MM-DD'),
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-time',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('run_count')), 'run_count'],
[fn('MAX', col('max_time')), 'max_time'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('total_time')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
maxTime: Number(r.max_time),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-count',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('run_count')), 'run_count'],
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('success_count')), 'success_count'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('run_count')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
runCount: Number(r.run_count),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
successRate:
Number(r.run_count) > 0
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
: '0',
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/runtime',
async (req: Request, res: Response, next: NextFunction) => {
try {
const runningInstances = await RunningInstanceModel.findAll({
where: {
status: InstanceStatus.running,
},
raw: true,
});
const queuedCrons = await CrontabModel.findAll({
where: {
status: 3, // queued
},
raw: true,
});
// Fetch cron names for running instances
const cronIds = [
...new Set(runningInstances.map((i: any) => i.cron_id)),
];
const crons =
cronIds.length > 0
? await CrontabModel.findAll({
where: { id: cronIds },
raw: true,
})
: [];
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
const now = dayjs().unix();
const running = runningInstances.map((inst: any) => {
const cron = cronMap.get(inst.cron_id);
return {
instanceId: inst.id,
id: inst.cron_id,
name: cron?.name || cron?.command || tf('任务#%s', inst.cron_id),
pid: inst.pid,
elapsed: inst.started_at ? now - inst.started_at : 0,
logPath: inst.log_path,
};
});
const dayAgo = dayjs().subtract(24, 'hour').unix();
const idleTasks = await CrontabModel.findAll({
where: {
isDisabled: 0,
status: 1,
last_execution_time: { [Op.lt]: dayAgo },
},
order: [['last_execution_time', 'ASC']],
limit: 5,
raw: true,
});
res.send({
code: 200,
data: {
runningCount: running.length,
queuedCount: queuedCrons.length,
running,
idleTasks: idleTasks.map((c: any) => ({
id: c.id,
name: c.name || c.command || tf('任务#%s', c.id),
lastRun: c.last_execution_time
? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm')
: '-',
})),
},
});
} catch (e) {
next(e);
}
},
);
route.get(
'/labels',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [crons, stats] = (await Promise.all([
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
]));
const statMap: Record<number, any> = {};
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
crons.forEach((c) => {
let rawLabels = c.labels;
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
const labels: string[] = Array.isArray(rawLabels)
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
: [];
if (labels.length === 0) {
labels.push(t('未分类'));
}
const st = statMap[c.id!];
labels.forEach((label: string) => {
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
labelMap[label].count += 1;
if (st) {
labelMap[label].runs += Number(st.run_count) || 0;
labelMap[label].success += Number(st.success_count) || 0;
labelMap[label].totalTime += Number(st.total_time) || 0;
}
});
});
const data = Object.entries(labelMap)
.map(([label, v]) => ({
label,
count: v.count,
todayRuns: v.runs,
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
}))
.sort((a, b) => b.todayRuns - a.todayRuns);
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/system',
async (req: Request, res: Response, next: NextFunction) => {
try {
const memUsage = process.memoryUsage();
res.send({
code: 200,
data: {
platform: os.platform(),
uptime: Math.floor(process.uptime()),
memTotal: os.totalmem(),
memFree: os.freemem(),
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
cpus: os.cpus().length,
},
});
} catch (e) {
next(e);
}
},
);
};
-27
View File
@@ -1,27 +0,0 @@
import { Router } from 'express';
import Logger from '../loaders/logger';
import { HealthService } from '../services/health';
import Container from 'typedi';
const route = Router();
export default (app: Router) => {
app.use('/', route);
route.get('/health', async (req, res) => {
try {
const healthService = Container.get(HealthService);
const health = await healthService.check();
res.status(200).send({
code: 200,
data: health,
});
} catch (err: any) {
Logger.error('Health check failed:', err);
res.status(500).send({
code: 500,
message: 'Health check failed',
error: err.message,
});
}
});
};
-153
View File
@@ -1,153 +0,0 @@
import { celebrate, Joi } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { t } from '../shared/i18n';
import {
getFileContentByName,
readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
const route = Router();
const blacklist = ['.tmp'];
export default (app: Router) => {
app.use('/logs', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const result = await readDirs(config.logPath, config.logPath, blacklist);
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
try {
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.query.file as string) || '',
);
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
const logPath = `${req.query.path as string}/${req.query.file as string}`;
const runningInstance = await RunningInstanceModel.findOne({
where: { log_path: logPath, status: InstanceStatus.running },
});
const content = await getFileContentByName(finalPath);
res.send({
code: 200,
data: removeAnsi(content),
logStatus: runningInstance ? 'running' : undefined,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
try {
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.params.file as string) || '',
);
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
type: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(path, filename);
if (!finalPath || blacklist.includes(path)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await rmPath(finalPath);
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.post(
'/download',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const logService = Container.get(LogService);
const filePath = logService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
};
-412
View File
@@ -1,412 +0,0 @@
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
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 ScriptService from '../services/script';
import { t } from '../shared/i18n';
import multer from 'multer';
import { writeFileWithLock } from '../shared/utils';
const route = Router();
function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return config.writePathList.some((x) => resolved.startsWith(x));
}
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.scriptPath);
},
filename: function (req, file, cb) {
cb(null, file.originalname);
},
});
const upload = multer({ storage: storage });
export default (app: Router) => {
app.use('/scripts', route);
route.get(
'/',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let result: IFile[] = [];
const blacklist = [
'node_modules',
'.git',
'.pnpm',
'pnpm-lock.yaml',
'yarn.lock',
'package-lock.json',
];
if (req.query.path) {
result = await readDir(
req.query.path as string,
config.scriptPath,
blacklist,
);
} else {
result = await readDirs(
config.scriptPath,
config.scriptPath,
blacklist,
(a, b) => {
if (a.type === b.type) {
return a.title.localeCompare(b.title);
} else {
return a.type === 'directory' ? -1 : 1;
}
},
);
}
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/detail',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
file: Joi.string().required(),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
req.query.file as string,
);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:file',
celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
req.params.file,
);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
upload.single('file'),
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, content, originFilename, directory } =
req.body as {
filename: string;
path: string;
content: string;
originFilename: string;
directory: string;
};
if (!path) {
path = config.scriptPath;
}
if (!path.endsWith('/')) {
path += '/';
}
if (!path.startsWith('/')) {
path = join(config.scriptPath, path);
}
if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
if (req.file) {
const uploadPath = join(path, filename);
if (!isPathAllowed(uploadPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(req.file.path, uploadPath);
return res.send({ code: 200 });
}
if (directory) {
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);
const filePath = join(path, filename);
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(path, { recursive: true });
const fileExists = await fileExist(filePath);
if (fileExists) {
await fs.copyFile(
originFilePath,
join(config.bakPath, originFilename.replace(/\//g, '')),
);
if (filename !== originFilename) {
await rmPath(originFilePath);
}
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().required().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, content, path } = req.body as {
filename: string;
content: string;
path: string;
};
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
type: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await rmPath(filePath);
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.post(
'/download',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
route.put(
'/run',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
content: Joi.string().optional().allow(''),
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, content, path } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await writeFileWithLock(filePath, content || '');
const scriptService = Container.get(ScriptService);
const result = await scriptService.runScript(filePath);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/stop',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
pid: Joi.number().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, pid } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
const logPath = join(config.logPath, path, `${name}.swap`);
const scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid);
setTimeout(() => {
rmPath(logPath);
}, 3000);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/rename',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
newFilename: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, newFilename } = req.body as {
filename: string;
path: string;
newFilename: string;
};
if (!path) {
path = '';
}
const filePath = join(config.scriptPath, path, filename);
const newPath = join(config.scriptPath, path, newFilename);
if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(filePath, newPath);
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
};
-498
View File
@@ -1,498 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import * as fs from 'fs/promises';
import config from '../config';
import SystemService from '../services/system';
import { celebrate, Joi } from 'celebrate';
import UserService from '../services/user';
import { t } from '../shared/i18n';
import { isDefaultAuthInfo } from '../shared/auth';
import {
getUniqPath,
handleLogPath,
parseVersion,
promiseExec,
} from '../config/util';
import dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router();
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.tmpPath);
},
filename: function (req, file, cb) {
cb(null, 'data.tgz');
},
});
const upload = multer({ storage: storage });
export default (app: Router) => {
app.use('/system', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const authInfo = await userService.getAuthInfo();
const { version, changeLog, changeLogLink, publishTime } =
await parseVersion(config.versionFile);
const isInitialized = !isDefaultAuthInfo(authInfo);
res.send({
code: 200,
data: {
isInitialized,
version,
publishTime: dayjs(publishTime).unix(),
branch: process.env.QL_BRANCH || 'master',
changeLog,
changeLogLink,
},
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/config',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const data = await systemService.getSystemConfig();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/log-remove-frequency',
celebrate({
body: Joi.object({
logRemoveFrequency: Joi.number().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateLogRemoveFrequency(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/cron-concurrency',
celebrate({
body: Joi.object({
cronConcurrency: Joi.number().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateCronConcurrency(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-proxy',
celebrate({
body: Joi.object({
dependenceProxy: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateDependenceProxy(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/node-mirror',
celebrate({
body: Joi.object({
nodeMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
res.setHeader('Content-type', 'application/octet-stream');
await systemService.updateNodeMirror(req.body, res);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/python-mirror',
celebrate({
body: Joi.object({
pythonMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updatePythonMirror(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/linux-mirror',
celebrate({
body: Joi.object({
linuxMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
res.setHeader('Content-type', 'application/octet-stream');
await systemService.updateLinuxMirror(req.body, res);
} catch (e) {
return next(e);
}
},
);
route.put(
'/update-check',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.checkUpdate();
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/update',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateSystem();
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/reload',
celebrate({
body: Joi.object({
type: Joi.string().optional().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/notify',
celebrate({
body: Joi.object({
title: Joi.string().required(),
content: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.notify(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/command-run',
celebrate({
body: Joi.object({
command: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
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 uniqPath = await getUniqPath(command, id);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`;
res.setHeader('Content-type', 'application/octet-stream');
await systemService.run(
{ ...req.body, logPath },
{
onStart: async (cp, startTime) => {
res.setHeader('QL-Task-Pid', `${cp.pid}`);
res.setHeader('QL-Task-Log', `${logPath}`);
},
onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end();
},
onError: async (message: string) => {
res.write(message);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
},
onLog: async (message: string) => {
res.write(message);
const absolutePath = await handleLogPath(logPath);
await logStreamManager.write(absolutePath, message);
},
},
);
} catch (e) {
return next(e);
}
},
);
route.put(
'/command-stop',
celebrate({
body: Joi.object({
command: Joi.string().optional(),
pid: Joi.number().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.stop(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/data/export',
celebrate({
body: Joi.object({
type: Joi.array().items(Joi.string()).optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.exportData(res, req.body.type);
} catch (e) {
return next(e);
}
},
);
route.put(
'/data/import',
upload.single('data'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.importData();
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.get(
'/log',
celebrate({
query: {
startTime: Joi.string().allow('').optional(),
endTime: Joi.string().allow('').optional(),
t: Joi.string().optional(),
},
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.getSystemLog(
res,
req.query as {
startTime?: string;
endTime?: string;
},
);
} catch (e) {
return next(e);
}
},
);
route.delete(
'/log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.deleteSystemLog();
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/auth/reset',
celebrate({
body: Joi.object({
retries: Joi.number().optional(),
twoFactorActivated: Joi.boolean().optional(),
password: Joi.string().optional(),
username: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const result = await userService.resetAuthInfo(req.body);
if (result) {
return res.send(result);
}
res.send({ code: 200, message: t('更新成功') });
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/timezone',
celebrate({
body: Joi.object({
timezone: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateTimezone(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/lang',
celebrate({
body: Joi.object({
lang: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateLanguage(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/panel-title',
celebrate({
body: Joi.object({
panelTitle: Joi.string().max(100).allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updatePanelTitle(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
body: Joi.object({
type: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.cleanDependence(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
};
-51
View File
@@ -1,51 +0,0 @@
import { NextFunction, Request, Response, Router } from 'express';
import Container from 'typedi';
import Logger from '../loaders/logger';
import SystemService from '../services/system';
const route = Router();
export default (app: Router) => {
app.use('/update', route);
route.put(
'/reload',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem();
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/system',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem('system');
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/data',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem('data');
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
};
-308
View File
@@ -1,308 +0,0 @@
import 'reflect-metadata';
import cluster, { type Worker } from 'cluster';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger';
import { monitoringMiddleware } from './middlewares/monitoring';
import { errStack } from './config/util';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
interface WorkerMetadata {
id: number;
pid: number;
serviceType: string;
startTime: Date;
}
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) {
await this.initializeDatabase();
}
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
await this.startWorkerProcess();
}
} catch (error) {
Logger.error(`Failed to start application:\n${errStack(error)}`);
process.exit(1);
}
}
private startMasterProcess() {
// Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
.then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
})
.catch((error) => {
Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
}
}
})
.catch((error) => {
Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
}
this.workerMetadataMap.delete(worker.id);
}
});
this.setupMasterShutdown();
}
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
this.workerMetadataMap.set(worker.id, {
id: worker.id,
pid: worker.process.pid!,
serviceType,
startTime: new Date(),
});
return worker;
}
private async initializeDatabase() {
const dbLoader = await import('./loaders/db');
await dbLoader.default();
}
private setupMiddlewares() {
this.app.use(helmet({
contentSecurityPolicy: false,
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
}
private setupMasterShutdown() {
const shutdown = async () => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
const workers = Object.values(cluster.workers || {});
const workerPromises: Promise<void>[] = [];
workers.forEach((worker) => {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`Worker ${worker.process.pid} exited`);
resolve();
});
try {
worker.send('shutdown');
} catch (error) {
Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
}
});
workerPromises.push(exitPromise);
}
});
try {
await Promise.race([
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
process.exit(1);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
try {
if (serviceType === 'http') {
await this.startHttpService();
} else {
await this.startGrpcService();
}
process.send?.('ready');
} catch (error) {
Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
process.exit(1);
}
}
private async startHttpService() {
// 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
const { initGrpcCerts } = await import('./config/grpcCerts');
await initGrpcCerts();
this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app');
await appLoader.default({ app: this.app });
const server = await this.httpServerService.initialize(
this.app,
config.port,
);
const serverLoader = await import('./loaders/server');
await (serverLoader.default as any)({ server });
this.setupWorkerShutdown('http');
}
private async startGrpcService() {
const { GrpcServerService } = await import('./services/grpc');
this.grpcServerService = Container.get(GrpcServerService);
await this.grpcServerService.initialize();
this.setupWorkerShutdown('grpc');
}
private setupWorkerShutdown(serviceType: string) {
process.on('message', async (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('[boot] Cron jobs re-registered successfully');
} catch (error) {
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
}
}
});
const shutdown = () => this.gracefulShutdown(serviceType);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async gracefulShutdown(serviceType: string) {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
try {
if (serviceType === 'http') {
await this.httpServerService?.shutdown();
} else {
await this.grpcServerService?.shutdown();
}
process.exit(0);
} catch (error) {
Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
process.exit(1);
}
}
}
const app = new Application();
app.start().catch((error) => {
Logger.error(`🙅‍♀️ Application failed to start:\n${errStack(error)}`);
process.exit(1);
});
-89
View File
@@ -1,89 +0,0 @@
import { maybeSudo } from './container';
export const LOG_END_SYMBOL = '     ';
export const TASK_COMMAND = 'task';
export const QL_COMMAND = 'ql';
export const TASK_PREFIX = `${TASK_COMMAND} `;
export const QL_PREFIX = `${QL_COMMAND} `;
export const SAMPLE_FILES = [
{
title: 'config.sample.sh',
value: 'sample/config.sample.sh',
target: 'config.sh',
},
{
title: 'notify.js',
value: 'sample/notify.js',
target: 'data/scripts/sendNotify.js',
},
{
title: 'notify.py',
value: 'sample/notify.py',
target: 'data/scripts/notify.py',
},
];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const NotificationModeStringMap = {
0: 'gotify',
1: 'goCqHttpBot',
2: 'serverChan',
3: 'pushDeer',
4: 'bark',
5: 'chat',
6: 'telegramBot',
7: 'dingtalkBot',
8: 'weWorkBot',
9: 'weWorkApp',
10: 'aibotk',
11: 'iGot',
12: 'pushPlus',
13: 'wePlusBot',
14: 'email',
15: 'pushMe',
16: 'feishu',
17: 'webhook',
18: 'chronocat',
19: 'ntfy',
20: 'wxPusherBot',
21: 'wxPusherSpt',
} as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
'Debian' | 'Ubuntu' | 'Alpine',
{
install: string;
uninstall: string;
info: string;
check(info: string): boolean;
}
> = {
Debian: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Ubuntu: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Alpine: {
install: 'apk add --no-check-certificate',
uninstall: 'apk del',
info: 'apk info -es',
check(info: string) {
return info.includes('installed');
},
},
};
-7
View File
@@ -1,7 +0,0 @@
export function isInContainer(): boolean {
return process.env.QL_CONTAINER === 'true';
}
export function maybeSudo(cmd: string): string {
return isInContainer() ? `sudo ${cmd}` : cmd;
}
-150
View File
@@ -1,150 +0,0 @@
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as os from 'os';
import path from 'path';
import config from './index';
import { fileExist } from './util';
import Logger from '../loaders/logger';
export interface GrpcTlsConfig {
caCert: string;
serverCert: string;
serverKey: string;
clientCert: string;
clientKey: string;
}
const certDir = path.join(config.configPath, 'grpc');
const caKeyPath = path.join(certDir, 'ca.key');
const caCertPath = path.join(certDir, 'ca.crt');
const serverKeyPath = path.join(certDir, 'server.key');
const serverCertPath = path.join(certDir, 'server.crt');
const clientKeyPath = path.join(certDir, 'client.key');
const clientCertPath = path.join(certDir, 'client.crt');
let cachedConfig: GrpcTlsConfig | null = null;
function run(cmd: string, execOpts?: Record<string, unknown>): string {
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
return (execSync(cmd, opts) as string).trim();
}
async function tmpFile(prefix: string): Promise<string> {
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
await fs.mkdir(dir, { recursive: true });
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
}
async function generateAllCerts(): Promise<GrpcTlsConfig> {
Logger.info('[boot] Generating gRPC mTLS certificates...');
const caKeyTmp = await tmpFile('ca_key');
const caCertTmp = await tmpFile('ca_cert');
const serverKeyTmp = await tmpFile('server_key');
const serverCsrTmp = await tmpFile('server_csr');
const serverExtTmp = await tmpFile('server_ext');
const clientKeyTmp = await tmpFile('client_key');
const clientCsrTmp = await tmpFile('client_csr');
const clientExtTmp = await tmpFile('client_ext');
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
const cleanup = async () => {
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
try { await fs.unlink(f); } catch {}
}
};
try {
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
const caCert = await fs.readFile(caCertTmp, 'utf-8');
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
// 2. 服务端
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
const serverCert = run(
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
);
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
// 3. 客户端
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
const clientCert = run(
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
);
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
await cleanup();
Logger.info('[boot] gRPC mTLS certificates generated successfully');
return { caCert, serverCert, serverKey, clientCert, clientKey };
} catch (e) {
await cleanup();
throw e;
}
}
async function saveCerts(tlsConfig: GrpcTlsConfig): Promise<void> {
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caCertPath, tlsConfig.caCert, { mode: 0o644 });
await fs.writeFile(serverCertPath, tlsConfig.serverCert, { mode: 0o644 });
await fs.writeFile(serverKeyPath, tlsConfig.serverKey, { mode: 0o600 });
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
Logger.info(`[boot] gRPC mTLS certificates saved to ${certDir}`);
}
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
const exists = await Promise.all([
fileExist(caCertPath),
fileExist(serverCertPath),
fileExist(serverKeyPath),
fileExist(clientCertPath),
fileExist(clientKeyPath),
]);
if (exists.some((e) => !e)) {
return null;
}
const [caCert, serverCert, serverKey, clientCert, clientKey] = await Promise.all([
fs.readFile(caCertPath, 'utf-8'),
fs.readFile(serverCertPath, 'utf-8'),
fs.readFile(serverKeyPath, 'utf-8'),
fs.readFile(clientCertPath, 'utf-8'),
fs.readFile(clientKeyPath, 'utf-8'),
]);
Logger.info('[boot] Loaded existing gRPC mTLS certificates from disk');
return { caCert, serverCert, serverKey, clientCert, clientKey };
}
export async function initGrpcCerts(): Promise<GrpcTlsConfig> {
if (cachedConfig) {
return cachedConfig;
}
let tlsConfig = await loadExistingCerts();
if (!tlsConfig) {
tlsConfig = await generateAllCerts();
await saveCerts(tlsConfig);
}
cachedConfig = tlsConfig;
return tlsConfig;
}
export function getGrpcCerts(): GrpcTlsConfig | null {
return cachedConfig;
}
-68
View File
@@ -1,68 +0,0 @@
import { request as undiciRequest, Dispatcher } from 'undici';
type RequestBaseOptions = {
dispatcher?: Dispatcher;
json?: Record<string, any>;
form?: string;
headers?: Record<string, string>;
} & Omit<Dispatcher.RequestOptions<null>, 'origin' | 'path' | 'method'>;
type RequestOptionsWithOptions = RequestBaseOptions &
Partial<Pick<Dispatcher.RequestOptions, 'method'>>;
type ResponseTypeMap = {
json: Record<string, any>;
text: string;
};
type ResponseTypeKey = keyof ResponseTypeMap;
async function request(
url: string,
options?: RequestOptionsWithOptions,
): Promise<Dispatcher.ResponseData<null>> {
const { json, form, body, headers = {}, ...rest } = options || {};
const finalHeaders = { ...headers } as Record<string, string>;
let finalBody = body;
if (json) {
finalHeaders['content-type'] = 'application/json';
finalBody = JSON.stringify(json);
} else if (form) {
finalBody = form;
delete finalHeaders['content-type'];
}
const res = await undiciRequest(url, {
method: 'POST',
headers: finalHeaders,
body: finalBody,
...rest,
});
return res;
}
async function post<T extends ResponseTypeKey = 'json'>(
url: string,
options?: RequestBaseOptions & { responseType?: T },
): Promise<ResponseTypeMap[T]> {
const resp = await request(url, { ...options, method: 'POST' });
const rawText = await resp.body.text();
if (options?.responseType === 'text') {
return rawText as ResponseTypeMap[T];
}
try {
return JSON.parse(rawText) as ResponseTypeMap[T];
} catch {
return rawText as ResponseTypeMap[T];
}
}
export const httpClient = {
post,
request,
};
-197
View File
@@ -1,197 +0,0 @@
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({
path: path.join(__dirname, '../../.env'),
});
interface Config {
port: number;
grpcPort: number;
bindHost: string;
bindHostGrpc: string;
nodeEnv: string;
isDevelopment: boolean;
isProduction: boolean;
jwt: {
secret: string;
expiresIn?: string;
};
cors: {
origin: string[];
methods: string[];
};
logs: {
level: string;
};
api: {
prefix: string;
};
}
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
bindHost: process.env.BIND_HOST || '::',
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
jwt: {
secret: process.env.JWT_SECRET || 'whyour-secret',
expiresIn: process.env.JWT_EXPIRES_IN,
},
cors: {
origin: process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',')
: ['*'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
},
};
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
if (!process.env.QL_DIR) {
let qlHomePath = path.join(__dirname, '../../');
if (qlHomePath.endsWith('/static/')) {
qlHomePath = path.join(qlHomePath, '../');
}
process.env.QL_DIR = qlHomePath.replace(/\/$/g, '');
}
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
let dataPath = path.join(rootPath, 'data/');
if (process.env.QL_DATA_DIR) {
dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, '');
}
const shellPath = path.join(rootPath, 'shell/');
const preloadPath = path.join(shellPath, 'preload/');
const tmpPath = path.join(rootPath, '.tmp/');
const samplePath = path.join(rootPath, 'sample/');
const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/');
const repoPath = path.join(dataPath, 'repo/');
const bakPath = path.join(dataPath, 'bak/');
const logPath = path.join(dataPath, 'log/');
const dbPath = path.join(dataPath, 'db/');
const uploadPath = path.join(dataPath, 'upload/');
const sshdPath = path.join(dataPath, 'ssh.d/');
const systemLogPath = path.join(dataPath, 'syslog/');
const dependenceCachePath = path.join(dataPath, 'dep_cache/');
const envFile = path.join(preloadPath, 'env.sh');
const jsEnvFile = path.join(preloadPath, 'env.js');
const pyEnvFile = path.join(preloadPath, 'env.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const langEnvFile = path.join(preloadPath, 'lang_env.sh');
const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json');
const extraFile = path.join(configPath, 'extra.sh');
const confBakDir = path.join(dataPath, 'config/bak/');
const sampleFile = path.join(samplePath, 'config.sample.sh');
const sqliteFile = path.join(samplePath, 'database.sqlite');
const configString = 'config sample crontab shareCode diy';
const versionFile = path.join(rootPath, 'version.yaml');
const dataTgzFile = path.join(tmpPath, 'data.tgz');
const shareShellFile = path.join(shellPath, 'share.sh');
const dependenceProxyFile = path.join(configPath, 'dependence-proxy.sh');
if (envFound.error) {
throw new Error("⚠️ Couldn't find .env file ⚠️");
}
export default {
...config,
jwt: config.jwt,
baseUrl,
rootPath,
tmpPath,
dataPath,
dataTgzFile,
shareShellFile,
dependenceProxyFile,
configString,
logPath,
extraFile,
authConfigFile,
confBakDir,
crontabFile,
sampleFile,
confFile,
envFile,
jsEnvFile,
pyEnvFile,
jsNotifyFile,
pyNotifyFile,
langEnvFile,
dbPath,
uploadPath,
configPath,
scriptPath,
repoPath,
samplePath,
blackFileList: [
'auth.json',
'config.sh.sample',
'cookie.sh',
'crontab.list',
'dependence-proxy.sh',
'env.sh',
'env.js',
'env.py',
'token.json',
'grpc',
'__pycache__',
],
writePathList: [configPath, scriptPath],
bakPath,
apiWhiteList: [
'/api/user/login',
'/api/health',
'/open/auth/token',
'/api/user/two-factor/login',
'/api/system',
'/api/user/init',
'/api/user/notification/init',
'/open/user/login',
'/open/user/two-factor/login',
'/open/system',
'/open/user/init',
'/open/user/notification/init',
],
versionFile,
lastVersionFile,
sqliteFile,
sshdPath,
systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
};
-28
View File
@@ -1,28 +0,0 @@
import { Request, Response } from 'express';
import pick from 'lodash/pick';
let pickedEnv: Record<string, string>;
function getPickedEnv() {
if (pickedEnv) return pickedEnv;
const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv', 'QL_DIR']);
if (picked.QlBaseUrl) {
if (!picked.QlBaseUrl.startsWith('/')) {
picked.QlBaseUrl = `/${picked.QlBaseUrl}`;
}
if (!picked.QlBaseUrl.endsWith('/')) {
picked.QlBaseUrl = `${picked.QlBaseUrl}/`;
}
}
pickedEnv = picked as Record<string, string>;
return picked;
}
export function serveEnv(_req: Request, res: Response) {
res.type('.js');
res.send(
Object.entries(getPickedEnv())
.map(([k, v]) => `window.__ENV__${k}=${JSON.stringify(v)};`)
.join('\n'),
);
}
-84
View File
@@ -1,84 +0,0 @@
export function createRandomString(min: number, max: number): string {
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const english = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
];
const ENGLISH = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
const special = ['-', '_'];
const config = num.concat(english).concat(ENGLISH).concat(special);
const arr = [];
arr.push(getOne(num));
arr.push(getOne(english));
arr.push(getOne(ENGLISH));
arr.push(getOne(special));
const len = min + Math.floor(Math.random() * (max - min + 1));
for (let i = 4; i < len; i++) {
arr.push(config[Math.floor(Math.random() * config.length)]);
}
const newArr = [];
for (let j = 0; j < len; j++) {
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
}
function getOne(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
return newArr.join('');
}
-46
View File
@@ -1,46 +0,0 @@
import { Subscription } from '../data/subscription';
import isNil from 'lodash/isNil';
export function formatUrl(doc: Subscription) {
let url = doc.url;
let host = '';
if (doc.type === 'private-repo') {
if (doc.pull_type === 'ssh-key') {
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
url = doc.url!.replace(host, doc.alias);
} else {
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
const { username, password } = doc.pull_option as any;
url = doc.url!.replace(host, `${username}:${password}@${host}`);
}
}
return { url, host };
}
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;
}
-780
View File
@@ -1,780 +0,0 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree';
import { promisify } from 'util';
import { load } from 'js-yaml';
import config from './index';
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
export * from './share';
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
function getOsTypeSync(): 'Debian' | 'Ubuntu' | 'Alpine' | undefined {
// 1. 环境变量覆盖
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') return 'Alpine';
if (envOs === 'debian') return 'Debian';
if (envOs === 'ubuntu') return 'Ubuntu';
// 2. 模块缓存(由 detectOS 设置)
if (osType) return osType;
// 3. 能力检测:检查包管理器二进制
try {
execSync('which apt-get', { stdio: 'ignore' });
return 'Debian';
} catch {
try {
execSync('which apk', { stdio: 'ignore' });
return 'Alpine';
} catch {
// macOS / 未知系统
}
}
return undefined;
}
export async function getFileContentByName(fileName: string) {
const _exsit = await fileExist(fileName);
if (_exsit) {
return await fs.readFile(fileName, 'utf8');
}
return '';
}
export function removeAnsi(text: string) {
return text.replace(/\x1b\[\d+m/g, '');
}
export async function getLastModifyFilePath(dir: string) {
let filePath = '';
const _exsit = await fileExist(dir);
if (_exsit) {
const arr = await fs.readdir(dir);
arr.forEach(async (item) => {
const fullpath = path.join(dir, item);
const stats = await fs.lstat(fullpath);
if (stats.isFile()) {
if (stats.mtimeMs >= 0) {
filePath = fullpath;
}
}
});
}
return filePath;
}
export function getToken(req: any) {
const { authorization = '' } = req.headers;
if (authorization && authorization.split(' ')[0] === 'Bearer') {
return (authorization as string)
.replace('Bearer ', '')
.replace('mobile-', '')
.replace('desktop-', '');
}
return '';
}
export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
const ua = userAgent.toLowerCase();
const testUa = (regexp: RegExp) => regexp.test(ua);
const testVs = (regexp: RegExp) =>
(ua.match(regexp) || [])
.toString()
.replace(/[^0-9|_.]/g, '')
.replace(/_/g, '.');
// 系统
let system = 'unknow';
if (testUa(/windows|win32|win64|wow32|wow64/g)) {
system = 'windows'; // windows系统
} else if (testUa(/macintosh|macintel/g)) {
system = 'macos'; // macos系统
} else if (testUa(/x11/g)) {
system = 'linux'; // linux系统
} else if (testUa(/android|adr/g)) {
system = 'android'; // android系统
} else if (testUa(/ios|iphone|ipad|ipod|iwatch/g)) {
system = 'ios'; // ios系统
} else if (testUa(/openharmony/g)) {
system = 'openharmony'; // openharmony系统
}
let platform = 'desktop';
if (system === 'windows' || system === 'macos' || system === 'linux') {
platform = 'desktop';
} else if (
system === 'android' ||
system === 'ios' ||
system === 'openharmony' ||
testUa(/mobile/g)
) {
platform = 'mobile';
}
return platform as 'mobile' | 'desktop';
}
export async function fileExist(file: any) {
try {
await fs.access(file);
return true;
} catch (error) {
return false;
}
}
export async function createFile(file: string, data: string = '') {
await fs.mkdir(path.dirname(file), { recursive: true });
await writeFileWithLock(file, data);
}
export async function handleLogPath(
logPath: string,
data: string = '',
): Promise<string> {
const absolutePath = path.resolve(config.logPath, logPath);
const logFileExist = await fileExist(absolutePath);
if (!logFileExist) {
await createFile(absolutePath, data);
}
return absolutePath;
}
export async function concurrentRun(
fnList: Array<() => Promise<any>> = [],
max = 5,
) {
if (!fnList.length) return;
const replyList: any[] = []; // 收集任务执行结果
const startTime = new Date().getTime(); // 记录任务执行开始时间
// 任务执行程序
const schedule = async (index: number) => {
return new Promise(async (resolve) => {
const fn = fnList[index];
if (!fn) return resolve(null);
// 执行当前异步任务
const reply = await fn();
replyList[index] = reply;
// 执行完当前任务后,继续执行任务池的剩余任务
await schedule(index + max);
resolve(null);
});
};
// 任务池执行程序
const scheduleList = new Array(max)
.fill(0)
.map((_, index) => schedule(index));
// 使用 Promise.all 批量执行
const r = await Promise.all(scheduleList);
const cost = (new Date().getTime() - startTime) / 1000;
return replyList;
}
enum FileType {
'directory',
'file',
}
export interface IFile {
title: string;
key: string;
type: 'directory' | 'file';
parent: string;
createTime: number;
size?: number;
children?: IFile[];
}
export function dirSort(a: IFile, b: IFile): number {
if (a.type === 'file' && b.type === 'file') {
return b.createTime - a.createTime;
} else if (a.type === 'directory' && b.type === 'directory') {
return a.title.localeCompare(b.title);
} else {
return a.type === 'directory' ? -1 : 1;
}
}
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);
}
export async function readDir(
dir: string,
baseDir: string = '',
blacklist: string[] = [],
): Promise<IFile[]> {
const absoluteDir = path.resolve(baseDir, dir);
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
return [];
}
const relativePath = path.relative(baseDir, absoluteDir);
try {
const files = await fs.readdir(absoluteDir);
const result: IFile[] = [];
for (const file of files) {
const subPath = path.join(absoluteDir, file);
const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file);
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) {
result.push({
title: file,
type: 'directory',
key,
parent: relativePath,
createTime: stats.birthtime.getTime(),
children: [],
});
} else {
result.push({
title: file,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
});
}
}
return result;
} catch (error: any) {
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
}
export async function promiseExec(command: string): Promise<string> {
try {
const { stderr, stdout } = await promisify(exec)(command, {
maxBuffer: 200 * 1024 * 1024,
encoding: 'utf8',
});
return stdout || stderr;
} catch (error) {
return JSON.stringify(error);
}
}
export async function promiseExecSuccess(command: string): Promise<string> {
try {
const { stdout } = await promisify(exec)(command, {
maxBuffer: 200 * 1024 * 1024,
encoding: 'utf8',
});
return stdout || '';
} catch (error) {
return '';
}
}
export function parseHeaders(headers: string) {
if (!headers) return {};
const parsed: any = {};
let key: string;
let val: string;
let i: number;
headers &&
headers.split('\n').forEach(function parser(line) {
i = line.indexOf(':');
key = line.substring(0, i).trim().toLowerCase();
val = line.substring(i + 1).trim();
if (!key) {
return;
}
parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val;
});
return parsed;
}
function parseString(
input: string,
valueFormatFn?: (v: string) => string,
): Record<string, string> {
const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g;
const matches: Record<string, string> = {};
let match;
while ((match = regex.exec(input)) !== null) {
const [, key, value] = match;
const _key = key.trim();
if (!_key || matches[_key]) {
continue;
}
let _value = value.trim();
try {
_value = valueFormatFn ? valueFormatFn(_value) : _value;
const jsonValue = JSON.parse(_value);
matches[_key] = jsonValue;
} catch (error) {
matches[_key] = _value;
}
}
return matches;
}
export function parseBody(
body: string,
contentType:
| 'application/json'
| 'multipart/form-data'
| 'application/x-www-form-urlencoded'
| 'text/plain',
valueFormatFn?: (v: string) => string,
) {
if (contentType === 'text/plain' || !body) {
return valueFormatFn && body ? valueFormatFn(body) : body;
}
const parsed = parseString(body, valueFormatFn);
switch (contentType) {
case 'multipart/form-data':
return Object.keys(parsed).reduce((p, c) => {
p.append(c, parsed[c]);
return p;
}, new FormData());
case 'application/x-www-form-urlencoded':
return Object.keys(parsed).reduce((p, c) => {
return p ? `${p}&${c}=${parsed[c]}` : `${c}=${parsed[c]}`;
});
}
return parsed;
}
export function psTree(pid: number): Promise<number[]> {
return new Promise((resolve, reject) => {
psTreeFun(pid, (err: any, children) => {
if (err) {
reject(err);
}
resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x)));
});
});
}
export async function killTask(pid: number) {
const pids = await psTree(pid);
if (pids.length) {
try {
[pid, ...pids].reverse().forEach((x) => {
process.kill(x, 15);
});
} catch (error) { }
} else {
process.kill(pid, 2);
}
}
export async function getPid(cmd: string) {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}' | head -1 | xargs echo -n`;
const pid = await promiseExec(taskCommand);
return pid ? Number(pid) : undefined;
}
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion {
version: string;
changeLogLink: string;
changeLog: string;
publishTime: string;
}
export async function parseVersion(path: string): Promise<IVersion> {
return load(await fs.readFile(path, 'utf8')) as IVersion;
}
export function parseContentVersion(content: string): IVersion {
return load(content) as IVersion;
}
export async function getUniqPath(
command: string,
id: string,
): Promise<string> {
let suffix = '';
if (/^\d+$/.test(id)) {
suffix = `_${id}`;
}
let items = command.split(/ +/);
const maxTimeCommandIndex = items.findIndex((x) => x === '-m');
if (maxTimeCommandIndex !== -1) {
items = items.slice(maxTimeCommandIndex + 2);
}
let str = items[0];
if (items[0] === TASK_COMMAND) {
str = items[1];
}
const dotIndex = str.lastIndexOf('.');
if (dotIndex !== -1) {
str = str.slice(0, dotIndex);
}
const slashIndex = str.lastIndexOf('/');
let tempStr = '';
if (slashIndex !== -1) {
tempStr = str.slice(0, slashIndex);
const _slashIndex = tempStr.lastIndexOf('/');
if (_slashIndex !== -1) {
tempStr = tempStr.slice(_slashIndex + 1);
}
str = `${tempStr}_${str.slice(slashIndex + 1)}`;
}
return `${str}${suffix}`;
}
export function safeJSONParse(value?: string) {
if (!value) {
return {};
}
try {
return JSON.parse(value);
} catch (error) {
Logger.error('[safeJSONParse error]', error);
return {};
}
}
export function errStack(error: unknown): string {
return error instanceof Error && error.stack
? error.stack
: String(error);
}
export async function rmPath(path: string) {
try {
const _exsit = await fileExist(path);
if (_exsit) {
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
}
} catch (error) {
Logger.error('[rmPath error]', error);
}
}
export async function setSystemTimezone(timezone: string): Promise<boolean> {
try {
if (!(await fileExist(`/usr/share/zoneinfo/${timezone}`))) {
throw new Error('Invalid 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]', error);
return false;
}
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
try:
from importlib.metadata import version
print(version(name))
except:
import importlib.util as u
import importlib.metadata as m
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? `apk info -es ${name}`
: maybeSudo(`dpkg-query -s ${name}`),
};
return baseCommands[type];
}
export function getInstallCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.python3]:
'pip3 install --disable-pip-version-check --root-user-action=ignore',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
};
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
}
export function getUninstallCommand(
type: DependenceTypes,
name: string,
): string {
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: maybeSudo('apt-get remove -y'),
};
return `${baseCommands[type]} ${name.trim()}`;
}
export function isDemoEnv() {
return process.env.DeployEnv === 'demo';
}
async function getOSReleaseInfo(): Promise<string> {
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
return osRelease;
}
function isDebian(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Debian');
}
function isUbuntu(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Ubuntu');
}
function isCentOS(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
}
function isAlpine(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Alpine');
}
export async function detectOS(): Promise<
'Debian' | 'Ubuntu' | 'Alpine' | undefined
> {
if (osType) return osType;
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') {
osType = 'Alpine';
return osType;
}
if (envOs === 'debian') {
osType = 'Debian';
return osType;
}
if (envOs === 'ubuntu') {
osType = 'Ubuntu';
return osType;
}
const platform = os.platform();
if (platform === 'linux') {
const osReleaseInfo = await getOSReleaseInfo();
if (isDebian(osReleaseInfo)) {
osType = 'Debian';
} else if (isUbuntu(osReleaseInfo)) {
osType = 'Ubuntu';
} else if (isAlpine(osReleaseInfo)) {
osType = 'Alpine';
} else {
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
}
} else if (platform === 'darwin') {
osType = undefined;
} else {
Logger.error(`Unsupported platform: ${platform}`);
console.error(`Unsupported platform: ${platform}`);
}
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
): Promise<string> {
const S = isInContainer() ? 'sudo ' : '';
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
const detectedOS = await detectOS();
if (!detectedOS) {
throw Error(`Unknown Linux Distribution`);
}
return await _updateLinuxMirror(detectedOS, mirror);
}
-71
View File
@@ -1,71 +0,0 @@
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'] },
],
},
);
-81
View File
@@ -1,81 +0,0 @@
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,
},
},
);
-98
View File
@@ -1,98 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { NotificationInfo } from './notify';
export class SystemInfo {
ip?: string;
type: AuthDataType;
info?: SystemModelInfo;
id?: number;
constructor(options: SystemInfo) {
this.ip = options.ip;
this.info = options.info;
this.type = options.type;
this.id = options.id;
}
}
export enum LoginStatus {
'success',
'fail',
}
export enum AuthDataType {
'loginLog' = 'loginLog',
'authToken' = 'authToken',
'notification' = 'notification',
'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig',
}
export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
logRemoveFrequency?: number;
cronConcurrency?: number;
dependenceProxy?: string;
nodeMirror?: string;
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
}
export interface LoginLogInfo {
timestamp?: number;
address?: string;
ip?: string;
platform?: string;
status?: LoginStatus;
}
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo {
username: string;
password: string;
retries: number;
lastlogon: number;
lastip: string;
lastaddr: string;
platform: string;
isTwoFactorChecking: boolean;
token: string;
tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
}
export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> &
LoginLogInfo &
Partial<AuthInfo>;
export interface SystemInstance
extends Model<SystemInfo, SystemInfo>,
SystemInfo {}
export const SystemModel = sequelize.define<SystemInstance>('Auth', {
ip: DataTypes.STRING,
type: DataTypes.STRING,
info: {
type: DataTypes.JSON,
allowNull: true,
},
});
-13
View File
@@ -1,13 +0,0 @@
export enum ScheduleType {
BOOT = '@boot',
ONCE = '@once',
}
export type ScheduleValidator = (schedule?: string) => boolean;
export type CronSchedulerPayload = {
name: string;
id: string;
schedule: string;
command: string;
extra_schedules: Array<{ schedule: string }>;
};
-28
View File
@@ -1,28 +0,0 @@
import expressLoader from './express';
import depInjectorLoader from './depInjector';
import Logger from './logger';
import initData from './initData';
import { Application } from 'express';
import linkDeps from './deps';
import initTask from './initTask';
import initFile from './initFile';
export default async ({ app }: { app: Application }) => {
depInjectorLoader();
Logger.info('[boot] Dependency loaded');
await linkDeps();
Logger.info('[boot] Link deps loaded');
await initFile();
Logger.info('[boot] Init file loaded');
await initData();
Logger.info('[boot] Init data loaded');
initTask();
Logger.info('[boot] Init task loaded');
expressLoader({ app });
Logger.info('[boot] Express loaded');
};
-8
View File
@@ -1,8 +0,0 @@
import Container from 'typedi';
import CronService from '../services/cron';
export default async () => {
const cronService = Container.get(CronService);
await cronService.bootTask();
};
-65
View File
@@ -1,65 +0,0 @@
import Logger from './logger';
import { EnvModel } from '../data/env';
import { CrontabModel } from '../data/cron';
import { DependenceModel } from '../data/dependence';
import { AppModel } from '../data/open';
import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data';
export default async () => {
try {
await CrontabModel.sync();
await DependenceModel.sync();
await AppModel.sync();
await SystemModel.sync();
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: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
{ table: 'Envs', column: 'labels', type: 'JSON' },
];
for (const migration of migrations) {
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
Logger.info('[boot] DB loaded');
} catch (error) {
Logger.error('[boot] DB load failed', error);
}
};
-51
View File
@@ -1,51 +0,0 @@
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import config from '../config/index';
import Logger from './logger';
async function linkCommand() {
const homeDir = os.homedir();
let userBinDir = path.join(homeDir, 'bin');
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const linkShell = [
{
src: 'update.sh',
dest: 'ql',
tmp: 'ql_tmp',
},
{
src: 'task.sh',
dest: 'task',
tmp: 'task_tmp',
},
];
for (const link of linkShell) {
const source = path.join(config.rootPath, 'shell', link.src);
const target = path.join(commandDir, link.dest);
const tmpTarget = path.join(commandDir, link.tmp);
try {
const stats = await fs.lstat(tmpTarget);
if (stats) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
}
}
export default async () => {
await linkCommand();
};
-225
View File
@@ -1,225 +0,0 @@
import express, { Request, Response, NextFunction, Application } from 'express';
import bodyParser from 'body-parser';
import cors from 'cors';
import routes from '../api';
import config from '../config';
import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
import { AuthInfo } from '../data/system';
import path from 'path';
import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
const frontendPath = path.join(config.rootPath, 'static/dist');
app.use(express.static(frontendPath));
app.use(
expressjwt({
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}),
);
app.use((req: Request, res, next) => {
if (!req.headers) {
req.platform = 'desktop';
} else {
const platform = getPlatform(req.headers['user-agent'] || '');
req.platform = platform;
}
return next();
});
app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (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 = 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 (!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}${pathLower === '/' ? '' : pathLower}`;
if (
!headerToken &&
originPath &&
config.apiWhiteList.includes(originPath)
) {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken
? t('Token 已失效')
: t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
});
app.use(async (req, res, next) => {
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 AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo);
if (isInitialized) {
return res.send({ code: 450, message: t('未知错误') });
} else {
return next();
}
});
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.get('*', (_, res, next) => {
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
}
});
});
app.use(errors());
app.use(
(
err: Error & { status: number },
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name === 'UnauthorizedError') {
return res
.status(err.status)
.send({ code: 401, message: err.message })
.end();
}
return next(err);
},
);
app.use(
(
err: Error & { errors: any[] },
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name.includes('Sequelize')) {
return res
.status(500)
.send({
code: 400,
message: `${err.message}`,
errors: err.errors,
})
.end();
}
return next(err);
},
);
app.use(
(
err: Error & { status: number },
req: Request,
res: Response,
next: NextFunction,
) => {
res.status(err.status || 500);
res.json({
code: err.status || 500,
message: err.message,
});
},
);
};
-249
View File
@@ -1,249 +0,0 @@
import DependenceService from '../services/dependence';
import { exec } from 'child_process';
import { Container } from 'typedi';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import CronService from '../services/cron';
import EnvService from '../services/env';
import { DependenceModel, DependenceStatus } from '../data/dependence';
import { Op } from 'sequelize';
import config from '../config';
import { CrontabViewModel, CronViewType } from '../data/cronView';
import { initPosition } from '../data/env';
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, isDemoEnv, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
import { AppModel } from '../data/open';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { setLang, systemLang } from '../shared/i18n';
export default async () => {
const cronService = Container.get(CronService);
const envService = Container.get(EnvService);
const dependenceService = Container.get(DependenceService);
const systemService = Container.get(SystemService);
const userService = Container.get(UserService);
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', 'dashboard'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
} else if (!systemApp.scopes.includes('dashboard')) {
await AppModel.update(
{ scopes: [...systemApp.scopes, 'dashboard'] },
{ where: { name: 'system' } },
);
}
const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig },
});
await SystemModel.findOrCreate({
where: { type: AuthDataType.notification },
});
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
});
if (!authConfig?.info || isDemoEnv()) {
let authInfo = {
username: 'admin',
password: 'admin',
};
try {
const authFileExist = await fileExist(config.authConfigFile);
if (authFileExist) {
const content = await readFile(config.authConfigFile, 'utf8');
authInfo = safeJSONParse(content);
}
} catch (error) {
Logger.warn('Failed to read auth config file, using default credentials');
}
await SystemModel.upsert({
id: authConfig?.id,
info: authInfo,
type: AuthDataType.authConfig,
});
}
const installDependencies = async () => {
const docs = await DependenceModel.findAll({
where: {},
order: [
['type', 'DESC'],
['createdAt', 'DESC'],
],
raw: true,
});
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: docs.map((x) => x.id!) } },
);
setTimeout(async () => {
await dependenceService.installDependenceOneByOne(docs);
const bootAfterLoader = await import('./bootAfter');
bootAfterLoader.default();
}, 5000);
};
// 初始化更新 linux/python/nodejs 镜像源配置
if (systemConfig.info?.pythonMirror) {
systemService.updatePythonMirror({
pythonMirror: systemConfig.info?.pythonMirror,
});
}
if (systemConfig.info?.linuxMirror) {
systemService.updateLinuxMirror(
{
linuxMirror: systemConfig.info?.linuxMirror,
},
undefined,
() => installDependencies(),
);
} else {
installDependencies();
}
if (systemConfig.info?.nodeMirror) {
systemService.updateNodeMirror({
nodeMirror: systemConfig.info?.nodeMirror,
});
}
// 初始化新增默认全部任务视图
CrontabViewModel.findAll({
where: { type: CronViewType., name: '全部任务' },
raw: true,
}).then((docs) => {
if (docs.length === 0) {
CrontabViewModel.create({
name: '全部任务',
type: CronViewType.,
position: initPosition / 2,
});
}
});
// 初始化更新所有任务状态为空闲
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped },
{ where: { status: InstanceStatus.running } },
);
// 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({
where: {
isDisabled: { [Op.ne]: 1 },
command: {
[Op.or]: [{ [Op.like]: `%ql repo%` }, { [Op.like]: `%ql raw%` }],
},
},
}).then((docs) => {
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
if (doc) {
exec(doc.command);
}
}
});
// 更新2.11.3以前的脚本路径
CrontabModel.findAll({
where: {
command: {
[Op.or]: [
{ [Op.like]: `%\/${config.rootPath}\/scripts\/%` },
{ [Op.like]: `%\/${config.rootPath}\/config\/%` },
{ [Op.like]: `%\/${config.rootPath}\/log\/%` },
{ [Op.like]: `%\/${config.rootPath}\/db\/%` },
],
},
},
}).then(async (docs) => {
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
if (doc) {
if (doc.command.includes(`${config.rootPath}/scripts/`)) {
await CrontabModel.update(
{ command: doc.command.replace(`${config.rootPath}/scripts/`, '') },
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/log/`)) {
await CrontabModel.update(
{
command: `${config.dataPath}/log/${doc.command.replace(
`${config.rootPath}/log/`,
'',
)}`,
},
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/config/`)) {
await CrontabModel.update(
{
command: `${config.dataPath}/config/${doc.command.replace(
`${config.rootPath}/config/`,
'',
)}`,
},
{ where: { id: doc.id } },
);
}
if (doc.command.includes(`${config.rootPath}/db/`)) {
await CrontabModel.update(
{
command: `${config.dataPath}/db/${doc.command.replace(
`${config.rootPath}/db/`,
'',
)}`,
},
{ where: { id: doc.id } },
);
}
}
}
});
// 初始化语言(必须在 autosave_crontab 之前)
const lang = systemConfig.info?.lang || systemLang();
setLang(lang);
// 确保 lang_env.sh 存在
try {
const langEnvExist = await fileExist(config.langEnvFile);
if (!langEnvExist) {
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
}
} catch { }
// 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
const apps = await openService.findApps();
await shareStore.updateAuthInfo(authInfo);
if (apps?.length) {
await shareStore.updateApps(apps);
}
};
-129
View File
@@ -1,129 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import Logger from './logger';
import { fileExist } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
const rootPath = process.env.QL_DIR as string;
let dataPath = path.join(rootPath, 'data/');
if (process.env.QL_DATA_DIR) {
dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, '');
}
const preloadPath = path.join(rootPath, 'shell/preload/');
const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/');
const logPath = path.join(dataPath, 'log/');
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');
const sampleNotifyJsFile = path.join(samplePath, 'notify.js');
const sampleNotifyPyFile = path.join(samplePath, 'notify.py');
const scriptNotifyJsFile = path.join(scriptPath, 'sendNotify.js');
const scriptNotifyPyFile = path.join(scriptPath, 'notify.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const TaskBeforeFile = path.join(configPath, 'task_before.sh');
const TaskBeforeJsFile = path.join(configPath, 'task_before.js');
const TaskBeforePyFile = path.join(configPath, 'task_before.py');
const TaskAfterFile = path.join(configPath, 'task_after.sh');
const homedir = os.homedir();
const sshPath = path.resolve(homedir, '.ssh');
const sshdPath = path.join(dataPath, 'ssh.d');
const systemLogPath = path.join(dataPath, 'syslog');
const directories = [
configPath,
scriptPath,
preloadPath,
logPath,
tmpPath,
rootTmpPath,
uploadPath,
sshPath,
bakPath,
sshdPath,
systemLogPath,
];
const files = [
{
target: confFile,
source: sampleConfigFile,
checkExistence: true,
},
{
target: jsNotifyFile,
source: sampleNotifyJsFile,
checkExistence: false,
},
{
target: pyNotifyFile,
source: sampleNotifyPyFile,
checkExistence: false,
},
{
target: scriptNotifyJsFile,
source: sampleNotifyJsFile,
checkExistence: true,
},
{
target: scriptNotifyPyFile,
source: sampleNotifyPyFile,
checkExistence: true,
},
{
target: TaskBeforeFile,
source: sampleTaskShellFile,
checkExistence: true,
},
{
target: TaskBeforeJsFile,
content:
'// The JavaScript code that executes before the JavaScript task execution will execute.',
checkExistence: true,
},
{
target: TaskBeforePyFile,
content:
'# The Python code that executes before the Python task execution will execute.',
checkExistence: true,
},
{
target: TaskAfterFile,
source: sampleTaskShellFile,
checkExistence: true,
},
];
export default async () => {
for (const dirPath of directories) {
if (!(await fileExist(dirPath))) {
await fs.mkdir(dirPath);
}
}
for (const item of files) {
const exists = await fileExist(item.target);
if (!item.checkExistence || !exists) {
if (!item.content && !item.source) {
throw new Error(
`Neither content nor source specified for ${item.target}`,
);
}
const content =
item.content ||
(await fs.readFile(item.source!, { encoding: 'utf-8' }));
await writeFileWithLock(item.target, content);
}
}
Logger.info('[boot] Init file down');
};
-75
View File
@@ -1,75 +0,0 @@
import { Container } from 'typedi';
import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
import { t } from '../shared/i18n';
export default async () => {
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token
let tokenCommand = `ts-node-transpile-only ${join(
config.rootPath,
'back/token.ts',
)}`;
const tokenFile = join(config.rootPath, 'static/build/token.js');
if (await fileExist(tokenFile)) {
tokenCommand = `node ${tokenFile}`;
}
const cron = {
id: NaN,
name: t('生成token'),
command: tokenCommand,
runOrigin: 'system',
} as ScheduleTaskType;
await scheduleService.cancelIntervalTask(cron);
scheduleService.createIntervalTask(
cron,
{
days: 28,
},
true,
);
// 运行删除日志任务
const data = await systemService.getSystemConfig();
if (data && data.info) {
if (data.info.logRemoveFrequency) {
const rmlogCron = {
id: data.id as number,
name: t('删除日志'),
command: `ql rmlog ${data.info.logRemoveFrequency}`,
runOrigin: 'system' as const,
};
await scheduleService.cancelIntervalTask(rmlogCron);
scheduleService.createIntervalTask(
rmlogCron,
{
days: data.info.logRemoveFrequency,
},
true,
);
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
}
await subscriptionService.setSshConfig();
const subs = await subscriptionService.list();
for (const sub of subs) {
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
}
};
-63
View File
@@ -1,63 +0,0 @@
import winston from 'winston';
import 'winston-daily-rotate-file';
import config from '../config';
import path from 'path';
const levelMap: Record<string, string> = {
info: '️', // info图标
warn: '⚠️', // 警告图标
error: '❌', // 错误图标
debug: '🐛', // debug调试图标
};
const baseFormat = [
winston.format.splat(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.align(),
];
const consoleFormat = winston.format.combine(
winston.format.colorize({ level: true }),
...baseFormat,
winston.format.printf((info) => {
return `[${info.level} ${info.timestamp}]:${info.message}`;
}),
);
const plainFormat = winston.format.combine(
winston.format.uncolorize(),
...baseFormat,
winston.format.printf((info) => {
return `[${levelMap[info.level] || ''}${info.level} ${info.timestamp}]:${
info.message
}`;
}),
);
const consoleTransport = new winston.transports.Console({
format: consoleFormat,
level: 'debug',
});
const fileTransport = new winston.transports.DailyRotateFile({
filename: path.join(config.systemLogPath, '%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '7d',
format: plainFormat,
level: config.logs.level || 'info',
});
const LoggerInstance = winston.createLogger({
level: 'debug',
levels: winston.config.npm.levels,
transports: [consoleTransport, fileTransport],
exceptionHandlers: [consoleTransport, fileTransport],
rejectionHandlers: [consoleTransport, fileTransport],
});
LoggerInstance.on('error', (error) => {
console.error('Logger error:', error);
});
export default LoggerInstance;
-16
View File
@@ -1,16 +0,0 @@
import { Server } from 'http';
import Logger from './logger';
import Sock from './sock';
export default async ({ server }: { server: Server }) => {
await Sock({ server });
Logger.info('[boot] Sock loaded');
process.on('uncaughtException', (error) => {
Logger.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
Logger.error('Unhandled rejection:', reason, promise);
});
};
-41
View File
@@ -1,41 +0,0 @@
import sockJs from 'sockjs';
import { Server } from 'http';
import { Container } from 'typedi';
import SockService from '../services/sock';
import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import config from '../config';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
const sockService = Container.get(SockService);
echo.on('connection', async (conn) => {
if (!conn.headers || !conn.url || !conn.pathname) {
conn.close('404');
}
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (isValidToken(authInfo, headerToken, platform)) {
sockService.addClient(conn);
conn.on('data', (message) => {
conn.write(message);
});
conn.on('close', function () {
sockService.removeClient(conn);
});
return;
}
conn.close('404');
});
echo.installHandlers(server);
};
-80
View File
@@ -1,80 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import Logger from '../loaders/logger';
import { performance } from 'perf_hooks';
import { metricsService } from '../services/metrics';
interface RequestMetrics {
method: string;
path: string;
duration: number;
statusCode: number;
timestamp: number;
platform?: string;
}
const requestMetrics: RequestMetrics[] = [];
export const monitoringMiddleware = (
req: Request,
res: Response,
next: NextFunction,
) => {
const start = performance.now();
const originalEnd = res.end;
res.end = function (chunk?: any, encoding?: any, cb?: any) {
const duration = performance.now() - start;
const metric: RequestMetrics = {
method: req.method,
path: req.path,
duration,
statusCode: res.statusCode,
timestamp: Date.now(),
platform: req.platform,
};
requestMetrics.push(metric);
metricsService.record('http_request', duration, {
method: req.method,
path: req.path,
statusCode: res.statusCode.toString(),
...(req.platform && { platform: req.platform }),
});
if (requestMetrics.length > 1000) {
requestMetrics.shift();
}
if (duration > 1000) {
Logger.warn(
`Slow request detected: ${req.method} ${
req.path
} took ${duration.toFixed(2)}ms`,
);
}
return originalEnd.call(this, chunk, encoding, cb);
};
next();
};
export const getMetrics = () => {
return {
totalRequests: requestMetrics.length,
averageDuration:
requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) /
requestMetrics.length,
requestsByMethod: requestMetrics.reduce((acc, curr) => {
acc[curr.method] = (acc[curr.method] || 0) + 1;
return acc;
}, {} as Record<string, number>),
requestsByPlatform: requestMetrics.reduce((acc, curr) => {
if (curr.platform) {
acc[curr.platform] = (acc[curr.platform] || 0) + 1;
}
return acc;
}, {} as Record<string, number>),
recentRequests: requestMetrics.slice(-10),
};
};
-278
View File
@@ -1,278 +0,0 @@
syntax = "proto3";
package com.ql.api;
message EnvItem {
optional int32 id = 1;
optional string name = 2;
optional string value = 3;
optional string remarks = 4;
optional int32 status = 5;
optional int64 position = 6;
}
message GetEnvsRequest { string searchValue = 1; }
message CreateEnvRequest { repeated EnvItem envs = 1; }
message UpdateEnvRequest { EnvItem env = 1; }
message DeleteEnvsRequest { repeated int32 ids = 1; }
message MoveEnvRequest {
int32 id = 1;
int32 fromIndex = 2;
int32 toIndex = 3;
}
message DisableEnvsRequest { repeated int32 ids = 1; }
message EnableEnvsRequest { repeated int32 ids = 1; }
message UpdateEnvNamesRequest {
repeated int32 ids = 1;
string name = 2;
}
message GetEnvByIdRequest { int32 id = 1; }
message EnvsResponse {
int32 code = 1;
repeated EnvItem data = 2;
optional string message = 3;
}
message EnvResponse {
int32 code = 1;
EnvItem data = 2;
optional string message = 3;
}
message Response {
int32 code = 1;
optional string message = 2;
}
message ExtraScheduleItem { string schedule = 1; }
message CronItem {
optional int32 id = 1;
optional string command = 2;
optional string schedule = 3;
optional string name = 4;
repeated string labels = 5;
optional int32 sub_id = 6;
repeated ExtraScheduleItem extra_schedules = 7;
optional string task_before = 8;
optional string task_after = 9;
optional int32 status = 10;
optional string log_path = 11;
optional int32 pid = 12;
optional int64 last_running_time = 13;
optional int64 last_execution_time = 14;
}
message CreateCronRequest {
string command = 1;
string schedule = 2;
optional string name = 3;
repeated string labels = 4;
optional int32 sub_id = 5;
repeated ExtraScheduleItem extra_schedules = 6;
optional string task_before = 7;
optional string task_after = 8;
}
message UpdateCronRequest {
int32 id = 1;
optional string command = 2;
optional string schedule = 3;
optional string name = 4;
repeated string labels = 5;
optional int32 sub_id = 6;
repeated ExtraScheduleItem extra_schedules = 7;
optional string task_before = 8;
optional string task_after = 9;
}
message DeleteCronsRequest { repeated int32 ids = 1; }
message GetCronsRequest {
optional string searchValue = 1;
}
message GetCronByIdRequest { int32 id = 1; }
message EnableCronsRequest { repeated int32 ids = 1; }
message DisableCronsRequest { repeated int32 ids = 1; }
message RunCronsRequest { repeated int32 ids = 1; }
message CronsResponse {
int32 code = 1;
repeated CronItem data = 2;
optional string message = 3;
}
message CronResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
message CronDetailRequest { string log_path = 1; }
message CronDetailResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
enum NotificationMode {
gotify = 0;
goCqHttpBot = 1;
serverChan = 2;
pushDeer = 3;
bark = 4;
chat = 5;
telegramBot = 6;
dingtalkBot = 7;
weWorkBot = 8;
weWorkApp = 9;
aibotk = 10;
iGot = 11;
pushPlus = 12;
wePlusBot = 13;
email = 14;
pushMe = 15;
feishu = 16;
webhook = 17;
chronocat = 18;
ntfy = 19;
wxPusherBot = 20;
wxPusherSpt = 21;
}
message NotificationInfo {
NotificationMode type = 1;
optional string gotifyUrl = 2;
optional string gotifyToken = 3;
optional int32 gotifyPriority = 4;
optional string goCqHttpBotUrl = 5;
optional string goCqHttpBotToken = 6;
optional string goCqHttpBotQq = 7;
optional string serverChanKey = 8;
optional string pushDeerKey = 9;
optional string pushDeerUrl = 10;
optional string synologyChatUrl = 11;
optional string barkPush = 12;
optional string barkIcon = 13;
optional string barkSound = 14;
optional string barkGroup = 15;
optional string barkLevel = 16;
optional string barkUrl = 17;
optional string barkArchive = 18;
optional string telegramBotToken = 19;
optional string telegramBotUserId = 20;
optional string telegramBotProxyHost = 21;
optional string telegramBotProxyPort = 22;
optional string telegramBotProxyAuth = 23;
optional string telegramBotApiHost = 24;
optional string dingtalkBotToken = 25;
optional string dingtalkBotSecret = 26;
optional string weWorkBotKey = 27;
optional string weWorkOrigin = 28;
optional string weWorkAppKey = 29;
optional string aibotkKey = 30;
optional string aibotkType = 31;
optional string aibotkName = 32;
optional string iGotPushKey = 33;
optional string pushPlusToken = 34;
optional string pushPlusUser = 35;
optional string pushPlusTemplate = 36;
optional string pushplusChannel = 37;
optional string pushplusWebhook = 38;
optional string pushplusCallbackUrl = 39;
optional string pushplusTo = 40;
optional string wePlusBotToken = 41;
optional string wePlusBotReceiver = 42;
optional string wePlusBotVersion = 43;
optional string emailService = 44;
optional string emailUser = 45;
optional string emailPass = 46;
optional string emailTo = 47;
optional string pushMeKey = 48;
optional string pushMeUrl = 49;
optional string chronocatURL = 50;
optional string chronocatQQ = 51;
optional string chronocatToken = 52;
optional string webhookHeaders = 53;
optional string webhookBody = 54;
optional string webhookUrl = 55;
optional string webhookMethod = 56;
optional string webhookContentType = 57;
optional string larkKey = 58;
optional string larkSecret = 69;
optional string ntfyUrl = 59;
optional string ntfyTopic = 60;
optional string ntfyPriority = 61;
optional string ntfyToken = 62;
optional string ntfyUsername = 63;
optional string ntfyPassword = 64;
optional string ntfyActions = 65;
optional string wxPusherBotAppToken = 66;
optional string wxPusherBotTopicIds = 67;
optional string wxPusherBotUids = 68;
optional string wxPusherSptList = 70;
}
message SystemNotifyRequest {
string title = 1;
string content = 2;
optional NotificationInfo notificationInfo = 3;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
rpc UpdateEnv(UpdateEnvRequest) returns (EnvResponse) {}
rpc DeleteEnvs(DeleteEnvsRequest) returns (Response) {}
rpc MoveEnv(MoveEnvRequest) returns (EnvResponse) {}
rpc DisableEnvs(DisableEnvsRequest) returns (Response) {}
rpc EnableEnvs(EnableEnvsRequest) returns (Response) {}
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
}
-4764
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
syntax = "proto3";
package com.ql.cron;
service Cron {
rpc addCron(AddCronRequest) returns (AddCronResponse);
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
}
message ISchedule { string schedule = 1; }
message ICron {
string id = 1;
string schedule = 2;
string command = 3;
repeated ISchedule extra_schedules = 4;
string name = 5;
}
message AddCronRequest { repeated ICron crons = 1; }
message AddCronResponse {}
message DeleteCronRequest { repeated string ids = 1; }
message DeleteCronResponse {}
-525
View File
@@ -1,525 +0,0 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// source: back/protos/cron.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
type CallOptions,
ChannelCredentials,
Client,
type ClientOptions,
type ClientUnaryCall,
type handleUnaryCall,
makeGenericClientConstructor,
Metadata,
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
export const protobufPackage = "com.ql.cron";
export interface ISchedule {
schedule: string;
}
export interface ICron {
id: string;
schedule: string;
command: string;
extra_schedules: ISchedule[];
name: string;
}
export interface AddCronRequest {
crons: ICron[];
}
export interface AddCronResponse {
}
export interface DeleteCronRequest {
ids: string[];
}
export interface DeleteCronResponse {
}
function createBaseISchedule(): ISchedule {
return { schedule: "" };
}
export const ISchedule: MessageFns<ISchedule> = {
encode(message: ISchedule, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.schedule !== "") {
writer.uint32(10).string(message.schedule);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ISchedule {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseISchedule();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.schedule = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ISchedule {
return { schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "" };
},
toJSON(message: ISchedule): unknown {
const obj: any = {};
if (message.schedule !== "") {
obj.schedule = message.schedule;
}
return obj;
},
create<I extends Exact<DeepPartial<ISchedule>, I>>(base?: I): ISchedule {
return ISchedule.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ISchedule>, I>>(object: I): ISchedule {
const message = createBaseISchedule();
message.schedule = object.schedule ?? "";
return message;
},
};
function createBaseICron(): ICron {
return { id: "", schedule: "", command: "", extra_schedules: [], name: "" };
}
export const ICron: MessageFns<ICron> = {
encode(message: ICron, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id);
}
if (message.schedule !== "") {
writer.uint32(18).string(message.schedule);
}
if (message.command !== "") {
writer.uint32(26).string(message.command);
}
for (const v of message.extra_schedules) {
ISchedule.encode(v!, writer.uint32(34).fork()).join();
}
if (message.name !== "") {
writer.uint32(42).string(message.name);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ICron {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseICron();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.schedule = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.command = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.extra_schedules.push(ISchedule.decode(reader, reader.uint32()));
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.name = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ICron {
return {
id: isSet(object.id) ? globalThis.String(object.id) : "",
schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "",
command: isSet(object.command) ? globalThis.String(object.command) : "",
extra_schedules: globalThis.Array.isArray(object?.extra_schedules)
? object.extra_schedules.map((e: any) => ISchedule.fromJSON(e))
: [],
name: isSet(object.name) ? globalThis.String(object.name) : "",
};
},
toJSON(message: ICron): unknown {
const obj: any = {};
if (message.id !== "") {
obj.id = message.id;
}
if (message.schedule !== "") {
obj.schedule = message.schedule;
}
if (message.command !== "") {
obj.command = message.command;
}
if (message.extra_schedules?.length) {
obj.extra_schedules = message.extra_schedules.map((e) => ISchedule.toJSON(e));
}
if (message.name !== "") {
obj.name = message.name;
}
return obj;
},
create<I extends Exact<DeepPartial<ICron>, I>>(base?: I): ICron {
return ICron.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ICron>, I>>(object: I): ICron {
const message = createBaseICron();
message.id = object.id ?? "";
message.schedule = object.schedule ?? "";
message.command = object.command ?? "";
message.extra_schedules = object.extra_schedules?.map((e) => ISchedule.fromPartial(e)) || [];
message.name = object.name ?? "";
return message;
},
};
function createBaseAddCronRequest(): AddCronRequest {
return { crons: [] };
}
export const AddCronRequest: MessageFns<AddCronRequest> = {
encode(message: AddCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.crons) {
ICron.encode(v!, writer.uint32(10).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): AddCronRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.crons.push(ICron.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): AddCronRequest {
return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
},
toJSON(message: AddCronRequest): unknown {
const obj: any = {};
if (message.crons?.length) {
obj.crons = message.crons.map((e) => ICron.toJSON(e));
}
return obj;
},
create<I extends Exact<DeepPartial<AddCronRequest>, I>>(base?: I): AddCronRequest {
return AddCronRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
const message = createBaseAddCronRequest();
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
return message;
},
};
function createBaseAddCronResponse(): AddCronResponse {
return {};
}
export const AddCronResponse: MessageFns<AddCronResponse> = {
encode(_: AddCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): AddCronResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(_: any): AddCronResponse {
return {};
},
toJSON(_: AddCronResponse): unknown {
const obj: any = {};
return obj;
},
create<I extends Exact<DeepPartial<AddCronResponse>, I>>(base?: I): AddCronResponse {
return AddCronResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AddCronResponse>, I>>(_: I): AddCronResponse {
const message = createBaseAddCronResponse();
return message;
},
};
function createBaseDeleteCronRequest(): DeleteCronRequest {
return { ids: [] };
}
export const DeleteCronRequest: MessageFns<DeleteCronRequest> = {
encode(message: DeleteCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.ids) {
writer.uint32(10).string(v!);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.ids.push(reader.string());
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DeleteCronRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.String(e)) : [] };
},
toJSON(message: DeleteCronRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids;
}
return obj;
},
create<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(base?: I): DeleteCronRequest {
return DeleteCronRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(object: I): DeleteCronRequest {
const message = createBaseDeleteCronRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseDeleteCronResponse(): DeleteCronResponse {
return {};
}
export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
encode(_: DeleteCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(_: any): DeleteCronResponse {
return {};
},
toJSON(_: DeleteCronResponse): unknown {
const obj: any = {};
return obj;
},
create<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(base?: I): DeleteCronResponse {
return DeleteCronResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(_: I): DeleteCronResponse {
const message = createBaseDeleteCronResponse();
return message;
},
};
export type CronService = typeof CronService;
export const CronService = {
addCron: {
path: "/com.ql.cron.Cron/addCron",
requestStream: false,
responseStream: false,
requestSerialize: (value: AddCronRequest) => Buffer.from(AddCronRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => AddCronRequest.decode(value),
responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
},
delCron: {
path: "/com.ql.cron.Cron/delCron",
requestStream: false,
responseStream: false,
requestSerialize: (value: DeleteCronRequest) => Buffer.from(DeleteCronRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => DeleteCronRequest.decode(value),
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
},
} as const;
export interface CronServer extends UntypedServiceImplementation {
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
}
export interface CronClient extends Client {
addCron(
request: AddCronRequest,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
addCron(
request: AddCronRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
addCron(
request: AddCronRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
delCron(
request: DeleteCronRequest,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall;
delCron(
request: DeleteCronRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall;
delCron(
request: DeleteCronRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall;
}
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): CronClient;
service: typeof CronService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
-22
View File
@@ -1,22 +0,0 @@
syntax = "proto3";
package com.ql.health;
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3;
}
ServingStatus status = 1;
}
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
-275
View File
@@ -1,275 +0,0 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// source: back/protos/health.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
type CallOptions,
ChannelCredentials,
Client,
type ClientOptions,
type ClientReadableStream,
type ClientUnaryCall,
type handleServerStreamingCall,
type handleUnaryCall,
makeGenericClientConstructor,
Metadata,
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
export const protobufPackage = "com.ql.health";
export interface HealthCheckRequest {
service: string;
}
export interface HealthCheckResponse {
status: HealthCheckResponse_ServingStatus;
}
export enum HealthCheckResponse_ServingStatus {
UNKNOWN = 0,
SERVING = 1,
NOT_SERVING = 2,
SERVICE_UNKNOWN = 3,
UNRECOGNIZED = -1,
}
export function healthCheckResponse_ServingStatusFromJSON(object: any): HealthCheckResponse_ServingStatus {
switch (object) {
case 0:
case "UNKNOWN":
return HealthCheckResponse_ServingStatus.UNKNOWN;
case 1:
case "SERVING":
return HealthCheckResponse_ServingStatus.SERVING;
case 2:
case "NOT_SERVING":
return HealthCheckResponse_ServingStatus.NOT_SERVING;
case 3:
case "SERVICE_UNKNOWN":
return HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN;
case -1:
case "UNRECOGNIZED":
default:
return HealthCheckResponse_ServingStatus.UNRECOGNIZED;
}
}
export function healthCheckResponse_ServingStatusToJSON(object: HealthCheckResponse_ServingStatus): string {
switch (object) {
case HealthCheckResponse_ServingStatus.UNKNOWN:
return "UNKNOWN";
case HealthCheckResponse_ServingStatus.SERVING:
return "SERVING";
case HealthCheckResponse_ServingStatus.NOT_SERVING:
return "NOT_SERVING";
case HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN:
return "SERVICE_UNKNOWN";
case HealthCheckResponse_ServingStatus.UNRECOGNIZED:
default:
return "UNRECOGNIZED";
}
}
function createBaseHealthCheckRequest(): HealthCheckRequest {
return { service: "" };
}
export const HealthCheckRequest: MessageFns<HealthCheckRequest> = {
encode(message: HealthCheckRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.service !== "") {
writer.uint32(10).string(message.service);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.service = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): HealthCheckRequest {
return { service: isSet(object.service) ? globalThis.String(object.service) : "" };
},
toJSON(message: HealthCheckRequest): unknown {
const obj: any = {};
if (message.service !== "") {
obj.service = message.service;
}
return obj;
},
create<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(base?: I): HealthCheckRequest {
return HealthCheckRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(object: I): HealthCheckRequest {
const message = createBaseHealthCheckRequest();
message.service = object.service ?? "";
return message;
},
};
function createBaseHealthCheckResponse(): HealthCheckResponse {
return { status: 0 };
}
export const HealthCheckResponse: MessageFns<HealthCheckResponse> = {
encode(message: HealthCheckResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.status !== 0) {
writer.uint32(8).int32(message.status);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.status = reader.int32() as any;
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): HealthCheckResponse {
return { status: isSet(object.status) ? healthCheckResponse_ServingStatusFromJSON(object.status) : 0 };
},
toJSON(message: HealthCheckResponse): unknown {
const obj: any = {};
if (message.status !== 0) {
obj.status = healthCheckResponse_ServingStatusToJSON(message.status);
}
return obj;
},
create<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(base?: I): HealthCheckResponse {
return HealthCheckResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(object: I): HealthCheckResponse {
const message = createBaseHealthCheckResponse();
message.status = object.status ?? 0;
return message;
},
};
export type HealthService = typeof HealthService;
export const HealthService = {
check: {
path: "/com.ql.health.Health/Check",
requestStream: false,
responseStream: false,
requestSerialize: (value: HealthCheckRequest) => Buffer.from(HealthCheckRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value),
responseSerialize: (value: HealthCheckResponse) => Buffer.from(HealthCheckResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value),
},
watch: {
path: "/com.ql.health.Health/Watch",
requestStream: false,
responseStream: true,
requestSerialize: (value: HealthCheckRequest) => Buffer.from(HealthCheckRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value),
responseSerialize: (value: HealthCheckResponse) => Buffer.from(HealthCheckResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value),
},
} as const;
export interface HealthServer extends UntypedServiceImplementation {
check: handleUnaryCall<HealthCheckRequest, HealthCheckResponse>;
watch: handleServerStreamingCall<HealthCheckRequest, HealthCheckResponse>;
}
export interface HealthClient extends Client {
check(
request: HealthCheckRequest,
callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
): ClientUnaryCall;
check(
request: HealthCheckRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
): ClientUnaryCall;
check(
request: HealthCheckRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
): ClientUnaryCall;
watch(request: HealthCheckRequest, options?: Partial<CallOptions>): ClientReadableStream<HealthCheckResponse>;
watch(
request: HealthCheckRequest,
metadata?: Metadata,
options?: Partial<CallOptions>,
): ClientReadableStream<HealthCheckResponse>;
}
export const HealthClient = makeGenericClientConstructor(HealthService, "com.ql.health.Health") as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): HealthClient;
service: typeof HealthService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
-141
View File
@@ -1,141 +0,0 @@
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger';
import { tf } from '../shared/i18n';
/**
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
* 提前拦截避免走 scheduleJob 后才发现无效。
*/
const isValidCronField = (cron: string): boolean => {
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
// node-schedule 会对这种字段返回 null
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
return false;
}
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
// cron-parser 接受但 node-schedule 拒绝,提前拦截
if (/\?/.test(cron)) {
return false;
}
return true;
};
const addCron = (
call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
callback: sendUnaryData<AddCronResponse>,
) => {
// ===== 第一遍:预校验所有 cron 表达式 =====
const validationErrors: string[] = [];
for (const item of call.request.crons) {
const { id, schedule, extra_schedules } = item;
if (!isValidCronField(schedule)) {
validationErrors.push(
tf(
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
String(id),
schedule,
),
);
}
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
if (!isValidCronField(x.schedule)) {
validationErrors.push(
tf(
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
String(id),
x.schedule,
),
);
}
});
}
}
if (validationErrors.length > 0) {
const details = validationErrors.join('\n');
const err: any = new Error(details);
err.code = status.INVALID_ARGUMENT;
err.details = details;
callback(err, null);
return;
}
// ===== 第二遍:注册所有任务 =====
for (const item of call.request.crons) {
const { id, schedule, command, extra_schedules, name } = item;
// 取消该 id 已有的旧任务
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel());
}
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
schedule,
command,
);
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
command,
);
});
}
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!mainJob) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
schedule,
);
}
const extraJobs = extra_schedules?.length
? extra_schedules.map((x) => {
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!job) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
x.schedule,
);
}
return job;
})
: [];
// 过滤 null(兜底保护,正常情况下预校验已拦截)
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
if (jobs.length > 0) {
scheduleStacks.set(id, jobs);
}
}
callback(null, null);
};
export { addCron };
-444
View File
@@ -1,444 +0,0 @@
import 'reflect-metadata';
import { Container } from 'typedi';
import EnvService from '../services/env';
import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js';
import {
CreateEnvRequest,
CronItem,
DeleteEnvsRequest,
DisableEnvsRequest,
EnableEnvsRequest,
EnvItem,
EnvResponse,
EnvsResponse,
GetEnvByIdRequest,
GetEnvsRequest,
MoveEnvRequest,
Response,
SystemNotifyRequest,
UpdateEnvNamesRequest,
UpdateEnvRequest,
} from '../protos/api';
import LoggerInstance from '../loaders/logger';
import pick from 'lodash/pick';
import SystemService from '../services/system';
import CronService from '../services/cron';
import {
CronDetailRequest,
CronDetailResponse,
CreateCronRequest,
UpdateCronRequest,
DeleteCronsRequest,
CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
Container.set('logger', LoggerInstance);
export const getEnvs = async (
call: ServerUnaryCall<GetEnvsRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>,
) => {
try {
const envService = Container.get(EnvService);
const data = await envService.envs(call.request.searchValue);
callback(null, {
code: 200,
data: data.map((x) => ({ ...x, remarks: x.remarks || '' })),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const createEnv = async (
call: ServerUnaryCall<CreateEnvRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>,
) => {
try {
const envService = Container.get(EnvService);
const data = await envService.create(call.request.envs);
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const updateEnv = async (
call: ServerUnaryCall<UpdateEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
if (!call.request.env?.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.update(
pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem,
);
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const deleteEnvs = async (
call: ServerUnaryCall<DeleteEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.remove(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const moveEnv = async (
call: ServerUnaryCall<MoveEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.move(call.request.id, {
fromIndex: call.request.fromIndex,
toIndex: call.request.toIndex,
});
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const disableEnvs = async (
call: ServerUnaryCall<DisableEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const enableEnvs = async (
call: ServerUnaryCall<EnableEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const updateEnvNames = async (
call: ServerUnaryCall<UpdateEnvNamesRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.updateNames({
ids: call.request.ids,
name: call.request.name,
});
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const getEnvById = async (
call: ServerUnaryCall<GetEnvByIdRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.getDb({ id: call.request.id });
callback(null, {
code: 200,
data: { ...data, remarks: data.remarks || '' },
});
} catch (e: any) {
callback(e);
}
};
export const systemNotify = async (
call: ServerUnaryCall<SystemNotifyRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
callback(e);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
return {
...data,
sub_id: data.sub_id ?? undefined,
extra_schedules: data.extra_schedules ?? [],
pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined,
};
};
export const getCronDetail = async (
call: ServerUnaryCall<CronDetailRequest, CronDetailResponse>,
callback: sendUnaryData<CronDetailResponse>,
) => {
try {
if (!call.request.log_path) {
return callback(null, {
code: 400,
data: undefined,
message: 'log_path is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.find({
log_path: call.request.log_path,
})) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const createCron = async (
call: ServerUnaryCall<CreateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const data = (await cronService.create(call.request)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const updateCron = async (
call: ServerUnaryCall<UpdateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const { id, ...fields } = call.request;
const updateRequest = {
id,
...Object.entries(fields).reduce((acc: any, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {}),
} as UpdateCronRequest;
const data = (await cronService.update(updateRequest)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const deleteCrons = async (
call: ServerUnaryCall<DeleteCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const cronService = Container.get(CronService);
await cronService.remove(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
) => {
try {
const cronService = Container.get(CronService);
const result = await cronService.crontabs({
searchValue: call.request.searchValue || '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
});
const data = result.data.map((x) => normalizeCronData(x as CronItem));
callback(null, {
code: 200,
data: data.filter((x): x is CronItem => x !== undefined),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(null, {
code: 404,
data: undefined,
message: e.message,
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
-54
View File
@@ -1,54 +0,0 @@
import { credentials } from '@grpc/grpc-js';
import {
AddCronRequest,
AddCronResponse,
CronClient,
DeleteCronRequest,
DeleteCronResponse,
} from '../protos/cron';
import config from '../config';
import { getGrpcCerts } from '../config/grpcCerts';
class Client {
private _client: CronClient | null = null;
private get client(): CronClient {
if (!this._client) {
const tlsConfig = getGrpcCerts()!;
this._client = new CronClient(
`localhost:${config.grpcPort}`,
credentials.createSsl(
Buffer.from(tlsConfig.caCert),
Buffer.from(tlsConfig.clientKey),
Buffer.from(tlsConfig.clientCert),
),
{ 'grpc.enable_http_proxy': 0 },
);
}
return this._client;
}
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
return new Promise((resolve, reject) => {
this.client.addCron({ crons: request }, (err, res) => {
if (err) {
reject(err);
}
resolve(res);
});
});
}
delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
return new Promise((resolve, reject) => {
this.client.delCron({ ids: request }, (err, res) => {
if (err) {
reject(err);
}
resolve(res);
});
});
}
}
export default new Client();
-6
View File
@@ -1,6 +0,0 @@
import nodeSchedule from 'node-schedule';
import { ToadScheduler } from 'toad-scheduler';
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>();
export const intervalSchedule = new ToadScheduler();
-37
View File
@@ -1,37 +0,0 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data';
import Logger from '../loaders/logger';
const delCron = (
call: ServerUnaryCall<DeleteCronRequest, DeleteCronResponse>,
callback: sendUnaryData<DeleteCronResponse>,
) => {
for (const id of call.request.ids) {
if (scheduleStacks.has(id)) {
Logger.info(
'[schedule][取消定时任务] 任务ID: %s',
id,
);
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
id,
error?.message || error,
);
}
});
scheduleStacks.delete(id);
}
}
callback(null, null);
};
export { delCron };
-32
View File
@@ -1,32 +0,0 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
import config from '../config';
import { promiseExec } from '../config/util';
const check = async (
call: ServerUnaryCall<HealthCheckRequest, HealthCheckResponse>,
callback: sendUnaryData<HealthCheckResponse>,
) => {
switch (call.request.service) {
case 'cron':
const res = await promiseExec(
`curl -s --noproxy '*' http://localhost:${config.port}/api/system`,
);
if (res.includes('200')) {
return callback(null, { status: 1 });
}
const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
);
return callback(
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
);
default:
return callback(null, { status: 1 });
}
};
export { check };
-49
View File
@@ -1,49 +0,0 @@
import { Service, Inject } from 'typedi';
import path, { join } from 'path';
import config from '../config';
import { getFileContentByName } from '../config/util';
import { t } from '../shared/i18n';
import { Response } from 'express';
import { request } from 'undici';
@Service()
export default class ConfigService {
constructor() {}
public async getFile(filePath: string, res: Response) {
let content = '';
if (!filePath) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const normalized = path.normalize(filePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const resolvedRoot = path.resolve(config.rootPath, normalized);
const resolvedConfig = path.resolve(config.configPath, normalized);
const isValidPath =
resolvedRoot.startsWith(config.scriptPath) ||
resolvedRoot.startsWith(config.configPath) ||
resolvedConfig.startsWith(config.scriptPath) ||
resolvedConfig.startsWith(config.configPath);
if (!isValidPath) {
return res.send({ code: 403, message: t('文件无法访问') });
}
if (config.blackFileList.includes(path.basename(normalized))) {
return res.send({ code: 403, message: t('文件无法访问') });
}
if (filePath.startsWith('sample/')) {
const res = await request(
`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));
}
res.send({ code: 200, data: content });
}
}
-973
View File
@@ -1,973 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
import {
getFileContentByName,
fileExist,
killTask,
killAllTasks,
getUniqPath,
safeJSONParse,
isDemoEnv,
} from '../config/util';
import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize';
import path from 'path';
import { TASK_PREFIX, QL_PREFIX } from '../config/const';
import cronClient from '../schedule/client';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
import dayjs from 'dayjs';
import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils';
import { t } from '../shared/i18n';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
import { isEmpty } from 'lodash';
@Service()
export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) { }
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
return true;
}
return false;
}
private get schedulerMode(): 'system' | 'node' {
const env = process.env.QL_SCHEDULER;
if (env === 'system') return 'system';
if (env === 'node') return 'node';
try {
execSync('which crond', { stdio: 'ignore' });
return 'system';
} catch {
return 'node';
}
}
private shouldUseCronClient(cron: Crontab): boolean {
if (this.schedulerMode === 'node') {
return !this.isSpecialSchedule(cron.schedule);
}
return this.isNodeCron(cron) && !this.isSpecialSchedule(cron.schedule);
}
private isOnceSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.ONCE);
}
private isBootSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.BOOT);
}
private isSpecialSchedule(schedule?: string) {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
}
private async getLogName(cron: Crontab) {
const { log_name, command, id } = cron;
if (log_name === '/dev/null') {
return log_name;
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
}
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
return uniqPath;
}
public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload);
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
if (isDemoEnv()) {
return doc;
}
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
// DB 和 crontab.list 有记录但调度器永远不会执行)
await CrontabModel.destroy({ where: { id: doc.id } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
}
await this.setCrontab();
return doc;
}
public async insert(payload: Crontab): Promise<Crontab> {
return await CrontabModel.create(payload, { returning: true });
}
public async update(payload: Partial<Crontab>): Promise<Crontab> {
const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) {
return newDoc;
}
try {
await cronClient.delCron([String(newDoc.id)]);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
if (this.shouldUseCronClient(newDoc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: this.makeCommand(newDoc),
extra_schedules: newDoc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
await CrontabModel.update(doc, { where: { id: doc.id } });
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (_recoveryError: any) {
this.logger.warn(
'[crontab] Failed to restore old cron job in scheduler after rollback:',
_recoveryError?.message || _recoveryError,
);
}
}
this.logger.error(
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
}
await this.setCrontab();
return newDoc;
}
public async updateDb(payload: Crontab): Promise<Crontab> {
await CrontabModel.update(payload, { where: { id: payload.id } });
return await this.getDb({ id: payload.id });
}
public async status({
ids,
status,
pid,
log_path,
last_running_time = 0,
last_execution_time = 0,
exit_code,
}: {
ids: number[];
status: CrontabStatus;
pid: number;
log_path: string;
last_running_time: number;
last_execution_time: number;
exit_code?: number;
}) {
let options: any = {
status,
pid,
log_path,
last_execution_time,
};
if (last_running_time > 0) {
options.last_running_time = last_running_time;
}
for (const id of ids) {
let cron;
try {
cron = await this.getDb({ id });
} catch (err) { }
if (!cron) {
continue;
}
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 } },
);
}
}
public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } });
try {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab();
}
public async pin(ids: number[]) {
await CrontabModel.update({ isPinned: 1 }, { where: { id: ids } });
}
public async unPin(ids: number[]) {
await CrontabModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async addLabels(ids: string[], labels: string[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await CrontabModel.update(
{
labels: Array.from(new Set((doc.labels || []).concat(labels))),
},
{ where: { id: doc.id } },
);
}
}
public async removeLabels(ids: string[], labels: string[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await CrontabModel.update(
{
labels: (doc.labels || []).filter((label) => !labels.includes(label)),
},
{ where: { id: doc.id } },
);
}
}
private formatViewQuery(query: any, viewQuery: any) {
if (viewQuery.filters && viewQuery.filters.length > 0) {
const primaryOperate = viewQuery.filterRelation === 'or' ? Op.or : Op.and;
if (!query[primaryOperate]) {
query[primaryOperate] = [];
}
for (const col of viewQuery.filters) {
const { property, value, operation } = col;
let q: any = {};
let operate2: any = null;
let operate: any = null;
switch (operation) {
case 'Reg':
operate = Op.like;
operate2 = Op.or;
break;
case 'NotReg':
operate = Op.notLike;
operate2 = Op.and;
break;
case 'In':
if (
property === 'status' &&
!value.includes(CrontabStatus.disabled)
) {
q[Op.and] = [
{ [property]: Array.isArray(value) ? value : [value] },
{ isDisabled: 0 },
];
} else {
q[Op.or] = [
{
[property]: Array.isArray(value) ? value : [value],
},
property === 'status' && value.includes(CrontabStatus.disabled)
? { isDisabled: 1 }
: {},
];
}
break;
case 'Nin':
q[Op.and] = [
{
[Op.or]: [
{
[property]: {
[Op.notIn]: Array.isArray(value) ? value : [value],
},
},
{
[property]: { [Op.is]: null },
},
],
},
property === 'status' && value.includes(2)
? { isDisabled: { [Op.ne]: 1 } }
: {},
];
break;
default:
break;
}
if (operate && operate2) {
q[property] = {
[Op.or]: [
{
[operate2]: [
{ [operate]: `%${value}%` },
{ [operate]: `%${encodeURI(value)}%` },
],
},
{
[operate2]: [
where(colFn(property), operate, `%${value}%`),
where(colFn(property), operate, `%${encodeURI(value)}%`),
],
},
],
};
}
query[primaryOperate].push(q);
}
}
}
private formatSearchText(query: any, searchText: string | undefined) {
if (searchText) {
if (!query[Op.and]) {
query[Op.and] = [];
}
let q: any = {};
const textArray = searchText.split(':');
switch (textArray[0]) {
case 'name':
case 'command':
case 'schedule':
case 'label':
const column = textArray[0] === 'label' ? 'labels' : textArray[0];
q[column] = {
[Op.or]: [
{ [Op.like]: `%${textArray[1]}%` },
{ [Op.like]: `%${encodeURI(textArray[1])}%` },
],
};
break;
default:
const reg = {
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeURI(searchText)}%` },
],
};
q[Op.or] = [
{
name: reg,
},
{
command: reg,
},
{
schedule: reg,
},
{
labels: reg,
},
];
break;
}
query[Op.and].push(q);
}
}
private formatFilterQuery(query: any, filterQuery: any) {
if (!isEmpty(filterQuery)) {
if (!query[Op.and]) {
query[Op.and] = [];
}
const filterKeys: any = Object.keys(filterQuery);
for (const key of filterKeys) {
let q: any = {};
if (!filterQuery[key]) continue;
if (key === 'status') {
if (filterQuery[key].includes(CrontabStatus.disabled)) {
q = { [Op.or]: [{ [key]: filterQuery[key] }, { isDisabled: 1 }] };
} else {
q = { [Op.and]: [{ [key]: filterQuery[key] }, { isDisabled: 0 }] };
}
} else {
q[key] = filterQuery[key];
}
query[Op.and].push(q);
}
}
}
private formatViewSort(order: string[][], viewQuery: any) {
if (viewQuery.sorts && viewQuery.sorts.length > 0) {
for (const { property, type } of viewQuery.sorts) {
order.unshift([property, type]);
}
}
}
public async find({
log_path,
}: {
log_path: string;
}): Promise<Crontab | undefined> {
try {
const result = await CrontabModel.findOne({ where: { log_path } });
return result?.get({ plain: true });
} catch (error) {
throw error;
}
}
public async crontabs(params?: {
searchValue: string;
page: string;
size: string;
sorter: string;
filters: string;
queryString: string;
}): Promise<{ data: Crontab[]; total: number }> {
const searchText = params?.searchValue;
const page = Number(params?.page || '0');
const size = Number(params?.size || '0');
const viewQuery = safeJSONParse(params?.queryString);
const filterQuery = safeJSONParse(params?.filters);
const sorterQuery = safeJSONParse(params?.sorter);
let query: any = {};
let order = [
['isPinned', 'DESC'],
['isDisabled', 'ASC'],
['status', 'ASC'],
['createdAt', 'DESC'],
];
this.formatViewQuery(query, viewQuery);
this.formatSearchText(query, searchText);
this.formatFilterQuery(query, filterQuery);
this.formatViewSort(order, viewQuery);
if (sorterQuery) {
const { field, type } = sorterQuery;
if (field && type) {
order.unshift([field, type]);
}
}
let condition: FindOptions<Crontab> = {
where: query,
order: order as Order,
};
if (page && size) {
condition.offset = (page - 1) * size;
condition.limit = size;
}
try {
const result = await CrontabModel.findAll(condition);
const count = await CrontabModel.count({ where: query });
return { data: result.map((x) => x.get({ plain: true })), total: count };
} catch (error) {
throw error;
}
}
public async getDb(query: FindOptions<Crontab>['where']): Promise<Crontab> {
const doc: any = await CrontabModel.findOne({ where: { ...query } });
if (!doc) {
throw new Error(`Cron ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async run(ids: number[]) {
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: ids } },
);
ids.forEach((id) => {
this.runSingle(id);
});
}
public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
// Kill all running instances of this task
try {
if (doc.pid) {
await killTask(doc.pid);
}
const command = doc.command.replace(/\s+/g, ' ').trim();
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
}
}
// Mark all running instances as stopped
const finishedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: finishedAt },
{ where: { cron_id: ids, status: InstanceStatus.running } },
);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: ids } },
);
}
public async stopInstance(instanceId: number) {
const instance = await RunningInstanceModel.findOne({
where: { id: instanceId, status: InstanceStatus.running },
});
if (!instance) {
return { code: 400, message: t('实例不存在或已停止') };
}
if (instance.pid) {
try {
await killTask(instance.pid);
} catch (error) {
this.logger.error(
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
);
}
}
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix(), exit_code: 143 },
{ where: { id: instanceId } },
);
// Check if there are still other running instances for this cron
const otherRunning = await RunningInstanceModel.count({
where: { cron_id: instance.cron_id, status: InstanceStatus.running },
});
if (otherRunning === 0) {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: instance.cron_id } },
);
}
return { code: 200, message: t('实例已停止') };
}
private async runSingle(cronId: number): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(() => {
return new Promise(async (resolve: any) => {
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;
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}`);
const cp = spawn(
`real_log_path=${logPath} no_delay=true ${this.makeCommand(
cron,
true,
)}`,
{ shell: '/bin/bash' },
);
await CrontabModel.update(
{ status: CrontabStatus.running, pid: cp.pid, log_path: logPath },
{ where: { id } },
);
cp.stdout.on('data', async (data) => {
await logStreamManager.write(absolutePath, data.toString());
});
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) => {
this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
await logStreamManager.write(absolutePath, JSON.stringify(err));
});
cp.on('exit', async (code) => {
this.logger.info(
'[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify(params),
code,
);
await logStreamManager.closeStream(absolutePath);
resolve({ ...params, pid: cp.pid, code });
});
});
});
}
public async disabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
try {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab();
}
public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const crons = docs
.filter((x) => this.shouldUseCronClient(x))
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
if (isDemoEnv()) {
return;
}
try {
await cronClient.addCron(crons);
} catch (error: any) {
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
await this.setCrontab();
}
public async log(id: number): Promise<{ content: string; status: string }> {
const doc = await this.getDb({ id });
if (!doc) {
return { content: '', status: 'empty' };
}
if (doc.log_name === '/dev/null') {
return { content: t('日志设置为忽略'), status: 'ignored' };
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) {
const content = await getFileContentByName(`${absolutePath}`);
const isRunning =
typeof doc.status === 'number' &&
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
return { content, status: isRunning ? 'running' : 'completed' };
} else {
return typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? { content: t('运行中...'), status: 'running' }
: { content: t('日志不存在...'), status: 'notFound' };
}
}
public async logs(id: number) {
const doc = await this.getDb({ id });
if (!doc || !doc.log_path) {
return [];
}
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
const dirExist = await fileExist(dir);
if (dirExist) {
let files = await fs.readdir(dir);
return (
await Promise.all(
files.map(async (x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})),
)
).sort((a, b) => b.time - a.time);
} else {
return [];
}
}
private makeCommand(tab: Crontab, realTime?: boolean) {
let command = tab.command.trim();
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`;
}
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell
if (tab.log_name) {
commandVariable += `log_name=${tab.log_name} `;
}
if (tab.task_before) {
commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''")
.replace(/;? *\n/g, ';')
.trim()}' `;
}
if (tab.task_after) {
commandVariable += `task_after='${tab.task_after
.replace(/'/g, "'\\''")
.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;
}
private async setCrontab(data?: { data: Crontab[]; total: number }) {
const tabs = data ?? (await this.crontabs());
var crontab_string = '';
tabs.data.forEach((tab) => {
if (
tab.isDisabled === 1 ||
this.isNodeCron(tab) ||
this.isSpecialSchedule(tab.schedule)
) {
crontab_string += '# ';
crontab_string += tab.schedule;
crontab_string += ' ';
crontab_string += this.makeCommand(tab);
crontab_string += '\n';
} else {
crontab_string += tab.schedule;
crontab_string += ' ';
crontab_string += this.makeCommand(tab);
crontab_string += '\n';
}
});
await writeFileWithLock(config.crontabFile, crontab_string);
if (this.schedulerMode === 'system') {
try {
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
}
await CrontabModel.update({ saved: true }, { where: {} });
}
public importCrontab() {
exec('crontab -l', (error, stdout) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
const lines = stdout.split('\n');
const namePrefix = new Date().getTime();
lines.reverse().forEach(async (line, index) => {
line = line.replace(/\t+/g, ' ');
const regex =
/^((\@[a-zA-Z]+\s+)|(([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+))/;
const command = line.replace(regex, '').trim();
const schedule = line.replace(command, '').trim();
if (
command &&
schedule &&
CronExpressionParser.parse(schedule).hasNext()
) {
const name = namePrefix + '_' + index;
const _crontab = await CrontabModel.findOne({
where: { command, schedule },
});
if (!_crontab) {
await this.create({ name, command, schedule });
} else {
_crontab.command = command;
_crontab.schedule = schedule;
await this.update(_crontab);
}
}
});
});
}
public async autosave_crontab() {
const tabs = await this.crontabs();
const regularCrons = tabs.data
.filter(
(x) =>
x.isDisabled !== 1 &&
this.shouldUseCronClient(x),
)
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, '');
return;
}
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
await this.setCrontab(tabs);
try {
await cronClient.addCron(regularCrons);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to register cron job in scheduler:',
error?.message || error,
);
}
}
public async bootTask() {
const tabs = await this.crontabs();
const bootTasks = tabs.data.filter(
(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) {
this.runSingle(task.id!);
}
}
}
}
-443
View File
@@ -1,443 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import {
Dependence,
DependenceStatus,
DependenceTypes,
DependenceModel,
versionDependenceCommandTypes,
} from '../data/dependence';
import { spawn } from 'cross-spawn';
import SockService from './sock';
import { FindOptions, Op } from 'sequelize';
import {
fileExist,
getPid,
killTask,
promiseExecSuccess,
getInstallCommand,
getUninstallCommand,
getGetCommand,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
@Service()
export default class DependenceService {
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
) { }
public async create(payloads: Dependence[]): Promise<Dependence[]> {
const tabs = payloads.map((x) => {
const tab = new Dependence({ ...x, status: DependenceStatus.queued });
return tab;
});
const docs = await this.insert(tabs);
this.installDependenceOneByOne(docs);
return docs;
}
public async insert(payloads: Dependence[]): Promise<Dependence[]> {
const docs = await DependenceModel.bulkCreate(payloads);
return docs;
}
public async update(
payload: Dependence & { id: string },
): Promise<Dependence> {
const { id, ...other } = payload;
const doc = await this.getDb({ id });
const tab = new Dependence({
...doc,
...other,
status: DependenceStatus.queued,
});
const newDoc = await this.updateDb(tab);
this.installDependenceOneByOne([newDoc]);
return newDoc;
}
private async updateDb(payload: Dependence): Promise<Dependence> {
await DependenceModel.update(payload, { where: { id: payload.id } });
return await this.getDb({ id: payload.id });
}
public async remove(ids: number[], force = false): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter(
(x) => x.status === DependenceStatus.installed,
);
await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
);
this.installDependenceOneByOne(docs, false, force);
}
return docs;
}
public async removeDb(ids: number[]) {
await DependenceModel.destroy({ where: { id: ids } });
}
public async dependencies(
{
searchValue,
type,
status,
}: {
searchValue: string;
type: keyof typeof DependenceTypes;
status: string;
},
sort: any = [],
query: any = {},
): Promise<Dependence[]> {
let condition = query;
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
if (status) {
condition.status = status.split(',').map(Number);
}
if (searchValue) {
const encodeText = encodeURI(searchValue);
condition.name = {
[Op.or]: [
{ [Op.like]: `%${searchValue}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
}
try {
return await this.find(condition, sort);
} catch (error) {
throw error;
}
}
public installDependenceOneByOne(
docs: Dependence[],
isInstall: boolean = true,
force: boolean = false,
): Promise<void> {
docs.forEach((dep) => {
this.installOrUninstallDependency(dep, isInstall, force);
});
return taskLimit.waitDependencyQueueDone();
}
public async reInstall(ids: number[]): Promise<Dependence[]> {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
);
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
this.installDependenceOneByOne(docs, true, true);
return docs;
}
public async cancel(ids: number[]) {
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
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),
]);
for (const pid of pids) {
pid && (await killTask(pid));
}
}
await DependenceModel.update(
{ status: DependenceStatus.cancelled },
{ where: { id: ids } },
);
}
private async find(query: any, sort: any = []): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({
where: { ...query },
order: [...sort, ['createdAt', 'DESC']],
});
return docs;
}
public async getDb(
query: FindOptions<Dependence>['where'],
): Promise<Dependence> {
const doc: any = await DependenceModel.findOne({ where: { ...query } });
if (!doc) {
throw new Error(`Dependency ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
private async updateLog(ids: number[], log: string): Promise<void> {
taskLimit.updateDepLog(async () => {
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
const newLog = doc?.log ? [...doc.log, log] : [log];
await DependenceModel.update(
{ log: newLog },
{ where: { id: doc.id } },
);
}
return null;
});
}
public installOrUninstallDependency(
dependency: Dependence,
isInstall: boolean = true,
force: boolean = false,
) {
return taskLimit.runDependeny(dependency, () => {
return new Promise(async (resolve) => {
if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null);
}
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 } });
let command = isInstall
? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName);
if (isLinuxDependence) {
command = isInstall
? `${linuxCommand.install} ${depName.trim()}`
: `${linuxCommand.uninstall} ${depName.trim()}`;
}
const startTime = dayjs();
const message = 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) {
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(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
const [, _depName, _depVersion] = depName.match(symbolRegx) || [];
if (_depVersion && _depName) {
depName = _depName;
depVersion = _depVersion;
}
}
const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
if (
depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
(isLinuxDependence &&
linuxCommand.check(depInfo.toLocaleLowerCase())) ||
isPythonDependence) &&
(!depVersion || depInfo.includes(depVersion))
) {
const endTime = dayjs();
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(
{ status: DependenceStatus.installed },
{ where: { id: depIds } },
);
return resolve(null);
}
}
const dependenceProxyFileExist = await fileExist(
config.dependenceProxyFile,
);
const proxyStr = dependenceProxyFileExist
? `source ${config.dependenceProxyFile} &&`
: '';
const cp = spawn(`${proxyStr} ${command}`, {
shell: '/bin/bash',
});
cp.stdout.on('data', async (data) => {
this.sockService.sendMessage({
type: socketMessageType,
message: data.toString(),
references: depIds,
status,
});
this.updateLog(depIds, data.toString());
});
cp.stderr.on('data', async (data) => {
this.sockService.sendMessage({
type: socketMessageType,
message: data.toString(),
references: depIds,
status,
});
this.updateLog(depIds, data.toString());
});
cp.on('error', async (err) => {
this.sockService.sendMessage({
type: socketMessageType,
message: JSON.stringify(err),
references: depIds,
status,
});
this.updateLog(depIds, JSON.stringify(err));
});
cp.on('exit', async (code) => {
const endTime = dayjs();
const isSucceed = code === 0;
const resultText = isSucceed ? t('成功') : t('失败');
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);
const docs = await DependenceModel.findAll({ where: { id: depIds } });
const _docIds = docs
.filter((x) => x.status !== DependenceStatus.cancelled)
.map((x) => x.id!);
if (_docIds.length > 0) {
await DependenceModel.update(
{ status: exitStatus },
{ where: { id: _docIds } },
);
}
// 如果删除依赖成功或者强制删除
if ((isSucceed || force) && !isInstall) {
this.removeDb(depIds);
}
resolve(null);
});
});
});
}
}
-93
View File
@@ -1,93 +0,0 @@
import { Server, ServerCredentials } from '@grpc/grpc-js';
import { CronService } from '../protos/cron';
import { HealthService } from '../protos/health';
import { ApiService } from '../protos/api';
import { addCron } from '../schedule/addCron';
import { delCron } from '../schedule/delCron';
import { check } from '../schedule/health';
import * as Api from '../schedule/api';
import Logger from '../loaders/logger';
import { promisify } from 'util';
import config from '../config';
import { metricsService } from './metrics';
import { Service } from 'typedi';
import { initGrpcCerts } from '../config/grpcCerts';
@Service()
export class GrpcServerService {
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
private formatGrpcAddress(host: string, port: number): string {
if (host === '::') {
return `[::]:${port}`;
}
return `${host}:${port}`;
}
async initialize() {
try {
this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron });
this.server.addService(ApiService, Api);
const tlsConfig = await initGrpcCerts();
const credentials = ServerCredentials.createSsl(
Buffer.from(tlsConfig.caCert),
[{ cert_chain: Buffer.from(tlsConfig.serverCert), private_key: Buffer.from(tlsConfig.serverKey) }],
true,
);
const grpcPort = config.grpcPort;
const hostsToTry = [
config.bindHostGrpc,
...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
let lastError: Error | null = null;
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return grpcPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
}
}
Logger.error('Failed to start gRPC service on all hosts');
throw lastError || new Error('Failed to start gRPC service');
} catch (err) {
Logger.error('Failed to start gRPC service:', err);
throw err;
}
}
async shutdown() {
try {
if (this.server) {
await new Promise((resolve) => {
this.server.tryShutdown(() => {
Logger.debug('gRPC service stopped');
metricsService.record('grpc_service_stop', 1);
resolve(null);
});
});
}
} catch (err) {
Logger.error('Error while shutting down gRPC service:', err);
throw err;
}
}
getServer() {
return this.server;
}
}
-72
View File
@@ -1,72 +0,0 @@
import { Service } from 'typedi';
import Logger from '../loaders/logger';
import { GrpcServerService } from './grpc';
import { HttpServerService } from './http';
interface HealthStatus {
status: 'ok' | 'error';
services: {
http: boolean;
grpc: boolean;
};
metrics: {
uptime: number;
memory: {
used: number;
total: number;
};
};
}
@Service()
export class HealthService {
private startTime = Date.now();
constructor(
private grpcServerService: GrpcServerService,
private httpServerService: HttpServerService,
) {}
async check(): Promise<HealthStatus> {
const status: HealthStatus = {
status: 'ok',
services: {
http: true,
grpc: true,
},
metrics: {
uptime: Math.floor((Date.now() - this.startTime) / 1000),
memory: {
used: process.memoryUsage().heapUsed,
total: process.memoryUsage().heapTotal,
},
},
};
try {
const httpServer = this.httpServerService.getServer();
if (!httpServer) {
status.services.http = false;
status.status = 'error';
}
} catch (err) {
status.services.http = false;
status.status = 'error';
Logger.error('HTTP server check failed:', err);
}
try {
const grpcServer = this.grpcServerService.getServer();
if (!grpcServer) {
status.services.grpc = false;
status.status = 'error';
}
} catch (err) {
status.services.grpc = false;
status.status = 'error';
Logger.error('gRPC server check failed:', err);
}
return status;
}
}
-73
View File
@@ -1,73 +0,0 @@
import express from 'express';
import Logger from '../loaders/logger';
import { metricsService } from './metrics';
import { Service } from 'typedi';
import { Server } from 'http';
import config from '../config';
@Service()
export class HttpServerService {
private server?: Server = undefined;
async initialize(expressApp: express.Application, port: number) {
const hostsToTry = [
config.bindHost,
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
let lastError: Error | null = null;
for (const host of hostsToTry) {
try {
const server = await this.tryListen(expressApp, port, host);
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
host
});
this.server = server;
return server;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
}
}
Logger.error('Failed to start HTTP service on all hosts');
throw lastError || new Error('Failed to start HTTP service');
}
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
return new Promise((resolve, reject) => {
const server = expressApp.listen(port, host, () => {
resolve(server);
});
server.on('error', (err: Error) => {
server.close();
reject(err);
});
});
}
async shutdown() {
try {
if (this.server) {
await new Promise((resolve) => {
this.server?.close(() => {
Logger.debug('HTTP service stopped');
metricsService.record('http_service_stop', 1);
resolve(null);
});
});
}
} catch (err) {
Logger.error('Error while shutting down HTTP service:', err);
throw err;
}
}
getServer() {
return this.server;
}
}
-14
View File
@@ -1,14 +0,0 @@
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
@Service()
export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.logPath, filePath, fileName);
return finalPath.startsWith(config.logPath) ? finalPath : '';
}
}
-92
View File
@@ -1,92 +0,0 @@
import { performance } from 'perf_hooks';
import Logger from '../loaders/logger';
interface Metric {
name: string;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
class MetricsService {
private metrics: Metric[] = [];
private static instance: MetricsService;
private constructor() {
// 定期清理旧数据
setInterval(() => {
const oneHourAgo = Date.now() - 3600000;
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
}, 60000);
}
static getInstance(): MetricsService {
if (!MetricsService.instance) {
MetricsService.instance = new MetricsService();
}
return MetricsService.instance;
}
record(name: string, value: number, tags?: Record<string, string>) {
this.metrics.push({
name,
value,
timestamp: Date.now(),
tags,
});
}
measure(name: string, fn: () => void, tags?: Record<string, string>) {
const start = performance.now();
try {
fn();
} finally {
const duration = performance.now() - start;
this.record(name, duration, tags);
}
}
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) {
const start = performance.now();
try {
await fn();
} finally {
const duration = performance.now() - start;
this.record(name, duration, tags);
}
}
getMetrics(name?: string, tags?: Record<string, string>) {
let filtered = this.metrics;
if (name) {
filtered = filtered.filter(m => m.name === name);
}
if (tags) {
filtered = filtered.filter(m => {
if (!m.tags) return false;
return Object.entries(tags).every(([key, value]) => m.tags![key] === value);
});
}
return {
count: filtered.length,
average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length,
min: Math.min(...filtered.map(m => m.value)),
max: Math.max(...filtered.map(m => m.value)),
metrics: filtered,
};
}
report() {
const report = {
timestamp: Date.now(),
metrics: this.getMetrics(),
};
Logger.info('性能指标报告:', report);
return report;
}
}
export const metricsService = MetricsService.getInstance();
-950
View File
@@ -1,950 +0,0 @@
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
import { parseBody, parseHeaders } from '../config/util';
import { NotificationInfo } from '../data/notify';
import { t } from '../shared/i18n';
import UserService from './user';
import { httpClient } from '../config/http';
import { ProxyAgent } from 'undici';
@Service()
export default class NotificationService {
@Inject((type) => UserService)
private userService!: UserService;
private modeMap = new Map([
['gotify', this.gotify],
['goCqHttpBot', this.goCqHttpBot],
['serverChan', this.serverChan],
['pushDeer', this.pushDeer],
['chat', this.chat],
['bark', this.bark],
['telegramBot', this.telegramBot],
['dingtalkBot', this.dingtalkBot],
['weWorkBot', this.weWorkBot],
['weWorkApp', this.weWorkApp],
['aibotk', this.aibotk],
['iGot', this.iGot],
['pushPlus', this.pushPlus],
['wePlusBot', this.wePlusBot],
['email', this.email],
['pushMe', this.pushMe],
['webhook', this.webhook],
['lark', this.lark],
['chronocat', this.chronocat],
['ntfy', this.ntfy],
['wxPusherBot', this.wxPusherBot],
['wxPusherSpt', this.wxPusherSpt],
['openiLink', this.openiLink],
]);
private title = '';
private content = '';
private params!: Omit<NotificationInfo, 'type'>;
private gotOption = {
timeout: 10000,
retry: 1,
};
constructor() {}
public async notify(
title: string,
content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> {
let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) {
this.title = title;
this.content = content;
let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
} catch (error: any) {
console.error(error);
}
}
return false;
}
public async testNotify(
info: NotificationInfo,
title: string,
content: string,
) {
const { type, ...rest } = info;
if (type) {
this.title = title;
this.content = content;
this.params = rest;
const notificationModeAction = this.modeMap.get(type);
return await notificationModeAction?.call(this);
}
return true;
}
private parseMailRecipients(value?: string) {
const recipients = (value || '')
.split(/[;]/)
.map((item) => item.trim())
.filter(Boolean);
return recipients.length > 0 ? recipients : undefined;
}
private async gotify() {
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try {
const res = await httpClient.post(
`${gotifyUrl}/message?token=${gotifyToken}`,
{
...this.gotOption,
body: `title=${encodeURIComponent(
this.title,
)}&message=${encodeURIComponent(
this.content,
)}&priority=${gotifyPriority}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
if (typeof res.id === 'number') {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async goCqHttpBot() {
const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params;
try {
const res = await httpClient.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
...this.gotOption,
json: { message: `${this.title}\n${this.content}` },
headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
});
if (res.retcode === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async serverChan() {
const { serverChanKey } = this.params;
const matchResult = serverChanKey.match(/^sctp(\d+)t/i);
const url =
matchResult && matchResult[1]
? `https://${matchResult[1]}.push.ft07.com/send/${serverChanKey}.send`
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
body: `title=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (res.errno === 0 || res.data.errno === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async pushDeer() {
const { pushDeerKey, pushDeerUrl } = this.params;
const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}&type=markdown`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (
res.content.result.length !== undefined &&
res.content.result.length > 0
) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async chat() {
const { synologyChatUrl } = this.params;
try {
const res = await httpClient.post(synologyChatUrl, {
...this.gotOption,
body: `payload={"text":"${this.title}\n${this.content}"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (res.success) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async bark() {
let {
barkPush,
barkIcon = '',
barkSound = '',
barkGroup = '',
barkLevel = '',
barkUrl = '',
barkArchive = '',
} = this.params;
if (!barkPush.startsWith('http')) {
barkPush = `https://api.day.app/${barkPush}`;
}
const url = `${barkPush}`;
const body = {
title: this.title,
body: this.content,
icon: barkIcon,
sound: barkSound,
group: barkGroup,
isArchive: barkArchive,
level: barkLevel,
url: barkUrl,
};
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: body,
headers: { 'Content-Type': 'application/json' },
});
if (res.code === 200) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async telegramBot() {
const {
telegramBotApiHost,
telegramBotProxyAuth,
telegramBotProxyHost,
telegramBotProxyPort,
telegramBotToken,
telegramBotUserId,
} = this.params;
const authStr = telegramBotProxyAuth ? `${telegramBotProxyAuth}@` : '';
const url = `${
telegramBotApiHost ? telegramBotApiHost : 'https://api.telegram.org'
}/bot${telegramBotToken}/sendMessage`;
let agent;
if (telegramBotProxyHost && telegramBotProxyPort) {
agent = new ProxyAgent({
uri: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
});
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
dispatcher: agent,
});
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 dingtalkBot() {
const { dingtalkBotSecret, dingtalkBotToken } = this.params;
let secretParam = '';
if (dingtalkBotSecret) {
const dateNow = Date.now();
const hmac = crypto.createHmac('sha256', dingtalkBotSecret);
hmac.update(`${dateNow}\n${dingtalkBotSecret}`);
const result = encodeURIComponent(hmac.digest('base64'));
secretParam = `&timestamp=${dateNow}&sign=${result}`;
}
const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
},
},
});
if (res.errcode === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async weWorkBot() {
const { weWorkBotKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } =
this.params;
const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
},
},
});
if (res.errcode === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async weWorkApp() {
const { weWorkAppKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } =
this.params;
const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] =
weWorkAppKey.split(',');
const url = `${weWorkOrigin}/cgi-bin/gettoken`;
const tokenRes = await httpClient.post(url, {
...this.gotOption,
json: {
corpid,
corpsecret,
},
});
let options: any = {
msgtype: 'mpnews',
mpnews: {
articles: [
{
title: `${this.title}`,
thumb_media_id,
author: t('智能助手'),
content_source_url: ``,
content: `${this.content.replace(/\n/g, '<br/>')}`,
digest: `${this.content}`,
},
],
},
};
switch (thumb_media_id) {
case '0':
options = {
msgtype: 'textcard',
textcard: {
title: `${this.title}`,
description: `${this.content}`,
url: 'https://github.com/whyour/qinglong',
btntxt: t('更多'),
},
};
break;
case '1':
options = {
msgtype: 'text',
text: {
content: `${this.title}\n\n${this.content}`,
},
};
break;
}
try {
const res = await httpClient.post(
`${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
{
...this.gotOption,
json: {
touser,
agentid,
safe: '0',
...options,
},
},
);
if (res.errcode === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async aibotk() {
const { aibotkKey, aibotkType, aibotkName } = this.params;
let url = '';
let json = {};
switch (aibotkType) {
case 'room':
url = 'https://api-bot.aibotk.com/openapi/v1/chat/room';
json = {
apiKey: `${aibotkKey}`,
roomName: `${aibotkName}`,
message: {
type: 1,
content: `${t('青龙快讯')}\n\n${this.title}\n${this.content}`,
},
};
break;
case 'contact':
url = 'https://api-bot.aibotk.com/openapi/v1/chat/contact';
json = {
apiKey: `${aibotkKey}`,
name: `${aibotkName}`,
message: {
type: 1,
content: `${t('青龙快讯')}\n\n${this.title}\n${this.content}`,
},
};
break;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: {
...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);
}
}
private async iGot() {
const { iGotPushKey } = this.params;
const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
body: `title=${this.title}&content=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (res.ret === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async pushPlus() {
const {
pushPlusToken,
pushPlusUser,
pushplusWebhook,
pushPlusTemplate,
pushplusChannel,
pushplusCallbackUrl,
pushplusTo,
} = this.params;
const url = `https://www.pushplus.plus/send`;
try {
let body = {
...this.gotOption,
json: {
token: `${pushPlusToken}`,
title: `${this.title}`,
content: `${this.content.replace(/[\n\r]/g, '<br>')}`,
topic: `${pushPlusUser || ''}`,
template: `${pushPlusTemplate || 'html'}`,
channel: `${pushplusChannel || 'wechat'}`,
webhook: `${pushplusWebhook || ''}`,
callbackUrl: `${pushplusCallbackUrl || ''}`,
to: `${pushplusTo || ''}`,
},
};
const res = await httpClient.post(url, body);
if (res.code === 200) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async wePlusBot() {
const { wePlusBotToken, wePlusBotReceiver, wePlusBotVersion } = this.params;
let content = this.content;
let template = 'txt';
if (this.content.length > 800) {
template = 'html';
content = content.replace(/[\n\r]/g, '<br>');
}
const url = `https://www.weplusbot.com/send`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: {
token: `${wePlusBotToken}`,
title: `${this.title}`,
template: `${template}`,
content: `${content}`,
receiver: `${wePlusBotReceiver || ''}`,
version: `${wePlusBotVersion || 'pro'}`,
},
});
if (res.code === 200) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async lark() {
let { larkKey, larkSecret } = this.params;
if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
}
const body: Record<string, any> = {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (larkSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${larkSecret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
try {
const res = await httpClient.post(larkKey, {
...this.gotOption,
json: body,
headers: { 'Content-Type': 'application/json' },
});
if (res.StatusCode === 0 || res.code === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async email() {
const { emailPass, emailService, emailUser, emailTo } = this.params;
const recipients = this.parseMailRecipients(emailTo) || emailUser;
try {
const transporter = nodemailer.createTransport({
service: emailService,
auth: {
user: emailUser,
pass: emailPass,
},
});
const info = await transporter.sendMail({
from: `"${t('青龙快讯')}" <${emailUser}>`,
to: recipients,
subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`,
});
transporter.close();
if (info.messageId) {
return true;
} else {
throw new Error(JSON.stringify(info));
}
} catch (error: any) {
throw error;
}
}
private async pushMe() {
const { pushMeKey, pushMeUrl } = this.params;
try {
const res = await httpClient.post<'text'>(
pushMeUrl || 'https://push.i-i.me/',
{
...this.gotOption,
json: {
push_key: pushMeKey,
title: this.title,
content: this.content,
},
headers: { 'Content-Type': 'application/json' },
},
);
if (res === 'success') {
return true;
} else {
throw new Error(res);
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async ntfy() {
const {
ntfyUrl,
ntfyTopic,
ntfyPriority,
ntfyToken,
ntfyUsername,
ntfyPassword,
ntfyActions,
} = this.params;
// 编码函数
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
const encodedText = Buffer.from(text).toString('base64');
return `=?${charset}?B?${encodedText}?=`;
};
try {
const headers: Record<string, string> = {
Title: encodeRfc2047(this.title),
Priority: `${ntfyPriority || '3'}`,
Icon: 'https://qn.whyour.cn/logo.png',
};
if (ntfyToken) {
headers['Authorization'] = `Bearer ${ntfyToken}`;
} else if (ntfyUsername && ntfyPassword) {
headers['Authorization'] = `Basic ${Buffer.from(
`${ntfyUsername}:${ntfyPassword}`,
).toString('base64')}`;
}
if (ntfyActions) {
headers['Actions'] = encodeRfc2047(ntfyActions);
}
const res = await httpClient.request(
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
{
...this.gotOption,
body: `${this.content}`,
headers: headers,
method: 'POST',
},
);
if (res.statusCode === 200) {
return true;
} else {
throw new Error(await res.body.text());
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async wxPusherBot() {
const { wxPusherBotAppToken, wxPusherBotTopicIds, wxPusherBotUids } =
this.params;
// 处理 topicIds,将分号分隔的字符串转为数组
const topicIds = wxPusherBotTopicIds
? wxPusherBotTopicIds
.split(';')
.map((id) => id.trim())
.filter((id) => id)
.map((id) => parseInt(id))
: [];
// 处理 uids,将分号分隔的字符串转为数组
const uids = wxPusherBotUids
? wxPusherBotUids
.split(';')
.map((uid) => uid.trim())
.filter((uid) => uid)
: [];
// topic_ids 和 uids 至少要有一个
if (!topicIds.length && !uids.length) {
throw new Error(t('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'));
}
const url = `https://wxpusher.zjiecode.com/api/send/message`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: {
appToken: wxPusherBotAppToken,
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
summary: this.title,
contentType: 2,
topicIds: topicIds,
uids: uids,
verifyPayType: 0,
},
});
if (res.code === 1000) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async wxPusherSpt() {
const { wxPusherSptList } = this.params;
// 处理 SPT,将逗号分隔的字符串转为数组
const spts = wxPusherSptList
? wxPusherSptList
.split(',')
.map((spt) => spt.trim())
.filter((spt) => spt)
: [];
if (!spts.length) {
throw new Error(t('wxPusher SPT 不能为空'));
}
if (spts.length > 10) {
throw new Error(t('wxPusher SPT 最多支持 10 个'));
}
const url = `https://wxpusher.zjiecode.com/api/send/message/simple-push`;
const json: any = {
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
summary: this.title,
contentType: 2,
};
// 单个 SPT 用 spt,多个用 sptList
if (spts.length === 1) {
json.spt = spts[0];
} else {
json.sptList = spts;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json,
});
if (res.code === 1000) {
return true;
} 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 {
const user_ids = chronocatQQ
.match(/user_id=(\d+)/g)
?.map((match: any) => match.split('=')[1]);
const group_ids = chronocatQQ
.match(/group_id=(\d+)/g)
?.map((match: any) => match.split('=')[1]);
const url = `${chronocatURL}/api/message/send`;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${chronocatToken}`,
};
for (const [chat_type, ids] of [
[1, user_ids],
[2, group_ids],
]) {
if (!ids) {
continue;
}
let _ids: any = ids;
for (const chat_id of _ids) {
const data = {
peer: {
chatType: chat_type,
peerUin: chat_id,
},
elements: [
{
elementType: 1,
textElement: {
content: `${this.title}\n\n${this.content}`,
},
},
],
};
const res = await httpClient.request(url, {
...this.gotOption,
json: data,
headers,
method: 'POST',
});
if (res.statusCode === 200) {
return true;
} else {
throw new Error(await res.body.text());
}
}
}
return false;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async webhook() {
const {
webhookUrl,
webhookBody,
webhookHeaders,
webhookMethod,
webhookContentType,
} = this.params;
if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
throw new Error(t('Url 或者 Body 中必须包含 $title'));
}
const headers = parseHeaders(webhookHeaders);
const body = parseBody(webhookBody, webhookContentType, (v) =>
v?.replaceAll('$title', this.title)?.replaceAll('$content', this.content),
);
const bodyParam = this.formatBody(webhookContentType, body);
const options = {
method: webhookMethod,
headers,
...this.gotOption,
allowGetBody: true,
...bodyParam,
};
try {
const formatUrl = webhookUrl
?.replaceAll('$title', encodeURIComponent(this.title))
?.replaceAll('$content', encodeURIComponent(this.content));
const res = await httpClient.request(formatUrl, options);
const text = await res.body.text();
if (String(res.statusCode).startsWith('20')) {
return true;
} else {
throw new Error(await res.body.text());
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private formatBody(contentType: string, body: any): object {
if (!body) return {};
switch (contentType) {
case 'application/json':
return { json: body };
case 'multipart/form-data':
return { form: body };
case 'application/x-www-form-urlencoded':
case 'text/plain':
return { body };
}
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);
}
}
}
-245
View File
@@ -1,245 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import nodeSchedule from 'node-schedule';
import { ChildProcessWithoutNullStreams } from 'child_process';
import {
ToadScheduler,
LongIntervalJob,
SimpleIntervalSchedule,
Task,
} from 'toad-scheduler';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
export interface ScheduleTaskType {
id?: number;
command: string;
name?: string;
schedule?: string;
runOrigin: 'subscription' | 'system' | 'script';
}
export interface TaskCallbacks {
onBefore?: (startTime: dayjs.Dayjs) => Promise<void>;
onStart?: (
cp: ChildProcessWithoutNullStreams,
startTime: dayjs.Dayjs,
) => Promise<void>;
onEnd?: (
cp: ChildProcessWithoutNullStreams,
endTime: dayjs.Dayjs,
diff: number,
) => Promise<void>;
onLog?: (message: string) => Promise<void>;
onError?: (message: string) => Promise<void>;
}
@Service()
export default class ScheduleService {
private scheduleStacks = new Map<string, nodeSchedule.Job>();
private intervalSchedule = new ToadScheduler();
private taskLimitMap = {
system: 'runWithSystemLimit' as const,
script: 'runWithScriptLimit' as const,
subscription: 'runWithSubscriptionLimit' as const,
};
constructor(@Inject('logger') private logger: winston.Logger) {}
async runTask(
command: string,
callbacks: TaskCallbacks = {},
params: {
schedule?: string;
name?: string;
command?: string;
id: string;
runOrigin: 'subscription' | 'system' | 'script';
},
completionTime: 'start' | 'end' = 'end',
) {
const { runOrigin, ...others } = params;
return taskLimit[this.taskLimitMap[runOrigin]](others, () => {
return new Promise(async (resolve, reject) => {
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify({
...others,
command,
})}`,
);
try {
const startTime = dayjs();
await callbacks.onBefore?.(startTime);
const cp = spawn(command, { shell: '/bin/bash' });
callbacks.onStart?.(cp, startTime);
completionTime === 'start' && resolve(cp.pid);
cp.stdout.on('data', async (data) => {
await callbacks.onLog?.(data.toString());
});
cp.stderr.on('data', async (data) => {
this.logger.info(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
data.toString(),
);
await callbacks.onError?.(data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
await callbacks.onError?.(JSON.stringify(err));
});
cp.on('exit', async (code) => {
this.logger.info(
'[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...others,
command,
}),
code,
);
const endTime = dayjs();
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
resolve({ ...others, pid: cp.pid, code });
});
} catch (error) {
this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
error,
);
await callbacks.onError?.(JSON.stringify(error));
}
});
});
}
async createCronTask(
{ id = 0, command, name, schedule = '', runOrigin }: ScheduleTaskType,
callbacks?: TaskCallbacks,
runImmediately = false,
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
_id,
schedule,
name,
command,
);
this.scheduleStacks.set(
_id,
nodeSchedule.scheduleJob(_id, schedule, async () => {
this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
});
}),
);
if (runImmediately) {
this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
});
}
}
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id);
}
}
async createIntervalTask(
{ id = 0, command, name = '', runOrigin }: ScheduleTaskType,
schedule: SimpleIntervalSchedule,
runImmediately = true,
callbacks?: TaskCallbacks,
) {
const _id = this.formatId(id);
this.logger.info(
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
_id,
name,
command,
);
const task = new Task(
name,
() => {
this.runTask(command, callbacks, {
name,
command,
id: _id,
runOrigin,
});
},
(err) => {
this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
},
);
const job = new LongIntervalJob(
{ runImmediately: false, ...schedule },
task,
{ id: _id },
);
this.intervalSchedule.addIntervalJob(job);
if (runImmediately) {
this.runTask(command, callbacks, {
name,
command,
id: _id,
runOrigin,
});
}
}
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info(
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
_id,
name,
);
this.intervalSchedule.removeById(_id);
}
private formatId(id: number): string {
return String(id);
}
}
-160
View File
@@ -1,160 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { Subscription } from '../data/subscription';
import { formatUrl } from '../config/subscription';
import config from '../config';
import { fileExist, rmPath } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
@Service()
export default class SshKeyService {
private homedir = os.homedir();
private sshPath = config.sshdPath;
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
constructor(@Inject('logger') private logger: winston.Logger) {
this.initSshConfigFile();
}
private async initSshConfigFile() {
let config = '';
const _exist = await fileExist(this.sshConfigFilePath);
if (_exist) {
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
} else {
await writeFileWithLock(this.sshConfigFilePath, '', { mode: '600' });
}
if (!config.includes(this.sshConfigHeader)) {
await writeFileWithLock(
this.sshConfigFilePath,
`${this.sshConfigHeader}\n\n${config}`,
{ mode: '600' },
);
}
}
private async generatePrivateKeyFile(
alias: string,
key: string,
): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
try {
await rmPath(filePath);
} catch { }
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
} catch (error) {
this.logger.error('生成私钥文件失败', error);
}
}
private async removePrivateKeyFile(alias: string): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
await rmPath(filePath);
} catch (error) {
this.logger.error('删除私钥文件失败', error);
}
}
private async generateSingleSshConfig(
alias: string,
host: string,
proxy?: string,
) {
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}`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
private async removeSshConfig(alias: string) {
try {
const filePath = path.join(this.sshPath, `${alias}.config`);
await rmPath(filePath);
} catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error);
}
}
public async addSSHKey(
key: string,
alias: string,
host: string,
proxy?: string,
): Promise<void> {
await this.generatePrivateKeyFile(alias, key);
await this.generateSingleSshConfig(alias, host, proxy);
}
public async removeSSHKey(
alias: string,
host: string,
proxy?: string,
): Promise<void> {
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
}
public async setSshConfig(docs: Subscription[]) {
for (const doc of docs) {
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
const { alias, proxy } = doc;
const { host } = formatUrl(doc);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
await this.generatePrivateKeyFile(
alias,
(doc.pull_option as any).private_key,
);
await this.generateSingleSshConfig(alias, host, proxy);
}
}
}
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`);
await this.removeSshConfig(`~global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
}
-592
View File
@@ -1,592 +0,0 @@
import { spawn } from 'cross-spawn';
import { Response } from 'express';
import fs from 'fs';
import { Agent, request } from 'undici';
import sum from 'lodash/sum';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
import { NotificationModeStringMap, TASK_COMMAND } from '../config/const';
import {
getPid,
killTask,
parseContentVersion,
parseVersion,
promiseExec,
readDirs,
rmPath,
setSystemTimezone,
} from '../config/util';
import {
DependenceModel,
DependenceStatus,
DependenceTypes,
} from '../data/dependence';
import { NotificationInfo } from '../data/notify';
import {
AuthDataType,
SystemInfo,
SystemInstance,
SystemModel,
SystemModelInfo,
} from '../data/system';
import taskLimit from '../shared/pLimit';
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 {
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
constructor(
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) { }
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
return {
...doc,
info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' },
};
}
private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> {
const { id, ...others } = payload;
await SystemModel.update(others, { where: { id } });
const doc = await this.getDb({ id });
return doc;
}
public async getDb(query: any): Promise<SystemInfo> {
const doc = await SystemModel.findOne({ where: query });
if (!doc) {
throw new Error(`System ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async updateNotificationMode(notificationInfo: NotificationInfo) {
const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify(
notificationInfo,
t('青龙'),
t('【蛟龙】测试通知 https://t.me/jiao_long'),
);
if (isSuccess) {
const result = await this.updateAuthDb({
type: AuthDataType.notification,
info: { ...notificationInfo },
});
return { code: 200, data: { ...result, code } };
} else {
return { code: 400, message: t('通知发送失败,请检查参数') };
}
}
public async updateLogRemoveFrequency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
const cron = {
id: result.id as number,
name: t('删除日志'),
command: `ql rmlog ${info.logRemoveFrequency}`,
runOrigin: 'system' as const,
};
if (oDoc.info?.logRemoveFrequency) {
await this.scheduleService.cancelIntervalTask(cron);
}
if (info.logRemoveFrequency && info.logRemoveFrequency > 0) {
this.scheduleService.createIntervalTask(
cron,
{
days: info.logRemoveFrequency,
},
true,
);
}
return { code: 200, data: info };
}
public async updateCronConcurrency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
if (info.cronConcurrency) {
await taskLimit.setCustomLimit(info.cronConcurrency);
}
return { code: 200, data: info };
}
public async updateDependenceProxy(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
}
return { code: 200, data: info };
}
public async updateNodeMirror(info: SystemModelInfo, res?: Response) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let cmd = 'pnpm config delete registry';
if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`;
}
let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({
where: {
type: DependenceTypes.nodejs,
status: DependenceStatus.installed,
},
});
if (docs.length > 0) {
command += ` && pnpm i -g`;
}
this.scheduleService.runTask(
command,
{
onStart: async (cp) => {
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end();
},
onEnd: async () => {
this.sockService.sendMessage({
type: 'updateNodeMirror',
message: 'update node mirror end',
status: 'completed',
});
},
onError: async (message: string) => {
this.sockService.sendMessage({ type: 'updateNodeMirror', message });
},
onLog: async (message: string) => {
this.sockService.sendMessage({ type: 'updateNodeMirror', message });
},
},
{
command,
id: 'update-node-mirror',
runOrigin: 'system',
},
);
}
public async updatePythonMirror(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let cmd = 'pip config unset global.index-url';
if (info.pythonMirror) {
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
}
await promiseExec(cmd);
return { code: 200, data: info };
}
public async updateLinuxMirror(
info: SystemModelInfo,
res?: Response,
onEnd?: () => void,
) {
const oDoc = await this.getSystemConfig();
if (os.platform() !== 'linux') {
return;
}
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
let hasError = false;
this.scheduleService.runTask(
command,
{
onStart: async (cp) => {
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end();
},
onEnd: async () => {
this.sockService.sendMessage({
type: 'updateLinuxMirror',
message: 'update linux mirror end',
status: 'completed',
});
onEnd?.();
if (!hasError) {
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
}
},
onError: async (message: string) => {
hasError = true;
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
},
onLog: async (message: string) => {
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
},
},
{
command,
id: 'update-linux-mirror',
runOrigin: 'system',
},
);
}
public async checkUpdate() {
try {
const currentVersionContent = await parseVersion(config.versionFile);
let lastVersionContent;
try {
const { body } = await request(
`${config.lastVersionFile}?t=${Date.now()}`,
{
dispatcher: new Agent({
keepAliveTimeout: 30000,
keepAliveMaxTimeout: 30000,
}),
},
);
const text = await body.text();
lastVersionContent = parseContentVersion(text);
} catch (error) { }
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
}
return {
code: 200,
data: {
hasNewVersion: this.checkHasNewVersion(
currentVersionContent.version,
lastVersionContent.version,
),
lastVersion: lastVersionContent.version,
lastLog: lastVersionContent.changeLog,
lastLogLink: lastVersionContent.changeLogLink,
},
};
} catch (error: any) {
return {
code: 400,
message: error.message,
};
}
}
private checkHasNewVersion(curVersion: string, lastVersion: string) {
const curArr = curVersion.split('.').map((x) => parseInt(x, 10));
const lastArr = lastVersion.split('.').map((x) => parseInt(x, 10));
if (curArr[0] < lastArr[0]) {
return true;
}
if (curArr[0] === lastArr[0] && curArr[1] < lastArr[1]) {
return true;
}
if (
curArr[0] === lastArr[0] &&
curArr[1] === lastArr[1] &&
curArr[2] < lastArr[2]
) {
return true;
}
return false;
}
public async updateSystem() {
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
cp.stdout.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
cp.stderr.on('data', (data) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: data.toString(),
});
});
cp.on('error', (err) => {
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',
});
});
return { code: 200 };
}
public async reloadSystem(target?: 'system' | 'data') {
const cmd = `real_time=true ql reload ${target || ''}`;
const cp = spawn(cmd, {
shell: '/bin/bash',
detached: true,
stdio: 'ignore',
});
cp.unref();
setTimeout(() => {
process.exit(0);
});
return { code: 200 };
}
public async notify({
title,
content,
notificationInfo,
}: {
title: string;
content: string;
notificationInfo?: NotificationInfo;
}) {
const typeString =
typeof notificationInfo?.type === 'number'
? NotificationModeStringMap[notificationInfo.type]
: undefined;
if (notificationInfo && typeString) {
notificationInfo.type = typeString;
}
const isSuccess = await this.notificationService.notify(
title,
content,
notificationInfo,
);
if (isSuccess) {
return { code: 200, message: t('通知发送成功') };
} else {
return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
}
}
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
});
}
public async stop({ command, pid }: { command: string; pid: number }) {
if (!pid && !command) {
return { code: 400, message: t('参数错误') };
}
if (pid) {
await killTask(pid);
return { code: 200 };
}
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
const _pid = await getPid(command);
if (_pid) {
await killTask(_pid);
return { code: 200 };
} else {
return { code: 400, message: t('任务未找到') };
}
}
public async exportData(res: Response, type?: string[]) {
try {
let dataDirs = ['db', 'upload'];
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
await promiseExec(
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
} ${dataPaths.join(' ')}`,
);
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
}
}
public async importData() {
try {
await promiseExec(`rm -rf ${path.join(config.tmpPath, 'data')}`);
const res = await promiseExec(
`cd ${config.tmpPath} && tar -zxvf ${config.dataTgzFile}`,
);
return { code: 200, data: res };
} catch (error: any) {
return { code: 400, message: error.message };
}
}
public async getSystemLog(
res: Response,
query: {
startTime?: string;
endTime?: string;
},
) {
const startTime = dayjs(query.startTime || undefined)
.startOf('d')
.valueOf();
const endTime = dayjs(query.endTime || undefined)
.endOf('d')
.valueOf();
const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result
.reverse()
.filter((x) => x.title.endsWith('.log'))
.filter((x) => x.createTime >= startTime && x.createTime <= endTime);
res.set({
'Content-Length': sum(logs.map((x) => x.size)),
});
(function sendFiles(res, fileNames) {
if (fileNames.length === 0) {
res.end();
return;
}
const currentLog = fileNames.shift();
if (currentLog) {
const currentFileStream = fs.createReadStream(
path.join(config.systemLogPath, currentLog.title),
);
currentFileStream.on('end', () => {
sendFiles(res, fileNames);
});
currentFileStream.pipe(res, { end: false });
}
})(res, logs);
}
public async deleteSystemLog() {
const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
for (const log of logs) {
await rmPath(path.join(config.systemLogPath, log.title));
}
}
public async updateTimezone(info: SystemModelInfo) {
if (!info.timezone) {
info.timezone = 'Asia/Shanghai';
}
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
const success = await setSystemTimezone(info.timezone);
if (success) {
return { code: 200, data: info };
} else {
return { code: 400, message: t('设置时区失败') };
}
}
public async updateLanguage(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const lang = info.lang || 'zh';
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, lang },
});
// Write to standalone lang_env.sh, sourced by shell scripts
try {
await fs.promises.writeFile(
config.langEnvFile,
`export QL_LANG='${lang}'\n`,
);
} catch (error) {
this.logger.error(`Failed to write lang_env.sh: ${error}`);
}
setLang(lang);
return { code: 200, data: { lang } };
}
public async updatePanelTitle(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const panelTitle = info.panelTitle?.trim() || '';
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, panelTitle },
});
return { code: 200, data: { panelTitle } };
}
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: t('参数错误') };
}
try {
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true });
} catch (error) { }
return { code: 200 };
}
}
-536
View File
@@ -1,536 +0,0 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { createRandomString } from '../config/util';
import config from '../config';
import jwt from 'jsonwebtoken';
import { authenticator } from '@otplib/preset-default';
import {
AuthDataType,
SystemInfo,
SystemModel,
SystemModelInfo,
LoginStatus,
AuthInfo,
TokenInfo,
} from '../data/system';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
import { Request } from 'express';
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';
@Service()
export default class UserService {
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
constructor(
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) {}
public async login(
payloads: {
username: string;
password: string;
},
req: Request,
needTwoFactor = true,
): Promise<any> {
let { username, password } = payloads;
const content = await this.getAuthInfo();
const timestamp = Date.now();
let {
username: cUsername,
password: cPassword,
retries = 0,
lastlogon,
lastip,
lastaddr,
twoFactorActivated,
tokens = {},
platform,
} = content;
const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil(
(retriesTime - (timestamp - lastlogon)) / 1000,
);
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime,
};
}
if (
username === cUsername &&
password === cPassword &&
twoFactorActivated &&
needTwoFactor
) {
await this.updateAuthInfo(content, {
isTwoFactorChecking: true,
});
return {
code: 420,
message: '',
};
}
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) {
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384',
});
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, {
token,
tokens: updatedTokens,
lastlogon: timestamp,
retries: 0,
lastip: ip,
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
});
this.notificationService.notify(
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,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.success,
},
});
this.getLoginLog();
return {
code: 200,
data: {
token,
lastip,
lastaddr,
lastlogon,
retries,
platform,
},
};
} else {
await this.updateAuthInfo(content, {
retries: retries + 1,
lastlogon: timestamp,
lastip: ip,
lastaddr: address,
platform: req.platform,
});
this.notificationService.notify(
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,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.fail,
},
});
this.getLoginLog();
if (retries > 2) {
const waitTime = Math.round(Math.pow(3, retries + 1));
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime,
};
} else {
return { code: 400, message: t('错误的用户名密码,请重试') };
}
}
}
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, {
token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: updatedTokens,
});
}
public async getLoginLog(): Promise<Array<SystemModelInfo | undefined>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.loginLog },
});
if (docs && docs.length > 0) {
const result = docs.sort(
(a, b) => b.info!.timestamp! - a.info!.timestamp!,
);
if (result.length > 100) {
const ids = result.slice(100).map((x) => x.id!);
await SystemModel.destroy({
where: { id: ids },
});
}
return result.map((x) => x.info);
}
return [];
}
private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await SystemModel.create({ ...payload }, { returning: true });
return doc;
}
public async updateUsernameAndPassword({
username,
password,
}: {
username: string;
password: string;
}) {
if (password === 'admin') {
return { code: 400, message: t('密码不能设置为admin') };
}
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { username, password });
return { code: 200, message: t('更新成功') };
}
public async updateAvatar(avatar: string) {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: t('更新成功') };
}
public async initTwoFactor() {
const secret = authenticator.generateSecret();
const authInfo = await this.getAuthInfo();
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
return { secret, url: otpauth };
}
public async activeTwoFactor(code: string) {
const authInfo = await this.getAuthInfo();
const isValid = authenticator.verify({
token: code,
secret: authInfo.twoFactorSecret,
});
if (isValid) {
await this.updateAuthInfo(authInfo, { twoFactorActivated: true });
}
return isValid;
}
public async twoFactorLogin(
{
username,
password,
code,
}: { username: string; password: string; code: string },
req: any,
) {
const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
if (!isTwoFactorChecking) {
return { code: 450, message: t('未知错误') };
}
const isValid = authenticator.verify({
token: code,
secret: twoFactorSecret,
});
if (isValid) {
return this.login({ username, password }, req, false);
} else {
const ip = requestIp.getClientIp(req) || '';
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp])
.filter(Boolean)
.join(' ');
}
await this.updateAuthInfo(authInfo, {
lastip: ip,
lastaddr: address,
platform: req.platform,
});
return { code: 430, message: t('验证失败') };
}
}
public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, {
twoFactorActivated: false,
twoFactorSecret: '',
});
return true;
}
public async getAuthInfo() {
const authInfo = await shareStore.getAuthInfo();
if (authInfo) {
return authInfo;
}
const doc = await this.getDb({ type: AuthDataType.authConfig });
return (doc.info || {}) as AuthInfo;
}
private async updateAuthInfo(authInfo: AuthInfo, info: Partial<AuthInfo>) {
const result = { ...authInfo, ...info };
await shareStore.updateAuthInfo(result);
await this.updateAuthDb({
type: AuthDataType.authConfig,
info: result,
});
}
public async getNotificationMode(): Promise<NotificationInfo> {
const doc = await this.getDb({ type: AuthDataType.notification });
return (doc.info || {}) as NotificationInfo;
}
private async updateAuthDb(payload: SystemInfo): Promise<any> {
let doc = await SystemModel.findOne({ where: { type: payload.type } });
if (doc) {
const updateResult = await SystemModel.update(payload, {
where: { id: doc.id },
returning: true,
});
doc = updateResult[1][0];
} else {
doc = await SystemModel.create(payload, { returning: true });
}
return doc;
}
public async getDb(query: any): Promise<SystemInfo> {
const doc = await SystemModel.findOne({ where: { ...query } });
if (!doc) {
throw new Error(`${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async updateNotificationMode(notificationInfo: NotificationInfo) {
const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify(
notificationInfo,
t('青龙'),
t('【蛟龙】测试通知 https://t.me/jiao_long'),
);
if (isSuccess) {
const result = await this.updateAuthDb({
type: AuthDataType.notification,
info: { ...notificationInfo },
});
return { code: 200, data: { ...result, code } };
} else {
return { code: 400, message: t('通知发送失败,请检查参数') };
}
}
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
if (password === 'admin') {
return { code: 400, message: t('密码不能设置为admin') };
}
const authInfo = await this.getAuthInfo();
const payload = pickBy(
{
retries,
twoFactorActivated,
password,
username,
},
(x) => !isNil(x),
);
await this.updateAuthInfo(authInfo, payload);
}
}
-54
View File
@@ -1,54 +0,0 @@
import { AuthInfo, TokenInfo } from '../data/system';
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
return (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
);
}
/**
* Validates if a token exists in the authentication info.
* Supports both legacy string tokens and new TokenInfo array format.
*
* @param authInfo - The authentication information
* @param headerToken - The token to validate
* @param platform - The platform (desktop, mobile)
* @returns true if the token is valid, false otherwise
*/
export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
if (headerToken === token) {
return true;
}
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
const platformTokens = tokens[platform];
// Handle null/undefined platformTokens
if (platformTokens === null || platformTokens === undefined) {
return false;
}
if (typeof platformTokens === 'string') {
// Legacy format: single string token
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
}
// Unexpected type - log warning and reject
return false;
}
-175
View File
@@ -1,175 +0,0 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'暂无权限': 'Access denied',
'参数错误': 'Invalid parameter',
'参数不正确': 'Invalid parameter',
'文件无法访问': 'File not accessible',
'文件不存在': 'File not found',
'路径不存在': 'Path does not exist',
'路径不正确': 'Invalid path',
'通知发送失败,请检查参数': 'Notification failed, check parameters',
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
'通知发送成功': 'Notification sent successfully',
'设置时区失败': 'Failed to set timezone',
'任务未找到': 'Task not found',
'密码不能设置为admin': 'Password cannot be admin',
'更新成功': 'Update successful',
'未知错误': 'Unknown error',
'验证失败': 'Verification failed',
'实例不存在或已停止': 'Instance does not exist or stopped',
'实例已停止': 'Instance stopped',
'确认停止实例': 'Confirm to stop instance',
'确认停止运行实例': 'Confirm to stop running instance',
'确认停止': 'Confirm to stop',
'确认停止定时任务': 'Confirm to stop scheduled task',
'确认删除': 'Confirm to delete',
'确认删除定时任务': 'Confirm to delete scheduled task',
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
'确认运行': 'Confirm to run',
'确认运行定时任务': 'Confirm to run scheduled task',
'确认保存': 'Confirm to save',
'确认保存文件': 'Confirm to save file',
'确认重启': 'Confirm restart',
'确认启用': 'Confirm to enable',
'确认禁用': 'Confirm to disable',
'确认': 'Confirm',
'删除成功': 'Deleted successfully',
'操作成功': 'Operation successful',
'参数不完整': 'Incomplete parameters',
'默认路径不支持删除': 'Default path cannot be deleted',
'必须在日志目录下': 'Must be within log directory',
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
'系统将在': 'System will',
'秒后自动刷新': 'refresh in seconds',
'生成数据中...': 'Generating data...',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
'不支持当前依赖类型': 'Unsupported dependency type',
'依赖已存在': 'Dependency already exists',
'依赖不存在': 'Dependency does not exist',
'该脚本正在运行中': 'Script is running',
'该脚本未在运行中': 'Script is not running',
'文件内容为空': 'File content is empty',
'文件名不能为空': 'File name cannot be empty',
'标签不能为空': 'Label cannot be empty',
'名称不能为空': 'Name cannot be empty',
'名称不能为保留关键字': 'Name cannot be reserved keyword',
'名称已存在': 'Name already exists',
'密码错误': 'Incorrect password',
'用户不存在': 'User does not exist',
'请输入用户名和密码': 'Please enter username and password',
'无权访问': 'Access denied',
'登录成功': 'Login successful',
'退出成功': 'Logout successful',
'Token 已失效': 'Token expired',
'Token 无效': 'Invalid token',
'两步骤验证已开启': '2FA enabled',
'两步骤验证已关闭': '2FA disabled',
'验证码错误': 'Invalid verification code',
'验证码已过期': 'Verification code expired',
'请先开启两步骤验证': 'Please enable 2FA first',
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
'用户已存在': 'User already exists',
'用户名不能为admin': 'Username cannot be admin',
'不能删除自己': 'Cannot delete yourself',
'不能禁用自己': 'Cannot disable yourself',
'文件路径无效': 'Invalid file path',
'保存成功': 'Saved successfully',
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
'订阅执行完成': 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
'绝对路径必须在日志目录内或使用 /dev/null':
'Absolute path must be within log directory or use /dev/null',
'请先登录': 'Please login first',
'运行中...': 'Running...',
'日志不存在...': 'Log does not exist...',
'未分类': 'Uncategorized',
'任务重复运行': 'Duplicate task execution',
'日志设置为忽略': 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule',
'日志名称只能包含字母、数字、下划线和连字符':
'Log name can only contain letters, numbers, underscores, and hyphens',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
'在': ' in ',
: '',
: 'login failed',
'ip地址': ', IP: ',
'任务#%s': 'Task#%s',
: 'Install',
: 'Uninstall',
: 'Succeeded',
: 'Failed',
'失败次数过多,请%s秒后重试':
'Too many failed attempts, please retry in %s seconds',
: 'Smart Assistant',
: 'More',
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒':
'Start %s dependency %s, start time %s\n\nCurrent system not supported\n\nDependency %s failed, end time %s, elapsed %s seconds',
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒':
'Already installed %s\n\n%s\n\nSkipping install\n\nDependency %s succeeded, end time %s, elapsed %s seconds',
'开始%s依赖 %s,开始时间 %s\n\n':
'Start %s dependency %s, start time %s\n\n',
'依赖%s%s,结束时间 %s,耗时 %s 秒':
'Dependency %s%s, end time %s, elapsed %s seconds',
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置':
'Task: %s, command: %s, schedule: %s, more than %d instances running, please check schedule settings',
: 'QingLong',
'【蛟龙】测试通知 https://t.me/jiao_long':
'[JiaoLong] Test notification https://t.me/jiao_long',
'生成token': 'Generate token',
'删除日志': 'Delete logs',
'## 开始执行... %s\n': '## Start executing... %s\n',
'执行before命令...': 'Execute before command...',
'执行after命令...': 'Execute after command...',
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
'调度器注册失败,任务创建已回滚':
'Scheduler registration failed, task creation rolled back',
'调度器注册失败,任务更新已回滚':
'Scheduler registration failed, task update rolled back',
'调度器注册失败,任务启用已回滚':
'Scheduler registration failed, task enable rolled back',
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
},
};
let currentLang: string = 'zh';
export function setLang(lang: string) {
currentLang = lang || 'zh';
shareStore.setLang(currentLang);
}
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en */
export function systemLang(): string {
const prefix = Intl.DateTimeFormat().resolvedOptions().locale.split('-')[0];
return prefix === 'en' ? 'en' : 'zh';
}
export function t(key: string, lang?: string): string {
const effectiveLang = lang || currentLang;
if (effectiveLang === 'en' && messages.en[key]) {
return messages.en[key];
}
return key;
}
export function tf(key: string, ...args: (string | number)[]): string {
return args.reduce<string>(
(str, arg) => str.replace(/%s|%d/, String(arg)),
t(key),
);
}
-33
View File
@@ -1,33 +0,0 @@
import { Dependence } from '../data/dependence';
import { ICron } from '../protos/cron';
export type Override<
T,
K extends Partial<{ [P in keyof T]: any }> | string,
> = K extends string
? Omit<T, K> & { [P in keyof T]: T[P] | unknown }
: Omit<T, keyof K> & K;
export type TCron = Override<Partial<ICron>, { id: string }>;
export interface IDependencyFn<T> {
(): Promise<T>;
dependency?: Dependence;
}
export interface ICronFn<T> {
(): Promise<T>;
cron?: TCron;
}
export interface ISchedule {
schedule?: string;
name?: string;
command?: string;
id: string;
}
export interface IScheduleFn<T> {
(): Promise<T>;
schedule?: ISchedule;
}
-110
View File
@@ -1,110 +0,0 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
*/
export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
}
}
/**
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);
}
/**
* Get the number of open streams
*/
getOpenStreamCount(): number {
return this.streams.size;
}
}
// Export a singleton instance for shared use
export const logStreamManager = new LogStreamManager();
-246
View File
@@ -1,246 +0,0 @@
import PQueue, { QueueAddOptions } from 'p-queue-cjs';
import os from 'os';
import { AuthDataType, SystemModel } from '../data/system';
import Logger from '../loaders/logger';
import { Dependence } from '../data/dependence';
import NotificationService from '../services/notify';
import { t, tf } from '../shared/i18n';
import {
ICronFn,
IDependencyFn,
ISchedule,
IScheduleFn,
TCron,
} from './interface';
import config from '../config';
import { credentials } from '@grpc/grpc-js';
import { ApiClient } from '../protos/api';
import { getGrpcCerts } from '../config/grpcCerts';
class TaskLimit {
private dependenyLimit = new PQueue({ concurrency: 1 });
private queuedDependencyIds = new Set<number>([]);
private queuedCrons = new Map<string, ICronFn<any>[]>();
private repeatCronNotifyMap = new Map<string, number>();
private updateLogLimit = new PQueue({ concurrency: 1 });
private cronLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private manualCronoLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private subscriptionLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private scriptLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private systemLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private _client: ApiClient | null = null;
private get client(): ApiClient {
if (!this._client) {
const tlsConfig = getGrpcCerts();
const creds = tlsConfig
? credentials.createSsl(
Buffer.from(tlsConfig.caCert),
Buffer.from(tlsConfig.clientKey),
Buffer.from(tlsConfig.clientCert),
)
: credentials.createInsecure();
this._client = new ApiClient(
`localhost:${config.grpcPort}`,
creds,
{ 'grpc.enable_http_proxy': 0 },
);
}
return this._client;
}
get cronLimitActiveCount() {
return this.cronLimit.pending;
}
get cronLimitPendingCount() {
return this.cronLimit.size;
}
get firstDependencyId() {
return [...this.queuedDependencyIds.values()][0];
}
private notificationService: NotificationService = new NotificationService();
constructor() {
this.setCustomLimit();
this.handleEvents();
}
private handleEvents() {
this.cronLimit.on('add', () => {
Logger.info(
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('active', () => {
Logger.info(
`[schedule][开始处理任务] 运行中任务数: ${
this.cronLimitActiveCount + 1
}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('completed', (param) => {
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
});
this.cronLimit.on('error', (error) => {
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
});
this.cronLimit.on('next', () => {
Logger.info(
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('idle', () => {
Logger.info(`[schedule][任务队列] 空闲中...`);
});
}
public removeQueuedDependency(dependency: Dependence) {
if (this.queuedDependencyIds.has(dependency.id!)) {
this.queuedDependencyIds.delete(dependency.id!);
}
}
public removeQueuedCron(id: string) {
if (this.queuedCrons.has(id)) {
const runs = this.queuedCrons.get(id);
if (runs && runs.length > 0) {
runs.pop();
this.queuedCrons.set(id, runs);
}
}
}
public async setCustomLimit(limit?: number) {
if (limit) {
this.cronLimit.concurrency = limit;
this.manualCronoLimit.concurrency = limit;
return;
}
await SystemModel.sync();
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
if (doc?.info?.cronConcurrency) {
this.cronLimit.concurrency = doc.info.cronConcurrency;
this.manualCronoLimit.concurrency = doc.info.cronConcurrency;
}
}
public async runWithCronLimit<T>(
cron: TCron,
fn: ICronFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.cron = cron;
let runs = this.queuedCrons.get(cron.id);
const result = runs?.length ? [...runs, fn] : [fn];
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
if (result?.length > 5) {
if (repeatTimes < 3) {
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
this.client.systemNotify(
{
title: t('任务重复运行'),
content: tf(
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置',
cron.name || '',
cron.command || '',
cron.schedule || '',
5,
),
},
(err, res) => {
if (err) {
Logger.error(
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
);
}
},
);
}
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
return;
}
this.queuedCrons.set(cron.id, result);
return this.cronLimit.add(fn, options);
}
public async manualRunWithCronLimit<T>(
fn: () => Promise<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
return this.manualCronoLimit.add(fn, options);
}
public async runWithSubscriptionLimit<T>(
schedule: TCron,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.subscriptionLimit.add(fn, options);
}
public async runWithSystemLimit<T>(
schedule: TCron,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.systemLimit.add(fn, options);
}
public async runWithScriptLimit<T>(
schedule: ISchedule,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.scriptLimit.add(fn, options);
}
public async waitDependencyQueueDone(): Promise<void> {
if (this.dependenyLimit.size === 0 && this.dependenyLimit.pending === 0) {
return;
}
return new Promise((resolve) => {
const onIdle = () => {
this.dependenyLimit.removeListener('idle', onIdle);
resolve();
};
this.dependenyLimit.on('idle', onIdle);
});
}
public runDependeny<T>(
dependency: Dependence,
fn: IDependencyFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
this.queuedDependencyIds.add(dependency.id!);
fn.dependency = dependency;
return this.dependenyLimit.add(fn, options);
}
public updateDepLog<T>(
fn: () => Promise<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
return this.updateLogLimit.add(fn, options);
}
}
export default new TaskLimit();
-92
View File
@@ -1,92 +0,0 @@
import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => {
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{ where: { cron_id: Number(cron.id), status: InstanceStatus.running } },
);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
command: cmd,
})}`,
);
const cp = spawn(cmd, { shell: '/bin/bash' });
cp.stderr.on('data', (data) => {
Logger.info(
'[schedule][执行任务失败] 命令: %s, 错误信息: %j',
cmd,
data.toString(),
);
});
cp.on('error', (err) => {
Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误信息: %j',
cmd,
err,
);
});
cp.on('exit', async (code) => {
taskLimit.removeQueuedCron(cron.id);
Logger.info(
'[schedule][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...cron,
command: cmd,
}),
code,
);
resolve({ ...cron, command: cmd, pid: cp.pid, code });
});
});
});
}
-42
View File
@@ -1,42 +0,0 @@
import { AuthInfo } from '../data/system';
import { App } from '../data/open';
import Keyv from 'keyv';
import KeyvSqlite from '@keyv/sqlite';
import config from '../config';
import path from 'path';
export enum EKeyv {
'apps' = 'apps',
'authInfo' = 'authInfo',
'lang' = 'lang',
}
export interface IKeyvStore {
apps: App[];
authInfo: AuthInfo;
lang: string;
}
const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite'));
export const keyvStore = new Keyv<IKeyvStore>({ store: keyvSqlite });
export const shareStore = {
getAuthInfo() {
return keyvStore.get<IKeyvStore['authInfo']>(EKeyv.authInfo);
},
updateAuthInfo(value: IKeyvStore['authInfo']) {
return keyvStore.set<IKeyvStore['authInfo']>(EKeyv.authInfo, value);
},
getApps() {
return keyvStore.get<IKeyvStore['apps']>(EKeyv.apps);
},
updateApps(apps: App[]) {
return keyvStore.set<IKeyvStore['apps']>(EKeyv.apps, apps);
},
getLang() {
return keyvStore.get<IKeyvStore['lang']>(EKeyv.lang);
},
setLang(value: IKeyvStore['lang']) {
return keyvStore.set<IKeyvStore['lang']>(EKeyv.lang, value);
},
};
-42
View File
@@ -1,42 +0,0 @@
import { lock } from 'proper-lockfile';
import os from 'os';
import path from 'path';
import { writeFile, open, chmod } from 'fs/promises';
import { fileExist } from '../config/util';
function getUniqueLockPath(filePath: string) {
const sanitizedPath = filePath
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/^_/, '');
return path.join(os.tmpdir(), `${sanitizedPath}.ql_lock`);
}
export async function writeFileWithLock(
filePath: string,
content: string,
options: Parameters<typeof writeFile>[2] = {},
) {
if (typeof options === 'string') {
options = { encoding: options };
}
if (!(await fileExist(filePath))) {
const fileHandle = await open(filePath, 'w');
fileHandle.close();
}
const lockfilePath = getUniqueLockPath(filePath);
const release = await lock(filePath, {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 3000,
},
lockfilePath,
});
await writeFile(filePath, content, { encoding: 'utf8', ...options });
if (options?.mode) {
await chmod(filePath, options.mode);
}
await release();
}
-11
View File
@@ -1,11 +0,0 @@
/// <reference types="express" />
export {};
declare global {
namespace Express {
interface Request {
platform: 'desktop' | 'mobile';
}
}
}
-96
View File
@@ -1,96 +0,0 @@
import { Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => {
if (
value.startsWith(ScheduleType.ONCE) ||
value.startsWith(ScheduleType.BOOT)
) {
return value;
}
// 检测裸 /N 模式:cron-parser 会接受,但 node-schedule 会返回 null
// 提前拦截,避免任务入库后调度器注册失败
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
return helpers.error('any.invalid');
}
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
if (/\?/.test(value)) {
return helpers.error('any.invalid');
}
try {
if (CronExpressionParser.parse(value).hasNext()) {
return value;
}
} catch (e) {
return helpers.error('any.invalid');
}
return helpers.error('any.invalid');
};
export const scheduleSchema = Joi.string()
.required()
.custom(validateSchedule)
.messages({
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
});
export const commonCronSchema = {
name: Joi.string().optional(),
command: Joi.string().required(),
schedule: scheduleSchema,
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
.optional()
.allow('')
.allow(null)
.custom((value, helpers) => {
if (!value) return value;
// Check if it's an absolute path
if (value.startsWith('/')) {
// Allow /dev/null as special case
if (value === '/dev/null') {
return value;
}
// For other absolute paths, ensure they are within the safe log directory
const normalizedValue = path.normalize(value);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedValue.startsWith(normalizedLogPath)) {
return helpers.error('string.unsafePath');
}
return value;
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
return helpers.error('string.max');
}
return value;
})
.messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
work_dir: Joi.string().optional().allow('').allow(null),
};
-81
View File
@@ -1,81 +0,0 @@
# Kubernetes deployment
This deploys Qinglong as a single-replica `StatefulSet` with persistent data at `/ql/data`.
```bash
kubectl apply -k deploy/kubernetes/overlays/local
kubectl -n qinglong rollout status statefulset/qinglong
```
Open the panel locally:
```bash
kubectl -n qinglong port-forward svc/qinglong 5700:5700
```
Then visit <http://127.0.0.1:5700>.
## Image registry overlays
Use `overlays/example` as the committed template for registry customization:
```yaml
whyour/qinglong:debian -> registry.example.com/whyour/qinglong:debian
```
Create `overlays/local/kustomization.yaml` for the actual cluster image. The `local` overlay is ignored by git so private registry names, digests, and credentials-related references stay local.
## Storage
The manifest creates a 5 GiB `ReadWriteOnce` PVC from the cluster's default `StorageClass`.
If your cluster has no default storage class, add `storageClassName` under:
```yaml
volumeClaimTemplates:
- metadata:
name: data
spec:
storageClassName: your-storage-class
```
Keep `replicas: 1`. Qinglong stores state in the persistent data directory, including SQLite files, so multiple replicas should not share the same data volume.
## Ingress example
If you expose Qinglong through an Ingress path other than `/`, set `QlBaseUrl` to the same path with leading and trailing slashes.
```yaml
env:
- name: QlBaseUrl
value: "/qinglong/"
```
Example Ingress:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: qinglong
namespace: qinglong
spec:
rules:
- host: qinglong.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: qinglong
port:
number: 5700
```
## Maintenance commands
```bash
kubectl -n qinglong logs -f statefulset/qinglong
kubectl -n qinglong exec -it statefulset/qinglong -- ql check
kubectl -n qinglong exec -it statefulset/qinglong -- ql update
```
@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- qinglong.yaml
-95
View File
@@ -1,95 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: qinglong
---
apiVersion: v1
kind: Service
metadata:
name: qinglong
namespace: qinglong
labels:
app.kubernetes.io/name: qinglong
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: qinglong
ports:
- name: http
port: 5700
targetPort: http
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: qinglong
namespace: qinglong
labels:
app.kubernetes.io/name: qinglong
spec:
serviceName: qinglong
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: qinglong
template:
metadata:
labels:
app.kubernetes.io/name: qinglong
spec:
securityContext:
fsGroup: 5432
fsGroupChangePolicy: OnRootMismatch
containers:
- name: qinglong
image: whyour/qinglong:debian
imagePullPolicy: IfNotPresent
env:
- name: QlBaseUrl
value: "/"
- name: TZ
value: Asia/Shanghai
ports:
- name: http
containerPort: 5700
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 60
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 6
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
volumeMounts:
- name: data
mountPath: /ql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
@@ -1,10 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: whyour/qinglong
newName: registry.example.com/whyour/qinglong
newTag: debian
+50 -99
View File
@@ -1,109 +1,60 @@
# Run Node package installation natively on the builder. Node/npm can spin at
# 100% CPU when Alpine s390x is emulated through QEMU.
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
ARG TARGETARCH
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \
&& apk add --no-cache git \
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
&& cd /tmp/build \
&& case "${TARGETARCH}" in \
amd64) NODE_ARCH=x64 ;; \
386) NODE_ARCH=ia32 ;; \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
esac \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \
pnpm install --prod
FROM python:3.11-alpine
FROM python:3.10-alpine
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.11
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
QL_CONTAINER=true \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN set -x \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
coreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
nodejs \
jq \
openssh \
procps \
netcat-openbsd \
unzip \
npm \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@users.noreply.github.com" \
&& git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \
&& rm -rf /root/.cache \
&& ulimit -c 0
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \
&& cp -f .env.example .env \
&& chmod 777 ${QL_DIR}/shell/*.sh \
&& chmod 777 ${QL_DIR}/docker/*.sh \
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
&& mkdir -p ${QL_DIR}/static \
&& cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
ENV PNPM_HOME=/root/.local/share/pnpm \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules:$PNPM_HOME \
LANG=zh_CN.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \
QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH}
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
RUN set -x \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
coreutils \
moreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
nginx \
nodejs \
jq \
openssh \
npm \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@@users.noreply.github.com" \
&& git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \
&& npm install -g pnpm \
&& pnpm add -g pm2 ts-node typescript tslib \
&& git clone -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \
&& cp -f .env.example .env \
&& chmod 777 ${QL_DIR}/shell/*.sh \
&& chmod 777 ${QL_DIR}/docker/*.sh \
&& pnpm install --prod \
&& rm -rf /root/.pnpm-store \
&& rm -rf /root/.local/share/pnpm/store \
&& rm -rf /root/.cache \
&& rm -rf /root/.npm \
&& git clone -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
&& mkdir -p ${QL_DIR}/static \
&& cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
-109
View File
@@ -1,109 +0,0 @@
# Run Node package installation natively on the builder. Node/npm can spin at
# 100% CPU when Alpine s390x is emulated through QEMU.
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
ARG TARGETARCH
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \
&& apk add --no-cache git \
&& npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
&& cd /tmp/build \
&& case "${TARGETARCH}" in \
amd64) NODE_ARCH=x64 ;; \
386) NODE_ARCH=ia32 ;; \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
esac \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \
pnpm install --prod
FROM python:3.10-alpine
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.10
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
QL_CONTAINER=true \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN set -x \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
coreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
nodejs \
jq \
openssh \
procps \
netcat-openbsd \
unzip \
npm \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@users.noreply.github.com" \
&& git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \
&& rm -rf /root/.cache \
&& ulimit -c 0
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \
&& cp -f .env.example .env \
&& chmod 777 ${QL_DIR}/shell/*.sh \
&& chmod 777 ${QL_DIR}/docker/*.sh \
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
&& mkdir -p ${QL_DIR}/static \
&& cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
-122
View File
@@ -1,122 +0,0 @@
# Node 20 Bookworm is the latest official Node image variant that covers the
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
FROM node:20-bookworm-slim AS nodebuilder
FROM python:3.11.14-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.11.14-slim-bookworm
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.11
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
QL_CONTAINER=true \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
ARG QL_UID=5432
ARG QL_GID=5432
RUN groupadd -g ${QL_GID} qinglong && \
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
chmod 700 /home/qinglong/.ssh && \
chown -R ${QL_UID}:${QL_GID} /home/qinglong && \
mkdir -p /etc/sudoers.d && \
echo 'qinglong ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qinglong
ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
apt-get update && \
apt-get upgrade -y && \
apt-get install --no-install-recommends -y git \
curl \
wget \
tzdata \
perl \
openssl \
openssh-client \
jq \
procps \
netcat-openbsd \
sudo \
unzip \
libatomic1 && \
apt-get clean && \
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" >/etc/timezone && \
git config --global user.email "qinglong@users.noreply.github.com" && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
rm -rf /etc/apt/apt.conf.d/docker-clean && \
ulimit -c 0
RUN mkdir -p ${QL_DIR} && \
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
USER qinglong
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \
cd ${QL_DIR} && \
cp -f .env.example .env && \
chmod 777 ${QL_DIR}/shell/*.sh && \
chmod 777 ${QL_DIR}/docker/*.sh && \
git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \
mkdir -p ${QL_DIR}/static && \
cp -rf /tmp/static/* ${QL_DIR}/static && \
rm -rf /tmp/static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/home/qinglong
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
USER root
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
VOLUME /ql/data
EXPOSE 5700
-122
View File
@@ -1,122 +0,0 @@
# Node 20 Bookworm is the latest official Node image variant that covers the
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
FROM node:20-bookworm-slim AS nodebuilder
FROM python:3.10-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.10-slim-bookworm
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.10
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
QL_CONTAINER=true \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
ARG QL_UID=5432
ARG QL_GID=5432
RUN groupadd -g ${QL_GID} qinglong && \
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
chmod 700 /home/qinglong/.ssh && \
chown -R ${QL_UID}:${QL_GID} /home/qinglong && \
mkdir -p /etc/sudoers.d && \
echo 'qinglong ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qinglong
ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get upgrade -y && \
apt-get install --no-install-recommends -y git \
curl \
wget \
tzdata \
perl \
openssl \
openssh-client \
jq \
procps \
netcat-openbsd \
sudo \
unzip \
libatomic1 && \
apt-get clean && \
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" >/etc/timezone && \
git config --global user.email "qinglong@users.noreply.github.com" && \
git config --global user.name "qinglong" && \
git config --global http.postBuffer 524288000 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
rm -rf /etc/apt/apt.conf.d/docker-clean && \
ulimit -c 0
RUN mkdir -p ${QL_DIR} && \
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
USER qinglong
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \
cd ${QL_DIR} && \
cp -f .env.example .env && \
chmod 777 ${QL_DIR}/shell/*.sh && \
chmod 777 ${QL_DIR}/docker/*.sh && \
git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \
mkdir -p ${QL_DIR}/static && \
cp -rf /tmp/static/* ${QL_DIR}/static && \
rm -rf /tmp/static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/home/qinglong
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
USER root
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
VOLUME /ql/data
EXPOSE 5700
+3 -4
View File
@@ -1,10 +1,9 @@
version: '2'
services:
web:
image: whyour/qinglong:latest # 基于 Debian 的版本:whyour/qinglong:debian
image: whyour/qinglong:latest
volumes:
- ./data:/ql/data
ports:
- "5700:5700"
environment:
QlBaseUrl: '/' # 部署路径非必须,以斜杠开头和结尾,比如 /test/
- "0.0.0.0:5700:5700"
restart: unless-stopped
+38 -123
View File
@@ -2,146 +2,61 @@
dir_shell=/ql/shell
. $dir_shell/share.sh
link_shell
export_ql_envs() {
export BACK_PORT="${ql_port}"
export GRPC_PORT="${ql_grpc_port}"
}
export isFirstStartServer=true
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
echo -e "======================1. 检测配置文件========================\n"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
cp -fv $nginx_conf /etc/nginx/nginx.conf
cp -fv $nginx_app_conf /etc/nginx/conf.d/front.conf
sed -i "s,QL_BASE_URL,${qlBaseUrl},g" /etc/nginx/conf.d/front.conf
pm2 l &>/dev/null
# ============================================
# 确保当前用户对 /ql 和 /ql/data 目录有写入权限
# /ql/data 是 Docker Volume 挂载点,权限可能与 /ql 不同,需单独检测
# ============================================
ensure_ql_permissions() {
local current_uid
local current_gid
current_uid=$(id -u)
current_gid=$(id -g)
patch_version &>/dev/null
echo
if [ "$current_uid" -eq 0 ]; then
return 0
fi
echo -e "======================2. 安装依赖========================\n"
update_depend
echo
# ---- 检查 /ql 目录 ----
if ! mkdir -p "$QL_DIR/.tmp" 2>/dev/null; then
if chown -R "$current_uid:$current_gid" "$QL_DIR" 2>/dev/null; then
log_with_style "INFO" "已修正 /ql 目录权限: UID=$current_uid GID=$current_gid"
else
local ql_owner
ql_owner=$(stat -c '%u' "$QL_DIR" 2>/dev/null || stat -f '%u' "$QL_DIR" 2>/dev/null)
log_with_style "ERROR" "============================================="
log_with_style "ERROR" " 权限错误:无法写入 /ql 目录"
log_with_style "ERROR" " 当前用户 UID: $current_uid"
log_with_style "ERROR" " /ql 目录所有者 UID: ${ql_owner:-未知}"
log_with_style "ERROR" ""
log_with_style "ERROR" " 解决方案:"
log_with_style "ERROR" " 1. 使用镜像内置用户: docker run --user ${ql_owner:-5432}:${ql_owner:-5432} ..."
log_with_style "ERROR" " 2. 使用 root 运行: 移除 --user 参数"
log_with_style "ERROR" " 3. 修正宿主机数据目录: chown -R $current_uid:$current_gid /path/to/ql/data"
log_with_style "ERROR" "============================================="
exit 1
fi
fi
rmdir "$QL_DIR/.tmp" 2>/dev/null || true
echo -e "======================3. 启动nginx========================\n"
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
echo -e "nginx启动成功...\n"
# ---- 检查 /ql/data 目录(Volume 挂载点,不在用户数据卷内创建临时文件) ----
if [ ! -w "$QL_DIR/data" ] || [ ! -x "$QL_DIR/data" ]; then
if chown "$current_uid:$current_gid" "$QL_DIR/data" 2>/dev/null; then
log_with_style "INFO" "已修正 /ql/data 目录权限: UID=$current_uid GID=$current_gid"
if [ ! -w "$QL_DIR/data" ] || [ ! -x "$QL_DIR/data" ]; then
log_with_style "ERROR" "修正后仍无法写入 /ql/data,请检查挂载的数据卷权限"
log_with_style "ERROR" "确保宿主机目录: chown -R $current_uid:$current_gid /your/data"
exit 1
fi
else
local data_owner
data_owner=$(stat -c '%u' "$QL_DIR/data" 2>/dev/null || stat -f '%u' "$QL_DIR/data" 2>/dev/null)
log_with_style "ERROR" "============================================="
log_with_style "ERROR" " 权限错误:无法写入 /ql/data (Volume 挂载点)"
log_with_style "ERROR" " 当前用户 UID: $current_uid"
log_with_style "ERROR" " /ql/data 所有者 UID: ${data_owner:-未知}"
log_with_style "ERROR" ""
log_with_style "ERROR" " 请修正宿主机数据目录权限:"
log_with_style "ERROR" " chown -R $current_uid:$current_gid /your/ql/data"
log_with_style "ERROR" "============================================="
exit 1
fi
fi
}
echo -e "======================4. 启动面板监控========================\n"
pm2 delete public &>/dev/null
pm2 start $dir_static/build/public.js -n public --source-map-support --time
echo -e "监控服务启动成功...\n"
# Fix DNS resolution issues in Alpine Linux
if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf
log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)"
fi
fi
echo -e "======================5. 启动控制面板========================\n"
pm2 delete panel &>/dev/null
pm2 start $dir_static/build/app.js -n panel --source-map-support --time
echo -e "控制面板启动成功...\n"
# 确保 /etc/hosts 包含 localhost 解析(应对精简镜像或仅 IPv4/IPv6 环境)
if ! grep -qE '^127\.0\.0\.1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then
echo "127.0.0.1 localhost" >> /etc/hosts
log_with_style "INFO" "🔧 0. 已添加 IPv4 localhost 解析"
fi
if ! grep -qE '^::1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then
echo "::1 localhost ip6-localhost ip6-loopback" >> /etc/hosts
log_with_style "INFO" "🔧 0. 已添加 IPv6 localhost 解析"
fi
# 自定义用户(非 qinglong/root)可能 HOME 为空或不可写
# 修正 HOME 确保 npm/pip/pm2 等工具有可用的缓存目录
if [ ! -w "$HOME" ]; then
mkdir -p "$QL_DIR/.tmp"
export HOME="$QL_DIR/.tmp"
fi
# 在一切操作之前检查目录权限
ensure_ql_permissions
log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs
export_ql_envs
. $dir_shell/env.sh
import_config "$@"
fix_config
# Try to initialize PM2, but don't fail if it doesn't work
pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案"
log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2
echo -e "======================6. 启动定时任务========================\n"
pm2 delete schedule &>/dev/null
pm2 start $dir_static/build/schedule.js -n schedule --source-map-support --time
echo -e "定时任务启动成功...\n"
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..."
echo -e "======================7. 启动bot========================\n"
nohup ql bot >$dir_log/bot.log 2>&1 &
echo -e "bot后台启动中...\n"
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
echo -e "======================8. 执行自定义脚本========================\n"
nohup ql extra >$dir_log/extra.log 2>&1 &
echo -e "自定义脚本后台执行中...\n"
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
echo -e "############################################################\n"
echo -e "容器启动成功..."
echo -e "\n请先访问5700端口,登录成功面板之后再执行添加定时任务..."
echo -e "############################################################\n"
# 自动检测调度模式:有 crond 二进制 → system 模式,否则 node 模式
if [ -z "$QL_SCHEDULER" ]; then
if command -v crond &>/dev/null; then
export QL_SCHEDULER="system"
else
export QL_SCHEDULER="node"
fi
fi
if [ "$QL_SCHEDULER" = "system" ]; then
crond -f > /dev/null
else
tail -f /dev/null
fi
crond -f >/dev/null
exec "$@"
+60
View File
@@ -0,0 +1,60 @@
upstream baseApi {
server 0.0.0.0:5600;
}
upstream publicApi {
server 0.0.0.0:5400;
}
map $http_upgrade $connection_upgrade {
default keep-alive;
'websocket' upgrade;
}
server {
listen 5700;
root /ql/static/dist;
ssl_session_timeout 5m;
location QL_BASE_URL/api/public/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://publicApi/api/public/;
}
location QL_BASE_URL/api/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://baseApi/api/;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location QL_BASE_URL/open/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://baseApi/open/;
}
gzip on;
gzip_static on;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.0;
location QL_BASE_URL/ {
index index.html index.htm;
try_files $uri $uri/ QL_BASE_URL/index.html;
}
location ~ .*\.(html)$ {
add_header Cache-Control no-cache;
}
}
+45
View File
@@ -0,0 +1,45 @@
user root;
worker_processes auto;
pcre_jit on;
error_log /var/log/nginx/error.log warn;
include /etc/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
client_max_body_size 20m;
client_body_buffer_size 20m;
keepalive_timeout 65;
sendfile on;
tcp_nodelay on;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:2m;
gzip on;
gzip_static on;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.0;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
include /etc/nginx/conf.d/*.conf;
}
-550
View File
@@ -1,550 +0,0 @@
# Project Architecture Guide
This document is written for AI coding agents and maintainers who need to understand and modify this project safely. It focuses on where behavior lives, how the application starts, and which files are usually involved for common changes.
## Project Summary
Qinglong is a timed task management platform. It provides a web admin panel for managing cron jobs, scripts, environment variables, subscriptions, dependencies, logs, configuration files, and system settings.
The repository is organized as a full-stack TypeScript application:
- `src/`: frontend admin panel, built with Umi Max, React, Ant Design, and Ant Design Pro Layout.
- `back/`: backend application, built with Express, TypeScript, typedi, Sequelize, SQLite, gRPC, and worker processes.
- `shell/`: runtime shell scripts used to execute tasks and preload task environments.
- `data/`: local runtime data, including scripts, logs, configs, SQLite database, uploaded files, and cloned repositories.
- `static/`: built frontend and backend artifacts.
- `docker/`: Docker images, compose file, and entrypoint.
- `sample/`: sample scripts and default config templates.
## High-Level Runtime Flow
```text
Browser
-> src/pages/*
-> src/utils/http.tsx
-> /api/*
-> back/api/*
-> back/services/*
-> back/data/* Sequelize models
-> data/db/database.sqlite
Cron/task execution
-> back/services/cron.ts
-> shell/task.sh or shell/otask.sh
-> data/scripts/*
-> data/log/*
Frontend assets in production
-> static/dist/*
-> served by back/loaders/express.ts
```
## Main Startup Path
Development starts from `package.json`:
```bash
pnpm start
```
This runs:
- `start:back`: `nodemon ./back/app.ts`
- `start:front`: `max dev`
Backend startup begins in `back/app.ts`.
Important details:
- The backend uses Node `cluster`.
- The primary process initializes the database first.
- A gRPC worker starts before the HTTP worker.
- The HTTP worker starts Express and serves API routes plus frontend static files.
- If the gRPC worker restarts, the HTTP worker is asked to re-register cron jobs.
Production-style backend output is generated by:
```bash
pnpm run build:back
```
The compiled backend is placed under `static/build`.
Frontend output is generated by:
```bash
pnpm run build:front
```
The compiled frontend is placed under `static/dist`.
## Backend Architecture
### Entry Point
- `back/app.ts`
Responsibilities:
- Creates the Express application.
- Starts primary/worker process logic.
- Initializes database in the primary process.
- Starts gRPC and HTTP workers.
- Handles graceful shutdown.
- Re-registers cron jobs after gRPC worker recovery.
### Loaders
- `back/loaders/app.ts`
- `back/loaders/express.ts`
- `back/loaders/db.ts`
- `back/loaders/depInjector.ts`
- `back/loaders/initData.ts`
- `back/loaders/initFile.ts`
- `back/loaders/initTask.ts`
- `back/loaders/server.ts`
- `back/loaders/sock.ts`
Loader responsibilities:
- Register dependency injection bindings.
- Sync Sequelize models.
- Initialize files and default data.
- Initialize scheduled tasks.
- Configure Express middleware.
- Register routes.
- Attach socket/server behavior.
`back/loaders/express.ts` is the main HTTP middleware and routing setup. It handles:
- CORS.
- Helmet.
- body parser.
- static frontend serving.
- JWT validation.
- token validation against shared auth state.
- `/open/*` rewrite to `/api/*`.
- route mounting through `back/api/index.ts`.
- frontend fallback to `static/dist/index.html`.
- API error handling.
### API Routes
- `back/api/index.ts`
This file registers all API modules:
- `user.ts`: login, initialization, authentication-related user endpoints.
- `env.ts`: environment variable endpoints.
- `config.ts`: config file endpoints.
- `log.ts`: log endpoints.
- `cron.ts`: cron/task endpoints.
- `script.ts`: script file endpoints.
- `open.ts`: open API/app token endpoints.
- `dependence.ts`: dependency management endpoints.
- `system.ts`: system information/settings endpoints.
- `subscription.ts`: subscription endpoints.
- `update.ts`: update/check endpoints.
- `health.ts`: health check endpoints.
Route files should stay thin. They should validate input, get a service from `typedi`'s `Container`, call the service, and return `{ code, data, message }` style responses.
### Services
- `back/services/*`
Services contain most business logic. Common examples:
- `cron.ts`: create/update/delete/run cron jobs, generate crontab data, manage logs, call scheduler client.
- `env.ts`: manage environment variables.
- `config.ts`: read/write config files.
- `script.ts`: manage script files.
- `subscription.ts`: manage script subscriptions and repository pulls.
- `dependence.ts`: install/manage runtime dependencies.
- `system.ts`: system info and settings.
- `notify.ts`: notification behavior.
- `sock.ts`: socket/log stream behavior.
- `grpc.ts`: gRPC server lifecycle.
- `http.ts`: HTTP server lifecycle.
When changing backend behavior, first find the API route, then follow it into the matching service. In most cases, the service is the right place for behavioral changes.
### Data Models
- `back/data/index.ts`
- `back/data/*.ts`
The backend uses Sequelize with SQLite. Database storage is configured in `back/data/index.ts`:
```text
data/db/database.sqlite
```
Common model files:
- `cron.ts`: cron job model.
- `cronView.ts`: saved cron table views.
- `env.ts`: environment variable model.
- `dependence.ts`: dependency model.
- `open.ts`: open API app/token model.
- `subscription.ts`: subscription model.
- `system.ts`: system settings model.
- `notify.ts`: notification-related data.
Model sync and simple column migrations are currently handled in `back/loaders/db.ts`.
### Configuration
- `back/config/index.ts`
This is the central runtime config file. It reads `.env`, establishes `QL_DIR`, and defines important paths:
- `dataPath`: runtime data root.
- `configPath`: config files.
- `scriptPath`: user scripts.
- `repoPath`: subscription repositories.
- `logPath`: task logs.
- `dbPath`: SQLite database location.
- `uploadPath`: uploaded files.
- `shellPath`: shell runtime scripts.
- `preloadPath`: JS/Python/Shell preload files.
Before hardcoding paths, check `back/config/index.ts`.
### Scheduling And gRPC
- `back/schedule/*`
- `back/protos/*`
- `back/services/grpc.ts`
The project has two scheduling paths:
- Standard crontab-style tasks are persisted and written through backend cron logic.
- Node/gRPC scheduler logic handles cases such as second-level cron expressions or additional schedules.
`back/services/cron.ts` decides whether a task needs the Node scheduler using schedule shape and `extra_schedules`.
### Shared Backend Utilities
- `back/shared/*`
- `back/config/util.ts`
- `back/config/share.ts`
- `back/config/http.ts`
Use these before adding new global helpers. Existing shared code includes:
- auth helpers.
- shared store.
- log stream manager.
- task runner helpers.
- concurrency limits.
- file locking utilities.
- HTTP/proxy helpers.
## Frontend Architecture
### Umi Config
- `.umirc.ts`
Important behavior:
- Dev server proxies API requests to `http://127.0.0.1:5700/`.
- Frontend build output is `static/dist`.
- Runtime env script is loaded from `./api/env.js`.
- `QlBaseUrl` affects frontend public path and routing base.
### App Initialization
- `src/app.ts`
Responsibilities:
- Load Chinese and English locale JSON.
- Determine locale from URL/cookie/localStorage.
- Set Umi locale.
- Apply `QlBaseUrl` as public path and router basename.
### Layout And Routes
- `src/layouts/defaultProps.tsx`
- `src/layouts/index.tsx`
`defaultProps.tsx` defines the main route/menu list. If adding a new page visible in the sidebar, update this file.
Current major pages:
- `src/pages/crontab`: timed task management.
- `src/pages/subscription`: subscription management.
- `src/pages/env`: environment variables.
- `src/pages/config`: config files.
- `src/pages/script`: script management.
- `src/pages/dependence`: dependency management.
- `src/pages/log`: log management.
- `src/pages/diff`: diff tool.
- `src/pages/setting`: system settings.
- `src/pages/login`: login.
- `src/pages/initialization`: first-run initialization.
- `src/pages/error`: error page.
### Frontend Utilities
- `src/utils/http.tsx`: API request helper.
- `src/utils/websocket.ts`: socket connection behavior.
- `src/utils/config.ts`: frontend config helpers.
- `src/utils/const.ts`: constants.
- `src/utils/date.ts`: date formatting helpers.
- `src/utils/init.ts`: initialization helpers.
- `src/utils/codemirror/*`: CodeMirror integration.
- `src/utils/monaco/*`: Monaco integration.
When changing a page's API behavior, inspect both the page file and `src/utils/http.tsx`.
### Components And Styling
- `src/components/*`: reusable UI components.
- `src/pages/**/index.less`: page-level styles.
- `src/pages/script/index.module.less` and `src/pages/log/index.module.less`: CSS module styles.
- `src/assets/fonts/*`: bundled fonts.
- `src/locales/*.json`: i18n text.
Follow the existing Ant Design and Ant Design Pro patterns when modifying UI.
## Shell Runtime
- `shell/task.sh`: task execution path.
- `shell/otask.sh`: alternate/manual task execution path.
- `shell/api.sh`: shell-side API helpers.
- `shell/env.sh`: environment setup.
- `shell/check.sh`: runtime check helpers.
- `shell/update.sh`: update helpers.
- `shell/rmlog.sh`: log cleanup.
- `shell/share.sh`: shared shell helpers.
- `shell/preload/*`: preload files injected into JS/Python/Shell task environments.
The backend often coordinates task execution, but the actual user script process environment is shaped by files in `shell/`.
## Runtime Data Directory
- `data/`
This directory is runtime state, not just source code. Be careful when modifying or deleting files here.
Important subdirectories:
- `data/db`: SQLite database.
- `data/config`: generated and user-edited config files.
- `data/scripts`: user scripts.
- `data/repo`: cloned subscription repositories.
- `data/log`: task logs.
- `data/upload`: uploaded files.
- `data/syslog`: system logs.
- `data/ssh.d`: SSH-related runtime data.
- `data/dep_cache`: dependency cache, when present.
Many bugs that appear as "backend logic" may involve state stored under `data/`.
## Docker And Release Files
- `docker/Dockerfile`
- `docker/310.Dockerfile`
- `docker/docker-compose.yml`
- `docker/docker-entrypoint.sh`
- `ecosystem.config.js`
- `version.yaml`
Use these when changing deployment, container startup, PM2 behavior, or release metadata.
## Common Modification Map
### Add Or Modify A Backend API
Typical files:
1. Add or update route in `back/api/<module>.ts`.
2. Add or update service logic in `back/services/<module>.ts`.
3. Add or update model in `back/data/<module>.ts` if persistence changes.
4. Add validation with `celebrate`/`Joi` near the route.
5. Update frontend caller in `src/pages/**` or `src/utils/**`.
### Add A New Frontend Page
Typical files:
1. Create `src/pages/<page>/index.tsx`.
2. Add styles in `src/pages/<page>/index.less` if needed.
3. Register route/menu in `src/layouts/defaultProps.tsx`.
4. Add locale strings in `src/locales/zh-CN.json` and `src/locales/en-US.json`.
5. Add API calls through the existing request helper.
### Change Cron/Task Behavior
Start with:
- `back/api/cron.ts`
- `back/services/cron.ts`
- `back/schedule/*`
- `shell/task.sh`
- `shell/otask.sh`
- `shell/preload/*`
Also inspect:
- `back/data/cron.ts`
- `back/validation/schedule.ts`
- `data/config/crontab.list`
- `data/log/*`
### Change Environment Variable Behavior
Start with:
- `back/api/env.ts`
- `back/services/env.ts`
- `back/data/env.ts`
- `src/pages/env/index.tsx`
Also inspect:
- `shell/preload/env.sh`
- `shell/preload/env.js`
- `shell/preload/env.py`
### Change Script Management
Start with:
- `back/api/script.ts`
- `back/services/script.ts`
- `src/pages/script/index.tsx`
- `data/scripts/*`
### Change Login/Auth/Security
Start with:
- `back/api/user.ts`
- `back/services/user.ts`
- `back/shared/auth.ts`
- `back/shared/store.ts`
- `back/loaders/express.ts`
- `back/token.ts`
- `src/pages/login/index.tsx`
- `src/pages/initialization/index.tsx`
Be careful with:
- JWT behavior.
- open API token behavior.
- first-run initialization.
- platform-specific session limits.
### Change Subscription Behavior
Start with:
- `back/api/subscription.ts`
- `back/services/subscription.ts`
- `back/data/subscription.ts`
- `src/pages/subscription/index.tsx`
- `data/repo/*`
### Change Dependency Management
Start with:
- `back/api/dependence.ts`
- `back/services/dependence.ts`
- `back/data/dependence.ts`
- `src/pages/dependence/index.tsx`
- `data/deps`
- `data/dep_cache`
### Change Logs Or Live Log Streaming
Start with:
- `back/api/log.ts`
- `back/services/log.ts`
- `back/services/sock.ts`
- `back/shared/logStreamManager.ts`
- `src/pages/log/index.tsx`
- `src/components/terminal.tsx`
- `data/log/*`
## Coding Conventions
Backend:
- Prefer adding business logic to services, not route files.
- Use `typedi` services consistently.
- Use existing config paths from `back/config/index.ts`.
- Return API responses in the existing `{ code, data, message }` shape.
- Use existing utilities before adding new helpers.
- Preserve current SQLite/Sequelize style unless doing a larger data-layer refactor.
Frontend:
- Follow existing Umi/React/Ant Design patterns.
- Keep route/menu changes in `src/layouts/defaultProps.tsx`.
- Use existing request/WebSocket helpers.
- Add or update locale strings for visible UI text.
- Keep page-specific styles near the page.
Shell/runtime:
- Treat `shell/` as part of production behavior.
- Test task execution changes with realistic scripts when possible.
- Be careful with path quoting and environment variable propagation.
Data:
- Treat `data/` as mutable runtime state.
- Do not delete runtime state unless explicitly requested.
- Schema changes should account for existing SQLite databases.
## Suggested First Steps For AI Agents
When asked to modify behavior:
1. Identify whether the change is frontend, backend, shell runtime, data model, or deployment.
2. Search by feature name in `src/pages`, `back/api`, and `back/services`.
3. Read the route file and matching service before editing.
4. If persistence is involved, read the matching `back/data` model and `back/loaders/db.ts`.
5. If task execution is involved, inspect `shell/` and `back/services/cron.ts`.
6. Make the smallest scoped change that matches existing patterns.
7. Run the most relevant check:
- `pnpm run build:back` for backend TypeScript changes.
- `pnpm run build:front` for frontend build changes.
- targeted manual task/API checks for shell and scheduler changes.
## Quick Directory Reference
```text
.
├── back/ Backend TypeScript application
│ ├── api/ Express route modules
│ ├── config/ Runtime config, paths, constants, helpers
│ ├── data/ Sequelize models and SQLite connection
│ ├── loaders/ Startup initialization and Express setup
│ ├── middlewares/ Express middlewares
│ ├── protos/ gRPC proto files and generated TS
│ ├── schedule/ Scheduler/gRPC client helpers
│ ├── services/ Business logic services
│ ├── shared/ Shared backend utilities
│ └── validation/ Joi validation schemas
├── src/ Frontend Umi/React application
│ ├── assets/ Fonts and static frontend assets
│ ├── components/ Shared UI components
│ ├── hooks/ Frontend hooks
│ ├── layouts/ Main layout and menu route config
│ ├── locales/ i18n JSON
│ ├── pages/ Feature pages
│ └── utils/ HTTP, WebSocket, editor, date, and config utilities
├── shell/ Task runtime shell scripts and preload files
├── data/ Runtime state: db, logs, scripts, repos, configs
├── docker/ Docker build and compose files
├── sample/ Sample scripts and default config templates
├── static/ Built frontend/backend artifacts
└── docs/ Project documentation
```
-22
View File
@@ -1,22 +0,0 @@
module.exports = {
apps: [
{
name: 'qinglong',
max_restarts: 5,
kill_timeout: 1000,
wait_ready: true,
listen_timeout: 5000,
source_map_support: true,
time: true,
script: 'static/build/app.js',
env: {
http_proxy: '',
https_proxy: '',
HTTP_PROXY: '',
HTTPS_PROXY: '',
all_proxy: '',
ALL_PROXY: '',
},
},
],
};
-15
View File
@@ -1,15 +0,0 @@
{
"watch": [
"back",
".env"
],
"ext": "js,ts,json",
"env": {
"NODE_ENV": "development",
"TS_NODE_PROJECT": "./back/tsconfig.json"
},
"verbose": true,
"execMap": {
"ts": "node --require ts-node/register"
}
}
+61 -106
View File
@@ -1,27 +1,20 @@
{
"name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.21.0-16",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
"url": "https://github.com/whyour/qinglong.git"
},
"author": "whyour",
"license": "Apache License 2.0",
"bugs": {
"url": "https://github.com/whyour/qinglong/issues"
},
"private": true,
"scripts": {
"start": "concurrently -n w: npm:start:*",
"start:back": "nodemon ./back/app.ts",
"start:front": "max dev",
"start:env": "pnpm run --filter @qinglong/env start",
"start:front": "pnpm run --filter @qinglong/web start",
"start:back": "pnpm run --filter @qinglong/back start",
"start:public": "pnpm run --filter @qinglong/public start",
"build:front": "max build",
"build:back": "tsc -p back/tsconfig.json",
"build:back": "tsc -p tsconfig.back.json",
"panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"schedule": "npm run build:back && node static/build/schedule.js",
"public": "npm run build:back && node static/build/public.js",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true"
"postinstall": "max setup 2>/dev/null || true",
"test": "umi-test",
"test:coverage": "umi-test --coverage"
},
"gitHooks": {
"pre-commit": "lint-staged"
@@ -34,11 +27,6 @@
"prettier --parser=typescript --write"
]
},
"bin": {
"ql": "shell/update.sh",
"task": "shell/task.sh",
"qinglong": "shell/start.sh"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
@@ -55,140 +43,107 @@
"monaco-editor",
"rc-field-form",
"@types/lodash.merge",
"rollup",
"styled-components"
"rollup"
],
"allowedVersions": {
"react": "18",
"react-dom": "18",
"dva-core": "2"
}
},
"overrides": {
"sqlite3": "npm:@whyour/sqlite3@1.1.0",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16"
}
},
"dependencies": {
"@ant-design/plots": "^2.6.8",
"@bufbuild/protobuf": "^2.10.0",
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"chokidar": "^4.0.1",
"compression": "^1.7.4",
"@sentry/node": "^7.12.1",
"@sentry/tracing": "^7.12.1",
"body-parser": "^1.19.2",
"celebrate": "^15.0.1",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
"cron-parser": "^5.4.0",
"cross-spawn": "^7.0.6",
"dayjs": "^1.11.13",
"dotenv": "^16.4.6",
"express": "^4.21.2",
"express-jwt": "^8.4.1",
"express-rate-limit": "^7.4.1",
"express-urlrewrite": "^2.0.3",
"helmet": "^8.1.0",
"hpagent": "^1.2.0",
"http-proxy-middleware": "^3.0.3",
"cron-parser": "^4.2.1",
"dayjs": "^1.11.2",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"express-jwt": "^6.1.1",
"express-urlrewrite": "^1.4.0",
"form-data": "^4.0.0",
"got": "^11.8.2",
"hpagent": "^0.1.2",
"iconv-lite": "^0.6.3",
"ip2region": "2.3.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"multer": "2.1.1",
"multer": "^1.4.4",
"nedb": "^1.8.0",
"node-schedule": "^2.1.0",
"nodemailer": "^8.0.1",
"p-queue-cjs": "7.3.4",
"proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2",
"request-ip": "3.3.0",
"sequelize": "^6.37.5",
"nodemailer": "^6.7.2",
"pstree.remy": "^1.1.8",
"reflect-metadata": "^0.1.13",
"sequelize": "^6.25.5",
"serve-handler": "^6.1.3",
"sockjs": "^0.3.24",
"sqlite3": "npm:@whyour/sqlite3@1.1.0",
"toad-scheduler": "^3.0.1",
"sqlite3": "npm:@louislam/sqlite3@^15.0.6",
"toad-scheduler": "^1.6.0",
"typedi": "^0.10.0",
"undici": "^7.9.0",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
"uuid": "^8.3.2",
"winston": "^3.6.0",
"yargs": "^17.3.1"
},
"devDependencies": {
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-layout": "6.38.22",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16",
"@monaco-editor/react": "4.2.1",
"@react-hook/resize-observer": "^2.0.2",
"@ant-design/icons": "^4.7.0",
"@ant-design/pro-layout": "^6.33.1",
"@monaco-editor/react": "4.4.6",
"@react-hook/resize-observer": "^1.2.6",
"@sentry/react": "^7.12.1",
"@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/cors": "^2.8.12",
"@types/cross-spawn": "^6.0.2",
"@types/express": "^4.17.13",
"@types/express-jwt": "^6.0.4",
"@types/file-saver": "2.0.2",
"@types/helmet": "^4.0.0",
"@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.185",
"@types/multer": "^1.4.7",
"@types/nedb": "^1.8.12",
"@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@types/qrcode.react": "^1.0.2",
"@types/react": "^18.0.20",
"@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.0.6",
"@types/request-ip": "0.0.41",
"@types/serve-handler": "^6.1.1",
"@types/sockjs": "^0.3.33",
"@types/sockjs-client": "^1.5.1",
"@types/uuid": "^8.3.4",
"@uiw/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.21.9",
"@umijs/max": "^4.4.4",
"@umijs/max": "^4.0.21",
"@umijs/ssr-darkreader": "^4.9.45",
"ahooks": "^3.7.8",
"ansi-to-react": "^6.1.6",
"antd": "^4.24.16",
"antd-img-crop": "^4.23.0",
"axios": "^1.4.0",
"antd": "^4.23.0",
"antd-img-crop": "^4.2.3",
"codemirror": "^5.65.2",
"compression-webpack-plugin": "9.2.0",
"concurrently": "^7.0.0",
"file-saver": "2.0.2",
"lint-staged": "^13.0.3",
"moment": "2.30.1",
"monaco-editor": "0.33.0",
"nodemon": "^3.0.1",
"monaco-editor": "^0.34.1",
"nodemon": "^2.0.15",
"prettier": "^2.5.1",
"pretty-bytes": "6.1.1",
"qiniu": "^7.4.0",
"qrcode.react": "^1.0.1",
"query-string": "^7.1.1",
"rc-tween-one": "^3.0.6",
"rc-virtual-list": "3.15.0",
"react": "18.3.1",
"react-copy-to-clipboard": "^5.1.0",
"react": "18.2.0",
"react-codemirror2": "^7.2.1",
"react-diff-viewer": "^3.1.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-intl-universal": "^2.12.0",
"react-router-dom": "6.26.1",
"react-dnd": "^14.0.2",
"react-dnd-html5-backend": "^14.0.0",
"react-dom": "18.2.0",
"react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0",
"ts-node": "^10.9.2",
"ts-proto": "^2.6.1",
"ts-node": "^10.6.0",
"tslib": "^2.4.0",
"typescript": "5.2.2",
"typescript": "4.8.4",
"umi-request": "^1.4.0",
"vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0"
"webpack": "^5.70.0",
"yorkie": "^2.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"watch": ["src", ".env"],
"ext": "js,ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "ts-node --transpile-only ./src/app.ts"
}
+138
View File
@@ -0,0 +1,138 @@
{
"name": "@qinglong/back",
"private": true,
"scripts": {
"start": "nodemon",
"build": "tsc"
},
"gitHooks": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*.{js,jsx,less,md,json}": [
"prettier --write"
],
"*.ts?(x)": [
"prettier --parser=typescript --write"
]
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
"react",
"react-dom",
"antd",
"dva",
"postcss",
"webpack",
"eslint",
"stylelint",
"redux",
"@babel/core",
"monaco-editor",
"rc-field-form",
"@types/lodash.merge",
"rollup"
],
"allowedVersions": {
"react": "18",
"react-dom": "18",
"dva-core": "2"
}
}
},
"dependencies": {
"@otplib/preset-default": "^12.0.1",
"@sentry/node": "^7.12.1",
"@sentry/tracing": "^7.12.1",
"body-parser": "^1.19.2",
"celebrate": "^15.0.1",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
"cron-parser": "^4.2.1",
"dayjs": "^1.11.2",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"express-jwt": "^6.1.1",
"express-urlrewrite": "^1.4.0",
"form-data": "^4.0.0",
"got": "^11.8.2",
"hpagent": "^0.1.2",
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"multer": "^1.4.4",
"nedb": "^1.8.0",
"node-schedule": "^2.1.0",
"nodemailer": "^6.7.2",
"pstree.remy": "^1.1.8",
"reflect-metadata": "^0.1.13",
"sequelize": "^6.25.5",
"serve-handler": "^6.1.3",
"sockjs": "^0.3.24",
"sqlite3": "npm:@louislam/sqlite3@^15.0.6",
"toad-scheduler": "^1.6.0",
"typedi": "^0.10.0",
"uuid": "^8.3.2",
"winston": "^3.6.0",
"yargs": "^17.3.1"
},
"devDependencies": {
"@ant-design/icons": "^4.7.0",
"@ant-design/pro-layout": "^6.33.1",
"@monaco-editor/react": "4.4.6",
"@react-hook/resize-observer": "^1.2.6",
"@sentry/react": "^7.12.1",
"@types/body-parser": "^1.19.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/express-jwt": "^6.0.4",
"@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.185",
"@types/multer": "^1.4.7",
"@types/nedb": "^1.8.12",
"@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4",
"@types/qrcode.react": "^1.0.2",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@types/serve-handler": "^6.1.1",
"@types/sockjs": "^0.3.33",
"@types/sockjs-client": "^1.5.1",
"@types/uuid": "^8.3.4",
"@umijs/max": "^4.0.21",
"@umijs/ssr-darkreader": "^4.9.45",
"ansi-to-react": "^6.1.6",
"antd": "^4.23.0",
"antd-img-crop": "^4.2.3",
"codemirror": "^5.65.2",
"compression-webpack-plugin": "9.2.0",
"concurrently": "^7.0.0",
"lint-staged": "^13.0.3",
"monaco-editor": "^0.34.1",
"nodemon": "^2.0.15",
"prettier": "^2.5.1",
"qiniu": "^7.4.0",
"qrcode.react": "^1.0.1",
"query-string": "^7.1.1",
"rc-tween-one": "^3.0.6",
"react": "18.2.0",
"react-codemirror2": "^7.2.1",
"react-diff-viewer": "^3.1.1",
"react-dnd": "^14.0.2",
"react-dnd-html5-backend": "^14.0.0",
"react-dom": "18.2.0",
"react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0",
"ts-node": "^10.6.0",
"tslib": "^2.4.0",
"typescript": "4.8.4",
"umi-request": "^1.4.0",
"vh-check": "^2.0.5",
"webpack": "^5.70.0",
"yorkie": "^2.0.0"
}
}
+81
View File
@@ -0,0 +1,81 @@
import { getFileContentByName, getLastModifyFilePath } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import * as fs from 'fs';
import { celebrate, Joi } from 'celebrate';
const route = Router();
export default (app: Router) => {
app.use('/configs', route);
route.get(
'/files',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const fileList = fs.readdirSync(config.configPath, 'utf-8');
res.send({
code: 200,
data: fileList
.filter((x) => !config.blackFileList.includes(x))
.map((x) => {
return { title: x, value: x };
}),
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let content = '';
if (config.blackFileList.includes(req.params.file)) {
res.send({ code: 403, message: '文件无法访问' });
}
if (req.params.file.includes('sample')) {
content = getFileContentByName(
`${config.samplePath}${req.params.file}`,
);
} else {
content = getFileContentByName(
`${config.configPath}${req.params.file}`,
);
}
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
},
);
route.post(
'/save',
celebrate({
body: Joi.object({
name: Joi.string().required(),
content: Joi.string().allow('').optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const { name, content } = req.body;
if (config.blackFileList.includes(name)) {
res.send({ code: 403, message: '文件无法访问' });
}
const path = `${config.configPath}${name}`;
fs.writeFileSync(path, content);
res.send({ code: 200, message: '保存成功' });
} catch (e) {
return next(e);
}
},
);
};
@@ -4,13 +4,7 @@ import { Logger } from 'winston';
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 cron_parser from 'cron-parser';
const route = Router();
export default (app: Router) => {
@@ -65,7 +59,7 @@ export default (app: Router) => {
try {
const cronViewService = Container.get(CronViewService);
if (req.body.type === 1) {
return res.send({ code: 400, message: t('参数错误') });
return res.send({ code: 400, message: '参数错误' });
} else {
const data = await cronViewService.update(req.body);
return res.send({ code: 200, data });
@@ -158,32 +152,26 @@ export default (app: Router) => {
}
});
route.get(
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.find(req.query as any);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.post(
'/',
celebrate({
body: Joi.object(commonCronSchema),
body: Joi.object({
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional(),
labels: Joi.array().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
return res.send({ code: 200, data });
if (cron_parser.parseExpression(req.body.schedule).hasNext()) {
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
return next(e);
}
@@ -312,8 +300,8 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const result = await cronService.log(req.params.id);
return res.send({ code: 200, data: result.content, logStatus: result.status });
const data = await cronService.log(req.params.id);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
@@ -324,16 +312,26 @@ export default (app: Router) => {
'/',
celebrate({
body: Joi.object({
...commonCronSchema,
labels: Joi.array().optional().allow(null),
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional().allow(null),
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.update(req.body);
return res.send({ code: 200, data });
if (
!req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const cronService = Container.get(CronService);
const data = await cronService.update(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) {
return next(e);
}
@@ -397,7 +395,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.importCrontab();
const data = await cronService.import_crontab();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
@@ -434,16 +432,16 @@ 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) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.status({
...req.body,
status: req.body.status ? parseInt(req.body.status) : undefined,
pid: req.body.pid ? parseInt(req.body.pid) : undefined,
status: parseInt(req.body.status),
pid: parseInt(req.body.pid) || '',
});
return res.send({ code: 200, data });
} catch (e) {
@@ -452,48 +450,6 @@ 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({

Some files were not shown because too many files have changed in this diff Show More