mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31aa353d7d | ||
|
|
aafd85cbcc | ||
|
|
70636900b2 | ||
|
|
331f4f9138 | ||
|
|
b06cb72334 | ||
|
|
89c6355635 | ||
|
|
68ffa106f8 | ||
|
|
9d5f58ef99 | ||
|
|
ef1c54a620 | ||
|
|
0218404d9f | ||
|
|
24f7d1a46f | ||
|
|
fa8910b3d6 | ||
|
|
2cab112391 | ||
|
|
2ab9c702e6 | ||
|
|
6dd98a8565 | ||
|
|
06c8d8b112 | ||
|
|
a5406bedca | ||
|
|
3d2b98a442 | ||
|
|
93d7ec69dc | ||
|
|
a303e33b4e | ||
|
|
44129ca088 | ||
|
|
def4917447 | ||
|
|
bc0f35e3f7 | ||
|
|
0deebcfc88 | ||
|
|
07fcb09cc6 | ||
|
|
6aefc61be6 | ||
|
|
bf9be821ba | ||
|
|
d42074f76a | ||
|
|
5c798a0e93 | ||
|
|
15d94f469b | ||
|
|
777fd3fb23 | ||
|
|
4cf2858ab0 | ||
|
|
2ff7a186e7 | ||
|
|
b2b1777c6b | ||
|
|
f355b4e441 | ||
|
|
489454daa0 | ||
|
|
db93ca9aa9 | ||
|
|
4758400df6 |
@@ -2,23 +2,44 @@ name: Build And Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
paths:
|
||||
- "**"
|
||||
- "!*.md"
|
||||
# CLI is verified separately; its shared Bash upgrade engine ships in images.
|
||||
- "!cli/**"
|
||||
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
|
||||
- "!.github/workflows/cli-package.yml"
|
||||
branches:
|
||||
- "master"
|
||||
- "develop"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
static_sync_only:
|
||||
description: Synchronize an existing static snapshot to GitLab and verify Gitee
|
||||
type: boolean
|
||||
default: false
|
||||
static_ref:
|
||||
description: Static branch or version name, for example v2.22.0
|
||||
type: string
|
||||
default: ''
|
||||
static_ref_type:
|
||||
description: Synchronize a branch, or a version branch and Git tag
|
||||
type: choice
|
||||
options: [branch, tag]
|
||||
default: branch
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
uses: ./.github/workflows/validate.yml
|
||||
|
||||
code_gitlab:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -35,37 +56,8 @@ jobs:
|
||||
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:
|
||||
if: ${{ !inputs.static_sync_only }}
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -98,62 +90,109 @@ jobs:
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
mkdir -p tmp
|
||||
cd ./tmp
|
||||
cp -rf ../static/* ./
|
||||
git init -b ${GITHUB_BRANCH} && git add .
|
||||
git config --local user.name 'github-actions[bot]'
|
||||
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
|
||||
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
|
||||
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
|
||||
static_gitlab:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: static-gitlab-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Push qinglong-static to GitLab
|
||||
env:
|
||||
STATIC_BRANCH: ${{ github.ref_name }}
|
||||
STATIC_REF_TYPE: ${{ github.ref_type }}
|
||||
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||
run: |
|
||||
set +e
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
|
||||
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
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"
|
||||
bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
|
||||
|
||||
static_gitee:
|
||||
verify-gitee-mirrors:
|
||||
needs: build-static
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 14
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
repository: [qinglong, qinglong-static]
|
||||
steps:
|
||||
- name: Push qinglong-static to Gitee
|
||||
- uses: actions/checkout@v6
|
||||
# Gitee Pull mirrors and GitHub push webhooks own synchronization.
|
||||
# Verification runs alongside Docker builds and needs no Gitee credentials.
|
||||
- name: Wait for Gitee Pull mirror
|
||||
if: ${{ matrix.repository == 'qinglong' }}
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong
|
||||
MIRROR_REPOSITORY: ${{ matrix.repository }}
|
||||
MIRROR_REF: ${{ github.ref_name }}
|
||||
MIRROR_REF_TYPE: ${{ github.ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }}
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false'
|
||||
run: |
|
||||
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
|
||||
bash scripts/wait-for-mirror.sh "https://github.com/whyour/$MIRROR_REPOSITORY.git" "https://gitee.com/whyour/$MIRROR_REPOSITORY.git" "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
- name: Notify and wait for Gitee static mirror
|
||||
if: ${{ matrix.repository == 'qinglong-static' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||
MIRROR_REF: ${{ github.ref_name }}
|
||||
MIRROR_REF_TYPE: ${{ github.ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
|
||||
run: |
|
||||
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
|
||||
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"
|
||||
sync-existing-static:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mirror: [gitlab]
|
||||
concurrency:
|
||||
group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Synchronize existing static snapshot
|
||||
env:
|
||||
STATIC_REF: ${{ inputs.static_ref }}
|
||||
STATIC_REF_TYPE: ${{ inputs.static_ref_type }}
|
||||
MIRROR_HOST: ${{ matrix.mirror }}.com
|
||||
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE"
|
||||
|
||||
verify-existing-static-gitee:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 14
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Wait for existing Gitee static snapshot
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
|
||||
MIRROR_REF: ${{ inputs.static_ref }}
|
||||
MIRROR_REF_TYPE: ${{ inputs.static_ref_type }}
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
|
||||
run: |
|
||||
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
|
||||
|
||||
build-alpine:
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
@@ -21,16 +21,27 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Publish the verified CLI package (master only)
|
||||
description: Publish the verified CLI package (master stable / develop prerelease)
|
||||
type: boolean
|
||||
default: false
|
||||
prerelease_version:
|
||||
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: cli-package-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
# Keep validation runs separate from releases; never interrupt a running release.
|
||||
# Master push releases share the same group as manually requested releases.
|
||||
group: >-
|
||||
cli-package-${{ github.workflow }}-${{ github.ref }}-${{
|
||||
((github.event_name == 'workflow_dispatch' && inputs.publish) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) && 'publish' || 'check' }}
|
||||
cancel-in-progress: >-
|
||||
${{ github.ref != 'refs/heads/master' &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.publish) }}
|
||||
|
||||
jobs:
|
||||
package:
|
||||
@@ -52,6 +63,20 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip
|
||||
npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2
|
||||
- name: Set requested prerelease version before verification
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.prerelease_version != ''
|
||||
working-directory: cli
|
||||
env:
|
||||
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$GITHUB_REF" != refs/heads/develop ||
|
||||
! "$PRERELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
|
||||
echo '::error::Prereleases require develop and an explicit alpha/beta/rc version.'
|
||||
exit 1
|
||||
fi
|
||||
npm version "$PRERELEASE_VERSION" --no-git-tag-version --ignore-scripts
|
||||
- run: npm ci --prefix cli --no-audit --no-fund
|
||||
- run: npm run check:cli
|
||||
- run: npm run test:cli
|
||||
@@ -72,9 +97,11 @@ jobs:
|
||||
needs: package
|
||||
if: >-
|
||||
github.repository == 'whyour/qinglong' &&
|
||||
github.ref == 'refs/heads/master' &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish))
|
||||
((github.ref == 'refs/heads/master' &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish))) ||
|
||||
(github.ref == 'refs/heads/develop' && github.event_name == 'workflow_dispatch' &&
|
||||
inputs.publish && inputs.prerelease_version != ''))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
@@ -91,6 +118,8 @@ jobs:
|
||||
name: qinglong-cli-${{ github.sha }}
|
||||
path: cli-package
|
||||
- name: Publish verified npm archive
|
||||
env:
|
||||
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -103,13 +132,30 @@ jobs:
|
||||
archive="${archives[0]}"
|
||||
metadata=$(tar -xOf "$archive" package/package.json)
|
||||
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata")
|
||||
version=$(jq -er '.version | select(type == "string" and test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))' <<< "$metadata")
|
||||
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
|
||||
if [[ "$GITHUB_REF" == refs/heads/develop ]]; then
|
||||
if [[ "$version" != "$PRERELEASE_VERSION" ||
|
||||
! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
|
||||
echo '::error::Archive version does not match the requested prerelease.'
|
||||
exit 1
|
||||
fi
|
||||
tag="${BASH_REMATCH[1]}"
|
||||
elif [[ "$GITHUB_REF" == refs/heads/master && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
tag=latest
|
||||
else
|
||||
echo '::error::Refusing to publish this branch/version combination.'
|
||||
exit 1
|
||||
fi
|
||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then
|
||||
jq -e --arg version "$version" '. == $version' version.json > /dev/null
|
||||
if [[ "$tag" != latest ]]; then
|
||||
echo '::error::Prerelease version already exists; choose a new version to test this build.'
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
|
||||
exit 0
|
||||
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
|
||||
echo '::error::Could not check the published CLI version; refusing to publish.'
|
||||
exit 1
|
||||
fi
|
||||
npm publish "./$archive" --access public --tag latest --ignore-scripts --registry=https://registry.npmjs.org
|
||||
npm publish "./$archive" --access public --tag "$tag" --ignore-scripts --registry=https://registry.npmjs.org
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Release readiness
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/release-readiness.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-readiness-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
regression:
|
||||
uses: ./.github/workflows/validate.yml
|
||||
|
||||
static:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: '8.3.1'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:front && pnpm build:back && pnpm build:info
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release-readiness-static
|
||||
path: static/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
|
||||
images:
|
||||
needs: [regression, static]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: alpine
|
||||
file: docker/Dockerfile
|
||||
cache: alpine
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
- image: debian
|
||||
file: docker/Dockerfile.debian
|
||||
cache: debian
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
- image: alpine-python3.10
|
||||
file: docker/Dockerfile.310
|
||||
cache: alpine-python3.10
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
|
||||
- image: debian-python3.10
|
||||
file: docker/Dockerfile.debian310
|
||||
cache: debian-python3.10
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Record source
|
||||
id: source
|
||||
env:
|
||||
SOURCE_URL: ${{ github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}
|
||||
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: release-readiness-static
|
||||
path: static/
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: false
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Build every release architecture
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
build-args: |
|
||||
QL_URL=${{ steps.source.outputs.url }}
|
||||
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.cache }}
|
||||
- name: Load native image for runtime verification
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
QL_URL=${{ steps.source.outputs.url }}
|
||||
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||
tags: qinglong-readiness:${{ matrix.image }}
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
- name: Verify native production runtime without external networking
|
||||
run: |
|
||||
docker run --rm --network none --entrypoint node \
|
||||
qinglong-readiness:${{ matrix.image }} \
|
||||
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.3.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||
- name: Export native Debian image for isolated dev validation
|
||||
if: matrix.image == 'debian'
|
||||
run: |
|
||||
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
|
||||
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
|
||||
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: matrix.image == 'debian'
|
||||
with:
|
||||
name: release-readiness-debian-image
|
||||
path: release-readiness-debian-image.*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,17 @@
|
||||
# Assign existing resources to accounts
|
||||
|
||||
The panel adds ownership columns automatically while preserving existing data. Unassigned historical data is visible only to administrators. Ordinary users can read their assigned environment variables, task metadata and subscription metadata; dependencies and execution remain administrative operations.
|
||||
|
||||
Prefer the authenticated administrator endpoint `PUT /api/user-management/:id/resources` with a body such as `{"resource":"envs","ids":[1,2]}`. Supported resources are `envs`, `crons` and `subscriptions`. Assignment does not run tasks or move files.
|
||||
|
||||
The optional local migration helper assigns every unassigned row to one existing account. Stop the panel and back up `data/db/database.sqlite` before using it; set `QL_DIR` or `QL_DATA_DIR` when the data directory differs from the repository default. The helper refuses to create a new empty database.
|
||||
|
||||
```bash
|
||||
node migrate-to-multiuser.js --list-users
|
||||
node migrate-to-multiuser.js --username=alice --dry-run
|
||||
node migrate-to-multiuser.js --username=alice
|
||||
```
|
||||
|
||||
Already assigned rows are preserved and writes run in a transaction. Dependencies may receive an owner for bookkeeping, but remain inaccessible to ordinary users. To allocate only selected rows, use the administrator resource endpoint instead of the bulk helper.
|
||||
|
||||
See [the permission contract](docs/development/multiuser-permissions.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Multi-user accounts
|
||||
|
||||
Administrators can create, disable and delete accounts in Settings → User Management. New administrator accounts have full access to the shared instance. Ordinary accounts can only read their assigned environment variables, task metadata and subscription metadata. They cannot execute tasks, read shared files or logs, install dependencies, or modify system settings.
|
||||
|
||||
Existing data with no owner remains visible only to administrators. The original administrator and its password migration, MFA and sessions are preserved. Accounts from older drafts that used bcrypt require an administrator password reset.
|
||||
|
||||
See [the permission contract](docs/development/multiuser-permissions.md) and [resource migration](MIGRATION_GUIDE.md). This draft does not provide isolated execution for untrusted users.
|
||||
+3
-7
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -73,15 +72,12 @@ export default (app: Router) => {
|
||||
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');
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
const cleanName = name.replace(/^data\/scripts\//, '');
|
||||
const normalized = resolveFileAccess(
|
||||
basePath,
|
||||
[cleanName],
|
||||
config.configPath,
|
||||
[name],
|
||||
config.blackFileList,
|
||||
);
|
||||
if (!normalized) {
|
||||
|
||||
+17
-14
@@ -1,24 +1,16 @@
|
||||
import { Joi, celebrate } from 'celebrate';
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import multer from 'multer';
|
||||
import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import { safeJSONParse } from '../config/util';
|
||||
import { t } from '../shared/i18n';
|
||||
import EnvService from '../services/env';
|
||||
const route = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.scriptPath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, file.originalname);
|
||||
},
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
const labelSchema = Joi.array()
|
||||
.items(Joi.string().trim().required())
|
||||
.min(1)
|
||||
@@ -282,12 +274,23 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const envService = Container.get(EnvService);
|
||||
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
|
||||
const parseContent = safeJSONParse(fileContent);
|
||||
let parseContent: unknown;
|
||||
try {
|
||||
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
|
||||
} catch {
|
||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||
}
|
||||
const data = Array.isArray(parseContent)
|
||||
? parseContent
|
||||
: [parseContent];
|
||||
if (data.every((x) => x.name && x.value)) {
|
||||
if (
|
||||
data.length > 0 &&
|
||||
data.every((x) =>
|
||||
x && typeof x.name === 'string' &&
|
||||
/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(x.name) &&
|
||||
typeof x.value === 'string' && x.value.length > 0,
|
||||
)
|
||||
) {
|
||||
const result = await envService.create(
|
||||
data.map((x) => ({
|
||||
name: x.name,
|
||||
|
||||
@@ -12,6 +12,7 @@ import subscription from './subscription';
|
||||
import update from './update';
|
||||
import dashboard from './dashboard';
|
||||
import health from './health';
|
||||
import userManagement from './userManagement';
|
||||
import clientIp from './clientIp';
|
||||
import retention from './retention';
|
||||
|
||||
@@ -30,6 +31,7 @@ export default () => {
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
userManagement(app);
|
||||
clientIp(app);
|
||||
retention(app);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
|
||||
import OpenService from '../services/open';
|
||||
import { Logger } from 'winston';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -100,6 +101,7 @@ export default (app: Router) => {
|
||||
|
||||
route.get(
|
||||
'/auth/token',
|
||||
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
|
||||
celebrate({
|
||||
query: {
|
||||
client_id: Joi.string().required(),
|
||||
|
||||
+199
-14
@@ -7,20 +7,62 @@ import { Logger } from 'winston';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import path, { join, parse } from 'path';
|
||||
import path, { dirname, join, parse, resolve, sep } from 'path';
|
||||
import ScriptService from '../services/script';
|
||||
import ScriptHistoryService from '../services/scriptHistory';
|
||||
import { t } from '../shared/i18n';
|
||||
import multer from 'multer';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import {
|
||||
ScriptHistoryError,
|
||||
HistoryUnavailableError,
|
||||
} from '../shared/scriptHistory';
|
||||
const route = Router();
|
||||
|
||||
function isPathAllowed(targetPath: string): boolean {
|
||||
const resolved = path.resolve(targetPath);
|
||||
return config.writePathList.some((x) =>
|
||||
Boolean(resolveFileAccess(x, [resolved], config.blackFileList)),
|
||||
Boolean(
|
||||
resolveFileAccess(
|
||||
x,
|
||||
[resolved],
|
||||
// Panel configuration secrets must not restrict user script filenames.
|
||||
path.resolve(x) === path.resolve(config.configPath)
|
||||
? config.blackFileList
|
||||
: [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function existingWritableRealPath(targetPath: string): Promise<string> {
|
||||
if (!isPathAllowed(targetPath)) {
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
const resolved = resolve(targetPath);
|
||||
for (const writableRoot of config.writePathList) {
|
||||
const root = resolve(writableRoot);
|
||||
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
||||
// Keep an explicit normalized boundary check at the filesystem operation.
|
||||
if (!resolved.startsWith(rootPrefix)) continue;
|
||||
const realPath = await fs.realpath(resolved);
|
||||
const realRoot = await fs.realpath(root);
|
||||
const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
|
||||
if (
|
||||
!realPath.startsWith(realRootPrefix) ||
|
||||
!resolveFileAccess(
|
||||
realRoot,
|
||||
[realPath],
|
||||
root === resolve(config.configPath) ? config.blackFileList : [],
|
||||
)
|
||||
) {
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
return realPath;
|
||||
}
|
||||
throw new ScriptHistoryError(403, '暂无权限');
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, config.tmpPath);
|
||||
@@ -55,11 +97,7 @@ export default (app: Router) => {
|
||||
];
|
||||
if (req.query.path) {
|
||||
if (
|
||||
!resolveFileAccess(
|
||||
config.scriptPath,
|
||||
[req.query.path as string],
|
||||
config.blackFileList,
|
||||
)
|
||||
!resolveFileAccess(config.scriptPath, [req.query.path as string])
|
||||
) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
@@ -115,6 +153,82 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
const historyError = (error: unknown, res: Response, next: NextFunction) => {
|
||||
if (error instanceof ScriptHistoryError) {
|
||||
return res.status(error.status).send({
|
||||
code: error.status,
|
||||
message: t(error.message),
|
||||
...(error instanceof HistoryUnavailableError
|
||||
? { historyUnavailable: true, currentHash: error.currentHash }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return next(error);
|
||||
};
|
||||
const historyQuery = {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
};
|
||||
|
||||
route.get(
|
||||
'/history',
|
||||
celebrate({ query: Joi.object(historyQuery).unknown(true) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.list(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.get(
|
||||
'/history/detail',
|
||||
celebrate({
|
||||
query: Joi.object({
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.detail(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
req.query.id as string,
|
||||
);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.put(
|
||||
'/history/restore',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
expectedHash: Joi.string().hex().length(64).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { path = '', filename, id, expectedHash } = req.body;
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.restore(path, filename, id, expectedHash);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
@@ -136,6 +250,11 @@ export default (app: Router) => {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().optional().allow(''),
|
||||
skipHistory: Joi.boolean().optional(),
|
||||
expectedHash: Joi.string()
|
||||
.hex()
|
||||
.length(64)
|
||||
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||
originFilename: Joi.string().optional().allow(''),
|
||||
directory: Joi.string().optional().allow(''),
|
||||
file: Joi.string().optional().allow(''),
|
||||
@@ -195,8 +314,56 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
// Check the canonical parent directly at the directory creation boundary.
|
||||
const parentPath = resolve(dirname(filePath));
|
||||
let parentCreated = false;
|
||||
for (const writableRoot of config.writePathList) {
|
||||
const root = resolve(writableRoot);
|
||||
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
||||
if (parentPath === root) {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
} else if (parentPath.startsWith(rootPrefix)) {
|
||||
await fs.mkdir(parentPath, { recursive: true });
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
parentCreated = true;
|
||||
break;
|
||||
}
|
||||
if (!parentCreated) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
const fileExists = await fileExist(filePath);
|
||||
if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) {
|
||||
let removeSource = filename !== originFilename;
|
||||
let backupSource = '';
|
||||
if (removeSource) {
|
||||
const [originRealPath, targetRealPath] = await Promise.all([
|
||||
existingWritableRealPath(originFilePath),
|
||||
existingWritableRealPath(filePath),
|
||||
]);
|
||||
// Aliases of one script are an in-place save. Removing the source
|
||||
// would also remove the destination behind a target symlink.
|
||||
removeSource = originRealPath !== targetRealPath;
|
||||
backupSource = originRealPath;
|
||||
}
|
||||
// Save-as removes the source after committing the destination. Keep
|
||||
// its original content too: destination history only protects the
|
||||
// file being overwritten, not the source being deleted.
|
||||
if (removeSource) {
|
||||
await fs.copyFile(
|
||||
backupSource,
|
||||
join(config.bakPath, originFilename.replace(/\//g, '')),
|
||||
);
|
||||
}
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path, filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
if (removeSource) await rmPath(originFilePath);
|
||||
return res.send({ code: 200, data });
|
||||
}
|
||||
if (fileExists) {
|
||||
await fs.copyFile(
|
||||
originFilePath,
|
||||
@@ -209,7 +376,7 @@ export default (app: Router) => {
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -221,6 +388,11 @@ export default (app: Router) => {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().required().allow(''),
|
||||
skipHistory: Joi.boolean().optional(),
|
||||
expectedHash: Joi.string()
|
||||
.hex()
|
||||
.length(64)
|
||||
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -238,10 +410,14 @@ export default (app: Router) => {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path || '', filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
return res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -368,12 +544,21 @@ export default (app: Router) => {
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
const logPath = join(config.logPath, path, `${name}.swap`);
|
||||
const logPath = resolveFileAccess(config.logPath, [
|
||||
path,
|
||||
`${name}.swap`,
|
||||
]);
|
||||
if (!logPath) {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
|
||||
const scriptService = Container.get(ScriptService);
|
||||
const result = await scriptService.stopScript(filePath, pid);
|
||||
setTimeout(() => {
|
||||
rmPath(logPath);
|
||||
const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (cleanupPath) {
|
||||
void rmPath(cleanupPath);
|
||||
}
|
||||
}, 3000);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
|
||||
+14
-9
@@ -8,15 +8,12 @@ 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 { getUniqPath, handleLogPath, parseVersion } from '../config/util';
|
||||
import { findCronId } from '../shared/cronCommand';
|
||||
import dayjs from 'dayjs';
|
||||
import multer from 'multer';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
const route = Router();
|
||||
const storage = multer.diskStorage({
|
||||
@@ -249,6 +246,7 @@ export default (app: Router) => {
|
||||
|
||||
route.put(
|
||||
'/command-run',
|
||||
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
command: Joi.string().required(),
|
||||
@@ -258,8 +256,13 @@ export default (app: Router) => {
|
||||
try {
|
||||
const systemService = Container.get(SystemService);
|
||||
const command = req.body.command;
|
||||
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
|
||||
let id = await promiseExec(idStr);
|
||||
const crontab = await fs
|
||||
.readFile(config.crontabFile, 'utf8')
|
||||
.catch((e) => {
|
||||
if (e.code === 'ENOENT') return '';
|
||||
throw e;
|
||||
});
|
||||
const id = findCronId(crontab, command);
|
||||
const uniqPath = await getUniqPath(command, id);
|
||||
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
||||
const logPath = `${uniqPath}/${logTime}.log`;
|
||||
@@ -274,7 +277,9 @@ export default (app: Router) => {
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
// Close the stream after task completion
|
||||
try {
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
await logStreamManager.closeStream(
|
||||
await handleLogPath(logPath),
|
||||
);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
|
||||
+39
-1
@@ -76,7 +76,17 @@ export default (app: Router) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const token = getToken(req);
|
||||
await userService.logout(req.platform, token);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
await Container.get(UserManagementService).revokeToken(
|
||||
req.user.userId,
|
||||
token,
|
||||
);
|
||||
} else {
|
||||
await userService.logout(req.platform, token);
|
||||
}
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -98,6 +108,16 @@ export default (app: Router) => {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
}
|
||||
const userService = Container.get(UserService);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
await Container.get(UserManagementService).update({
|
||||
id: req.user.userId,
|
||||
...req.body,
|
||||
});
|
||||
return res.send({ code: 200 });
|
||||
}
|
||||
const result = await userService.updateUsernameAndPassword(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
@@ -110,10 +130,28 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
const account = await Container.get(UserManagementService).get(
|
||||
req.user.userId,
|
||||
);
|
||||
return res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
username: account.username,
|
||||
role: account.role,
|
||||
id: account.id,
|
||||
readOnly: account.role === 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
const authInfo = await userService.getAuthInfo();
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
role: 0,
|
||||
username: authInfo.username,
|
||||
avatar: authInfo.avatar,
|
||||
twoFactorActivated: authInfo.twoFactorActivated,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import UserManagementService from '../services/userManagement';
|
||||
import { UserRole, UserStatus } from '../data/user';
|
||||
import { EnvModel } from '../data/env';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
|
||||
const route = Router();
|
||||
|
||||
// Middleware to check if user is admin
|
||||
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.user && req.user.role === UserRole.admin) {
|
||||
return next();
|
||||
}
|
||||
return res.status(403).send({ code: 403, message: '需要管理员权限' });
|
||||
};
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/user-management', route);
|
||||
|
||||
// List all users (admin only)
|
||||
route.get(
|
||||
'/',
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const data = await userManagementService.list(
|
||||
req.query.searchValue as string,
|
||||
);
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/:id/resources',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
params: Joi.object({ id: Joi.number().integer().positive().required() }),
|
||||
body: Joi.object({
|
||||
resource: Joi.string()
|
||||
.valid('envs', 'crons', 'subscriptions')
|
||||
.required(),
|
||||
ids: Joi.array()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.unique()
|
||||
.items(Joi.number().integer().positive())
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
await Container.get(UserManagementService).get(id);
|
||||
const models: Record<string, any> = {
|
||||
envs: EnvModel,
|
||||
crons: CrontabModel,
|
||||
subscriptions: SubscriptionModel,
|
||||
};
|
||||
const [count] = await models[req.body.resource].update(
|
||||
{ userId: id },
|
||||
{ where: { id: req.body.ids } },
|
||||
);
|
||||
return res.json({ code: 200, data: { assigned: count } });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get a specific user (admin only)
|
||||
route.get(
|
||||
'/:id',
|
||||
requireAdmin,
|
||||
celebrate({ params: Joi.object({ id: Joi.number().integer().positive().required() }) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const data = await userManagementService.get(Number(req.params.id));
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new user (admin only)
|
||||
route.post(
|
||||
'/',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().trim().max(255).required(),
|
||||
password: Joi.string().min(6).max(1024).required(),
|
||||
role: Joi.number()
|
||||
.valid(UserRole.admin, UserRole.user)
|
||||
.default(UserRole.user),
|
||||
status: Joi.number()
|
||||
.valid(UserStatus.active, UserStatus.disabled)
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const data = await userManagementService.create(req.body);
|
||||
res.send({ code: 200, data, message: '创建用户成功' });
|
||||
} catch (e: any) {
|
||||
return res.send({ code: 400, message: e.message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update a user (admin only)
|
||||
route.put(
|
||||
'/',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
id: Joi.number().integer().positive().required(),
|
||||
username: Joi.string().trim().max(255).required(),
|
||||
password: Joi.string().min(6).max(1024).optional().allow(''),
|
||||
role: Joi.number().valid(UserRole.admin, UserRole.user),
|
||||
status: Joi.number()
|
||||
.valid(UserStatus.active, UserStatus.disabled)
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const data = await userManagementService.update(req.body);
|
||||
res.send({ code: 200, data, message: '更新用户成功' });
|
||||
} catch (e: any) {
|
||||
return res.send({ code: 400, message: e.message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete users (admin only)
|
||||
route.delete(
|
||||
'/',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
body: Joi.array()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.unique()
|
||||
.items(Joi.number().integer().positive())
|
||||
.required(),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const count = await userManagementService.delete(req.body);
|
||||
res.send({ code: 200, data: count, message: '删除用户成功' });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
+93
-45
@@ -1,6 +1,5 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } 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';
|
||||
@@ -24,71 +23,120 @@ 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`);
|
||||
function run(args: string[]): string {
|
||||
return execFileSync('openssl', args, {
|
||||
stdio: 'pipe',
|
||||
timeout: 30000,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
||||
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
||||
|
||||
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');
|
||||
await fs.mkdir(certDir, { recursive: true, mode: 0o700 });
|
||||
// Atomically reserve a private workspace for keys, CSRs and OpenSSL side files.
|
||||
const workspace = await fs.mkdtemp(path.join(certDir, '.generate-'));
|
||||
const caKeyTmp = path.join(workspace, 'ca.key');
|
||||
const caCertTmp = path.join(workspace, 'ca.crt');
|
||||
const serverKeyTmp = path.join(workspace, 'server.key');
|
||||
const serverCsrTmp = path.join(workspace, 'server.csr');
|
||||
const serverExtTmp = path.join(workspace, 'server.ext');
|
||||
const clientKeyTmp = path.join(workspace, 'client.key');
|
||||
const clientCsrTmp = path.join(workspace, 'client.csr');
|
||||
const clientExtTmp = path.join(workspace, 'client.ext');
|
||||
|
||||
const cleanup = async () => {
|
||||
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
|
||||
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
|
||||
try { await fs.unlink(f); } catch {}
|
||||
}
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
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`);
|
||||
run(['genrsa', '-out', caKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-x509',
|
||||
'-days',
|
||||
'3650',
|
||||
'-key',
|
||||
caKeyTmp,
|
||||
'-out',
|
||||
caCertTmp,
|
||||
'-subj',
|
||||
'/CN=qinglong-ca/O=qinglong/C=CN',
|
||||
]);
|
||||
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
|
||||
const caCert = await fs.readFile(caCertTmp, 'utf-8');
|
||||
await fs.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`,
|
||||
run(['genrsa', '-out', serverKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-key',
|
||||
serverKeyTmp,
|
||||
'-out',
|
||||
serverCsrTmp,
|
||||
'-subj',
|
||||
'/CN=grpc-server',
|
||||
]);
|
||||
await fs.writeFile(
|
||||
serverExtTmp,
|
||||
'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n',
|
||||
);
|
||||
const serverCert = run([
|
||||
'x509',
|
||||
'-req',
|
||||
'-days',
|
||||
'3650',
|
||||
'-in',
|
||||
serverCsrTmp,
|
||||
'-CA',
|
||||
caCertTmp,
|
||||
'-CAkey',
|
||||
caKeyTmp,
|
||||
'-CAserial',
|
||||
path.join(workspace, 'ca.srl'),
|
||||
'-CAcreateserial',
|
||||
'-extfile',
|
||||
serverExtTmp,
|
||||
]);
|
||||
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
|
||||
|
||||
// 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`);
|
||||
run(['genrsa', '-out', clientKeyTmp, '2048']);
|
||||
run([
|
||||
'req',
|
||||
'-new',
|
||||
'-key',
|
||||
clientKeyTmp,
|
||||
'-out',
|
||||
clientCsrTmp,
|
||||
'-subj',
|
||||
'/CN=grpc-client',
|
||||
]);
|
||||
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
|
||||
const clientCert = run(
|
||||
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
|
||||
);
|
||||
const clientCert = run([
|
||||
'x509',
|
||||
'-req',
|
||||
'-days',
|
||||
'3650',
|
||||
'-in',
|
||||
clientCsrTmp,
|
||||
'-CA',
|
||||
caCertTmp,
|
||||
'-CAkey',
|
||||
caKeyTmp,
|
||||
'-CAserial',
|
||||
path.join(workspace, 'ca.srl'),
|
||||
'-CAcreateserial',
|
||||
'-extfile',
|
||||
clientExtTmp,
|
||||
]);
|
||||
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
||||
|
||||
await cleanup();
|
||||
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
||||
|
||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||
} catch (e) {
|
||||
} finally {
|
||||
await cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-26
@@ -1,5 +1,7 @@
|
||||
import { Subscription } from '../data/subscription';
|
||||
import isNil from 'lodash/isNil';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
|
||||
|
||||
export function formatUrl(doc: Subscription) {
|
||||
let url = doc.url;
|
||||
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
|
||||
if (doc.type === 'private-repo') {
|
||||
if (doc.pull_type === 'ssh-key') {
|
||||
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
|
||||
url = doc.url!.replace(host, doc.alias);
|
||||
url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
|
||||
} else {
|
||||
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
|
||||
const { username, password } = doc.pull_option as any;
|
||||
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
|
||||
}
|
||||
|
||||
export function formatCommand(doc: Subscription, url?: string) {
|
||||
let command = `SUB_ID=${doc.id} ql `;
|
||||
let _url = url || formatUrl(doc).url;
|
||||
const {
|
||||
type,
|
||||
whitelist,
|
||||
blacklist,
|
||||
dependences,
|
||||
branch,
|
||||
extensions,
|
||||
proxy,
|
||||
autoAddCron,
|
||||
autoDelCron,
|
||||
} = doc;
|
||||
if (type === 'file') {
|
||||
command += `raw "${_url}" "${proxy || ''}" "${
|
||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
||||
} else {
|
||||
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
|
||||
dependences || ''
|
||||
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
|
||||
isNil(autoAddCron) ? true : Boolean(autoAddCron)
|
||||
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
|
||||
}
|
||||
return command;
|
||||
const args =
|
||||
doc.type === 'file'
|
||||
? ['raw', url || formatUrl(doc).url, doc.proxy || '']
|
||||
: [
|
||||
'repo',
|
||||
url || formatUrl(doc).url,
|
||||
doc.whitelist || '',
|
||||
doc.blacklist || '',
|
||||
doc.dependences || '',
|
||||
doc.branch || '',
|
||||
doc.extensions || '',
|
||||
doc.proxy || '',
|
||||
];
|
||||
args.push(
|
||||
String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
|
||||
String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
|
||||
);
|
||||
return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
|
||||
.map((arg) => shellQuote(arg!))
|
||||
.join(' ')}`;
|
||||
}
|
||||
|
||||
+6
-4
@@ -846,23 +846,25 @@ async function _updateLinuxMirror(
|
||||
mirrorDomainWithScheme: string,
|
||||
): Promise<string> {
|
||||
const S = isInContainer() ? 'sudo ' : '';
|
||||
// APT otherwise returns zero when index downloads fail with transient errors.
|
||||
const aptUpdate = `${S}apt-get update --error-on=any`;
|
||||
let filePath: string, currentDomainWithScheme: string | null;
|
||||
switch (osType) {
|
||||
case 'Debian':
|
||||
filePath = '/etc/apt/sources.list.d/debian.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
|
||||
}
|
||||
case 'Ubuntu':
|
||||
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
|
||||
}
|
||||
case 'Alpine':
|
||||
filePath = '/etc/apk/repositories';
|
||||
|
||||
@@ -25,6 +25,7 @@ export class Crontab {
|
||||
log_name?: string;
|
||||
allow_multiple_instances?: 1 | 0;
|
||||
work_dir?: string;
|
||||
userId?: number;
|
||||
|
||||
constructor(options: Crontab) {
|
||||
this.name = options.name;
|
||||
@@ -52,6 +53,7 @@ export class Crontab {
|
||||
this.log_name = options.log_name;
|
||||
this.allow_multiple_instances = options.allow_multiple_instances || 0;
|
||||
this.work_dir = options.work_dir;
|
||||
this.userId = options.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,4 +97,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
log_name: DataTypes.STRING,
|
||||
allow_multiple_instances: DataTypes.NUMBER,
|
||||
work_dir: DataTypes.STRING,
|
||||
userId: { type: DataTypes.INTEGER, allowNull: true },
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export class Dependence {
|
||||
name: string;
|
||||
log?: string[];
|
||||
remark?: string;
|
||||
userId?: number;
|
||||
|
||||
constructor(options: Dependence) {
|
||||
this.id = options.id;
|
||||
@@ -21,6 +22,7 @@ export class Dependence {
|
||||
this.name = options.name.trim();
|
||||
this.log = options.log || [];
|
||||
this.remark = options.remark || '';
|
||||
this.userId = options.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +61,6 @@ export const DependenceModel = sequelize.define<DependenceInstance>(
|
||||
status: DataTypes.NUMBER,
|
||||
log: DataTypes.JSON,
|
||||
remark: DataTypes.STRING,
|
||||
userId: { type: DataTypes.NUMBER, allowNull: true },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export class Env {
|
||||
position?: number;
|
||||
name?: string;
|
||||
remarks?: string;
|
||||
userId?: number;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
|
||||
@@ -23,6 +24,7 @@ export class Env {
|
||||
this.position = options.position;
|
||||
this.name = options.name;
|
||||
this.remarks = options.remarks || '';
|
||||
this.userId = options.userId;
|
||||
this.isPinned = options.isPinned || 0;
|
||||
this.labels = options.labels || [];
|
||||
}
|
||||
@@ -46,6 +48,7 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
|
||||
position: DataTypes.NUMBER,
|
||||
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
||||
remarks: DataTypes.STRING,
|
||||
userId: { type: DataTypes.NUMBER, allowNull: true },
|
||||
isPinned: DataTypes.NUMBER,
|
||||
labels: DataTypes.JSON,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export class Subscription {
|
||||
proxy?: string;
|
||||
autoAddCron?: 1 | 0;
|
||||
autoDelCron?: 1 | 0;
|
||||
userId?: number;
|
||||
|
||||
constructor(options: Subscription) {
|
||||
this.id = options.id;
|
||||
@@ -60,6 +61,7 @@ export class Subscription {
|
||||
this.proxy = options.proxy;
|
||||
this.autoAddCron = options.autoAddCron ? 1 : 0;
|
||||
this.autoDelCron = options.autoDelCron ? 1 : 0;
|
||||
this.userId = options.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,5 +113,6 @@ export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
|
||||
proxy: { type: DataTypes.STRING, allowNull: true },
|
||||
autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
|
||||
autoDelCron: { type: DataTypes.NUMBER, allowNull: true },
|
||||
userId: { type: DataTypes.NUMBER, allowNull: true },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { sequelize } from '.';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
|
||||
export class User {
|
||||
id?: number;
|
||||
username: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
sessions?: { digest: string; platform: string }[];
|
||||
|
||||
constructor(options: User) {
|
||||
this.id = options.id;
|
||||
this.username = options.username;
|
||||
this.password = options.password;
|
||||
this.role = options.role ?? UserRole.user;
|
||||
this.status =
|
||||
typeof options.status === 'number' && UserStatus[options.status]
|
||||
? options.status
|
||||
: UserStatus.active;
|
||||
this.createdAt = options.createdAt;
|
||||
this.updatedAt = options.updatedAt;
|
||||
this.sessions = options.sessions || [];
|
||||
}
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
'admin' = 0,
|
||||
'user' = 1,
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
'active' = 0,
|
||||
'disabled' = 1,
|
||||
}
|
||||
|
||||
export interface UserInstance extends Model<User, User>, User {}
|
||||
export const UserModel = sequelize.define<UserInstance>('User', {
|
||||
sessions: { type: DataTypes.JSON, allowNull: false, defaultValue: [] },
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
unique: true,
|
||||
allowNull: false,
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: UserRole.user,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: UserStatus.active,
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { CrontabViewModel } from '../data/cronView';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import { RunningInstanceModel } from '../data/runningInstance';
|
||||
import { sequelize } from '../data';
|
||||
import { UserModel } from '../data/user';
|
||||
import { migrateUserSchema } from '../shared/userSchemaMigrations';
|
||||
import { migrateSchema } from '../shared/schemaMigrations';
|
||||
|
||||
export default async () => {
|
||||
@@ -23,7 +25,9 @@ export default async () => {
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
|
||||
await UserModel.sync();
|
||||
await migrateSchema(sequelize);
|
||||
await migrateUserSchema(sequelize);
|
||||
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
|
||||
+33
-1
@@ -13,7 +13,10 @@ import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||
import { AuthInfo } from '../data/system';
|
||||
import path from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import { Container } from 'typedi';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AppScope } from '../data/open';
|
||||
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||
|
||||
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
||||
@@ -143,6 +146,26 @@ export default ({ app }: { app: Application }) => {
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
|
||||
req.user = { role: 0 };
|
||||
return next();
|
||||
}
|
||||
const claims = jwt.decode(headerToken);
|
||||
let account = null;
|
||||
if (
|
||||
typeof claims === 'object' &&
|
||||
Number.isSafeInteger(claims?.userId) &&
|
||||
claims!.userId > 0
|
||||
) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
account = await Container.get(UserManagementService).validateToken(
|
||||
headerToken,
|
||||
req.platform,
|
||||
);
|
||||
}
|
||||
if (account) {
|
||||
req.user = { userId: account.id, role: account.role };
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -176,7 +199,15 @@ export default ({ app }: { app: Application }) => {
|
||||
});
|
||||
|
||||
app.use(rewrite('/open/*', '/api/$1'));
|
||||
app.use(config.api.prefix, routes());
|
||||
app.use(
|
||||
config.api.prefix,
|
||||
async (req, res, next) => {
|
||||
if (!req.user || req.user.role === 0) return next();
|
||||
const { userAccess } = await import('../middlewares/userAccess');
|
||||
return userAccess(req, res, next);
|
||||
},
|
||||
routes(),
|
||||
);
|
||||
|
||||
app.get('*', (_, res, next) => {
|
||||
const indexPath = path.join(frontendPath, 'index.html');
|
||||
@@ -190,6 +221,7 @@ export default ({ app }: { app: Application }) => {
|
||||
});
|
||||
|
||||
app.use(errors());
|
||||
app.use(uniqueConstraintError);
|
||||
|
||||
app.use(
|
||||
(
|
||||
|
||||
+12
-26
@@ -13,12 +13,11 @@ 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 { fileExist, isDemoEnv, safeJSONParse } from '../config/util';
|
||||
import OpenService from '../services/open';
|
||||
import { shareStore } from '../shared/store';
|
||||
import Logger from './logger';
|
||||
import cronClient from '../schedule/client';
|
||||
import { AppModel } from '../data/open';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { setLang, systemLang } from '../shared/i18n';
|
||||
|
||||
@@ -31,24 +30,7 @@ export default async () => {
|
||||
const openService = Container.get(OpenService);
|
||||
|
||||
// 初始化增加系统配置
|
||||
let systemApp = (
|
||||
await AppModel.findOne({
|
||||
where: { name: 'system' },
|
||||
})
|
||||
)?.get({ plain: true });
|
||||
if (!systemApp) {
|
||||
systemApp = await AppModel.create({
|
||||
name: 'system',
|
||||
scopes: ['crons', 'system', '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' } },
|
||||
);
|
||||
}
|
||||
await openService.initializeSystemApp();
|
||||
const [systemConfig] = await SystemModel.findOrCreate({
|
||||
where: { type: AuthDataType.systemConfig },
|
||||
});
|
||||
@@ -144,7 +126,11 @@ export default async () => {
|
||||
|
||||
// 清空所有运行中的实例记录(服务重启后进程已不存在)
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped },
|
||||
{
|
||||
status: InstanceStatus.stopped,
|
||||
finished_at: Math.floor(Date.now() / 1000),
|
||||
// The previous process's exit code cannot be recovered after a restart.
|
||||
},
|
||||
{ where: { status: InstanceStatus.running } },
|
||||
);
|
||||
|
||||
@@ -237,15 +223,15 @@ export default async () => {
|
||||
} catch { }
|
||||
|
||||
// 初始化保存一次ck和定时任务数据
|
||||
cronClient.readiness.configure(() => cronService.autosave_crontab(true));
|
||||
cronClient.readiness.configure(async () => {
|
||||
await cronService.autosave_crontab(true);
|
||||
await systemService.restoreCronConcurrency();
|
||||
});
|
||||
await cronClient.readiness.recover();
|
||||
|
||||
await envService.set_envs();
|
||||
|
||||
const authInfo = await userService.getAuthInfo();
|
||||
const apps = await openService.findApps();
|
||||
await shareStore.updateAuthInfo(authInfo);
|
||||
if (apps?.length) {
|
||||
await shareStore.updateApps(apps);
|
||||
}
|
||||
await openService.refreshApps();
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ import config from '../config';
|
||||
import { fileExist } from '../config/util';
|
||||
import { join } from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import Logger from './logger';
|
||||
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
|
||||
|
||||
export default async () => {
|
||||
const systemService = Container.get(SystemService);
|
||||
@@ -24,6 +26,7 @@ export default async () => {
|
||||
if (await fileExist(tokenFile)) {
|
||||
tokenCommand = `node ${tokenFile}`;
|
||||
}
|
||||
tokenCommand += ' --renew';
|
||||
const cron = {
|
||||
id: NaN,
|
||||
name: t('生成token'),
|
||||
@@ -70,6 +73,12 @@ export default async () => {
|
||||
await subscriptionService.setSshConfig();
|
||||
const subs = await subscriptionService.list();
|
||||
for (const sub of subs) {
|
||||
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
|
||||
try {
|
||||
const doc = sub.get({ plain: true });
|
||||
assertSubscriptionAlias(doc.alias);
|
||||
await subscriptionService.handleTask(doc, !sub.is_disabled);
|
||||
} catch (error) {
|
||||
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+28
-2
@@ -6,6 +6,7 @@ import { getPlatform } from '../config/util';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { isValidToken } from '../shared/auth';
|
||||
import config from '../config';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export default async ({ server }: { server: Server }) => {
|
||||
const echo = sockJs.createServer({
|
||||
@@ -17,6 +18,30 @@ export default async ({ server }: { server: Server }) => {
|
||||
let sessionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let checking = false;
|
||||
|
||||
const validateSession = async (
|
||||
authInfo: any,
|
||||
token: string,
|
||||
platform: string,
|
||||
) => {
|
||||
if (isValidToken(authInfo, token, platform, config.jwt.secret)) return true;
|
||||
const claims = jwt.decode(token);
|
||||
if (
|
||||
!claims ||
|
||||
typeof claims === 'string' ||
|
||||
!Number.isSafeInteger(claims.userId) ||
|
||||
claims.userId <= 0
|
||||
)
|
||||
return false;
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
const account = await Container.get(UserManagementService).validateToken(
|
||||
token,
|
||||
platform,
|
||||
);
|
||||
return account?.role === 0;
|
||||
};
|
||||
|
||||
const checkSessions = async () => {
|
||||
if (checking || sessions.size === 0) return;
|
||||
checking = true;
|
||||
@@ -35,7 +60,7 @@ export default async ({ server }: { server: Server }) => {
|
||||
}
|
||||
let valid = platforms.get(platform);
|
||||
if (valid === undefined) {
|
||||
valid = isValidToken(current, token, platform, config.jwt.secret);
|
||||
valid = await validateSession(current, token, platform);
|
||||
platforms.set(platform, valid);
|
||||
}
|
||||
if (!valid) conn.close('401');
|
||||
@@ -77,7 +102,8 @@ export default async ({ server }: { server: Server }) => {
|
||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||
|
||||
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||
if (await validateSession(authInfo, headerToken, platform)) {
|
||||
if (closed) return;
|
||||
sockService.addClient(conn);
|
||||
sessions.set(conn, { token: headerToken, platform });
|
||||
if (!sessionTimer) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { UniqueConstraintError } from 'sequelize';
|
||||
import { t } from '../shared/i18n';
|
||||
|
||||
// Handle the database constraint itself so concurrent writes are covered too.
|
||||
const uniqueConstraintError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
if (!(err instanceof UniqueConstraintError)) return next(err);
|
||||
// Constraint details can contain environment values or application credentials.
|
||||
res
|
||||
.status(409)
|
||||
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
|
||||
};
|
||||
|
||||
export default uniqueConstraintError;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Op } from 'sequelize';
|
||||
import { EnvModel } from '../data/env';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
|
||||
// Ordinary users are read-only until task workers and shared files are isolated.
|
||||
export async function userAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
if (!req.user || req.user.role === 0) return next();
|
||||
const userId = req.user.userId;
|
||||
if (!Number.isSafeInteger(userId) || userId! <= 0)
|
||||
return res.status(403).json({ code: 403, message: '需要有效用户会话' });
|
||||
const pathname = req.path.replace(/\/$/, '') || '/';
|
||||
if (
|
||||
(req.method === 'GET' &&
|
||||
['/user', '/system', '/health'].includes(pathname)) ||
|
||||
(req.method === 'POST' && pathname === '/user/logout')
|
||||
)
|
||||
return next();
|
||||
if (req.method === 'GET' && pathname === '/crons/views')
|
||||
return res.json({ code: 200, data: [] });
|
||||
const match = pathname.match(
|
||||
/^\/(envs|crons|subscriptions)(?:\/([1-9][0-9]*))?$/,
|
||||
);
|
||||
if (req.method !== 'GET' || !match)
|
||||
return res
|
||||
.status(403)
|
||||
.json({
|
||||
code: 403,
|
||||
message: '普通用户只读自己的数据,执行和系统修改由管理员负责',
|
||||
});
|
||||
try {
|
||||
const models: Record<string, any> = {
|
||||
envs: EnvModel,
|
||||
crons: CrontabModel,
|
||||
subscriptions: SubscriptionModel,
|
||||
};
|
||||
const model = models[match[1]];
|
||||
const where: any = { userId };
|
||||
if (match[2]) {
|
||||
const id = Number(match[2]);
|
||||
if (!Number.isSafeInteger(id))
|
||||
return res.status(400).json({ code: 400, message: '无效 ID' });
|
||||
where.id = id;
|
||||
const doc = await model.findOne({ where });
|
||||
return doc
|
||||
? res.json({ code: 200, data: doc.get({ plain: true }) })
|
||||
: res.status(404).json({ code: 404, message: '数据不存在' });
|
||||
}
|
||||
const search =
|
||||
typeof req.query.searchValue === 'string'
|
||||
? req.query.searchValue.slice(0, 1024)
|
||||
: '';
|
||||
if (search) where.name = { [Op.like]: `%${search}%` };
|
||||
const docs = await model.findAll({ where, order: [['createdAt', 'DESC']] });
|
||||
const data = docs.map((doc: any) => doc.get({ plain: true }));
|
||||
return res.json({
|
||||
code: 200,
|
||||
data: match[1] === 'crons' ? { data, total: data.length } : data,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package com.ql.cron;
|
||||
service Cron {
|
||||
rpc addCron(AddCronRequest) returns (AddCronResponse);
|
||||
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
|
||||
rpc setConcurrency(SetConcurrencyRequest) returns (AddCronResponse);
|
||||
}
|
||||
|
||||
message ISchedule { string schedule = 1; }
|
||||
@@ -27,3 +28,6 @@ message AddCronResponse {}
|
||||
message DeleteCronRequest { repeated string ids = 1; }
|
||||
|
||||
message DeleteCronResponse {}
|
||||
|
||||
// Zero restores the default limit.
|
||||
message SetConcurrencyRequest { int32 concurrency = 1; }
|
||||
|
||||
+89
-1
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v3.17.3
|
||||
// protoc v5.28.3
|
||||
// source: back/protos/cron.proto
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -48,6 +48,11 @@ export interface DeleteCronRequest {
|
||||
export interface DeleteCronResponse {
|
||||
}
|
||||
|
||||
/** Zero restores the default limit. */
|
||||
export interface SetConcurrencyRequest {
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
function createBaseISchedule(): ISchedule {
|
||||
return { schedule: "" };
|
||||
}
|
||||
@@ -452,6 +457,64 @@ export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseSetConcurrencyRequest(): SetConcurrencyRequest {
|
||||
return { concurrency: 0 };
|
||||
}
|
||||
|
||||
export const SetConcurrencyRequest: MessageFns<SetConcurrencyRequest> = {
|
||||
encode(message: SetConcurrencyRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.concurrency !== 0) {
|
||||
writer.uint32(8).int32(message.concurrency);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): SetConcurrencyRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
let end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseSetConcurrencyRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 8) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.concurrency = reader.int32();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): SetConcurrencyRequest {
|
||||
return { concurrency: isSet(object.concurrency) ? globalThis.Number(object.concurrency) : 0 };
|
||||
},
|
||||
|
||||
toJSON(message: SetConcurrencyRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.concurrency !== 0) {
|
||||
obj.concurrency = Math.round(message.concurrency);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(base?: I): SetConcurrencyRequest {
|
||||
return SetConcurrencyRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(object: I): SetConcurrencyRequest {
|
||||
const message = createBaseSetConcurrencyRequest();
|
||||
message.concurrency = object.concurrency ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
export type CronService = typeof CronService;
|
||||
export const CronService = {
|
||||
addCron: {
|
||||
@@ -472,11 +535,21 @@ export const CronService = {
|
||||
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
|
||||
},
|
||||
setConcurrency: {
|
||||
path: "/com.ql.cron.Cron/setConcurrency",
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (value: SetConcurrencyRequest) => Buffer.from(SetConcurrencyRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer) => SetConcurrencyRequest.decode(value),
|
||||
responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface CronServer extends UntypedServiceImplementation {
|
||||
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
|
||||
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
|
||||
setConcurrency: handleUnaryCall<SetConcurrencyRequest, AddCronResponse>;
|
||||
}
|
||||
|
||||
export interface CronClient extends Client {
|
||||
@@ -510,6 +583,21 @@ export interface CronClient extends Client {
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
setConcurrency(
|
||||
request: SetConcurrencyRequest,
|
||||
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
setConcurrency(
|
||||
request: SetConcurrencyRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
setConcurrency(
|
||||
request: SetConcurrencyRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: AddCronResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
}
|
||||
|
||||
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
|
||||
|
||||
@@ -68,7 +68,11 @@ const addCron = (
|
||||
schedule,
|
||||
async () => {
|
||||
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
|
||||
await runCron(item.command, item);
|
||||
await runCron(
|
||||
item.command,
|
||||
item,
|
||||
() => scheduleStacks.get(item.id) === jobs,
|
||||
);
|
||||
},
|
||||
{
|
||||
name: `${item.id}: ${item.name || ''}`,
|
||||
|
||||
@@ -62,6 +62,25 @@ class Client {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
async setConcurrency(concurrency: number): Promise<void> {
|
||||
await this.waitForReady(2000);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.setConcurrency(
|
||||
{ concurrency }, new Metadata(), { deadline: Date.now() + 5000 },
|
||||
(err) => {
|
||||
if (err) {
|
||||
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
|
||||
this.readiness.invalidate();
|
||||
Object.assign(err, { status: 503 });
|
||||
}
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async addCron(
|
||||
request: AddCronRequest['crons'],
|
||||
replace = false
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||
import os from 'os';
|
||||
import { AddCronResponse, SetConcurrencyRequest } from '../protos/cron';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
|
||||
export async function setConcurrency(
|
||||
call: ServerUnaryCall<SetConcurrencyRequest, AddCronResponse>,
|
||||
callback: sendUnaryData<AddCronResponse>,
|
||||
) {
|
||||
const limit = call.request.concurrency;
|
||||
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
|
||||
callback(
|
||||
Object.assign(new Error('Invalid concurrency'), {
|
||||
code: status.INVALID_ARGUMENT,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||
callback(null, {});
|
||||
} catch (error) {
|
||||
callback(error as Error, null);
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,12 @@ export default class ConfigService {
|
||||
|
||||
public async getFile(filePath: string, res: Response) {
|
||||
let content = '';
|
||||
if (!filePath) {
|
||||
if (!filePath || filePath.startsWith('data/scripts/')) {
|
||||
return res.send({ code: 403, message: t('文件无法访问') });
|
||||
}
|
||||
const scriptFile = filePath.startsWith('data/scripts/');
|
||||
const resolved = resolveFileAccess(
|
||||
scriptFile ? config.scriptPath : config.configPath,
|
||||
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
|
||||
config.configPath,
|
||||
[filePath],
|
||||
config.blackFileList,
|
||||
);
|
||||
if (!resolved) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
import { resolveLogPath } from '../shared/logPath';
|
||||
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
|
||||
import {
|
||||
RunningInstanceModel,
|
||||
@@ -246,6 +247,9 @@ export default class CronService {
|
||||
last_execution_time: number;
|
||||
exit_code?: number;
|
||||
}) {
|
||||
if (log_path && log_path !== '/dev/null') {
|
||||
resolveLogPath(config.logPath, log_path);
|
||||
}
|
||||
let options: any = {
|
||||
status,
|
||||
pid,
|
||||
@@ -925,7 +929,9 @@ export default class CronService {
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
|
||||
const absolutePath = doc.log_path
|
||||
? resolveLogPath(config.logPath, doc.log_path)
|
||||
: '';
|
||||
const logFileExist = doc.log_path && (await fileExist(absolutePath));
|
||||
if (logFileExist) {
|
||||
const chunk = await readLogChunk(`${absolutePath}`, options);
|
||||
@@ -955,12 +961,12 @@ export default class CronService {
|
||||
|
||||
public async logs(id: number) {
|
||||
const doc = await this.getDb({ id });
|
||||
if (!doc || !doc.log_path) {
|
||||
if (!doc || !doc.log_path || doc.log_path === '/dev/null') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relativeDir = path.dirname(`${doc.log_path}`);
|
||||
const dir = path.resolve(config.logPath, relativeDir);
|
||||
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
|
||||
const dirExist = await fileExist(dir);
|
||||
if (dirExist) {
|
||||
let files = await fs.readdir(dir);
|
||||
|
||||
@@ -262,10 +262,9 @@ export default class EnvService {
|
||||
.map((x) => x.value)
|
||||
.join('&')
|
||||
.replace(/\\/g, '\\\\')}`;
|
||||
js_env_string += `process.env.${key}=\`${_env_value.replace(
|
||||
/\`/g,
|
||||
'\\`',
|
||||
)}\`;\n`;
|
||||
js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify(
|
||||
group.map((x) => x.value).join('&'),
|
||||
)};\n`;
|
||||
py_env_string += `os.environ['${key}']='''${_env_value.replace(
|
||||
/\'/g,
|
||||
"\\'",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HealthService } from '../protos/health';
|
||||
import { ApiService } from '../protos/api';
|
||||
import { addCron } from '../schedule/addCron';
|
||||
import { delCron } from '../schedule/delCron';
|
||||
import { setConcurrency } from '../schedule/setConcurrency';
|
||||
import { check } from '../schedule/health';
|
||||
import * as Api from '../schedule/api';
|
||||
import Logger from '../loaders/logger';
|
||||
@@ -27,7 +28,7 @@ export class GrpcServerService {
|
||||
async initialize() {
|
||||
try {
|
||||
this.server.addService(HealthService, { check });
|
||||
this.server.addService(CronService, { addCron, delCron });
|
||||
this.server.addService(CronService, { addCron, delCron, setConcurrency });
|
||||
this.server.addService(ApiService, Api);
|
||||
|
||||
const tlsConfig = await initGrpcCerts();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
import path from 'path';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
@@ -9,10 +8,6 @@ export default class LogService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
public checkFilePath(filePath: string, fileName: string) {
|
||||
return resolveFileAccess(
|
||||
config.logPath,
|
||||
[filePath || '', fileName],
|
||||
config.blackFileList,
|
||||
);
|
||||
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
|
||||
}
|
||||
}
|
||||
|
||||
+116
-34
@@ -6,27 +6,77 @@ import { v4 as uuidV4 } from 'uuid';
|
||||
import sequelize, { Op } from 'sequelize';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { t } from '../shared/i18n';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { writeFile, rename, rm, stat, chmod } from 'fs/promises';
|
||||
import config from '../config';
|
||||
|
||||
@Service()
|
||||
export default class OpenService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
// Every database mutation and full authentication-cache publication shares
|
||||
// this cross-process lock. Lock a separate marker, not token.json itself.
|
||||
private async withAppsLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const target = path.join(config.configPath, '.apps-state');
|
||||
const release = await lock(target, {
|
||||
realpath: false,
|
||||
stale: 10_000,
|
||||
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 3000 },
|
||||
});
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
public async initializeSystemApp(): Promise<void> {
|
||||
await this.withAppsLock(async () => {
|
||||
const doc = await AppModel.findOne({ where: { name: 'system' } });
|
||||
const app = doc?.get({ plain: true });
|
||||
if (!app) {
|
||||
await this.insert({
|
||||
name: 'system',
|
||||
scopes: ['crons', 'system', 'dashboard'],
|
||||
client_id: createRandomString(12, 12),
|
||||
client_secret: createRandomString(24, 24),
|
||||
});
|
||||
} else if (!app.scopes.includes('dashboard')) {
|
||||
await AppModel.update(
|
||||
{ scopes: [...app.scopes, 'dashboard'] },
|
||||
{ where: { id: app.id } },
|
||||
);
|
||||
}
|
||||
await shareStore.updateApps(await this.find({}));
|
||||
});
|
||||
}
|
||||
|
||||
public async refreshApps(): Promise<void> {
|
||||
await this.withAppsLock(async () => {
|
||||
await shareStore.updateApps(await this.find({}));
|
||||
});
|
||||
}
|
||||
|
||||
public async findApps(): Promise<App[] | null> {
|
||||
const docs = await this.find({});
|
||||
return docs;
|
||||
}
|
||||
|
||||
public async create(payload: App): Promise<App> {
|
||||
const tab = { ...payload };
|
||||
tab.client_id = createRandomString(12, 12);
|
||||
tab.client_secret = createRandomString(24, 24);
|
||||
const doc = await this.insert(tab);
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
return { ...doc, tokens: [] };
|
||||
return this.withAppsLock(async () => {
|
||||
const tab = { ...payload };
|
||||
tab.client_id = createRandomString(12, 12);
|
||||
tab.client_secret = createRandomString(24, 24);
|
||||
const doc = await this.insert(tab);
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
return { ...doc, tokens: [] };
|
||||
});
|
||||
}
|
||||
|
||||
public async insert(payload: App): Promise<App> {
|
||||
private async insert(payload: App): Promise<App> {
|
||||
const doc = await AppModel.create(payload, { returning: true });
|
||||
return doc.get({ plain: true });
|
||||
}
|
||||
@@ -41,10 +91,12 @@ export default class OpenService {
|
||||
}
|
||||
|
||||
private async updateDb(payload: Partial<App>): Promise<App> {
|
||||
await AppModel.update(payload, { where: { id: payload.id } });
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
return apps?.find((x) => x.id === payload.id) as App;
|
||||
return this.withAppsLock(async () => {
|
||||
await AppModel.update(payload, { where: { id: payload.id } });
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
return apps?.find((x) => x.id === payload.id) as App;
|
||||
});
|
||||
}
|
||||
|
||||
public async getDb(query: Record<string, any>): Promise<App> {
|
||||
@@ -56,9 +108,11 @@ export default class OpenService {
|
||||
}
|
||||
|
||||
public async remove(ids: number[]) {
|
||||
await AppModel.destroy({ where: { id: ids } });
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
return this.withAppsLock(async () => {
|
||||
await AppModel.destroy({ where: { id: ids } });
|
||||
const apps = await this.find({});
|
||||
await shareStore.updateApps(apps);
|
||||
});
|
||||
}
|
||||
|
||||
public async resetSecret(id: number): Promise<App> {
|
||||
@@ -112,7 +166,15 @@ export default class OpenService {
|
||||
return docs.map((x) => x.get({ plain: true }));
|
||||
}
|
||||
|
||||
public async authToken({
|
||||
public async authToken(credentials: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}): Promise<any> {
|
||||
return this.withAppsLock(() => this.issueToken(credentials));
|
||||
}
|
||||
|
||||
// Caller holds withAppsLock through database update and cache publication.
|
||||
private async issueToken({
|
||||
client_id,
|
||||
client_secret,
|
||||
}: {
|
||||
@@ -156,27 +218,47 @@ export default class OpenService {
|
||||
}
|
||||
}
|
||||
|
||||
public async generateSystemToken(): Promise<{
|
||||
public async generateSystemToken(renew = false): Promise<{
|
||||
value: string;
|
||||
expiration: number;
|
||||
}> {
|
||||
const apps = await shareStore.getApps();
|
||||
const systemApp = apps?.find((x) => x.name === 'system');
|
||||
if (!systemApp) {
|
||||
throw new Error('system app not found');
|
||||
}
|
||||
const now = Math.round(Date.now() / 1000);
|
||||
const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
|
||||
if (currentToken) {
|
||||
return currentToken;
|
||||
}
|
||||
const { data } = await this.authToken({
|
||||
client_id: systemApp.client_id,
|
||||
client_secret: systemApp.client_secret,
|
||||
return this.withAppsLock(async () => {
|
||||
// Read after acquiring the lock; a concurrent issuer may have renewed.
|
||||
const systemApp = await this.getDb({ name: 'system' });
|
||||
const now = Math.round(Date.now() / 1000);
|
||||
let appToken = systemApp.tokens
|
||||
?.filter((token) => token.expiration > now)
|
||||
.sort((left, right) => right.expiration - left.expiration)[0];
|
||||
if (renew || !appToken) {
|
||||
const { data } = await this.issueToken({
|
||||
client_id: systemApp.client_id,
|
||||
client_secret: systemApp.client_secret,
|
||||
});
|
||||
appToken = { value: data.token, expiration: data.expiration };
|
||||
} else {
|
||||
// Recover a prior database commit whose cache publication failed.
|
||||
await shareStore.updateApps(await this.find({}));
|
||||
}
|
||||
const tokenFile = path.join(config.configPath, 'token.json');
|
||||
const temporaryFile = `${tokenFile}.${process.pid}.tmp`;
|
||||
const mode = await stat(tokenFile).then(
|
||||
(file) => file.mode & 0o777,
|
||||
(error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return 0o600;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await writeFile(temporaryFile, `${JSON.stringify(appToken)}${os.EOL}`, {
|
||||
mode,
|
||||
});
|
||||
await chmod(temporaryFile, mode);
|
||||
// Shell readers see a complete old or new document, never a partial one.
|
||||
await rename(temporaryFile, tokenFile);
|
||||
} finally {
|
||||
await rm(temporaryFile, { force: true });
|
||||
}
|
||||
return appToken;
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
value: data.token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +66,7 @@ export default class ScriptService {
|
||||
}
|
||||
|
||||
public checkFilePath(filePath: string, fileName: string) {
|
||||
return resolveFileAccess(
|
||||
config.scriptPath,
|
||||
[filePath || '', fileName],
|
||||
config.blackFileList,
|
||||
);
|
||||
return resolveFileAccess(config.scriptPath, [filePath || '', fileName]);
|
||||
}
|
||||
|
||||
public async getFile(filePath: string, fileName: string) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import path from 'path';
|
||||
import { Service } from 'typedi';
|
||||
import config from '../config';
|
||||
import { ScriptHistory } from '../shared/scriptHistory';
|
||||
|
||||
/** Panel configuration and lifecycle; the storage core stays independently testable. */
|
||||
@Service()
|
||||
export default class ScriptHistoryService extends ScriptHistory {
|
||||
constructor() {
|
||||
super(config.scriptPath, path.join(config.dataPath, 'script-history'));
|
||||
}
|
||||
}
|
||||
+43
-23
@@ -8,10 +8,22 @@ import { formatUrl } from '../config/subscription';
|
||||
import config from '../config';
|
||||
import { fileExist, rmPath } from '../config/util';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import {
|
||||
getSubscriptionSshAlias,
|
||||
resolveSubscriptionPath,
|
||||
} from '../shared/subscriptionPath';
|
||||
|
||||
@Service()
|
||||
export default class SshKeyService {
|
||||
private homedir = os.homedir();
|
||||
// OpenSSH uses the effective user's passwd entry, not the HOME override.
|
||||
private get homedir(): string {
|
||||
try {
|
||||
return os.userInfo().homedir;
|
||||
} catch {
|
||||
// Custom container UIDs may have no passwd entry. Keep startup working.
|
||||
return os.homedir();
|
||||
}
|
||||
}
|
||||
private sshPath = config.sshdPath;
|
||||
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
|
||||
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
|
||||
@@ -21,6 +33,10 @@ export default class SshKeyService {
|
||||
}
|
||||
|
||||
private async initSshConfigFile() {
|
||||
await fs.mkdir(path.dirname(this.sshConfigFilePath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
let config = '';
|
||||
const _exist = await fileExist(this.sshConfigFilePath);
|
||||
if (_exist) {
|
||||
@@ -42,7 +58,7 @@ export default class SshKeyService {
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.sshPath, alias);
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||
try {
|
||||
await rmPath(filePath);
|
||||
} catch { }
|
||||
@@ -54,7 +70,7 @@ export default class SshKeyService {
|
||||
|
||||
private async removePrivateKeyFile(alias: string): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.sshPath, alias);
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias);
|
||||
await rmPath(filePath);
|
||||
} catch (error) {
|
||||
this.logger.error('删除私钥文件失败', error);
|
||||
@@ -66,18 +82,19 @@ export default class SshKeyService {
|
||||
host: string,
|
||||
proxy?: string,
|
||||
) {
|
||||
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
|
||||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
|
||||
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
|
||||
}
|
||||
if (host === 'github.com') {
|
||||
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
|
||||
}
|
||||
const proxyStr = proxy
|
||||
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
|
||||
: '';
|
||||
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join(
|
||||
this.sshPath,
|
||||
alias,
|
||||
)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
||||
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
|
||||
await writeFileWithLock(
|
||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
||||
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||
config,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
@@ -88,7 +105,7 @@ export default class SshKeyService {
|
||||
|
||||
private async removeSshConfig(alias: string) {
|
||||
try {
|
||||
const filePath = path.join(this.sshPath, `${alias}.config`);
|
||||
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
|
||||
await rmPath(filePath);
|
||||
} catch (error) {
|
||||
this.logger.error(`删除ssh配置文件${alias}失败`, error);
|
||||
@@ -101,6 +118,7 @@ export default class SshKeyService {
|
||||
host: string,
|
||||
proxy?: string,
|
||||
): Promise<void> {
|
||||
alias = getSubscriptionSshAlias(alias);
|
||||
await this.generatePrivateKeyFile(alias, key);
|
||||
await this.generateSingleSshConfig(alias, host, proxy);
|
||||
}
|
||||
@@ -110,6 +128,7 @@ export default class SshKeyService {
|
||||
host: string,
|
||||
proxy?: string,
|
||||
): Promise<void> {
|
||||
alias = getSubscriptionSshAlias(alias);
|
||||
await this.removePrivateKeyFile(alias);
|
||||
await this.removeSshConfig(alias);
|
||||
}
|
||||
@@ -117,15 +136,19 @@ export default class SshKeyService {
|
||||
public async setSshConfig(docs: Subscription[]) {
|
||||
for (const doc of docs) {
|
||||
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
|
||||
const { alias, proxy } = doc;
|
||||
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);
|
||||
try {
|
||||
const alias = getSubscriptionSshAlias(doc.alias);
|
||||
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, doc.proxy);
|
||||
} catch (error) {
|
||||
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,12 +167,9 @@ export default class SshKeyService {
|
||||
|
||||
private async generateGlobalSshConfig(alias: string) {
|
||||
// Create a config that matches all hosts, making this key globally available
|
||||
const config = `Host *\n IdentityFile ${path.join(
|
||||
this.sshPath,
|
||||
alias,
|
||||
)}\n StrictHostKeyChecking no\n`;
|
||||
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
|
||||
await writeFileWithLock(
|
||||
`${path.join(this.sshPath, `${alias}.config`)}`,
|
||||
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
|
||||
config,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
|
||||
+107
-32
@@ -20,7 +20,7 @@ import {
|
||||
} from '../config/util';
|
||||
import fs from 'fs/promises';
|
||||
import { FindOptions, Op } from 'sequelize';
|
||||
import path, { join } from 'path';
|
||||
import path from 'path';
|
||||
import ScheduleService, { TaskCallbacks } from './schedule';
|
||||
import { SimpleIntervalSchedule } from 'toad-scheduler';
|
||||
import SockService from './sock';
|
||||
@@ -30,6 +30,14 @@ import dayjs from 'dayjs';
|
||||
import { LOG_END_SYMBOL } from '../config/const';
|
||||
import { formatCommand, formatUrl } from '../config/subscription';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
|
||||
import { resolveLogPath } from '../shared/logPath';
|
||||
import {
|
||||
getSubscriptionStorageCandidates,
|
||||
isSubscriptionStorageName,
|
||||
resolveSubscriptionStoragePath,
|
||||
} from '../shared/subscriptionStorage';
|
||||
import { withSubscriptionMutation } from '../shared/subscriptionMutationLock';
|
||||
import CrontabService from './cron';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
@@ -88,9 +96,11 @@ export default class SubscriptionService {
|
||||
needCreate = true,
|
||||
runImmediately = false,
|
||||
) {
|
||||
const { url } = formatUrl(doc);
|
||||
|
||||
doc.command = formatCommand(doc, url as string);
|
||||
// Cancelling by ID must also work for invalid historical subscriptions.
|
||||
if (needCreate) {
|
||||
const { url } = formatUrl(doc);
|
||||
doc.command = formatCommand(doc, url as string);
|
||||
}
|
||||
|
||||
if (doc.schedule_type === 'crontab') {
|
||||
this.scheduleService.cancelCronTask(doc as any);
|
||||
@@ -222,11 +232,14 @@ export default class SubscriptionService {
|
||||
}
|
||||
|
||||
public async create(payload: Subscription): Promise<Subscription> {
|
||||
const tab = new Subscription(payload);
|
||||
const doc = await this.insert(tab);
|
||||
await this.handleTask(doc.get({ plain: true }));
|
||||
await this.setSshConfig();
|
||||
return doc;
|
||||
assertSubscriptionAlias(payload.alias);
|
||||
return withSubscriptionMutation(async () => {
|
||||
const tab = new Subscription(payload);
|
||||
const doc = await this.insert(tab);
|
||||
await this.handleTask(doc.get({ plain: true }));
|
||||
await this.setSshConfig();
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
public async insert(payload: Subscription): Promise<SubscriptionInstance> {
|
||||
@@ -234,12 +247,27 @@ export default class SubscriptionService {
|
||||
}
|
||||
|
||||
public async update(payload: Subscription): Promise<Subscription> {
|
||||
const doc = await this.getDb({ id: payload.id });
|
||||
const tab = new Subscription({ ...doc, ...payload });
|
||||
const newDoc = await this.updateDb(tab);
|
||||
await this.handleTask(newDoc, !newDoc.is_disabled);
|
||||
await this.setSshConfig();
|
||||
return newDoc;
|
||||
assertSubscriptionAlias(payload.alias);
|
||||
return withSubscriptionMutation(async () => {
|
||||
const doc = await this.getDb({ id: payload.id });
|
||||
const tab = new Subscription({ ...doc, ...payload });
|
||||
const newDoc = await this.updateDb(tab);
|
||||
await this.handleTask(newDoc, !newDoc.is_disabled);
|
||||
await this.removeSshConfigForSubscription(doc);
|
||||
await this.setSshConfig();
|
||||
return newDoc;
|
||||
});
|
||||
}
|
||||
|
||||
private async removeSshConfigForSubscription(doc: Subscription) {
|
||||
if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return;
|
||||
// Invalid historical rows must remain deletable without deriving unsafe paths.
|
||||
try {
|
||||
assertSubscriptionAlias(doc.alias);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await this.sshKeyService.removeSSHKey(doc.alias, '');
|
||||
}
|
||||
|
||||
public async updateDb(payload: Subscription): Promise<Subscription> {
|
||||
@@ -262,6 +290,7 @@ export default class SubscriptionService {
|
||||
last_running_time: number;
|
||||
last_execution_time: number;
|
||||
}) {
|
||||
if (log_path) resolveLogPath(config.logPath, log_path);
|
||||
const options: any = {
|
||||
status,
|
||||
pid,
|
||||
@@ -279,25 +308,71 @@ export default class SubscriptionService {
|
||||
}
|
||||
|
||||
public async remove(ids: number[], query: { force?: boolean }) {
|
||||
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
await this.handleTask(doc.get({ plain: true }), false);
|
||||
}
|
||||
await SubscriptionModel.destroy({ where: { id: ids } });
|
||||
await this.setSshConfig();
|
||||
|
||||
if (query?.force === true) {
|
||||
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
|
||||
if (crons?.length) {
|
||||
await this.crontabService.remove(crons.map((x) => x.id!));
|
||||
return withSubscriptionMutation(async () => {
|
||||
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
|
||||
const storagePaths: string[] = [];
|
||||
const retainedPaths: string[] = [];
|
||||
const collectPaths = (
|
||||
doc: Subscription,
|
||||
targets: string[],
|
||||
deleting = false,
|
||||
) => {
|
||||
for (const names of getSubscriptionStorageCandidates(doc)) {
|
||||
// A URL accepted by one engine can produce an invalid name in another.
|
||||
// Validate each candidate independently, retaining the safe candidate.
|
||||
if (!Object.values(names).every(isSubscriptionStorageName)) continue;
|
||||
const resolve = deleting ? resolveSubscriptionStoragePath : path.resolve;
|
||||
const scriptPath = resolve(config.scriptPath, names.script);
|
||||
if (scriptPath) targets.push(scriptPath);
|
||||
if (names.repo) {
|
||||
const repoPath = resolve(config.repoPath, names.repo);
|
||||
if (repoPath) targets.push(repoPath);
|
||||
}
|
||||
if (names.raw) {
|
||||
const rawPath = resolve(path.join(config.dataPath, 'raw'), names.raw);
|
||||
if (rawPath) targets.push(rawPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Validate all deletion targets before changing rows or removing any files.
|
||||
if (query?.force === true) {
|
||||
for (const doc of docs) {
|
||||
assertSubscriptionAlias(doc.alias);
|
||||
resolveSubscriptionStoragePath(config.scriptPath, doc.alias);
|
||||
resolveSubscriptionStoragePath(config.repoPath, doc.alias);
|
||||
collectPaths(doc, storagePaths, true);
|
||||
}
|
||||
const remaining = await SubscriptionModel.findAll({ where: {} });
|
||||
for (const doc of remaining) {
|
||||
if (!ids.includes(doc.id!)) collectPaths(doc, retainedPaths);
|
||||
}
|
||||
}
|
||||
for (const doc of docs) {
|
||||
const filePath = join(config.scriptPath, doc.alias);
|
||||
const repoPath = join(config.repoPath, doc.alias);
|
||||
await rmPath(filePath);
|
||||
await rmPath(repoPath);
|
||||
await this.handleTask(doc.get({ plain: true }), false);
|
||||
}
|
||||
}
|
||||
await SubscriptionModel.destroy({ where: { id: ids } });
|
||||
for (const doc of docs) {
|
||||
await this.removeSshConfigForSubscription(doc);
|
||||
}
|
||||
await this.setSshConfig();
|
||||
|
||||
if (query?.force === true) {
|
||||
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
|
||||
if (crons?.length) {
|
||||
await this.crontabService.remove(crons.map((x) => x.id!));
|
||||
}
|
||||
for (const storagePath of new Set(storagePaths)) {
|
||||
// rm is recursive: an ancestor must also be retained if a surviving
|
||||
// subscription uses a nested branch, and vice versa.
|
||||
const shared = retainedPaths.some((retained) =>
|
||||
retained === storagePath ||
|
||||
retained.startsWith(`${storagePath}${path.sep}`) ||
|
||||
storagePath.startsWith(`${retained}${path.sep}`),
|
||||
);
|
||||
if (!shared) await rmPath(storagePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getDb(
|
||||
@@ -399,7 +474,7 @@ export default class SubscriptionService {
|
||||
|
||||
if (doc.log_path) {
|
||||
const relativeDir = path.dirname(`${doc.log_path}`);
|
||||
const dir = path.resolve(config.logPath, relativeDir);
|
||||
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
|
||||
const _exist = await fileExist(dir);
|
||||
if (_exist) {
|
||||
let files = await fs.readdir(dir);
|
||||
|
||||
+52
-22
@@ -1,4 +1,7 @@
|
||||
import { spawn } from 'cross-spawn';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { shellQuote } from '../shared/shellQuote';
|
||||
import { Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import { Agent, request } from 'undici';
|
||||
@@ -31,6 +34,8 @@ import {
|
||||
SystemModelInfo,
|
||||
} from '../data/system';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import cronClient from '../schedule/client';
|
||||
import { withSchedulerMutation } from '../shared/schedulerMutationLock';
|
||||
import NotificationService from './notify';
|
||||
import ScheduleService, { TaskCallbacks } from './schedule';
|
||||
import SockService from './sock';
|
||||
@@ -119,15 +124,29 @@ export default class SystemService {
|
||||
}
|
||||
|
||||
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);
|
||||
const limit = info.cronConcurrency ?? 0;
|
||||
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
|
||||
throw Object.assign(new Error(t('参数错误')), { status: 400 });
|
||||
}
|
||||
return { code: 200, data: info };
|
||||
return withSchedulerMutation(async () => {
|
||||
const oDoc = await this.getSystemConfig();
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, ...info, cronConcurrency: limit },
|
||||
});
|
||||
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||
await cronClient.setConcurrency(limit);
|
||||
return { code: 200, data: info };
|
||||
});
|
||||
}
|
||||
|
||||
public async restoreCronConcurrency() {
|
||||
await withSchedulerMutation(async () => {
|
||||
const doc = await this.getSystemConfig();
|
||||
const limit = doc.info?.cronConcurrency ?? 0;
|
||||
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
|
||||
await cronClient.setConcurrency(limit);
|
||||
});
|
||||
}
|
||||
|
||||
public async updateDependenceProxy(info: SystemModelInfo) {
|
||||
@@ -139,7 +158,7 @@ export default class SystemService {
|
||||
if (info.dependenceProxy) {
|
||||
await fs.promises.writeFile(
|
||||
config.dependenceProxyFile,
|
||||
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
|
||||
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
|
||||
);
|
||||
} else {
|
||||
await fs.promises.rm(config.dependenceProxyFile);
|
||||
@@ -155,7 +174,7 @@ export default class SystemService {
|
||||
});
|
||||
let cmd = 'pnpm config delete registry';
|
||||
if (info.nodeMirror) {
|
||||
cmd = `pnpm config set registry ${info.nodeMirror}`;
|
||||
cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
|
||||
}
|
||||
let command = `cd && ${cmd}`;
|
||||
const docs = await DependenceModel.findAll({
|
||||
@@ -204,7 +223,7 @@ export default class SystemService {
|
||||
});
|
||||
let cmd = 'pip config unset global.index-url';
|
||||
if (info.pythonMirror) {
|
||||
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
|
||||
cmd = `pip3 config set global.index-url ${shellQuote(info.pythonMirror)}`;
|
||||
}
|
||||
await promiseExec(cmd);
|
||||
return { code: 200, data: info };
|
||||
@@ -220,7 +239,6 @@ export default class SystemService {
|
||||
return;
|
||||
}
|
||||
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
|
||||
let hasError = false;
|
||||
this.scheduleService.runTask(
|
||||
command,
|
||||
{
|
||||
@@ -228,14 +246,15 @@ export default class SystemService {
|
||||
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
|
||||
res?.end();
|
||||
},
|
||||
onEnd: async () => {
|
||||
onEnd: async (cp) => {
|
||||
this.sockService.sendMessage({
|
||||
type: 'updateLinuxMirror',
|
||||
message: 'update linux mirror end',
|
||||
status: 'completed',
|
||||
});
|
||||
onEnd?.();
|
||||
if (!hasError) {
|
||||
// Tools such as sudo and apt may write warnings to stderr on success.
|
||||
if (cp?.exitCode === 0 && cp.signalCode === null) {
|
||||
await this.updateAuthDb({
|
||||
...oDoc,
|
||||
info: { ...oDoc.info, ...info },
|
||||
@@ -243,7 +262,6 @@ export default class SystemService {
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
hasError = true;
|
||||
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
|
||||
},
|
||||
onLog: async (message: string) => {
|
||||
@@ -357,7 +375,10 @@ export default class SystemService {
|
||||
}
|
||||
|
||||
public async reloadSystem(target?: 'system' | 'data') {
|
||||
const cmd = `real_time=true ql reload ${target || ''}`;
|
||||
if (target && !['system', 'data'].includes(target)) {
|
||||
return { code: 400, message: t('参数错误') };
|
||||
}
|
||||
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
|
||||
const cp = spawn(cmd, {
|
||||
shell: '/bin/bash',
|
||||
detached: true,
|
||||
@@ -438,11 +459,20 @@ export default class SystemService {
|
||||
if (type && type.length) {
|
||||
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
|
||||
}
|
||||
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
|
||||
await promiseExec(
|
||||
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
|
||||
} ${dataPaths.join(' ')}`,
|
||||
);
|
||||
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
|
||||
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
|
||||
if (dataDirs.some((dir) => !allowed.has(dir))) {
|
||||
return res.status(400).send({ code: 400, message: t('参数错误') });
|
||||
}
|
||||
if (dataDirs.includes('scripts')) dataDirs.push('script-history');
|
||||
// History is created lazily; old/fresh installations must still export.
|
||||
if (!fs.existsSync(path.join(config.dataPath, 'script-history'))) {
|
||||
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
|
||||
}
|
||||
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
|
||||
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
|
||||
cwd: path.dirname(config.dataPath),
|
||||
});
|
||||
res.download(config.dataTgzFile);
|
||||
} catch (error: any) {
|
||||
return res.send({ code: 400, message: error.message });
|
||||
@@ -565,7 +595,7 @@ export default class SystemService {
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
config.langEnvFile,
|
||||
`export QL_LANG='${lang}'\n`,
|
||||
`export QL_LANG=${shellQuote(lang)}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write lang_env.sh: ${error}`);
|
||||
|
||||
+36
-12
@@ -33,9 +33,14 @@ import {
|
||||
verifyPassword,
|
||||
} from '../shared/password';
|
||||
import { serializeAuthMutation } from '../shared/authMutation';
|
||||
import { LoginThrottle } from '../shared/loginThrottle';
|
||||
import UserManagementService from './userManagement';
|
||||
|
||||
@Service()
|
||||
export default class UserService {
|
||||
private loginThrottle = new LoginThrottle();
|
||||
@Inject((type) => UserManagementService)
|
||||
private userManagementService!: UserManagementService;
|
||||
@Inject((type) => NotificationService)
|
||||
private notificationService!: NotificationService;
|
||||
|
||||
@@ -106,11 +111,8 @@ export default class UserService {
|
||||
return { code: 403, message: t('该 IP 已被列入黑名单') };
|
||||
}
|
||||
|
||||
const retriesTime = Math.pow(3, retries) * 1000;
|
||||
if (retries > 2 && timestamp - lastlogon < retriesTime) {
|
||||
const waitTime = Math.ceil(
|
||||
(retriesTime - (timestamp - lastlogon)) / 1000,
|
||||
);
|
||||
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
|
||||
if (waitTime > 0) {
|
||||
return {
|
||||
code: 410,
|
||||
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||
@@ -118,6 +120,24 @@ export default class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
if (username !== cUsername) {
|
||||
const account = await this.userManagementService.authenticate(
|
||||
username,
|
||||
password,
|
||||
);
|
||||
if (account) {
|
||||
this.loginThrottle.reset(ip);
|
||||
const token = await this.userManagementService.issueToken(
|
||||
account,
|
||||
req.platform,
|
||||
);
|
||||
return {
|
||||
code: 200,
|
||||
data: { token, username: account.username, role: account.role },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMatches =
|
||||
username === cUsername && (await verifyPassword(password, cPassword));
|
||||
|
||||
@@ -133,6 +153,7 @@ export default class UserService {
|
||||
}
|
||||
|
||||
if (passwordMatches) {
|
||||
this.loginThrottle.reset(ip);
|
||||
const data = createRandomString(50, 100);
|
||||
const expiration = twoFactorActivated ? '60d' : '20d';
|
||||
let token = jwt.sign({ data }, config.jwt.secret, {
|
||||
@@ -206,8 +227,9 @@ export default class UserService {
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const failures = this.loginThrottle.fail(ip, timestamp);
|
||||
await this.updateAuthInfo(content, {
|
||||
retries: retries + 1,
|
||||
retries: failures,
|
||||
lastlogon: timestamp,
|
||||
lastip: ip,
|
||||
lastaddr: address,
|
||||
@@ -239,8 +261,8 @@ export default class UserService {
|
||||
},
|
||||
});
|
||||
this.getLoginLog();
|
||||
if (retries > 2) {
|
||||
const waitTime = Math.round(Math.pow(3, retries + 1));
|
||||
if (failures > 3) {
|
||||
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
|
||||
return {
|
||||
code: 410,
|
||||
message: tf('失败次数过多,请%s秒后重试', waitTime),
|
||||
@@ -439,8 +461,8 @@ export default class UserService {
|
||||
const authInfo = await this.getAuthInfo();
|
||||
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
|
||||
const now = Date.now();
|
||||
const retries = authInfo.retries || 0;
|
||||
if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) {
|
||||
const source = getClientIp(req);
|
||||
if (this.loginThrottle.retryAfter(source, now) > 0) {
|
||||
return { code: 410, message: t('失败次数过多,请稍后重试') };
|
||||
}
|
||||
if (
|
||||
@@ -472,9 +494,8 @@ export default class UserService {
|
||||
.join(' ');
|
||||
}
|
||||
await this.updateAuthInfo(authInfo, {
|
||||
retries: retries + 1,
|
||||
retries: this.loginThrottle.fail(source, now),
|
||||
lastlogon: now,
|
||||
isTwoFactorChecking: retries + 1 < 5,
|
||||
lastip: ip,
|
||||
lastaddr: address,
|
||||
platform: req.platform,
|
||||
@@ -690,5 +711,8 @@ export default class UserService {
|
||||
});
|
||||
}
|
||||
await this.updateAuthInfo(authInfo, payload);
|
||||
if (retries === 0 || password !== undefined || username !== undefined) {
|
||||
this.loginThrottle.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import { User, UserModel, UserStatus } from '../data/user';
|
||||
import { Op } from 'sequelize';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from '../config';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
isPasswordHash,
|
||||
} from '../shared/password';
|
||||
import { serializeAuthMutation } from '../shared/authMutation';
|
||||
import { shareStore } from '../shared/store';
|
||||
|
||||
type PublicUser = Omit<User, 'password' | 'sessions'>;
|
||||
function publicUser(user: User): PublicUser {
|
||||
const { password, sessions, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
function digest(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
@Service()
|
||||
export default class UserManagementService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
public async list(searchText?: string): Promise<PublicUser[]> {
|
||||
const where = searchText
|
||||
? { username: { [Op.like]: `%${searchText}%` } }
|
||||
: {};
|
||||
return (await UserModel.findAll({ where })).map((x) =>
|
||||
publicUser(x.get({ plain: true })),
|
||||
);
|
||||
}
|
||||
public async get(id: number): Promise<PublicUser> {
|
||||
const doc = await UserModel.findByPk(id);
|
||||
if (!doc) throw Object.assign(new Error('用户不存在'), { status: 404 });
|
||||
return publicUser(doc.get({ plain: true }));
|
||||
}
|
||||
public async getByUsername(username: string): Promise<User | null> {
|
||||
return (
|
||||
(await UserModel.findOne({ where: { username } }))?.get({
|
||||
plain: true,
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async create(payload: User): Promise<PublicUser> {
|
||||
const auth = await shareStore.getAuthInfo();
|
||||
if (
|
||||
auth?.username === payload.username ||
|
||||
(await this.getByUsername(payload.username))
|
||||
)
|
||||
throw Object.assign(new Error('用户名已存在'), { status: 400 });
|
||||
if (!payload.password || payload.password.length < 6)
|
||||
throw Object.assign(new Error('密码长度至少为6位'), { status: 400 });
|
||||
const user = new User({
|
||||
...payload,
|
||||
password: await hashPassword(payload.password),
|
||||
sessions: [],
|
||||
});
|
||||
return publicUser((await UserModel.create(user)).get({ plain: true }));
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async update(
|
||||
payload: Partial<User> & { id: number },
|
||||
): Promise<PublicUser> {
|
||||
const existing = await this.get(payload.id);
|
||||
if (payload.username && payload.username !== existing.username) {
|
||||
const auth = await shareStore.getAuthInfo();
|
||||
if (
|
||||
auth?.username === payload.username ||
|
||||
(await this.getByUsername(payload.username))
|
||||
)
|
||||
throw Object.assign(new Error('用户名已存在'), { status: 400 });
|
||||
}
|
||||
const changes: Partial<User> = {};
|
||||
for (const key of ['username', 'role', 'status'] as const)
|
||||
if (payload[key] !== undefined) (changes as any)[key] = payload[key];
|
||||
if (payload.password) {
|
||||
if (payload.password.length < 6)
|
||||
throw Object.assign(new Error('密码长度至少为6位'), { status: 400 });
|
||||
changes.password = await hashPassword(payload.password);
|
||||
}
|
||||
if (Object.keys(changes).length) changes.sessions = [];
|
||||
await UserModel.update(changes, { where: { id: payload.id } });
|
||||
return this.get(payload.id);
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async delete(ids: number[]): Promise<number> {
|
||||
return UserModel.destroy({ where: { id: ids } });
|
||||
}
|
||||
public async authenticate(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<User | null> {
|
||||
const user = await this.getByUsername(username);
|
||||
if (
|
||||
!user ||
|
||||
user.status !== UserStatus.active ||
|
||||
!isPasswordHash(user.password)
|
||||
)
|
||||
return null;
|
||||
return (await verifyPassword(password, user.password)) ? user : null;
|
||||
}
|
||||
// Caller holds the shared account mutation queue during authentication and issuance.
|
||||
public async issueToken(user: User, platform: string): Promise<string> {
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, nonce: randomBytes(32).toString('hex') },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.expiresIn || '20d', algorithm: 'HS384' },
|
||||
);
|
||||
const sessions = [
|
||||
...(user.sessions || []).filter((x) => x.platform !== platform),
|
||||
{ digest: digest(token), platform },
|
||||
].slice(-10);
|
||||
await UserModel.update({ sessions }, { where: { id: user.id } });
|
||||
return token;
|
||||
}
|
||||
public async validateToken(
|
||||
token: string,
|
||||
platform: string,
|
||||
): Promise<PublicUser | null> {
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwt.secret, {
|
||||
algorithms: ['HS384'],
|
||||
});
|
||||
if (
|
||||
typeof payload === 'string' ||
|
||||
!Number.isSafeInteger(payload.userId) ||
|
||||
payload.userId <= 0
|
||||
)
|
||||
return null;
|
||||
const user = (await UserModel.findByPk(payload.userId))?.get({
|
||||
plain: true,
|
||||
});
|
||||
if (
|
||||
!user ||
|
||||
user.status !== UserStatus.active ||
|
||||
!user.sessions?.some(
|
||||
(x) => x.digest === digest(token) && x.platform === platform,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return publicUser(user);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async revokeToken(id: number, token: string): Promise<void> {
|
||||
const user = await UserModel.findByPk(id);
|
||||
if (!user) return;
|
||||
await user.update({
|
||||
sessions: (user.sessions || []).filter((x) => x.digest !== digest(token)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Match command text literally: looking up a task must never execute its input.
|
||||
export function findCronId(crontab: string, command: string): string {
|
||||
if (!command) return '';
|
||||
for (const line of crontab.split('\n')) {
|
||||
const id = /\bID=(\d+)\s/.exec(line);
|
||||
if (id && line.includes(command, id.index + id[0].length)) {
|
||||
return id[1];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { shareStore } from './store';
|
||||
const messages: Record<string, Record<string, string>> = {
|
||||
zh: {},
|
||||
en: {
|
||||
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
|
||||
'暂无权限': 'Access denied',
|
||||
'参数错误': 'Invalid parameter',
|
||||
'参数不正确': 'Invalid parameter',
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface IDependencyFn<T> {
|
||||
export interface ICronFn<T> {
|
||||
(): Promise<T>;
|
||||
cron?: TCron;
|
||||
isCurrent?: () => boolean;
|
||||
}
|
||||
|
||||
export interface ISchedule {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
|
||||
/** Check stored paths too: old database rows are not trusted input. */
|
||||
export function resolveLogPath(root: string, value: string): string {
|
||||
const target = resolveFileAccess(root, [value]);
|
||||
if (!target)
|
||||
throw Object.assign(new Error('Log path is outside the log directory'), {
|
||||
status: 400,
|
||||
});
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
const RETRY_TTL = 15 * 60 * 1000;
|
||||
const MAX_SOURCES = 10000;
|
||||
const MAX_RETRY_SECONDS = 5 * 60;
|
||||
|
||||
/** Source-specific, bounded backoff; persisted account history is not a lock. */
|
||||
export class LoginThrottle {
|
||||
private sources = new Map<
|
||||
string,
|
||||
{ failures: number; lastFailure: number }
|
||||
>();
|
||||
|
||||
private get(source: string, now: number) {
|
||||
const state = this.sources.get(source);
|
||||
if (state && now - state.lastFailure >= RETRY_TTL) {
|
||||
this.sources.delete(source);
|
||||
return undefined;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
retryAfter(source: string, now: number): number {
|
||||
const state = this.get(source, now);
|
||||
if (!state || state.failures < 3) return 0;
|
||||
const seconds = Math.min(3 ** state.failures, MAX_RETRY_SECONDS);
|
||||
return Math.max(
|
||||
0,
|
||||
Math.ceil((seconds * 1000 - (now - state.lastFailure)) / 1000),
|
||||
);
|
||||
}
|
||||
|
||||
fail(source: string, now: number): number {
|
||||
const failures = Math.min((this.get(source, now)?.failures || 0) + 1, 6);
|
||||
this.sources.delete(source);
|
||||
if (this.sources.size >= MAX_SOURCES) {
|
||||
this.sources.delete(this.sources.keys().next().value!);
|
||||
}
|
||||
this.sources.set(source, { failures, lastFailure: now });
|
||||
return failures;
|
||||
}
|
||||
|
||||
reset(source: string) {
|
||||
this.sources.delete(source);
|
||||
}
|
||||
clear() {
|
||||
this.sources.clear();
|
||||
}
|
||||
}
|
||||
+16
-9
@@ -50,11 +50,9 @@ class TaskLimit {
|
||||
Buffer.from(tlsConfig.clientCert),
|
||||
)
|
||||
: credentials.createInsecure();
|
||||
this._client = new ApiClient(
|
||||
`localhost:${config.grpcPort}`,
|
||||
creds,
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
this._client = new ApiClient(`localhost:${config.grpcPort}`, creds, {
|
||||
'grpc.enable_http_proxy': 0,
|
||||
});
|
||||
}
|
||||
return this._client;
|
||||
}
|
||||
@@ -113,12 +111,15 @@ class TaskLimit {
|
||||
}
|
||||
}
|
||||
|
||||
public removeQueuedCron(id: string) {
|
||||
public removeQueuedCron(id: string, completed?: ICronFn<any>) {
|
||||
if (this.queuedCrons.has(id)) {
|
||||
const runs = this.queuedCrons.get(id);
|
||||
if (runs && runs.length > 0) {
|
||||
runs.pop();
|
||||
this.queuedCrons.set(id, runs);
|
||||
const remaining = completed
|
||||
? runs.filter((run) => run !== completed)
|
||||
: runs.slice(0, -1);
|
||||
if (remaining.length) this.queuedCrons.set(id, remaining);
|
||||
else this.queuedCrons.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,9 +144,15 @@ class TaskLimit {
|
||||
cron: TCron,
|
||||
fn: ICronFn<T>,
|
||||
options?: Partial<QueueAddOptions>,
|
||||
isCurrent: () => boolean = () => true,
|
||||
): Promise<T | void> {
|
||||
if (!isCurrent()) return;
|
||||
fn.cron = cron;
|
||||
let runs = this.queuedCrons.get(cron.id);
|
||||
fn.isCurrent = isCurrent;
|
||||
// Invalidated snapshots must not consume the new revision's repeat budget.
|
||||
const runs = this.queuedCrons
|
||||
.get(cron.id)
|
||||
?.filter((run) => run.isCurrent?.() !== false);
|
||||
const result = runs?.length ? [...runs, fn] : [fn];
|
||||
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
|
||||
if (result?.length > 5) {
|
||||
|
||||
+15
-4
@@ -8,15 +8,23 @@ import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||
import dayjs from 'dayjs';
|
||||
import { observeChildProcess, asError } from './childProcess';
|
||||
|
||||
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
return taskLimit.runWithCronLimit(cron, async () => {
|
||||
export function runCron(
|
||||
cmd: string,
|
||||
cron: ICron,
|
||||
isCurrent: () => boolean = () => true,
|
||||
): Promise<number | void> {
|
||||
const execute = async () => {
|
||||
try {
|
||||
if (!isCurrent()) return;
|
||||
// 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) },
|
||||
});
|
||||
|
||||
// A mutation may commit while this queued run is reading the database.
|
||||
if (!isCurrent() || !existingCron || existingCron.isDisabled) return;
|
||||
|
||||
// Default to single instance mode (0) for backward compatibility
|
||||
const allowSingleInstances =
|
||||
existingCron?.allow_multiple_instances === 0;
|
||||
@@ -56,6 +64,8 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// In particular, invalidation can happen while stopping a previous run.
|
||||
if (!isCurrent()) return;
|
||||
Logger.info(
|
||||
`[schedule][开始执行任务] 参数 ${JSON.stringify({
|
||||
...cron,
|
||||
@@ -94,7 +104,8 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
asError(error).message,
|
||||
);
|
||||
} finally {
|
||||
taskLimit.removeQueuedCron(cron.id);
|
||||
taskLimit.removeQueuedCron(cron.id, execute);
|
||||
}
|
||||
});
|
||||
};
|
||||
return taskLimit.runWithCronLimit(cron, execute, undefined, isCurrent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import path from 'path';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
import { brotliCompress, brotliDecompress, constants } from 'zlib';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
import { getUniqueLockPath } from './utils';
|
||||
|
||||
const compress = promisify(brotliCompress);
|
||||
const decompress = promisify(brotliDecompress);
|
||||
const hash = (content: string) =>
|
||||
createHash('sha256').update(content).digest('hex');
|
||||
|
||||
export const SCRIPT_HISTORY_LIMITS = {
|
||||
versions: 20,
|
||||
fileBytes: 1024 * 1024,
|
||||
unpackedBytes: 8 * 1024 * 1024,
|
||||
archiveBytes: 2 * 1024 * 1024,
|
||||
totalBytes: 32 * 1024 * 1024,
|
||||
files: 1000,
|
||||
};
|
||||
|
||||
type Source = 'baseline' | 'save' | 'restore' | 'external';
|
||||
interface Version {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
source: Source;
|
||||
hash: string;
|
||||
content: string;
|
||||
}
|
||||
interface Archive {
|
||||
format: 1;
|
||||
path: string;
|
||||
versions: Version[];
|
||||
head?: { hash: string; createdAt: string; source: Source };
|
||||
}
|
||||
|
||||
export class ScriptHistoryError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class HistoryUnavailableError extends ScriptHistoryError {
|
||||
constructor(public currentHash: string) {
|
||||
super(413, '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本');
|
||||
}
|
||||
}
|
||||
interface SaveOptions {
|
||||
skipHistory?: boolean;
|
||||
expectedHash?: string;
|
||||
}
|
||||
|
||||
/** Lazy, bounded storage: no watcher, database, daemon, or in-memory cache.
|
||||
* Full snapshots share a Brotli window, so similar revisions compress together
|
||||
* without a patch chain. All mutations use the existing script lock and a
|
||||
* cross-process archive lock. History is durable before overwriting the script. */
|
||||
export class ScriptHistory {
|
||||
constructor(
|
||||
private scriptRoot: string,
|
||||
private historyRoot: string,
|
||||
private blacklist: string[] = [],
|
||||
private limits = SCRIPT_HISTORY_LIMITS,
|
||||
) {}
|
||||
|
||||
private async target(directory: string, filename: string) {
|
||||
const candidate = resolveFileAccess(
|
||||
this.scriptRoot,
|
||||
[directory, filename],
|
||||
this.blacklist,
|
||||
);
|
||||
if (!candidate) throw new ScriptHistoryError(403, '暂无权限');
|
||||
try {
|
||||
const file = await fs.realpath(candidate);
|
||||
const root = await fs.realpath(this.scriptRoot);
|
||||
const relative = path.relative(root, file);
|
||||
const key = hash(relative);
|
||||
return {
|
||||
file,
|
||||
relative,
|
||||
archive: path.join(this.historyRoot, key + '.br'),
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT')
|
||||
throw new ScriptHistoryError(404, '脚本不存在');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async readText(file: string) {
|
||||
const handle = await fs.open(file, 'r');
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) throw new ScriptHistoryError(400, '请选择脚本文件');
|
||||
if (stat.size > this.limits.fileBytes)
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
// Bounded read even if an external process grows the file after stat.
|
||||
const bytes = Buffer.alloc(this.limits.fileBytes + 1);
|
||||
let size = 0;
|
||||
while (size < bytes.length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
size,
|
||||
bytes.length - size,
|
||||
null,
|
||||
);
|
||||
if (!bytesRead) break;
|
||||
size += bytesRead;
|
||||
}
|
||||
const buffer = bytes.subarray(0, size);
|
||||
const content = buffer.toString('utf8');
|
||||
if (
|
||||
size > this.limits.fileBytes ||
|
||||
content.includes('\0') ||
|
||||
!Buffer.from(content).equals(buffer)
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async readArchive(file: string, relative: string): Promise<Archive> {
|
||||
try {
|
||||
const stat = await fs.lstat(file);
|
||||
if (!stat.isFile() || stat.size > this.limits.archiveBytes)
|
||||
throw new Error('Invalid archive');
|
||||
const bytes = await decompress(await fs.readFile(file), {
|
||||
maxOutputLength: this.limits.unpackedBytes,
|
||||
});
|
||||
const archive: Archive = JSON.parse(bytes.toString('utf8'));
|
||||
if (
|
||||
archive.format !== 1 ||
|
||||
archive.path !== relative ||
|
||||
!Array.isArray(archive.versions) ||
|
||||
archive.versions.length > this.limits.versions ||
|
||||
archive.versions.some(
|
||||
(v) =>
|
||||
typeof v.content !== 'string' ||
|
||||
typeof v.id !== 'string' ||
|
||||
typeof v.createdAt !== 'string' ||
|
||||
!['baseline', 'save', 'restore', 'external'].includes(v.source) ||
|
||||
Buffer.byteLength(v.content) > this.limits.fileBytes ||
|
||||
hash(v.content) !== v.hash,
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid archive');
|
||||
}
|
||||
return archive;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT')
|
||||
return { format: 1, path: relative, versions: [] };
|
||||
throw new ScriptHistoryError(500, '历史版本读取失败,请检查历史文件');
|
||||
}
|
||||
}
|
||||
|
||||
async list(directory: string, filename: string) {
|
||||
const target = await this.target(directory, filename);
|
||||
const [current, archive] = await Promise.all([
|
||||
this.readText(target.file),
|
||||
this.readArchive(target.archive, target.relative),
|
||||
]);
|
||||
const currentHash = hash(current);
|
||||
const versions = archive.versions
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(({ content, ...v }) => ({
|
||||
...v,
|
||||
size: Buffer.byteLength(content),
|
||||
identical: v.hash === currentHash,
|
||||
}));
|
||||
return { versions, currentHash, limit: this.limits.versions };
|
||||
}
|
||||
|
||||
async detail(directory: string, filename: string, id: string) {
|
||||
const target = await this.target(directory, filename);
|
||||
const [current, archive] = await Promise.all([
|
||||
this.readText(target.file),
|
||||
this.readArchive(target.archive, target.relative),
|
||||
]);
|
||||
const version = archive.versions.find((v) => v.id === id);
|
||||
if (!version) throw new ScriptHistoryError(404, '历史版本不存在或已清理');
|
||||
return { version, current, currentHash: hash(current) };
|
||||
}
|
||||
|
||||
async save(
|
||||
directory: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
options: SaveOptions = {},
|
||||
) {
|
||||
return this.mutate(directory, filename, { content, ...options });
|
||||
}
|
||||
|
||||
async restore(
|
||||
directory: string,
|
||||
filename: string,
|
||||
id: string,
|
||||
expectedHash: string,
|
||||
) {
|
||||
return this.mutate(directory, filename, { id, expectedHash });
|
||||
}
|
||||
|
||||
private async mutate(
|
||||
directory: string,
|
||||
filename: string,
|
||||
change:
|
||||
| ({ content: string } & SaveOptions)
|
||||
| { id: string; expectedHash: string },
|
||||
) {
|
||||
const target = await this.target(directory, filename);
|
||||
const releaseFile = await lock(target.file, {
|
||||
lockfilePath: getUniqueLockPath(target.file),
|
||||
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||
});
|
||||
try {
|
||||
let current: string;
|
||||
try {
|
||||
current = await this.readText(target.file);
|
||||
if (
|
||||
'content' in change &&
|
||||
(Buffer.byteLength(change.content) > this.limits.fileBytes ||
|
||||
change.content.includes('\0'))
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!('content' in change) ||
|
||||
!(error instanceof ScriptHistoryError) ||
|
||||
error.status !== 413
|
||||
)
|
||||
throw error;
|
||||
// Hash oversized or non-UTF-8 files without reading them all into memory.
|
||||
const digest = createHash('sha256');
|
||||
for await (const chunk of createReadStream(target.file))
|
||||
digest.update(chunk);
|
||||
const currentHash = digest.digest('hex');
|
||||
if (!change.skipHistory) throw new HistoryUnavailableError(currentHash);
|
||||
if (change.expectedHash !== currentHash)
|
||||
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
|
||||
await this.preparedWrite(target.file, change.content, (commit) =>
|
||||
commit(),
|
||||
);
|
||||
return {
|
||||
content: change.content,
|
||||
changed: true,
|
||||
historyRecorded: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
('id' in change || change.skipHistory) &&
|
||||
hash(current) !== change.expectedHash
|
||||
) {
|
||||
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
|
||||
}
|
||||
if ('content' in change && change.content === current)
|
||||
return { content: current, changed: false };
|
||||
await fs.mkdir(this.historyRoot, { recursive: true, mode: 0o700 });
|
||||
const releaseHistory = await lock(this.historyRoot, {
|
||||
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||
});
|
||||
try {
|
||||
const archive = await this.readArchive(target.archive, target.relative);
|
||||
const restored =
|
||||
'id' in change
|
||||
? archive.versions.find((v) => v.id === change.id)
|
||||
: undefined;
|
||||
if ('id' in change && !restored)
|
||||
throw new ScriptHistoryError(404, '历史版本不存在或已清理');
|
||||
const content =
|
||||
'content' in change ? change.content : restored!.content;
|
||||
if (content === current) return { content, changed: false };
|
||||
if (
|
||||
Buffer.byteLength(content) > this.limits.fileBytes ||
|
||||
content.includes('\0')
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
const currentHash = hash(current);
|
||||
if (
|
||||
archive.versions[archive.versions.length - 1]?.hash !== currentHash
|
||||
) {
|
||||
const head =
|
||||
archive.head?.hash === currentHash ? archive.head : undefined;
|
||||
archive.versions.push({
|
||||
id: randomUUID(),
|
||||
createdAt: head?.createdAt || new Date().toISOString(),
|
||||
source:
|
||||
head?.source ||
|
||||
(archive.versions.length ? 'external' : 'baseline'),
|
||||
hash: currentHash,
|
||||
content: current,
|
||||
});
|
||||
}
|
||||
// Only prior contents are stored. Head metadata labels the next snapshot,
|
||||
// but is used only if its hash matches the file. A failed file write can
|
||||
// never introduce a phantom content version or lose the old snapshot.
|
||||
archive.head = {
|
||||
hash: hash(content),
|
||||
createdAt: new Date().toISOString(),
|
||||
source: 'id' in change ? 'restore' : 'save',
|
||||
};
|
||||
const packed = await this.pack(archive);
|
||||
const cleanupPending = await this.preparedWrite(
|
||||
target.file,
|
||||
content,
|
||||
(commit) => this.store(target.archive, packed, commit),
|
||||
);
|
||||
return {
|
||||
content,
|
||||
changed: true,
|
||||
historyRecorded: true,
|
||||
cleanupPending,
|
||||
};
|
||||
} finally {
|
||||
await releaseHistory();
|
||||
}
|
||||
} finally {
|
||||
await releaseFile();
|
||||
}
|
||||
}
|
||||
|
||||
private async pack(archive: Archive) {
|
||||
archive.versions = archive.versions.slice(-this.limits.versions);
|
||||
while (true) {
|
||||
const serialized = Buffer.from(JSON.stringify(archive));
|
||||
if (serialized.length <= this.limits.unpackedBytes) {
|
||||
const compressed = await compress(serialized, {
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: 4,
|
||||
[constants.BROTLI_PARAM_LGWIN]: 22,
|
||||
},
|
||||
});
|
||||
if (compressed.length <= this.limits.archiveBytes) return compressed;
|
||||
}
|
||||
if (archive.versions.length <= 1)
|
||||
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
|
||||
archive.versions.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage the script before publishing history. A failed write/fsync never
|
||||
* truncates the original. Preserve the existing file's ownership and mode. */
|
||||
private async preparedWrite<T>(
|
||||
file: string,
|
||||
content: string,
|
||||
run: (commit: () => Promise<void>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const stat = await fs.stat(file);
|
||||
const temporary = path.join(
|
||||
path.dirname(file),
|
||||
`.ql-save-${randomUUID()}.tmp`,
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(temporary, content, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
if (
|
||||
typeof process.getuid === 'function' &&
|
||||
typeof process.getgid === 'function' &&
|
||||
(stat.uid !== process.getuid() || stat.gid !== process.getgid())
|
||||
) {
|
||||
await fs.chown(temporary, stat.uid, stat.gid);
|
||||
}
|
||||
await fs.chmod(temporary, stat.mode & 0o7777);
|
||||
const handle = await fs.open(temporary, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return await run(() => fs.rename(temporary, file));
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async store(
|
||||
file: string,
|
||||
content: Buffer,
|
||||
commit: () => Promise<void>,
|
||||
) {
|
||||
const entries = [];
|
||||
for (const name of await fs.readdir(this.historyRoot)) {
|
||||
if (!/^[a-f0-9]{64}\.br$/.test(name)) continue;
|
||||
const fullPath = path.join(this.historyRoot, name);
|
||||
if (fullPath === file) continue;
|
||||
const stat = await fs.lstat(fullPath);
|
||||
entries.push({ file: fullPath, size: stat.size, time: stat.mtimeMs });
|
||||
}
|
||||
entries.sort((a, b) => a.time - b.time);
|
||||
let total = entries.reduce((sum, v) => sum + v.size, content.length);
|
||||
const victims: string[] = [];
|
||||
while (
|
||||
entries.length &&
|
||||
(total > this.limits.totalBytes || entries.length >= this.limits.files)
|
||||
) {
|
||||
const oldest = entries.shift()!;
|
||||
victims.push(oldest.file);
|
||||
total -= oldest.size;
|
||||
}
|
||||
if (total > this.limits.totalBytes)
|
||||
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
|
||||
const previous = await fs.readFile(file).catch((error) => {
|
||||
if (error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
const temporary = path.join(this.historyRoot, '.pending.tmp');
|
||||
const publish = async (bytes: Buffer) => {
|
||||
await fs.unlink(temporary).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
||||
const handle = await fs.open(temporary, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fs.rename(temporary, file);
|
||||
};
|
||||
try {
|
||||
await publish(content);
|
||||
try {
|
||||
await commit();
|
||||
} catch (error) {
|
||||
// The original script is still intact. Restore its exact history before
|
||||
// returning an error; no unrelated archive has been removed yet.
|
||||
if (previous) await publish(previous);
|
||||
else await fs.unlink(file);
|
||||
throw error;
|
||||
}
|
||||
// Quota eviction is post-commit maintenance. Report cleanup failures as a
|
||||
// warning, not a failed save (which would encourage a misleading retry).
|
||||
let cleanupPending = false;
|
||||
for (const victim of victims) {
|
||||
try {
|
||||
await fs.unlink(victim);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') cleanupPending = true;
|
||||
}
|
||||
}
|
||||
return cleanupPending;
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Quote one literal POSIX shell argument, including quotes and substitutions. */
|
||||
export function shellQuote(value: string): string {
|
||||
if (value.includes('\0')) throw new Error('Invalid shell argument');
|
||||
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import lockfile from 'proper-lockfile';
|
||||
import path from 'path';
|
||||
import config from '../config';
|
||||
|
||||
// Protect subscription storage ownership from the first database read through
|
||||
// cleanup. Creation and URL/branch updates must use the same cross-process lock.
|
||||
export async function withSubscriptionMutation<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const target = `${path.resolve(config.scriptPath)}.subscriptions`;
|
||||
let release: () => Promise<void>;
|
||||
try {
|
||||
release = await lockfile.lock(target, {
|
||||
realpath: false,
|
||||
stale: 30000,
|
||||
update: 10000,
|
||||
retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
||||
});
|
||||
} catch (cause) {
|
||||
throw Object.assign(
|
||||
new Error('Subscription configuration is busy', { cause }),
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export function assertSubscriptionAlias(alias: string): void {
|
||||
if (
|
||||
typeof alias !== 'string' ||
|
||||
alias.length > 200 ||
|
||||
!/^[^\s\\\p{C}]+$/u.test(alias) ||
|
||||
alias.startsWith('~') ||
|
||||
alias.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
) {
|
||||
throw Object.assign(new Error('Invalid subscription alias'), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep ordinary SSH names and use a reserved namespace for path-like aliases.
|
||||
* The database alias and its script/log directories remain unchanged. */
|
||||
export function getSubscriptionSshAlias(alias: string): string {
|
||||
assertSubscriptionAlias(alias);
|
||||
if (/^[\p{L}\p{N}_.+-]+$/u.test(alias) && !alias.endsWith('.config')) {
|
||||
return alias;
|
||||
}
|
||||
return `~sub_${createHash('sha256').update(alias).digest('hex')}`;
|
||||
}
|
||||
|
||||
export function resolveSubscriptionPath(
|
||||
root: string,
|
||||
alias: string,
|
||||
suffix = '',
|
||||
): string {
|
||||
// Internal SSH filenames cannot collide with user-supplied aliases.
|
||||
if (!/^~sub_[a-f0-9]{64}$/.test(alias)) {
|
||||
assertSubscriptionAlias(
|
||||
alias.startsWith('~global_') ? alias.slice(8) : alias,
|
||||
);
|
||||
}
|
||||
const target = resolveFileAccess(root, [alias + suffix]);
|
||||
if (!target)
|
||||
throw Object.assign(new Error('Invalid subscription path'), {
|
||||
status: 400,
|
||||
});
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Subscription } from '../data/subscription';
|
||||
import { formatUrl } from '../config/subscription';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
|
||||
// Storage namespaces come from URLs and branches, not user aliases. In
|
||||
// particular, a legal Git directory can exceed the alias's 200-character limit.
|
||||
export function isSubscriptionStorageName(name: string): boolean {
|
||||
return (
|
||||
typeof name === 'string' &&
|
||||
!!name &&
|
||||
!path.isAbsolute(name) &&
|
||||
!/[\0\r\n\\]/.test(name) &&
|
||||
!name.split('/').some((part) => !part || part === '.' || part === '..')
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSubscriptionStoragePath(
|
||||
root: string,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const target = isSubscriptionStorageName(name)
|
||||
? resolveFileAccess(root, [name])
|
||||
: '';
|
||||
if (!target) {
|
||||
if (isSubscriptionStorageName(name)) {
|
||||
try {
|
||||
fs.lstatSync(path.resolve(root, name));
|
||||
} catch (error: any) {
|
||||
// A long query can make the Shell candidate unrepresentable while
|
||||
// the CLI's pathname-based file exists. Use the actual filesystem
|
||||
// limit: macOS permits Unicode names exceeding 255 UTF-8 bytes.
|
||||
if (error.code === 'ENAMETOOLONG') return undefined;
|
||||
}
|
||||
}
|
||||
throw Object.assign(new Error('Invalid subscription path'), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Match shell/update.sh get_uniq_path and update_raw, including SCP URLs. */
|
||||
export function getSubscriptionStorageNames(
|
||||
doc: Subscription,
|
||||
engine: 'shell' | 'cli' = 'shell',
|
||||
): {
|
||||
script: string;
|
||||
repo?: string;
|
||||
raw?: string;
|
||||
} {
|
||||
// Old imported rows may only have an alias and no URL.
|
||||
if (!doc.url) return { script: doc.alias, repo: doc.alias };
|
||||
const url = formatUrl(doc).url!;
|
||||
const trimmed = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
const basename = trimmed.slice(trimmed.lastIndexOf('/') + 1);
|
||||
const dot = basename.lastIndexOf('.');
|
||||
const repo = dot < 0 ? basename : basename.slice(0, dot);
|
||||
const parentUrl = engine === 'cli' ? trimmed : url;
|
||||
const slash = parentUrl.lastIndexOf('/');
|
||||
const parent =
|
||||
engine === 'shell' && slash < 0 ? url : parentUrl.slice(0, slash);
|
||||
const owner = parent.slice(parent.lastIndexOf('/') + 1);
|
||||
const author = owner.slice(owner.lastIndexOf(':') + 1);
|
||||
const stem = `${author.slice(author.lastIndexOf('.') + 1)}_${repo}`;
|
||||
if (doc.type === 'file') {
|
||||
const suffix =
|
||||
engine === 'cli'
|
||||
? path.extname(new URL(url).pathname)
|
||||
: `.${url.slice(url.lastIndexOf('.') + 1)}`;
|
||||
const raw = `${stem}${suffix}`;
|
||||
return { script: `raw_${raw}`, raw };
|
||||
}
|
||||
const directory = doc.branch ? `${stem}_${doc.branch}` : stem;
|
||||
return { script: directory, repo: directory };
|
||||
}
|
||||
|
||||
// A subscription may have run through either engine before being deleted.
|
||||
export function getSubscriptionStorageCandidates(doc: Subscription) {
|
||||
const candidates: ReturnType<typeof getSubscriptionStorageNames>[] = [];
|
||||
for (const engine of ['shell', 'cli'] as const) {
|
||||
try {
|
||||
candidates.push(getSubscriptionStorageNames(doc, engine));
|
||||
} catch {
|
||||
// Historical rows may have an invalid SSH alias or missing credentials.
|
||||
// Retain their URL-derived storage without needing an executable SSH URL.
|
||||
try {
|
||||
candidates.push(
|
||||
getSubscriptionStorageNames(
|
||||
{
|
||||
url: doc.url,
|
||||
alias: doc.alias,
|
||||
branch: doc.branch,
|
||||
type: doc.type === 'private-repo' ? 'public-repo' : doc.type,
|
||||
},
|
||||
engine,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// One engine's invalid URL must not hide the other engine's candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { QueryTypes, Sequelize } from 'sequelize';
|
||||
export async function migrateUserSchema(database: Sequelize): Promise<void> {
|
||||
const columns = [
|
||||
{ table: 'Users', column: 'sessions', type: "JSON NOT NULL DEFAULT '[]'" },
|
||||
...['Envs', 'Crontabs', 'Subscriptions', 'Dependences'].map((table) => ({
|
||||
table,
|
||||
column: 'userId',
|
||||
type: 'INTEGER',
|
||||
})),
|
||||
];
|
||||
await database.transaction(async (transaction) => {
|
||||
for (const { table, column, type } of columns) {
|
||||
const fields = await database.query<{ name: string }>(
|
||||
`PRAGMA table_info("${table}")`,
|
||||
{ type: QueryTypes.SELECT, transaction },
|
||||
);
|
||||
if (!fields.length)
|
||||
throw new Error(`User migration table is missing: ${table}`);
|
||||
if (!fields.some((field) => field.name === column))
|
||||
await database.query(
|
||||
`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`,
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import { writeFile, open, chmod } from 'fs/promises';
|
||||
import { fileExist } from '../config/util';
|
||||
|
||||
function getUniqueLockPath(filePath: string) {
|
||||
export function getUniqueLockPath(filePath: string) {
|
||||
const sanitizedPath = filePath
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.replace(/^_/, '');
|
||||
|
||||
+5
-16
@@ -2,30 +2,19 @@ import 'reflect-metadata';
|
||||
import OpenService from './services/open';
|
||||
import { Container } from 'typedi';
|
||||
import LoggerInstance from './loaders/logger';
|
||||
import config from './config';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { writeFileWithLock } from './shared/utils';
|
||||
|
||||
const tokenFile = path.join(config.configPath, 'token.json');
|
||||
|
||||
async function getToken() {
|
||||
try {
|
||||
Container.set('logger', LoggerInstance);
|
||||
const openService = Container.get(OpenService);
|
||||
const appToken = await openService.generateSystemToken();
|
||||
const appToken = await openService.generateSystemToken(
|
||||
process.argv.includes('--renew'),
|
||||
);
|
||||
console.log(appToken.value);
|
||||
await writeFile({
|
||||
value: appToken.value,
|
||||
expiration: appToken.expiration,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFile(data: any) {
|
||||
await writeFileWithLock(tokenFile, `${JSON.stringify(data)}${os.EOL}`);
|
||||
}
|
||||
|
||||
getToken();
|
||||
|
||||
Vendored
+5
@@ -6,6 +6,11 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
platform: 'desktop' | 'mobile';
|
||||
user?: {
|
||||
userId?: number;
|
||||
role?: number;
|
||||
};
|
||||
auth?: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"bin": {
|
||||
"ql": "dist/npm/ql.js"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "Authenticated remote management CLI for QingLong 2.x",
|
||||
"type": "commonjs",
|
||||
"bin": {
|
||||
|
||||
@@ -13,6 +13,10 @@ execFileSync(
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
);
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
||||
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
||||
@@ -45,7 +49,9 @@ const remote = require('esbuild').buildSync({
|
||||
metafile: true,
|
||||
});
|
||||
for (const input of Object.keys(remote.metafile.inputs)) {
|
||||
if (/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input))
|
||||
if (
|
||||
/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input)
|
||||
)
|
||||
throw new Error(
|
||||
'Local implementation leaked into remote npm bundle: ' + input,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# OpenAPI 全量参考 / Complete OpenAPI reference
|
||||
|
||||
CLI 对应当前 develop 的 143 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
|
||||
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
|
||||
|
||||
The catalogue covers 143 active routes in the current develop backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
|
||||
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
|
||||
|
||||
## 认证与权限 / Authentication and permissions
|
||||
|
||||
@@ -46,6 +46,9 @@ ql env create --data @envs.json --json
|
||||
ql env update 8 --data '{"name":"EXAMPLE","value":"value"}' --json
|
||||
ql script create --file ./demo.js --data '{"filename":"demo.js","path":""}' --json
|
||||
ql script get --query '{"file":"demo.js","path":""}' --json
|
||||
ql script history-list --query '{"filename":"demo.js","path":""}' --json
|
||||
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
|
||||
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
|
||||
ql config save --data '{"name":"example.sh","content":"# example"}' --json
|
||||
ql config get --query '{"path":"example.sh"}' --json
|
||||
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
|
||||
@@ -133,6 +136,9 @@ System/user operations below execute remotely through the panel API. They do not
|
||||
| `ql config save ` | POST | `configs/save` | --data, --query |
|
||||
| `ql script list ` | GET | `scripts` | --query |
|
||||
| `ql script get ` | GET | `scripts/detail` | --query |
|
||||
| `ql script history-list ` | GET | `scripts/history` | --query |
|
||||
| `ql script history-detail ` | GET | `scripts/history/detail` | --query |
|
||||
| `ql script history-restore ` | PUT | `scripts/history/restore` | --data, --query |
|
||||
| `ql script create ` | POST | `scripts` | --data, --file (file), --query |
|
||||
| `ql script update ` | PUT | `scripts` | --data, --file (file), --query |
|
||||
| `ql script delete ` | DELETE | `scripts` | --data, --query |
|
||||
|
||||
@@ -70,10 +70,26 @@ if [ -n "$__ql_timeout" ]; then
|
||||
fi
|
||||
if [ "$__ql_command" = true ]; then
|
||||
"\${__ql_args[@]}" "\${__ql_script_args[@]}"
|
||||
_task_exit_code=$?
|
||||
else
|
||||
. "\${__ql_args[0]}" "\${__ql_script_args[@]}"
|
||||
# Match task.sh: keep script exit/exec/options inside a child shell while
|
||||
# retaining before-hook variables and functions for the script.
|
||||
# Keep the supervisor alive until the child's signal/EXIT traps finish, but
|
||||
# do not turn user cancellation into an ordinary completion with after hooks.
|
||||
__ql_saved_signals=$(trap -p INT TERM HUP QUIT ALRM TSTP)
|
||||
__ql_cancel_code=''
|
||||
trap '__ql_cancel_code=$((128 + $(kill -l INT)))' INT
|
||||
trap '__ql_cancel_code=$((128 + $(kill -l TERM)))' TERM
|
||||
trap '__ql_cancel_code=$((128 + $(kill -l HUP)))' HUP
|
||||
trap '__ql_cancel_code=$((128 + $(kill -l QUIT)))' QUIT
|
||||
trap '__ql_cancel_code=$((128 + $(kill -l ALRM)))' ALRM
|
||||
trap '__ql_cancel_code=$((128 + $(kill -l TSTP)))' TSTP
|
||||
( . "\${__ql_args[0]}" "\${__ql_script_args[@]}" )
|
||||
_task_exit_code=$?
|
||||
trap - INT TERM HUP QUIT ALRM TSTP
|
||||
eval "$__ql_saved_signals"
|
||||
if [ -n "$__ql_cancel_code" ] && ! [ -f "$__ql_timeout" ]; then exit "$__ql_cancel_code"; fi
|
||||
fi
|
||||
_task_exit_code=$?
|
||||
if [ -n "$__ql_timeout" ] && [ -f "$__ql_timeout" ]; then _task_exit_code=124; fi
|
||||
__ql_run_after
|
||||
exit "$_task_exit_code"
|
||||
|
||||
@@ -8,12 +8,33 @@ import { checkedProcess } from '../runtime/process';
|
||||
import { operationSignal, withoutCancellation } from '../runtime/cancellation';
|
||||
import { installPanelDependencies, startPanel, stopPanel } from './operator';
|
||||
|
||||
// The Bash image updater and local CLI must enforce the same build provenance.
|
||||
const artifacts = require('./upgradeArtifacts.cjs') as {
|
||||
releaseBranch(env: NodeJS.ProcessEnv): string;
|
||||
verifyUpgrade(source: string, staticRoot: string): Promise<unknown>;
|
||||
waitForHealth(
|
||||
env: NodeJS.ProcessEnv,
|
||||
timeout?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void>;
|
||||
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
|
||||
stageUpgrade(options: {
|
||||
root: string;
|
||||
tmp: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
mirror: 'github' | 'gitee';
|
||||
signal?: AbortSignal;
|
||||
run(program: string, args: string[], capture?: boolean): Promise<unknown>;
|
||||
install(source: string): Promise<void>;
|
||||
}): Promise<{ source: string; static: string }>;
|
||||
selectedUpgrade(
|
||||
tmp: string,
|
||||
branch: string,
|
||||
): Promise<{ source: string; static: string }>;
|
||||
};
|
||||
|
||||
export function releaseBranch(context: LocalContext): string {
|
||||
return ['develop', 'debian', 'debian-dev'].includes(
|
||||
context.env.QL_BRANCH ?? '',
|
||||
)
|
||||
? context.env.QL_BRANCH!
|
||||
: 'master';
|
||||
return artifacts.releaseBranch(context.env);
|
||||
}
|
||||
|
||||
async function validateTree(
|
||||
@@ -48,134 +69,27 @@ export async function stageUpgrade(
|
||||
context: LocalContext,
|
||||
mirror: 'github' | 'gitee',
|
||||
): Promise<{ source: string; static: string }> {
|
||||
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
|
||||
const workspace = await fs.mkdtemp(
|
||||
path.join(context.paths.dir_tmp!, 'upgrade-'),
|
||||
return artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
||||
artifacts.stageUpgrade({
|
||||
root: context.root,
|
||||
tmp: context.paths.dir_tmp!,
|
||||
env: context.env,
|
||||
mirror,
|
||||
signal: operationSignal(),
|
||||
run: (program, args, capture) =>
|
||||
checkedProcess(program, args, { env: context.env, capture }),
|
||||
install: (source) => installPanelDependencies(context, source),
|
||||
}),
|
||||
);
|
||||
const branch = releaseBranch(context);
|
||||
try {
|
||||
for (const repository of ['qinglong', 'qinglong-static']) {
|
||||
const url =
|
||||
mirror === 'github'
|
||||
? `https://github.com/whyour/${repository}/archive/refs/heads/${branch}.zip`
|
||||
: `https://gitee.com/whyour/${repository}/repository/archive/${branch}.zip`;
|
||||
const archive = path.join(workspace, `${repository}.zip`);
|
||||
await checkedProcess(
|
||||
'curl',
|
||||
[
|
||||
'--fail',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--output',
|
||||
archive,
|
||||
url,
|
||||
],
|
||||
{ env: context.env },
|
||||
);
|
||||
const listing = await checkedProcess('unzip', ['-Z1', archive], {
|
||||
env: context.env,
|
||||
capture: true,
|
||||
});
|
||||
const expected = `${repository}-${branch}`;
|
||||
for (const name of listing.stdout.split('\n').filter(Boolean)) {
|
||||
if (
|
||||
name.includes('\\') ||
|
||||
name.includes('\r') ||
|
||||
name.startsWith('/') ||
|
||||
name.split('/').includes('..') ||
|
||||
name.split('/')[0] !== expected
|
||||
)
|
||||
throw new Error(translate(context.env, '升级归档包含无效路径。'));
|
||||
}
|
||||
// Reject Unix symlinks before extraction, including links that could redirect
|
||||
// a later archive entry outside the staging directory.
|
||||
const modes = await checkedProcess('unzip', ['-Z', '-l', archive], {
|
||||
env: context.env,
|
||||
capture: true,
|
||||
});
|
||||
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
||||
throw new Error(translate(context.env, '升级归档包含符号链接。'));
|
||||
await checkedProcess('unzip', ['-oq', archive, '-d', workspace], {
|
||||
env: context.env,
|
||||
});
|
||||
await validateTree(path.join(workspace, expected), context.env);
|
||||
}
|
||||
const source = path.join(workspace, `qinglong-${branch}`);
|
||||
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
|
||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
||||
const manifest = await fs.readFile(path.join(source, 'package.json'));
|
||||
const existing = await fs.readFile(path.join(context.root, 'package.json'));
|
||||
if (!manifest.equals(existing))
|
||||
await installPanelDependencies(context, source);
|
||||
// Publish a marker only after both archives and dependency installation succeed.
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({ source, static: staticRoot }),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const pointer = path.join(workspace, 'pointer.json');
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ directory: path.basename(workspace) }),
|
||||
{
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
await fs.rename(
|
||||
pointer,
|
||||
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
|
||||
);
|
||||
return { source, static: staticRoot };
|
||||
} catch (error) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedUpgrade(
|
||||
context: LocalContext,
|
||||
): Promise<{ source: string; static: string }> {
|
||||
const branch = releaseBranch(context);
|
||||
const fallback = {
|
||||
source: path.join(context.paths.dir_tmp!, `qinglong-${branch}`),
|
||||
static: path.join(context.paths.dir_tmp!, `qinglong-static-${branch}`),
|
||||
};
|
||||
const pointer = await fs
|
||||
.readFile(
|
||||
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
|
||||
'utf8',
|
||||
)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (pointer === undefined) return fallback;
|
||||
const directory: unknown = JSON.parse(pointer)?.directory;
|
||||
if (
|
||||
typeof directory !== 'string' ||
|
||||
!/^upgrade-[A-Za-z0-9_-]+$/.test(directory)
|
||||
)
|
||||
throw new Error(translate(context.env, '暂存升级指针无效。'));
|
||||
const workspace = path.join(context.paths.dir_tmp!, directory);
|
||||
const selected = {
|
||||
source: path.join(workspace, `qinglong-${branch}`),
|
||||
static: path.join(workspace, `qinglong-static-${branch}`),
|
||||
};
|
||||
for (const location of [workspace, selected.source, selected.static]) {
|
||||
const stat = await fs.lstat(location);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||
throw new Error(translate(context.env, '暂存升级目录无效。'));
|
||||
}
|
||||
const ready = JSON.parse(
|
||||
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
||||
return artifacts.selectedUpgrade(
|
||||
context.paths.dir_tmp!,
|
||||
releaseBranch(context),
|
||||
);
|
||||
if (ready?.source !== selected.source || ready?.static !== selected.static)
|
||||
throw new Error(translate(context.env, '暂存升级就绪记录与目录不匹配。'));
|
||||
return selected;
|
||||
}
|
||||
|
||||
export interface ReloadLifecycle {
|
||||
@@ -345,8 +259,7 @@ export async function reloadPanel(
|
||||
const staticRoot = selected.static;
|
||||
await validateTree(source, context.env);
|
||||
await validateTree(staticRoot, context.env);
|
||||
await fs.access(path.join(source, 'package.json'));
|
||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
||||
await artifacts.verifyUpgrade(source, staticRoot);
|
||||
const entries = await fs.readdir(source);
|
||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||
const relativeData = path.relative(context.root, context.data);
|
||||
@@ -371,7 +284,16 @@ export async function reloadPanel(
|
||||
if (selection)
|
||||
replacements.push({ source: selection.source, target: selection.target });
|
||||
try {
|
||||
return await replaceAndReload(context, replacements);
|
||||
return await artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
||||
replaceAndReload(context, replacements, {
|
||||
stop: stopPanel,
|
||||
start: async (ctx) => {
|
||||
const service = await startPanel(ctx);
|
||||
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
|
||||
return service;
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
// Shared by the image's Bash updater and the optional local CLI. Use only
|
||||
// installed code to validate downloads; never execute a downloaded verifier.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { createHash, randomUUID } = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
|
||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
const releaseBranch = (env) =>
|
||||
['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master';
|
||||
|
||||
async function manifestAt(staticRoot) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
manifest.version !== 1 ||
|
||||
manifest.dirty !== false ||
|
||||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
|
||||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
|
||||
!manifest.files ||
|
||||
typeof manifest.files !== 'object' ||
|
||||
Array.isArray(manifest.files)
|
||||
)
|
||||
throw new Error('Invalid or dirty build manifest / 构建清单无效。');
|
||||
for (const required of ['build/app.js', 'dist/index.html'])
|
||||
if (!Object.hasOwn(manifest.files, required))
|
||||
throw new Error(`Missing build output / 缺少构建产物: ${required}`);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function verifyUpgrade(source, staticRoot) {
|
||||
const manifest = await manifestAt(staticRoot);
|
||||
if (
|
||||
hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !==
|
||||
manifest.lockfileSha256
|
||||
)
|
||||
throw new Error(
|
||||
'Source and build lockfiles differ / 源码与构建依赖不一致。',
|
||||
);
|
||||
await fs.access(path.join(source, 'package.json'));
|
||||
await fs.access(path.join(source, 'sample/config.sample.sh'));
|
||||
const files = {};
|
||||
async function visit(directory, prefix = '') {
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const name = prefix + entry.name;
|
||||
if (name === 'build-info.json') continue;
|
||||
const filename = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await visit(filename, `${name}/`);
|
||||
else if (entry.isFile()) files[name] = hash(await fs.readFile(filename));
|
||||
else
|
||||
throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`);
|
||||
}
|
||||
}
|
||||
await visit(staticRoot);
|
||||
const actual = Object.keys(files).sort(),
|
||||
expected = Object.keys(manifest.files).sort();
|
||||
if (
|
||||
JSON.stringify(actual) !== JSON.stringify(expected) ||
|
||||
actual.some((name) => files[name] !== manifest.files[name])
|
||||
)
|
||||
throw new Error(
|
||||
'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。',
|
||||
);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function withUpgradeLock(tmp, operation) {
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
const lock = path.join(tmp, 'upgrade.lock');
|
||||
const token = randomUUID();
|
||||
const owner = `owner-${process.pid}-${token}`;
|
||||
const candidate = path.join(tmp, `upgrade-lock-${token}`);
|
||||
let acquired = false;
|
||||
await fs.mkdir(candidate, { mode: 0o700 });
|
||||
try {
|
||||
// Publish a nonempty directory atomically: a live lock can never be
|
||||
// replaced, and a crash before publication cannot leave an ownerless lock.
|
||||
await fs.writeFile(path.join(candidate, owner), '', {
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
for (;;) {
|
||||
try {
|
||||
await fs.rename(candidate, lock);
|
||||
acquired = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(lock);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') continue;
|
||||
throw error;
|
||||
}
|
||||
if (!entries.length) continue;
|
||||
const heldOwner = entries[0];
|
||||
const match = /^owner-([1-9]\d*)-[a-f0-9-]{36}$/.exec(heldOwner);
|
||||
let pid = match ? Number(match[1]) : 0;
|
||||
if (entries.length === 1 && heldOwner === 'pid') {
|
||||
try {
|
||||
pid = Number(await fs.readFile(path.join(lock, heldOwner), 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let stale = false;
|
||||
if (
|
||||
entries.length === 1 &&
|
||||
Number.isSafeInteger(pid) &&
|
||||
pid > 0 &&
|
||||
pid <= 0x7fffffff
|
||||
) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (error) {
|
||||
if (error.code === 'ESRCH') stale = true;
|
||||
}
|
||||
}
|
||||
if (!stale)
|
||||
throw new Error(
|
||||
'Another upgrade is running / 已有更新或重载正在执行。',
|
||||
);
|
||||
// Removing the observed unique marker is a compare-and-delete. A
|
||||
// contender cannot unlink the marker belonging to a replacement lock.
|
||||
// Legacy pid files are supported; new owners never reuse that filename.
|
||||
try {
|
||||
await fs.unlink(path.join(lock, heldOwner));
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
return await operation();
|
||||
} finally {
|
||||
if (acquired) {
|
||||
await fs.unlink(path.join(lock, owner)).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
await fs.rmdir(lock).catch((error) => {
|
||||
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code))
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
await fs.rm(candidate, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) {
|
||||
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
||||
const branch = releaseBranch(env);
|
||||
try {
|
||||
async function extract(repository, ref) {
|
||||
const archive = path.join(workspace, `${repository}.zip`);
|
||||
const url =
|
||||
mirror === 'github'
|
||||
? `https://github.com/whyour/${repository}/archive/${ref}.zip`
|
||||
: `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`;
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
'--output',
|
||||
archive,
|
||||
url,
|
||||
]);
|
||||
const listing = await run('unzip', ['-Z1', archive], true);
|
||||
const expected = `${repository}-${ref}`;
|
||||
const names = listing.stdout.split('\n').filter(Boolean);
|
||||
if (
|
||||
!names.length ||
|
||||
names.some(
|
||||
(name) =>
|
||||
name.includes('\\') ||
|
||||
name.includes('\r') ||
|
||||
name.startsWith('/') ||
|
||||
name.split('/').includes('..') ||
|
||||
name.split('/')[0] !== expected,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid archive paths / 升级归档路径无效。');
|
||||
const modes = await run('unzip', ['-Z', '-l', archive], true);
|
||||
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
||||
throw new Error('Archive contains symlinks / 升级归档包含符号链接。');
|
||||
await run('unzip', ['-oq', archive, '-d', workspace]);
|
||||
return path.join(workspace, expected);
|
||||
}
|
||||
// Static branches are mutable snapshots. Their manifest pins the exact
|
||||
// source revision, avoiding a race between two independent branch reads.
|
||||
const staticRoot = await extract('qinglong-static', branch);
|
||||
const manifest = await manifestAt(staticRoot);
|
||||
const downloadedSource = await extract('qinglong', manifest.sourceCommit);
|
||||
const source = path.join(workspace, `qinglong-${branch}`);
|
||||
await fs.rename(downloadedSource, source);
|
||||
await verifyUpgrade(source, staticRoot);
|
||||
let dependenciesChanged = false;
|
||||
for (const name of ['package.json', 'pnpm-lock.yaml']) {
|
||||
const current = await fs
|
||||
.readFile(path.join(root, name))
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return Buffer.alloc(0);
|
||||
});
|
||||
if (!(await fs.readFile(path.join(source, name))).equals(current))
|
||||
dependenciesChanged = true;
|
||||
}
|
||||
if (
|
||||
!(await fs.stat(path.join(root, 'node_modules')).catch(() => undefined))
|
||||
)
|
||||
dependenciesChanged = true;
|
||||
if (dependenciesChanged) {
|
||||
await install(source);
|
||||
await fs.access(path.join(source, 'node_modules'));
|
||||
await verifyUpgrade(source, staticRoot);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const selected = { source, static: staticRoot };
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({
|
||||
...selected,
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
dependenciesChanged,
|
||||
}),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const pointer = path.join(workspace, 'pointer.json');
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ directory: path.basename(workspace) }),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
|
||||
const previous = await fs
|
||||
.readFile(readyPointer, 'utf8')
|
||||
.catch(() => undefined);
|
||||
signal?.throwIfAborted();
|
||||
await fs.rename(pointer, readyPointer);
|
||||
// Retire only our previous successful stage, after publishing its replacement.
|
||||
try {
|
||||
const directory = previous && JSON.parse(previous).directory;
|
||||
if (
|
||||
typeof directory === 'string' &&
|
||||
/^upgrade-[A-Za-z0-9_-]+$/.test(directory) &&
|
||||
directory !== path.basename(workspace)
|
||||
)
|
||||
await fs.rm(path.join(tmp, directory), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch {
|
||||
/* Cleanup cannot invalidate the newly published stage. */
|
||||
}
|
||||
return selected;
|
||||
} catch (error) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedUpgrade(tmp, branch) {
|
||||
const pointer = JSON.parse(
|
||||
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
|
||||
);
|
||||
if (
|
||||
typeof pointer.directory !== 'string' ||
|
||||
!/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory)
|
||||
)
|
||||
throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。');
|
||||
const workspace = path.join(tmp, pointer.directory);
|
||||
const selected = {
|
||||
source: path.join(workspace, `qinglong-${branch}`),
|
||||
static: path.join(workspace, `qinglong-static-${branch}`),
|
||||
};
|
||||
for (const directory of [workspace, selected.source, selected.static]) {
|
||||
const stat = await fs.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||
throw new Error('Invalid staging directory / 暂存目录无效。');
|
||||
}
|
||||
const ready = JSON.parse(
|
||||
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
||||
);
|
||||
if (ready.source !== selected.source || ready.static !== selected.static)
|
||||
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
|
||||
const manifest = await verifyUpgrade(selected.source, selected.static);
|
||||
if (ready.sourceCommit !== manifest.sourceCommit)
|
||||
throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。');
|
||||
if (ready.dependenciesChanged)
|
||||
await fs.access(path.join(selected.source, 'node_modules'));
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function replaceAndReload(replacements, lifecycle) {
|
||||
const changed = [];
|
||||
let attemptedStart = false;
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
try {
|
||||
await lifecycle.stop();
|
||||
for (const { source, target } of replacements) {
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
const backup = `${target}.ql-backup-${randomUUID()}`;
|
||||
const existed = !!(await fs.lstat(target).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return undefined;
|
||||
}));
|
||||
if (existed) {
|
||||
try {
|
||||
await fs.rename(target, backup);
|
||||
} catch (error) {
|
||||
if (error.code !== 'EXDEV') throw error;
|
||||
try {
|
||||
await fs.cp(target, backup, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
} catch (copyError) {
|
||||
await fs.rm(backup, { recursive: true, force: true });
|
||||
throw copyError;
|
||||
}
|
||||
changed.push({ target, backup, existed });
|
||||
await fs.rm(target, { recursive: true });
|
||||
}
|
||||
}
|
||||
if (changed.at(-1)?.target !== target)
|
||||
changed.push({ target, backup, existed });
|
||||
await fs.cp(source, target, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
});
|
||||
}
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
attemptedStart = true;
|
||||
await lifecycle.start();
|
||||
lifecycle.signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
// Do not modify files under a still-running failed startup. Keep backups
|
||||
// for recovery if stopping the new process itself fails.
|
||||
if (attemptedStart) await lifecycle.stop();
|
||||
for (const item of changed.reverse()) {
|
||||
await fs.rm(item.target, { recursive: true, force: true });
|
||||
if (item.existed) {
|
||||
try {
|
||||
await fs.rename(item.backup, item.target);
|
||||
} catch (restoreError) {
|
||||
if (restoreError.code !== 'EXDEV') throw restoreError;
|
||||
await fs.cp(item.backup, item.target, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await fs.rm(item.backup, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
await lifecycle.start(true);
|
||||
throw error;
|
||||
}
|
||||
for (const item of changed)
|
||||
if (item.existed)
|
||||
await fs
|
||||
.rm(item.backup, { recursive: true, force: true })
|
||||
.catch(() =>
|
||||
console.error(`Retained backup / 保留备份: ${item.backup}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function reloadSystem({
|
||||
root,
|
||||
data,
|
||||
tmp,
|
||||
staticRoot,
|
||||
config,
|
||||
env,
|
||||
lifecycle,
|
||||
}) {
|
||||
const selected = await selectedUpgrade(tmp, releaseBranch(env));
|
||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||
const relative = path.relative(root, data);
|
||||
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
reserved.add(relative.split(path.sep)[0]);
|
||||
const replacements = (await fs.readdir(selected.source))
|
||||
.filter((name) => !reserved.has(name))
|
||||
.map((name) => ({
|
||||
source: path.join(selected.source, name),
|
||||
target: path.join(root, name),
|
||||
}));
|
||||
replacements.push(
|
||||
{ source: selected.static, target: staticRoot },
|
||||
{
|
||||
source: path.join(selected.source, 'sample/config.sample.sh'),
|
||||
target: path.join(config, 'config.sample.sh'),
|
||||
},
|
||||
);
|
||||
await replaceAndReload(replacements, lifecycle);
|
||||
// Cleanup after a committed installation must not report a failed update.
|
||||
await fs
|
||||
.rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), {
|
||||
force: true,
|
||||
})
|
||||
.catch(() => {});
|
||||
await fs
|
||||
.rm(path.dirname(selected.source), { recursive: true, force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function waitForHealth(env, timeout = 60000, signal) {
|
||||
const port = Number(env.QlPort || 5700);
|
||||
let base = env.QlBaseUrl || '';
|
||||
if (base && !base.startsWith('/')) base = `/${base}`;
|
||||
if (base.endsWith('/')) base = base.slice(0, -1);
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
const healthy = await new Promise((resolve) => {
|
||||
let timer;
|
||||
const finish = (healthy) => {
|
||||
clearTimeout(timer);
|
||||
resolve(healthy);
|
||||
};
|
||||
const req = http.get(
|
||||
{ hostname: '127.0.0.1', port, path: `${base}/api/health`, signal },
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 65536) req.destroy();
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const result = JSON.parse(body);
|
||||
finish(
|
||||
res.statusCode === 200 &&
|
||||
result.code === 200 &&
|
||||
result.data?.status === 'ok',
|
||||
);
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
res.on('error', () => finish(false));
|
||||
},
|
||||
);
|
||||
// Socket inactivity timeouts do not bound a response that keeps streaming.
|
||||
timer = setTimeout(
|
||||
() => req.destroy(new Error('Health probe timed out')),
|
||||
Math.min(1000, Math.max(1, deadline - Date.now())),
|
||||
);
|
||||
req.on('error', () => finish(false));
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (healthy) return;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining > 0) {
|
||||
try {
|
||||
await delay(Math.min(500, remaining), undefined, { signal });
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Backend failed health check / 后端启动检查失败。');
|
||||
}
|
||||
|
||||
function runProcess(env, program, args, capture = false, signal) {
|
||||
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(program, args, {
|
||||
env,
|
||||
detached: process.platform !== 'win32',
|
||||
// Pipes keep close pending until children have released their output.
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let aborted = false;
|
||||
let failure;
|
||||
let escalation;
|
||||
const kill = (name) => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
process.kill(
|
||||
process.platform === 'win32' ? child.pid : -child.pid,
|
||||
name,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === 'EPERM') {
|
||||
child.kill(name);
|
||||
return;
|
||||
}
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
};
|
||||
const abort = () => {
|
||||
if (aborted) return;
|
||||
aborted = true;
|
||||
kill('SIGTERM');
|
||||
escalation = setTimeout(() => kill('SIGKILL'), 1000);
|
||||
escalation.unref();
|
||||
};
|
||||
child.stdout.on('data', (chunk) => {
|
||||
if (capture) stdout += chunk;
|
||||
else process.stdout.write(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
||||
child.once('error', (error) => {
|
||||
failure = error;
|
||||
});
|
||||
child.once('exit', () => {
|
||||
// The shell can exit before a descendant that ignores SIGTERM.
|
||||
if (aborted) kill('SIGKILL');
|
||||
});
|
||||
child.once('close', (code) => {
|
||||
if (aborted) kill('SIGKILL');
|
||||
clearTimeout(escalation);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (aborted) reject(signal.reason);
|
||||
else if (failure) reject(failure);
|
||||
else if (code === 0) resolve({ stdout });
|
||||
else reject(new Error(`${program} failed (exit ${code})`));
|
||||
});
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
});
|
||||
}
|
||||
|
||||
async function withLifecycleHooks(root, tmp, env, operation) {
|
||||
const helpers = await fs.readFile(path.join(root, 'shell/share.sh'), 'utf8');
|
||||
const environment = await fs.readFile(
|
||||
path.join(root, 'shell/env.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const directory = await fs.mkdtemp(path.join(tmp, 'upgrade-hooks-'));
|
||||
try {
|
||||
// Keep the installed functions available through replacement and rollback.
|
||||
// Their pkill patterns must never become part of the bash command line.
|
||||
const snapshot = path.join(directory, 'lifecycle.sh');
|
||||
await fs.writeFile(
|
||||
snapshot,
|
||||
`${helpers}\n${environment}\nimport_config
|
||||
case "$1" in
|
||||
stop) delete_pm2 ;;
|
||||
start) reload_pm2 ;;
|
||||
install) npm_install_2 "$2"; exit "$exit_status" ;;
|
||||
*) printf '%s\\n' 'Unknown lifecycle operation' >&2; exit 2 ;;
|
||||
esac
|
||||
`,
|
||||
{
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
const hook = (operation, args = [], signal) =>
|
||||
runProcess(
|
||||
env,
|
||||
'bash',
|
||||
['--', snapshot, operation, ...args],
|
||||
false,
|
||||
signal,
|
||||
);
|
||||
return await operation(hook);
|
||||
} finally {
|
||||
await fs
|
||||
.rm(directory, { recursive: true, force: true })
|
||||
.catch(() =>
|
||||
console.error(
|
||||
`Retained lifecycle snapshot / 保留生命周期快照: ${directory}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = process.env,
|
||||
root = env.QL_DIR;
|
||||
if (!root || !path.isAbsolute(root))
|
||||
throw new Error('QL_DIR must be absolute.');
|
||||
const tmp = env.dir_tmp || path.join(root, '.tmp');
|
||||
const controller = new AbortController();
|
||||
for (const name of ['SIGINT', 'SIGTERM'])
|
||||
process.on(name, () =>
|
||||
controller.abort(new Error(`Upgrade interrupted (${name})`)),
|
||||
);
|
||||
const run = (program, args, capture) =>
|
||||
runProcess(env, program, args, capture, controller.signal);
|
||||
await withUpgradeLock(tmp, () =>
|
||||
withLifecycleHooks(root, tmp, env, async (hook) => {
|
||||
const lifecycle = {
|
||||
signal: controller.signal,
|
||||
stop: () => hook('stop'),
|
||||
start: async (recover) => {
|
||||
await hook('start', [], recover ? undefined : controller.signal);
|
||||
await waitForHealth(
|
||||
env,
|
||||
60000,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
},
|
||||
};
|
||||
if (process.argv[2] === 'update') {
|
||||
await stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
mirror: process.argv[3],
|
||||
run,
|
||||
signal: controller.signal,
|
||||
install: (source) => hook('install', [source], controller.signal),
|
||||
});
|
||||
console.log('更新包下载及校验成功...');
|
||||
if (process.argv[4] !== 'true') return;
|
||||
} else if (process.argv[2] !== 'reload')
|
||||
throw new Error('Unknown upgrade command.');
|
||||
await reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
data: env.dir_data || path.join(root, 'data'),
|
||||
staticRoot: env.dir_static || path.join(root, 'static'),
|
||||
config: env.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle,
|
||||
});
|
||||
console.log('更新及启动检查成功...');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
releaseBranch,
|
||||
verifyUpgrade,
|
||||
withUpgradeLock,
|
||||
stageUpgrade,
|
||||
selectedUpgrade,
|
||||
replaceAndReload,
|
||||
reloadSystem,
|
||||
waitForHealth,
|
||||
runProcess,
|
||||
withLifecycleHooks,
|
||||
main,
|
||||
};
|
||||
if (require.main === module)
|
||||
main().catch((error) => {
|
||||
console.error(`更新失败: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -53,23 +53,12 @@ export async function request(
|
||||
// Check HTTP status before decoding (proxies may return HTML on denial).
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
const suffix =
|
||||
method !== 'GET' && response.status >= 500
|
||||
? translate(
|
||||
process.env,
|
||||
' 执行结果可能未知,请先检查%s状态再考虑重试。',
|
||||
resource,
|
||||
)
|
||||
: '';
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'API 请求被拒绝(HTTP %s),请检查%s。%s',
|
||||
response.status,
|
||||
credentialsHint,
|
||||
suffix,
|
||||
),
|
||||
[401, 403].includes(response.status) ? 3 : 1,
|
||||
rejectResponse(
|
||||
response.status,
|
||||
'HTTP',
|
||||
method,
|
||||
resource,
|
||||
credentialsHint,
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -100,28 +89,63 @@ export async function request(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (endpoint.split('?')[0] === 'user/login' && isRecord(result) && result.code === 420)
|
||||
fail(translate(process.env, '需要双因素验证,请调用 user two-factor-login。'), 3);
|
||||
if (
|
||||
endpoint.split('?')[0] === 'user/login' &&
|
||||
isRecord(result) &&
|
||||
result.code === 420
|
||||
)
|
||||
fail(
|
||||
translate(process.env, '需要双因素验证,请调用 user two-factor-login。'),
|
||||
3,
|
||||
);
|
||||
if (!isRecord(result) || result.code !== 200) {
|
||||
const status =
|
||||
isRecord(result) && typeof result.code === 'number'
|
||||
? result.code
|
||||
: undefined;
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'API 请求被拒绝(code %s),请检查%s。',
|
||||
status ?? translate(process.env, '未知'),
|
||||
credentialsHint,
|
||||
),
|
||||
status === 401 || status === 403 ? 3 : 1,
|
||||
);
|
||||
rejectResponse(status, 'code', method, resource, credentialsHint);
|
||||
}
|
||||
if (options.output)
|
||||
fail(translate(process.env, '接口返回 JSON,未保存下载文件。'));
|
||||
return result;
|
||||
}
|
||||
|
||||
function rejectResponse(
|
||||
status: number | undefined,
|
||||
kind: 'HTTP' | 'code',
|
||||
method: string,
|
||||
resource: string,
|
||||
credentialsHint: string,
|
||||
): never {
|
||||
const denied = status === 401 || status === 403;
|
||||
const reason = denied
|
||||
? translate(process.env, '请检查%s。', credentialsHint)
|
||||
: status === 409
|
||||
? translate(process.env, '资源冲突,请检查重复的名称或值。')
|
||||
: status !== undefined && status >= 500
|
||||
? translate(process.env, '服务端错误,请检查服务状态和日志。')
|
||||
: translate(process.env, '请求未成功,请检查请求参数和服务日志。');
|
||||
const suffix =
|
||||
method !== 'GET' && (status === undefined || status >= 500)
|
||||
? translate(
|
||||
process.env,
|
||||
' 执行结果可能未知,请先检查%s状态再考虑重试。',
|
||||
resource,
|
||||
)
|
||||
: '';
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'API 请求失败(%s %s)。%s%s',
|
||||
kind,
|
||||
status ?? translate(process.env, '未知'),
|
||||
reason,
|
||||
suffix,
|
||||
),
|
||||
denied ? 3 : 1,
|
||||
);
|
||||
}
|
||||
|
||||
export async function authenticate(config: Credentials): Promise<Session> {
|
||||
const query = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
|
||||
@@ -12,6 +12,39 @@ export interface OpenOperation {
|
||||
anonymous?: boolean;
|
||||
}
|
||||
export const openOperations: readonly OpenOperation[] = [
|
||||
{ name: 'account list', method: 'GET', path: 'user-management' },
|
||||
{
|
||||
name: 'account get',
|
||||
method: 'GET',
|
||||
path: 'user-management/:id',
|
||||
params: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'account create',
|
||||
method: 'POST',
|
||||
path: 'user-management',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'account update',
|
||||
method: 'PUT',
|
||||
path: 'user-management',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'account delete',
|
||||
method: 'DELETE',
|
||||
path: 'user-management',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'account assign',
|
||||
method: 'PUT',
|
||||
path: 'user-management/:id/resources',
|
||||
params: ['id'],
|
||||
body: 'json',
|
||||
},
|
||||
|
||||
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
|
||||
{
|
||||
name: 'task view-create',
|
||||
@@ -275,6 +308,18 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
{ name: 'config save', method: 'POST', path: 'configs/save', body: 'json' },
|
||||
{ name: 'script list', method: 'GET', path: 'scripts' },
|
||||
{ name: 'script get', method: 'GET', path: 'scripts/detail' },
|
||||
{ name: 'script history-list', method: 'GET', path: 'scripts/history' },
|
||||
{
|
||||
name: 'script history-detail',
|
||||
method: 'GET',
|
||||
path: 'scripts/history/detail',
|
||||
},
|
||||
{
|
||||
name: 'script history-restore',
|
||||
method: 'PUT',
|
||||
path: 'scripts/history/restore',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'script create',
|
||||
method: 'POST',
|
||||
@@ -466,7 +511,12 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
path: 'system/config/dependence-clean',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
|
||||
{
|
||||
name: 'dashboard record',
|
||||
method: 'POST',
|
||||
path: 'dashboard/record',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
|
||||
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
||||
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
||||
|
||||
@@ -241,7 +241,7 @@ export async function openCommand(
|
||||
body,
|
||||
authenticated: !op.anonymous,
|
||||
output: values.output as string | undefined,
|
||||
text: op.path === 'system/log',
|
||||
text: ['system/log', 'system/config/node-mirror'].includes(op.path),
|
||||
timeoutMs: values.timeout ? Number(values.timeout) * 1000 : undefined,
|
||||
});
|
||||
const data =
|
||||
|
||||
@@ -151,6 +151,12 @@ export const english: Record<string, string> = {
|
||||
应用凭据: 'application credentials',
|
||||
'应用凭据和 %s 权限': 'application credentials and %s permission',
|
||||
未知: 'unknown',
|
||||
'请检查%s。': 'Check %s.',
|
||||
'资源冲突,请检查重复的名称或值。': 'Resource conflict; check for duplicate names or values.',
|
||||
'服务端错误,请检查服务状态和日志。': 'Server error; check service status and logs.',
|
||||
'请求未成功,请检查请求参数和服务日志。': 'Request unsuccessful; check request parameters and service logs.',
|
||||
'API 请求失败(%s %s)。%s%s': 'API request failed (%s %s). %s%s',
|
||||
|
||||
' 执行结果可能未知,请先检查%s状态再考虑重试。':
|
||||
' Execution outcome may be unknown; check %s status before retrying.',
|
||||
'API 请求被拒绝(HTTP %s),请检查%s。%s':
|
||||
|
||||
@@ -7,7 +7,7 @@ const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { createContext, sourceEnvironment } = require('../../dist/internal/runtime/context');
|
||||
const { executeTask } = require('../../dist/internal/execution/taskRunner');
|
||||
|
||||
test('unmodified legacy Shell and TS agree on shell hook state and account modes', async (t) => {
|
||||
test('Shell and CLI agree on isolated script state and account modes', async (t) => {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-differential-')),
|
||||
);
|
||||
@@ -31,7 +31,7 @@ test('unmodified legacy Shell and TS agree on shell hook state and account modes
|
||||
path.join(root, 'static/build'),
|
||||
])
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
for (const name of ['task.sh', 'task-timeout.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
await fs.copyFile(
|
||||
path.resolve(__dirname, '../../../shell', name),
|
||||
path.join(context.paths.dir_shell, name),
|
||||
@@ -110,13 +110,13 @@ test('unmodified legacy Shell and TS agree on shell hook state and account modes
|
||||
async () => {
|
||||
const cases = [
|
||||
{ name: 'return', script: 'return 7', code: 7, legacy: 0, after: true },
|
||||
{ name: 'exit', script: 'exit 7', code: 7, legacy: 7, after: false },
|
||||
{ name: 'exit', script: 'exit 7', code: 7, legacy: 0, after: true },
|
||||
{
|
||||
name: 'errexit',
|
||||
script: 'set -e; false',
|
||||
code: 1,
|
||||
legacy: 1,
|
||||
after: false,
|
||||
legacy: 0,
|
||||
after: true,
|
||||
},
|
||||
{
|
||||
name: 'trap-return',
|
||||
@@ -130,8 +130,8 @@ test('unmodified legacy Shell and TS agree on shell hook state and account modes
|
||||
name: 'trap-exit',
|
||||
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; exit 7',
|
||||
code: 7,
|
||||
legacy: 7,
|
||||
after: false,
|
||||
legacy: 0,
|
||||
after: true,
|
||||
trap: true,
|
||||
},
|
||||
];
|
||||
@@ -174,8 +174,8 @@ test('unmodified legacy Shell and TS agree on shell hook state and account modes
|
||||
assert.equal(result.exitCode, item.code, item.name);
|
||||
const expected = [
|
||||
'before',
|
||||
...(item.after ? [`after:${item.code}`] : []),
|
||||
...(item.trap ? ['exit-trap'] : []),
|
||||
...(item.after ? [`after:${item.code}`] : []),
|
||||
];
|
||||
for (const file of [legacyTrace, modernTrace])
|
||||
assert.deepEqual(
|
||||
@@ -355,6 +355,7 @@ t() { :; }
|
||||
sleep() { printf DELAYED; }
|
||||
enter_script_workdir() { :; }
|
||||
run_else() { :; }
|
||||
run_task_command() { "$@"; }
|
||||
which_program=true
|
||||
main "$@"
|
||||
`,
|
||||
|
||||
@@ -10,7 +10,7 @@ test('legacy Shell and TS resolve executable paths after selecting the working d
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
for (const dir of ['shell/preload', 'shell/lang', 'data/config', 'data/scripts/bin', 'data/scripts/custom', 'data/log', 'static/build', 'bin'])
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
for (const name of ['task.sh', 'task-timeout.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../shell', name), path.join(root, 'shell', name));
|
||||
for (const name of ['zh.sh', 'en.sh'])
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../shell/lang', name), path.join(root, 'shell/lang', name));
|
||||
|
||||
@@ -10,7 +10,7 @@ test('legacy Shell and TS preserve task stdin in each execution mode', t => {
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
for (const dir of ['shell/preload', 'shell/lang', 'data/config', 'data/scripts', 'data/log', 'static/build', 'bin'])
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
for (const name of ['task.sh', 'task-timeout.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../shell', name), path.join(root, 'shell', name));
|
||||
for (const name of ['zh.sh', 'en.sh'])
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../shell/lang', name), path.join(root, 'shell/lang', name));
|
||||
|
||||
@@ -445,7 +445,7 @@ test('hook exports reach scripts and after hooks share shell state with inline c
|
||||
assert.match(log, /hook:before:inline:after:7/);
|
||||
});
|
||||
|
||||
test('ordinary shell tasks share unexported variables and functions across before script and after hooks', async (t) => {
|
||||
test('shell tasks inherit before-hook state without leaking script state into after hooks', async (t) => {
|
||||
const { executeTask } = require('../../dist/internal/execution/taskRunner');
|
||||
const root = sandbox(t);
|
||||
const context = createContext(
|
||||
@@ -464,7 +464,7 @@ test('ordinary shell tasks share unexported variables and functions across befor
|
||||
);
|
||||
fs.writeFileSync(
|
||||
context.paths.file_task_after,
|
||||
'script_function\nprintf "after:%s:%s:%s\\n" "$before_value" "$script_value" "$_task_exit_code"\n',
|
||||
'if declare -F script_function >/dev/null; then exit 99; fi\nprintf "after:%s:%s:%s\\n" "$before_value" "${script_value-unset}" "$_task_exit_code"\n',
|
||||
);
|
||||
const result = await executeTask(context, {
|
||||
argv: ['shared.sh'],
|
||||
@@ -478,7 +478,7 @@ test('ordinary shell tasks share unexported variables and functions across befor
|
||||
);
|
||||
assert.match(
|
||||
log,
|
||||
/before:private\narg:space value\nscript:changed\nafter:private:changed:7/,
|
||||
/before:private\narg:space value\nafter:private:unset:7/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -681,7 +681,7 @@ test('designated shell accounts retain hook functions and script state in a shar
|
||||
path.join(context.paths.dir_log, result.logPath),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(log, /selected:third&first\nafter:third&first:from_script:4/);
|
||||
assert.match(log, /selected:third&first\nafter:third&first::4/);
|
||||
});
|
||||
|
||||
test('lifecycle reports share execution identity and statistics survive a rejected final status', async (t) => {
|
||||
|
||||
@@ -15,8 +15,17 @@ const {
|
||||
reloadPanel,
|
||||
} = require('../../dist/internal/maintenance/upgrade');
|
||||
const { parse } = require('../helpers/commands.cjs');
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
} = require('../../../test/helpers/upgrade-fixture.cjs');
|
||||
|
||||
async function fixture(t) {
|
||||
t.mock.method(
|
||||
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
'waitForHealth',
|
||||
async () => {},
|
||||
);
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-operator-')),
|
||||
);
|
||||
@@ -119,14 +128,27 @@ test('PM2 restarts retain configured ports without mutating legacy environment',
|
||||
ctx.env.CALLS = path.join(ctx.root, 'ports.jsonl');
|
||||
ctx.env.BACK_PORT = '5600';
|
||||
ctx.env.GRPC_PORT = '5400';
|
||||
await stub(ctx, 'pm2', 'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify([process.env.BACK_PORT,process.env.GRPC_PORT])+"\\n");');
|
||||
await stub(
|
||||
ctx,
|
||||
'pm2',
|
||||
'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify([process.env.BACK_PORT,process.env.GRPC_PORT])+"\\n");',
|
||||
);
|
||||
await startPanel(ctx);
|
||||
ctx.env.QlPort = '5799';
|
||||
ctx.env.QlGrpcPort = '5599';
|
||||
await startPanel(ctx);
|
||||
assert.deepEqual((await fs.readFile(ctx.env.CALLS, 'utf8')).trim().split('\n').map(JSON.parse), [
|
||||
['5700', '5500'], ['5700', '5500'], ['5799', '5599'], ['5799', '5599'],
|
||||
]);
|
||||
assert.deepEqual(
|
||||
(await fs.readFile(ctx.env.CALLS, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(JSON.parse),
|
||||
[
|
||||
['5700', '5500'],
|
||||
['5700', '5500'],
|
||||
['5799', '5599'],
|
||||
['5799', '5599'],
|
||||
],
|
||||
);
|
||||
assert.equal(ctx.env.BACK_PORT, '5600');
|
||||
assert.equal(ctx.env.GRPC_PORT, '5400');
|
||||
});
|
||||
@@ -199,24 +221,41 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
const payloads = path.join(ctx.root, 'payloads');
|
||||
const archives = path.join(ctx.root, 'archives');
|
||||
await fs.mkdir(archives);
|
||||
await fs.mkdir(path.join(payloads, 'qinglong-master'), { recursive: true });
|
||||
await fs.mkdir(path.join(payloads, `qinglong-${sourceCommit}`), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.mkdir(path.join(payloads, 'qinglong-static-master/build'), {
|
||||
recursive: true,
|
||||
});
|
||||
const manifest = '{"name":"fixture"}';
|
||||
await fs.writeFile(path.join(ctx.root, 'package.json'), manifest);
|
||||
await fs.writeFile(
|
||||
path.join(payloads, 'qinglong-master/package.json'),
|
||||
path.join(payloads, `qinglong-${sourceCommit}/package.json`),
|
||||
manifest,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(payloads, 'qinglong-static-master/build/app.js'),
|
||||
'console.log("fixture")',
|
||||
);
|
||||
await fs.mkdir(path.join(payloads, `qinglong-${sourceCommit}/sample`));
|
||||
await fs.writeFile(
|
||||
path.join(payloads, `qinglong-${sourceCommit}/sample/config.sample.sh`),
|
||||
'new sample',
|
||||
);
|
||||
writeManifest(
|
||||
path.join(payloads, `qinglong-${sourceCommit}`),
|
||||
path.join(payloads, 'qinglong-static-master'),
|
||||
);
|
||||
await fs.writeFile(path.join(ctx.root, 'pnpm-lock.yaml'), 'fixture lock\n');
|
||||
await fs.mkdir(path.join(ctx.root, 'node_modules'));
|
||||
for (const repo of ['qinglong', 'qinglong-static'])
|
||||
execFileSync(
|
||||
'/usr/bin/zip',
|
||||
['-qry', path.join(archives, `${repo}.zip`), `${repo}-master`],
|
||||
[
|
||||
'-qry',
|
||||
path.join(archives, `${repo}.zip`),
|
||||
`${repo}-${repo === 'qinglong' ? sourceCommit : 'master'}`,
|
||||
],
|
||||
{ cwd: payloads },
|
||||
);
|
||||
ctx.env.ARCHIVES = archives;
|
||||
@@ -225,18 +264,6 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
'curl',
|
||||
'const fs=require("node:fs"),p=require("node:path"),args=process.argv.slice(2),out=args[args.indexOf("--output")+1]; fs.copyFileSync(p.join(process.env.ARCHIVES,p.basename(out)),out);',
|
||||
);
|
||||
await fs.mkdir(path.join(payloads, 'qinglong-master/sample'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(payloads, 'qinglong-master/sample/config.sample.sh'),
|
||||
'new sample',
|
||||
);
|
||||
execFileSync(
|
||||
'/usr/bin/zip',
|
||||
['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'],
|
||||
{ cwd: payloads },
|
||||
);
|
||||
const staged = await stageUpgrade(ctx, 'github');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(staged.source, 'package.json'), 'utf8'),
|
||||
@@ -249,15 +276,15 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
||||
'utf8',
|
||||
),
|
||||
),
|
||||
staged,
|
||||
{ ...staged, sourceCommit, dependenciesChanged: false },
|
||||
);
|
||||
await fs.symlink(
|
||||
'../../outside',
|
||||
path.join(payloads, 'qinglong-master/escape'),
|
||||
path.join(payloads, `qinglong-${sourceCommit}/escape`),
|
||||
);
|
||||
execFileSync(
|
||||
'/usr/bin/zip',
|
||||
['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'],
|
||||
['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`],
|
||||
{ cwd: payloads },
|
||||
);
|
||||
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/);
|
||||
@@ -728,7 +755,7 @@ test('invalid or incomplete staged pointers cannot stop the installed panel', as
|
||||
reloadPanel(ctx, 'system'),
|
||||
(error) =>
|
||||
error.code === 'ENOENT' &&
|
||||
error.path === path.join(ctx.paths.dir_tmp, 'qinglong-master'),
|
||||
error.path === path.join(ctx.paths.dir_tmp, 'upgrade-ready-master.json'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -783,6 +810,7 @@ test('system upgrade preserves installed dotenv bytes and permissions even when
|
||||
);
|
||||
await fs.writeFile(path.join(staticRoot, 'build/app.js'), '');
|
||||
await fs.writeFile(path.join(source, '.env'), 'PORT=9999\nREPLACE_ME=true\n');
|
||||
writeManifest(source, staticRoot);
|
||||
const target = path.join(ctx.root, '.env');
|
||||
const original = 'PORT=5700\nPRIVATE_VALUE=preserved\n';
|
||||
await fs.writeFile(target, original, { mode: 0o600 });
|
||||
|
||||
@@ -27,13 +27,14 @@ test('shared Shell lifecycle scopes timeouts to the task and runs cleanup exactl
|
||||
{ name: 'failure', task: 'return 7', code: 7 },
|
||||
{ name: 'function timeout preserves state', task: 'STATE=changed; sleep 10', code: 124, timeout: true, state: 'changed' },
|
||||
{ name: 'external timeout', args: ['sleep', '10'], code: 124, timeout: true },
|
||||
{ name: 'Shell script timeout preserves state', task: 'STATE=changed; sleep 10', shell: true, code: 124, timeout: true, state: 'changed' },
|
||||
{ name: 'Shell script timeout preserves parent hook state', task: 'STATE=changed; sleep 10', shell: true, code: 124, timeout: true },
|
||||
{ name: 'slow before is outside task timeout', before: 'sleep 0.2', task: 'return 0', code: 0 },
|
||||
{ name: 'slow after is outside task timeout', after: 'sleep 0.2', task: 'return 0', code: 0 },
|
||||
{ name: 'background child cannot hold pipes after timeout cleanup', task: '(trap "" INT; sleep 30) & sleep 30', after: 'sleep 0.3', code: 124, timeout: true },
|
||||
{ name: 'script may redirect fd 3', shell: true, task: 'exec 3> "$TRACE.fd"; return 0', after: 'sleep 0.3', code: 0 },
|
||||
{ name: 'before may close fd 3 and redirect fd 9', before: 'exec 3>&- 9> "$TRACE.lock"', task: 'return 0', after: 'sleep 0.3', code: 0 },
|
||||
{ name: 'timeout cleanup survives script fd 3 redirection', shell: true, task: 'exec 3> "$TRACE.fd"; STATE=changed; sleep 30', after: 'sleep 0.3', code: 124, timeout: true, state: 'changed' },
|
||||
{ name: 'timeout cleanup survives script fd 3 redirection', shell: true, task: 'exec 3> "$TRACE.fd"; STATE=changed; sleep 30', after: 'sleep 0.3', code: 124, timeout: true },
|
||||
{ name: 'Shell exit preserves user trap and runs after once', shell: true, task: 'trap \'printf "exit\\n" >> "$TRACE"\' EXIT; exit 9', code: 9, userExit: true },
|
||||
{ name: 'explicit exit preserves user trap and skips after', task: 'trap \'printf "exit\\n" >> "$TRACE"\' EXIT; exit 9', code: 9, noAfter: true, userExit: true },
|
||||
{ name: 'user EXIT trap survives timeout with parent cleanup', task: 'trap \'printf "exit\\n" >> "$TRACE"\' EXIT; sleep 10', code: 124, timeout: true, userExit: true },
|
||||
{ name: 'forced termination falls back to cleanup', task: 'trap "" INT; sleep 30', code: 124, timeout: true },
|
||||
|
||||
@@ -6,10 +6,16 @@ const os = require('node:os');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
||||
const operator = require('../../dist/internal/maintenance/operator');
|
||||
const { writeManifest } = require('../../../test/helpers/upgrade-fixture.cjs');
|
||||
const selectedLoader =
|
||||
'// QL_CLI_ROOT dist/local/entrypoints.js dist/local/cronEntrypoint.js\n';
|
||||
|
||||
async function fixture(t) {
|
||||
t.mock.method(
|
||||
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
'waitForHealth',
|
||||
async () => {},
|
||||
);
|
||||
const base = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-upgrade-selection-'),
|
||||
);
|
||||
@@ -43,6 +49,7 @@ async function fixture(t) {
|
||||
await fs.writeFile(incoming, '// original released loader');
|
||||
await fs.writeFile(path.join(ctx.paths.dir_static, 'build/app.js'), 'old');
|
||||
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'new');
|
||||
writeManifest(source, staticRoot);
|
||||
return { ctx, source, staticRoot, loader, incoming };
|
||||
}
|
||||
|
||||
@@ -104,3 +111,33 @@ test('unsupported target and unrecognized loader fail before service stop', asyn
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(await fs.readFile(f.loader, 'utf8'), 'unrecognized');
|
||||
});
|
||||
|
||||
test('successful process start with failed health check rolls back the installed version', async (t) => {
|
||||
const f = await fixture(t);
|
||||
t.mock.method(operator, 'stopPanel', async () => {});
|
||||
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
|
||||
let probes = 0;
|
||||
t.mock.method(
|
||||
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
'waitForHealth',
|
||||
async () => {
|
||||
if (++probes === 1) throw new Error('health check failed');
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
path.join(f.ctx.paths.dir_static, 'build/app.js'),
|
||||
'utf8',
|
||||
),
|
||||
'old',
|
||||
);
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
reloadPanel(f.ctx, 'system', { source: f.source, static: f.staticRoot }),
|
||||
/health check failed/,
|
||||
);
|
||||
assert.equal(probes, 2);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.ctx.root, 'package.json'), 'utf8'),
|
||||
'{"version":"2.19.0"}',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ test('API diagnostics preserve resource scope, uncertainty, codes and secret sup
|
||||
language === 'en' ? /outcome.*unknown/ : /结果.*未知/,
|
||||
);
|
||||
}
|
||||
if (failure !== 'network') {
|
||||
if (failure === 'permission' || failure === 'api') {
|
||||
assert.ok(
|
||||
error.message.includes(
|
||||
endpoint.startsWith('subscriptions')
|
||||
@@ -89,3 +89,54 @@ test('API diagnostics preserve resource scope, uncertainty, codes and secret sup
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTP and body errors distinguish conflicts, authentication and server failures without leaking bodies', async (t) => {
|
||||
const previous = process.env.QL_LANG;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.QL_LANG;
|
||||
else process.env.QL_LANG = previous;
|
||||
});
|
||||
for (const language of ['en', 'zh']) {
|
||||
process.env.QL_LANG = language;
|
||||
for (const kind of ['HTTP', 'code'])
|
||||
for (const status of [400, 401, 403, 409, 500, 503])
|
||||
for (const method of ['GET', 'PUT']) {
|
||||
t.mock.method(
|
||||
global,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ code: status, message: 'secret-value' }),
|
||||
{ status: kind === 'HTTP' ? status : 200 },
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
request(
|
||||
{ url: 'https://fixture.invalid', token: 'secret-token' },
|
||||
'envs',
|
||||
{ method },
|
||||
),
|
||||
(error) => {
|
||||
assert.equal(error.exitCode, [401, 403].includes(status) ? 3 : 1);
|
||||
assert.match(error.message, new RegExp(`${kind} ${status}`));
|
||||
assert.doesNotMatch(error.message, /secret-|fixture/);
|
||||
if ([401, 403].includes(status))
|
||||
assert.match(error.message, /credentials|凭据/);
|
||||
else
|
||||
assert.doesNotMatch(
|
||||
error.message,
|
||||
/credentials|permission|凭据|权限/,
|
||||
);
|
||||
if (status === 409) assert.match(error.message, /conflict|冲突/);
|
||||
if (status >= 500)
|
||||
assert.match(error.message, /Server error|服务端错误/);
|
||||
if (status >= 500 && method === 'PUT')
|
||||
assert.match(error.message, /unknown|未知/);
|
||||
else assert.doesNotMatch(error.message, /unknown|未知/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
t.mock.restoreAll();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,6 +58,31 @@ test('dashboard record sends execution statistics and rejects a missing body bef
|
||||
assert.equal(f.requests.length, 1);
|
||||
});
|
||||
|
||||
test('script history commands send exact query parameters and restore payloads', async t => {
|
||||
const f = await fixture(t);
|
||||
const identity = { filename: '中文 script.js', path: 'nested folder' };
|
||||
const version = { ...identity, id: '32ec8b22-2c48-4bcf-85d4-b394198417ba' };
|
||||
for (const [command, endpoint, query] of [
|
||||
['history-list', 'scripts/history', identity],
|
||||
['history-detail', 'scripts/history/detail', version],
|
||||
]) {
|
||||
success(await f.run(['script', command, '--query', JSON.stringify(query)]));
|
||||
const req = f.requests.at(-1), url = new URL(req.url, 'http://localhost');
|
||||
assert.equal(req.method, 'GET');
|
||||
assert.equal(url.pathname, '/panel/open/' + endpoint);
|
||||
for (const [key, value] of Object.entries(query)) assert.equal(url.searchParams.get(key), value);
|
||||
assert.equal(req.body, '');
|
||||
}
|
||||
const payload = { ...version, expectedHash: 'a'.repeat(64) };
|
||||
success(await f.run(['script', 'history-restore', '--data', '-'], JSON.stringify(payload)));
|
||||
assert.equal(f.requests.at(-1).method, 'PUT');
|
||||
assert.equal(f.requests.at(-1).url, '/panel/open/scripts/history/restore');
|
||||
assert.deepEqual(JSON.parse(f.requests.at(-1).body), payload);
|
||||
const count = f.requests.length;
|
||||
assert.equal((await f.run(['script', 'history-restore'])).code, 2);
|
||||
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
|
||||
});
|
||||
|
||||
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
|
||||
const root = path.resolve(__dirname, '../../../back/api');
|
||||
const expected = [];
|
||||
@@ -186,3 +211,29 @@ test('owner two-factor challenge remains actionable without exposing server mess
|
||||
assert.equal(result.code, 3); assert.match(result.err, /two-factor-login/); assert.doesNotMatch(result.err, /SERVER-SECRET/);
|
||||
assert.equal(f.requests.length, 1); assert.equal(f.requests[0].headers.authorization, undefined);
|
||||
});
|
||||
|
||||
test('node mirror configuration accepts the real empty response for named and raw API commands', async t => {
|
||||
const f = await fixture(t);
|
||||
f.reply((_req, res) => { res.writeHead(200, { 'content-type': 'application/octet-stream' }); res.end(); });
|
||||
for (const args of [
|
||||
['system', 'config-node-mirror'],
|
||||
['api', 'request', 'PUT', '/open/system/config/node-mirror'],
|
||||
]) {
|
||||
const reply = success(await f.run([...args, '--data', '{"nodeMirror":"https://registry.npmmirror.com"}']));
|
||||
assert.equal(reply.code, 200);
|
||||
assert.equal(f.requests.at(-1).method, 'PUT');
|
||||
assert.equal(f.requests.at(-1).url, '/panel/open/system/config/node-mirror');
|
||||
}
|
||||
assert.equal(f.requests.length, 2, 'configuration writes must not be retried');
|
||||
});
|
||||
|
||||
test('empty node mirror responses still reject authentication and server failures without retries', async t => {
|
||||
const f = await fixture(t);
|
||||
for (const status of [401, 403, 500]) {
|
||||
f.reply((_req, res) => { res.writeHead(status, { 'content-type': 'application/octet-stream' }); res.end(); });
|
||||
const result = await f.run(['system', 'config-node-mirror', '--data', '{}']);
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.equal(result.out, '');
|
||||
}
|
||||
assert.equal(f.requests.length, 3);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 多用户权限范围
|
||||
|
||||
系统原有管理员仍使用现有初始化、密码迁移、双重验证与会话校验。新增账号使用 scrypt 密码哈希;用户接口不返回密码哈希和会话记录。密码、角色、状态或用户名修改会撤销该账号的已有会话;退出、禁用和删除账号也会使令牌失效。仅签名有效而未登记的 JWT 不能作为用户会话。
|
||||
|
||||
普通用户只读自己名下的环境变量、任务元数据和订阅元数据,可查看自己的账号信息并退出。无归属的历史数据属于管理员,普通用户不能访问。共享脚本、配置、日志、依赖安装、任务执行、终端命令、系统设置及账号管理均需管理员权限。前端显示只读提示,并仅显示任务、环境变量和订阅菜单。
|
||||
|
||||
管理员可在系统设置中创建、修改、禁用、删除账号。新增管理员角色属于完全可信的系统管理员,具有全局操作权限。原有管理员账号保留在原有认证配置中,不复制到新用户表。
|
||||
|
||||
管理员可通过 `PUT /api/user-management/:id/resources` 分配资源,请求体例如 `{"resource":"envs","ids":[1,2]}`。`resource` 支持 `envs`、`crons`、`subscriptions`;用户 ID 和资源 ID 必须为正整数。分配接口不会移动文件或执行任务。
|
||||
|
||||
脚本目录、环境变量导出文件和系统执行身份仍为实例共用。当前草稿不向普通用户开放执行;若要向不可信用户开放执行,必须增加独立执行环境、独立凭据和工作目录,以及相应的越权测试。
|
||||
|
||||
旧草稿曾使用 bcrypt 的账号需要由管理员重置密码后登录;bcrypt 哈希不会按明文密码比较。正式合并前应在 Linux 容器中验收账号管理与资源分配界面。
|
||||
@@ -24,7 +24,7 @@ order remain available. This caches the dependency directory, not its contents.
|
||||
| `task script.py` / `.pyc` | Python preload imports generated environment, runs the shell/command before hooks, imports `task_before.py`, then applies account selection | `QLAPI.notify(...)` imports `__ql_notify__` on first call |
|
||||
| `task script.js` / `.mjs` / `.ts` | Node preload imports generated environment, runs shell/command before hooks, requires `task_before.js`, then applies account selection | `QLAPI.notify(...)` requires `__ql_notify__.js` on first call |
|
||||
| `task script.sh` | Shell sources the generated environment and runs shell before hooks | No automatic Python/Node notification module |
|
||||
| Explicit interpreter or other command, e.g. `task python3 script.py` | Existing generic-command path uses shell environment/before hooks; it does not automatically install the Python/Node preload | No automatic notification module unless configured by the caller |
|
||||
| Explicit interpreter or other command, e.g. `task python3 script.py` | Retains generated environment and shell before-hook exports; it does not automatically install the Python/Node preload | No automatic notification module unless configured by the caller |
|
||||
|
||||
TypeScript uses `ts-node-transpile-only`; MJS uses the same Node preload plus the
|
||||
ESM loader. There are no separate built-in Ruby, Go, Java or PHP preload modules.
|
||||
@@ -38,6 +38,13 @@ child shell/Node. Node also still loads its gRPC client eagerly. These are remai
|
||||
startup costs, not work performed by the cron library, and are not removed by this
|
||||
change.
|
||||
|
||||
Shell scripts run in a child shell that inherits the wrapper's environment and
|
||||
before-hook functions. This lets the wrapper run its after hooks and report the
|
||||
exit status even when a script calls `exit`, replaces itself with `exec`, or fails
|
||||
under `set -e`. The script's own EXIT trap still runs in the child. Changes made
|
||||
inside the script (variables, working directory, functions and shell options)
|
||||
do not propagate back to the wrapper or its after hooks.
|
||||
|
||||
## Lazy notifications
|
||||
|
||||
Scripts keep calling `QLAPI.notify` with the same arguments. When no notification
|
||||
@@ -52,3 +59,20 @@ resolved.
|
||||
Provider initialization, including provider configuration reads, now happens at
|
||||
first use. A script's explicit `import __ql_notify__`, `require(...)`, or independent
|
||||
notification helper still loads that module immediately.
|
||||
|
||||
## Task timeouts
|
||||
|
||||
`task -m DURATION ...` also applies to sourced Shell scripts. The timeout worker
|
||||
inherits before-hook variables, arrays and functions without serializing them.
|
||||
At the deadline, the worker's process group receives TERM, followed by KILL after
|
||||
0.5 seconds, so ordinary child processes cannot continue after the timeout.
|
||||
Explicit interpreters and other commands use the same mechanism. Processes that
|
||||
intentionally create a separate session/process group are outside this boundary.
|
||||
The wrapper records status 124 and runs its after hook; concurrent account mode
|
||||
retains its existing aggregate status behavior. Zero disables the timeout.
|
||||
|
||||
Cron executions waiting for a concurrency slot retain their schedule revision.
|
||||
Disabling, deleting or replacing that revision prevents its queued executions
|
||||
from starting when a slot becomes available. Already running executions retain
|
||||
the existing stop behavior. Invalidated callbacks do not count against a new
|
||||
revision's limit of five pending/running executions.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 青龙 2.23.0
|
||||
|
||||
## 新增功能
|
||||
|
||||
- 脚本历史版本:查看、对比和恢复历史内容;每个脚本保留最多 20 个版本,支持不超过 1 MiB 的 UTF-8 文本脚本。恢复前校验当前内容哈希,发生并发修改时返回冲突,避免覆盖新内容。
|
||||
- 独立远程 CLI 0.1.2 增加脚本历史查看、对比和恢复命令,并完善资源冲突及认证错误反馈。最低要求仍为 Node.js 22.12。
|
||||
|
||||
## 修复与优化
|
||||
|
||||
- 普通任务、额外规则和订阅的 Node 调度路径迁移到 node-cron。保留旧规则标准化、日期/星期 OR、L/# 等兼容行为,补充 `@annually`、`@midnight`、`@minutely`。
|
||||
- 禁用、删除或修改任务后,旧规则对应的排队执行不会再启动。完善任务进程超时、停止回收、Shell before/after 环境传递和退出状态。
|
||||
- 修复系统令牌未主动续期和认证缓存并发更新:定时生成与启动后重新计时会主动续期,避免长期运行后失效。Shell API 每次请求检查令牌,遇到 401 时刷新并重试一次;其他错误不自动重试。
|
||||
- 降低任务启动开销;JS/Python 的 QLAPI 通知依赖在首次通知调用时加载,减少不发送通知的任务启动成本。
|
||||
- 加固环境变量名和字面值、订阅参数、路径访问及 SSH 配置输入;保留合法的中文及分支别名兼容。
|
||||
- 修复页面升级、`ql update` 和静态资源的版本同步,完善升级暂存校验及应用、完整备份与恢复流程。
|
||||
- 修复 Debian 上的依赖安装、通知和调度回归,补齐前端缺失的中英文翻译,改进 Gitee/GitLab 静态资源镜像同步检查。
|
||||
- 最小升级 compression、multer、@grpc/grpc-js、proxy-addr、websocket-driver、sequelize,修补现有 HTTP、上传、代理信任、WebSocket、gRPC 和 JSON 查询路径相关风险。完整依赖治理、Node/pnpm 迁移及工具打包调整延期。
|
||||
|
||||
## 升级说明
|
||||
|
||||
升级前停止面板与任务,备份完整持久化数据目录,包括数据库、脚本、配置、日志、依赖缓存及已有的 `script-history` 目录,并记录原镜像摘要和启动配置。使用面板备份接口时,确认选择了脚本、配置和日志;仅备份数据库不能恢复全部数据。
|
||||
|
||||
升级后试运行代表性的 JavaScript、Python、Shell 任务,检查订阅、环境变量、依赖安装以及通知。存在夏令时的时区采用 node-cron 行为:跳过春季不存在的当地时间,秋季重复小时不重复执行;Asia/Shanghai 不受影响。调度器不提供进程退出期间的持久化补跑保证。
|
||||
|
||||
任务超时会终止普通子进程所在的进程组并记录退出状态 124;主动创建独立会话/进程组的程序不在该边界内。并发账号执行模式保留既有汇总退出状态。
|
||||
|
||||
本次沿用现有 Dockerfile 和 pnpm 8.3.1。默认 Debian 镜像使用 Debian 12 与 Node.js 20,默认 Alpine 镜像的 Node.js 由 APK 提供;默认 Python 为 3.11。Node 20 与 Node 18 构建运行时已结束上游维护,相关迁移延期,功能验证通过不代表全部依赖风险清零。
|
||||
|
||||
2.23.0 是 Python 3.10 兼容镜像的最后一个版本。`2.23.0-python3.10` 和 `2.23.0-debian-python3.10` 发布后停止维护,不再提供后续更新;请验证脚本和依赖后迁移至默认 Python 3.11 镜像。
|
||||
|
||||
主包 `@whyour/qinglong` 与独立远程包 `@whyour/qinglong-cli` 的 `ql` 命令用途不同。在已有面板的主机上使用独立 npm prefix 或 `npm exec --package=@whyour/qinglong-cli -- ql ...`,避免覆盖面板内置入口。
|
||||
|
||||
发布时间:2026-10-11 12:27:02(Asia/Shanghai)。本版本已完成四类镜像验证及独立 dev 环境超过两小时的持续功能验收。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 脚本编辑历史
|
||||
|
||||
脚本管理工具栏的“历史版本”打开右侧抽屉。选择版本后,对比历史内容与服务器上当前已保存文件;桌面使用双栏对比,手机使用行内对比。恢复需要确认,有未保存修改时必须先保存或取消。恢复前保留当前版本,因此恢复操作也可以撤销。普通保存不再弹出不可恢复确认。
|
||||
|
||||
## 记录范围
|
||||
|
||||
- 编辑器 `PUT /api/scripts` 和调试窗口 `POST /api/scripts` 覆盖已有脚本时,在内容变化时留档,首次修改前的内容也保留;相同内容不写历史。
|
||||
- 历史只包含保存前真实存在的内容;不把失败写入当成一个内容版本。
|
||||
- 上传、新增、重命名、删除、订阅更新和外部编辑不主动留档。若下次面板保存时检测到外部变更,会保留那一刻的内容,标记为“面板外修改后”。这不代表外部修改之前的内容受到保护。
|
||||
- 按脚本规范化后的相对路径隔离历史;不同目录同名文件不会混淆,指向同一脚本的安全符号链接共用历史。重命名后不会自动迁移历史。调试器另存为覆盖已有脚本时,目标旧内容进入历史,删除源文件前将其旧内容保留到现有 bak 目录;备份失败则不覆盖目标、不删除源文件。源和目标实际指向同一脚本时(如安全符号链接或不同路径写法),按原地保存处理,保留两个路径,不删除源文件。历史不能用于回收已删除的文件。
|
||||
- 历史记录支持至多 1 MiB 的 UTF-8 文本文件(含空文件)。当前文件或新内容超出范围时,界面要求明确确认“不记录历史直接保存”;取消则保持文件和草稿不变。确认后以 `skipHistory: true` 和服务端返回的当前哈希重试,锁内检查哈希以防确认期间覆盖他人修改。成功后明确提示本次未留档。大文件可以通过此流程缩小,后续恢复普通留档。未确认的 API 请求返回 413,并包含 `historyUnavailable` 和 `currentHash`;历史读取损坏等其他错误不允许用此选项绕过。
|
||||
|
||||
## 存储与资源边界
|
||||
|
||||
位置:`<QL_DATA_DIR>/script-history/<SHA-256(规范相对路径)>.br`。目录位于脚本目录之外,避免进入脚本树、订阅和任务扫描。目录权限 `0700`,历史文件权限 `0600`。目录随整个数据目录备份。
|
||||
|
||||
每个脚本仅一个 Brotli 压缩 JSON 包,存储完整的旧内容快照以及时间、来源、内容哈希。新文件内容不再重复存储,只保存头版本元数据,供下一次留档标注来源。面板服务通过 TypeDI 注册为单例,路由使用 `Container.get(ScriptHistoryService)` 获取;构造实例只初始化配置,首次发生保存时才创建历史目录。包内重复文本共享压缩窗口,不依赖补丁链,也不需要额外数据库、文件监听、后台任务或常驻缓存。使用 Node 内置 zlib 异步压缩,Brotli quality=4、window=4 MiB,不新增依赖。
|
||||
|
||||
| 限制 | 默认值 |
|
||||
| --- | --- |
|
||||
| 每脚本历史版本 | 最多 20 个 |
|
||||
| 单个脚本内容 | 1 MiB |
|
||||
| 单包解压后的 JSON | 8 MiB |
|
||||
| 单个压缩历史包 | 2 MiB |
|
||||
| 所有压缩历史包合计 | 32 MiB |
|
||||
| 有历史的脚本数 | 最多 1000 个 |
|
||||
| 历史写入暂存文件 | 共享一个,最多 2 MiB |
|
||||
| 脚本写入暂存文件 | 每次保存一个,与本次保存内容等大,结束后清理 |
|
||||
|
||||
达到单文件限制时删除最旧版本,至少保留本次覆盖前的内容;无法保留则拒绝覆盖。达到全局限制时,在脚本成功提交后优先清理最久未更新的脚本历史包(该文件的整份历史),不删除脚本本身。历史暂存、提交或脚本提交失败时不清理任何其他历史;脚本提交失败会回滚本文件历史。如果提交成功但清理失败,返回 `cleanupPending`,界面提示检查存储权限和空间,后续保存重新尝试清理。配额是正常清理成功后的目标,事务期间和清理失败时会暂时超出。32 MiB 是文件内容字节数,不包含文件系统块分配、目录和锁的开销。清理只在发生保存时执行,通常最多扫描 1000 个包的元数据(清理失败期间可能更多),不解压其他脚本的历史。读取仅解压当前脚本,限制解压输出长度。
|
||||
|
||||
文件保存复用现有锁路径,同时以独立跨进程目录锁串行化历史写入和配额清理;`finally` 释放锁。先准备并同步脚本暂存文件,保留文件权限和所有者;随后写并同步历史暂存文件、原子提交历史,再原子替换脚本。任一步准备失败不会截断原脚本;最后替换脚本失败时还原此前历史;成功后才清理配额。超限确认保存不创建新历史包,使用同样的脚本暂存和替换方式,并流式计算旧文件哈希。外部进程不遵守该锁时,不承诺与其并发写入的事务一致性。
|
||||
|
||||
恢复请求携带预览时的当前内容哈希,锁内检查后才写入;文件变化则返回 409。历史读写与脚本读取采用相同的路径和符号链接边界检查。面板配置目录中的敏感文件仍受黑名单保护;脚本目录中的 auth.json、token.json 等普通脚本名称可以正常保存和留档。
|
||||
|
||||
## 压缩样本
|
||||
|
||||
运行 `node scripts/benchmark-script-history.cjs` 可复现比较。模拟同一脚本连续 20 次小修改,比较独立 gzip、合并 gzip 与合并 Brotli。首次本机测量:
|
||||
|
||||
| 样本 | 独立 gzip | 合并 gzip | 合并 Brotli(quality 4) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| sample/notify.js | 242,430 B | 221,832 B | 13,930 B |
|
||||
| shell/task.sh | 30,159 B | 2,317 B | 1,737 B |
|
||||
|
||||
这是文本重复度较高的模拟样本,不是所有工作负载的压缩保证。Brotli 合包特别适合大小超过 gzip 32 KiB 窗口、但每次只改少量内容的脚本。增量补丁可能在部分样本更小,但增加补丁生成、链式恢复、裁剪和损坏恢复的复杂度;本实现选择压缩合包与硬上限,在磁盘、CPU 和可靠性之间取平衡。
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/scripts/history?filename=...&path=...`:版本元数据、当前哈希、保留数量。
|
||||
- `GET /api/scripts/history/detail?filename=...&path=...&id=...`:历史内容、当前内容及当前哈希。
|
||||
- `PUT /api/scripts/history/restore`:`{filename, path, id, expectedHash}`。
|
||||
|
||||
沿用现有 scripts 路由的认证与权限。历史 ID 使用 UUID;不存在或已清理返回 404,文件已变更返回 409,文本或容量限制返回 413。
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Multi-User Data Migration Script
|
||||
*
|
||||
* This script migrates existing data (Cron, Env, Subscription, Dependence)
|
||||
* to be associated with specific users.
|
||||
*
|
||||
* Usage:
|
||||
* node migrate-to-multiuser.js --userId=1
|
||||
* node migrate-to-multiuser.js --username=admin
|
||||
* node migrate-to-multiuser.js --list-users
|
||||
*
|
||||
* Options:
|
||||
* --userId=<id> Assign all legacy data to user with this ID
|
||||
* --username=<name> Assign all legacy data to user with this username
|
||||
* --list-users List all users in the system
|
||||
* --dry-run Show what would be changed without making changes
|
||||
* --help Show this help message
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
// Load environment variables
|
||||
require('dotenv').config();
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
dbPath: path.join(
|
||||
process.env.QL_DATA_DIR ||
|
||||
path.join(process.env.QL_DIR || __dirname, 'data'),
|
||||
'db',
|
||||
),
|
||||
rootPath: __dirname,
|
||||
};
|
||||
|
||||
if (!fs.existsSync(path.join(config.dbPath, 'database.sqlite'))) {
|
||||
throw new Error(
|
||||
'Existing database not found; start the panel and verify QL_DATA_DIR before migration.',
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Sequelize
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(config.dbPath, 'database.sqlite'),
|
||||
logging: false,
|
||||
});
|
||||
|
||||
// Define models
|
||||
const UserModel = sequelize.define('User', {
|
||||
username: Sequelize.STRING,
|
||||
password: Sequelize.STRING,
|
||||
role: Sequelize.INTEGER,
|
||||
status: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const CrontabModel = sequelize.define('Crontab', {
|
||||
name: Sequelize.STRING,
|
||||
command: Sequelize.STRING,
|
||||
schedule: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const EnvModel = sequelize.define('Env', {
|
||||
name: Sequelize.STRING,
|
||||
value: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const SubscriptionModel = sequelize.define('Subscription', {
|
||||
name: Sequelize.STRING,
|
||||
url: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const DependenceModel = sequelize.define('Dependence', {
|
||||
name: Sequelize.STRING,
|
||||
type: Sequelize.INTEGER,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
// Parse command line arguments
|
||||
function parseArgs() {
|
||||
const args = {
|
||||
userId: null,
|
||||
username: null,
|
||||
listUsers: false,
|
||||
dryRun: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
process.argv.slice(2).forEach((arg) => {
|
||||
if (arg.startsWith('--userId=')) {
|
||||
args.userId = parseInt(arg.split('=')[1]);
|
||||
} else if (arg.startsWith('--username=')) {
|
||||
args.username = arg.split('=')[1];
|
||||
} else if (arg === '--list-users') {
|
||||
args.listUsers = true;
|
||||
} else if (arg === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
args.help = true;
|
||||
}
|
||||
});
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// Show help
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Multi-User Data Migration Script
|
||||
|
||||
This script migrates existing data (Cron, Env, Subscription, Dependence)
|
||||
to be associated with specific users.
|
||||
|
||||
Usage:
|
||||
node migrate-to-multiuser.js --userId=1
|
||||
node migrate-to-multiuser.js --username=admin
|
||||
node migrate-to-multiuser.js --list-users
|
||||
|
||||
Options:
|
||||
--userId=<id> Assign all legacy data to user with this ID
|
||||
--username=<name> Assign all legacy data to user with this username
|
||||
--list-users List all users in the system
|
||||
--dry-run Show what would be changed without making changes
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
# List all users
|
||||
node migrate-to-multiuser.js --list-users
|
||||
|
||||
# Migrate all data to user ID 1 (dry run)
|
||||
node migrate-to-multiuser.js --userId=1 --dry-run
|
||||
|
||||
# Migrate all data to user 'admin'
|
||||
node migrate-to-multiuser.js --username=admin
|
||||
|
||||
Note: This script will only migrate data where userId is NULL or undefined.
|
||||
Data already assigned to users will not be changed.
|
||||
`);
|
||||
}
|
||||
|
||||
// List all users
|
||||
async function listUsers() {
|
||||
const users = await UserModel.findAll();
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('\nNo users found in the database.');
|
||||
console.log(
|
||||
'Please create users first using the User Management interface.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\nUsers in the system:');
|
||||
console.log('ID\tUsername\tRole\t\tStatus');
|
||||
console.log('--\t--------\t----\t\t------');
|
||||
|
||||
users.forEach((user) => {
|
||||
const role = user.role === 0 ? 'Admin' : 'User';
|
||||
const status = user.status === 0 ? 'Enabled' : 'Disabled';
|
||||
console.log(`${user.id}\t${user.username}\t\t${role}\t\t${status}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Get statistics of legacy data
|
||||
async function getStatistics() {
|
||||
const stats = {
|
||||
crons: await CrontabModel.count({ where: { userId: null } }),
|
||||
envs: await EnvModel.count({ where: { userId: null } }),
|
||||
subscriptions: await SubscriptionModel.count({ where: { userId: null } }),
|
||||
dependences: await DependenceModel.count({ where: { userId: null } }),
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Migrate data to a specific user
|
||||
async function migrateData(userId, dryRun = false) {
|
||||
const stats = await getStatistics();
|
||||
|
||||
console.log('\nLegacy Data Statistics:');
|
||||
console.log(` Cron tasks: ${stats.crons}`);
|
||||
console.log(` Environment variables: ${stats.envs}`);
|
||||
console.log(` Subscriptions: ${stats.subscriptions}`);
|
||||
console.log(` Dependencies: ${stats.dependences}`);
|
||||
console.log('');
|
||||
|
||||
if (
|
||||
stats.crons + stats.envs + stats.subscriptions + stats.dependences ===
|
||||
0
|
||||
) {
|
||||
console.log('No legacy data found. All data is already assigned to users.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('DRY RUN: No changes will be made.\n');
|
||||
console.log(`Would assign all legacy data to user ID ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Migrating data to user ID ${userId}...`);
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Migrate crons
|
||||
if (stats.crons > 0) {
|
||||
await CrontabModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.crons} cron tasks`);
|
||||
}
|
||||
|
||||
// Migrate envs
|
||||
if (stats.envs > 0) {
|
||||
await EnvModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.envs} environment variables`);
|
||||
}
|
||||
|
||||
// Migrate subscriptions
|
||||
if (stats.subscriptions > 0) {
|
||||
await SubscriptionModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.subscriptions} subscriptions`);
|
||||
}
|
||||
|
||||
// Migrate dependences
|
||||
if (stats.dependences > 0) {
|
||||
await DependenceModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.dependences} dependencies`);
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
console.log('\n✓ Migration completed successfully!');
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('\n✗ Migration failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
if (args.help) {
|
||||
showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Test database connection
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connection established.');
|
||||
|
||||
if (args.listUsers) {
|
||||
await listUsers();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate arguments
|
||||
if (!args.userId && !args.username) {
|
||||
console.error('\nError: You must specify either --userId or --username');
|
||||
console.log('Use --help for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
let userId = args.userId;
|
||||
|
||||
if (args.username) {
|
||||
const user = await UserModel.findOne({
|
||||
where: { username: args.username },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error(`\nError: User '${args.username}' not found.`);
|
||||
console.log('Use --list-users to see available users.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
userId = user.id;
|
||||
console.log(`Found user '${args.username}' with ID ${userId}`);
|
||||
} else {
|
||||
// Verify user exists
|
||||
const user = await UserModel.findByPk(userId);
|
||||
|
||||
if (!user) {
|
||||
console.error(`\nError: User with ID ${userId} not found.`);
|
||||
console.log('Use --list-users to see available users.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Found user '${user.username}' with ID ${userId}`);
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
await migrateData(userId, args.dryRun);
|
||||
} catch (error) {
|
||||
console.error('\nError:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { main, listUsers, migrateData, getStatistics };
|
||||
+8
-6
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@whyour/qinglong",
|
||||
"packageManager": "pnpm@8.3.1",
|
||||
"version": "2.22.0",
|
||||
"version": "2.23.0",
|
||||
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -74,20 +74,22 @@
|
||||
"overrides": {
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/view": "6.39.16"
|
||||
"@codemirror/view": "6.39.16",
|
||||
"proxy-addr": "2.0.8",
|
||||
"websocket-driver": "0.7.5"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/plots": "^2.6.8",
|
||||
"@bufbuild/protobuf": "^2.10.0",
|
||||
"@grpc/grpc-js": "^1.14.0",
|
||||
"@grpc/grpc-js": "1.14.5",
|
||||
"@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",
|
||||
"compression": "1.8.2",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^5.4.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -106,7 +108,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"keyv": "^5.2.3",
|
||||
"lodash": "^4.17.21",
|
||||
"multer": "2.1.1",
|
||||
"multer": "2.3.0",
|
||||
"node-cron": "4.6.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"p-queue-cjs": "7.3.4",
|
||||
@@ -114,7 +116,7 @@
|
||||
"ps-tree": "^1.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"request-ip": "3.3.0",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "6.37.8",
|
||||
"sockjs": "^0.3.24",
|
||||
"sqlite3": "npm:@whyour/sqlite3@1.1.2",
|
||||
"toad-scheduler": "^3.0.1",
|
||||
|
||||
Generated
+33
-29
@@ -4,6 +4,8 @@ overrides:
|
||||
sqlite3: npm:@whyour/sqlite3@1.1.2
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.16
|
||||
proxy-addr: 2.0.8
|
||||
websocket-driver: 0.7.5
|
||||
|
||||
dependencies:
|
||||
'@ant-design/plots':
|
||||
@@ -13,8 +15,8 @@ dependencies:
|
||||
specifier: ^2.10.0
|
||||
version: 2.10.0
|
||||
'@grpc/grpc-js':
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.0
|
||||
specifier: 1.14.5
|
||||
version: 1.14.5
|
||||
'@grpc/proto-loader':
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0
|
||||
@@ -34,8 +36,8 @@ dependencies:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
compression:
|
||||
specifier: ^1.7.4
|
||||
version: 1.7.5
|
||||
specifier: 1.8.2
|
||||
version: 1.8.2
|
||||
cors:
|
||||
specifier: ^2.8.5
|
||||
version: 2.8.5
|
||||
@@ -91,8 +93,8 @@ dependencies:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
multer:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
node-cron:
|
||||
specifier: 4.6.0
|
||||
version: 4.6.0
|
||||
@@ -115,8 +117,8 @@ dependencies:
|
||||
specifier: 3.3.0
|
||||
version: 3.3.0
|
||||
sequelize:
|
||||
specifier: ^6.37.5
|
||||
version: 6.37.5(@whyour/sqlite3@1.1.2)
|
||||
specifier: 6.37.8
|
||||
version: 6.37.8(@whyour/sqlite3@1.1.2)
|
||||
sockjs:
|
||||
specifier: ^0.3.24
|
||||
version: 0.3.24
|
||||
@@ -2962,8 +2964,8 @@ packages:
|
||||
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
|
||||
dev: true
|
||||
|
||||
/@grpc/grpc-js@1.14.0:
|
||||
resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==}
|
||||
/@grpc/grpc-js@1.14.5:
|
||||
resolution: {integrity: sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==}
|
||||
engines: {node: '>=12.10.0'}
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.8.0
|
||||
@@ -4940,7 +4942,7 @@ packages:
|
||||
'@umijs/bundler-utils': 4.4.4
|
||||
'@umijs/mako': 0.11.1
|
||||
chalk: 4.1.2
|
||||
compression: 1.7.5
|
||||
compression: 1.8.2
|
||||
connect-history-api-fallback: 2.0.0
|
||||
cors: 2.8.5
|
||||
express: 4.21.2
|
||||
@@ -6828,15 +6830,16 @@ packages:
|
||||
serialize-javascript: 6.0.2
|
||||
dev: true
|
||||
|
||||
/compression@1.7.5:
|
||||
resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==}
|
||||
/compression@1.8.2:
|
||||
resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
compressible: 2.0.18
|
||||
debug: 2.6.9
|
||||
destroy: 1.2.0
|
||||
negotiator: 0.6.4
|
||||
on-headers: 1.0.2
|
||||
on-headers: 1.1.0
|
||||
safe-buffer: 5.2.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
@@ -7731,6 +7734,7 @@ packages:
|
||||
|
||||
/dottie@2.0.6:
|
||||
resolution: {integrity: sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
dev: false
|
||||
|
||||
/dprint-node@1.0.8:
|
||||
@@ -8527,7 +8531,7 @@ packages:
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 0.1.12
|
||||
proxy-addr: 2.0.7
|
||||
proxy-addr: 2.0.8
|
||||
qs: 6.13.0
|
||||
range-parser: 1.2.1
|
||||
safe-buffer: 5.2.1
|
||||
@@ -8611,7 +8615,7 @@ packages:
|
||||
resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
dependencies:
|
||||
websocket-driver: 0.7.4
|
||||
websocket-driver: 0.7.5
|
||||
|
||||
/fb-watchman@2.0.2:
|
||||
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
|
||||
@@ -10751,8 +10755,8 @@ packages:
|
||||
/ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
/multer@2.1.1:
|
||||
resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==}
|
||||
/multer@2.3.0:
|
||||
resolution: {integrity: sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
dependencies:
|
||||
append-field: 1.0.0
|
||||
@@ -11140,8 +11144,8 @@ packages:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
/on-headers@1.0.2:
|
||||
resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==}
|
||||
/on-headers@1.1.0:
|
||||
resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
/once@1.4.0:
|
||||
@@ -12177,8 +12181,8 @@ packages:
|
||||
long: 5.2.3
|
||||
dev: false
|
||||
|
||||
/proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
/proxy-addr@2.0.8:
|
||||
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@@ -13910,8 +13914,8 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
dev: false
|
||||
|
||||
/sequelize@6.37.5(@whyour/sqlite3@1.1.2):
|
||||
resolution: {integrity: sha512-10WA4poUb3XWnUROThqL2Apq9C2NhyV1xHPMZuybNMCucDsbbFuKg51jhmyvvAUyUqCiimwTZamc3AHhMoBr2Q==}
|
||||
/sequelize@6.37.8(@whyour/sqlite3@1.1.2):
|
||||
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
ibm_db: '*'
|
||||
@@ -13945,7 +13949,7 @@ packages:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/validator': 13.12.2
|
||||
debug: 4.3.7(supports-color@5.5.0)
|
||||
debug: 4.4.3
|
||||
dottie: 2.0.6
|
||||
inflection: 1.13.4
|
||||
lodash: 4.17.21
|
||||
@@ -13953,7 +13957,7 @@ packages:
|
||||
moment-timezone: 0.5.46
|
||||
pg-connection-string: 2.7.0
|
||||
retry-as-promised: 7.0.4
|
||||
semver: 7.6.3
|
||||
semver: 7.7.4
|
||||
sequelize-pool: 7.1.0
|
||||
sqlite3: /@whyour/sqlite3@1.1.2
|
||||
toposort-class: 1.0.1
|
||||
@@ -14123,7 +14127,7 @@ packages:
|
||||
dependencies:
|
||||
faye-websocket: 0.11.4
|
||||
uuid: 8.3.2
|
||||
websocket-driver: 0.7.4
|
||||
websocket-driver: 0.7.5
|
||||
dev: false
|
||||
|
||||
/sonic-boom@2.8.0:
|
||||
@@ -15528,8 +15532,8 @@ packages:
|
||||
javascript-stringify: 2.1.0
|
||||
dev: true
|
||||
|
||||
/websocket-driver@0.7.4:
|
||||
resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==}
|
||||
/websocket-driver@0.7.5:
|
||||
resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
dependencies:
|
||||
http-parser-js: 0.5.8
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Reproducible storage comparison, without touching actual script history.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
for (const file of ['sample/notify.js', 'shell/task.sh']) {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
const versions = Array.from({ length: 20 }, (_, i) => ({
|
||||
content: source + '\n// edit ' + i,
|
||||
}));
|
||||
const raw = Buffer.from(JSON.stringify(versions));
|
||||
const strategies = {
|
||||
gzipSeparate: () =>
|
||||
versions.reduce(
|
||||
(sum, version) => sum + zlib.gzipSync(version.content).length,
|
||||
0,
|
||||
),
|
||||
gzipBundle: () => zlib.gzipSync(raw).length,
|
||||
brotliBundle: () =>
|
||||
zlib.brotliCompressSync(raw, {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
||||
[zlib.constants.BROTLI_PARAM_LGWIN]: 22,
|
||||
},
|
||||
}).length,
|
||||
};
|
||||
for (const [strategy, compress] of Object.entries(strategies)) {
|
||||
const start = performance.now();
|
||||
const bytes = compress();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
file,
|
||||
strategy,
|
||||
rawBytes: raw.length,
|
||||
bytes,
|
||||
ms: +(performance.now() - start).toFixed(2),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
destination=$1
|
||||
ref=$2
|
||||
ref_type=$3
|
||||
git check-ref-format "refs/heads/$ref"
|
||||
script_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
# Publish only a complete artifact from the checked-out source commit.
|
||||
node "$script_dir/../docker/verify-build.cjs"
|
||||
if [[ "$ref_type" == 'tag' ]]; then
|
||||
version=$(awk '$1 == "version:" { print $2; exit }' version.yaml)
|
||||
if [[ "$ref" != "v$version" ]]; then
|
||||
echo "::error::Release tag $ref does not match version.yaml ($version)"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "$ref_type" != 'branch' ]]; then
|
||||
echo "::error::Unsupported ref type: $ref_type"
|
||||
exit 1
|
||||
fi
|
||||
workspace=$(mktemp -d)
|
||||
trap 'rm -rf "$workspace"' EXIT
|
||||
cp -a static/. "$workspace/"
|
||||
git init -b "$ref" "$workspace"
|
||||
git -C "$workspace" config user.name 'github-actions[bot]'
|
||||
git -C "$workspace" config user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git -C "$workspace" add -f .
|
||||
git -C "$workspace" commit --allow-empty -m "Build static from $(git rev-parse HEAD)"
|
||||
refs=("refs/heads/$ref:refs/heads/$ref")
|
||||
if [[ "$ref_type" == 'tag' ]]; then
|
||||
# Keep version branches for existing archive URLs and publish real Git tags.
|
||||
git -C "$workspace" tag "$ref"
|
||||
refs+=("refs/tags/$ref:refs/tags/$ref")
|
||||
fi
|
||||
git -C "$workspace" push --force --quiet "$destination" "${refs[@]}"
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Static branches are orphan snapshots. Fetch complete commits for the branch
|
||||
# that was built: a shallow/default-branch clone is rejected by mirror servers.
|
||||
set -euo pipefail
|
||||
destination=$1
|
||||
branch=$2
|
||||
ref_type=${3:-branch}
|
||||
git check-ref-format "refs/heads/$branch"
|
||||
workspace=$(mktemp -d)
|
||||
trap 'rm -rf "$workspace"' EXIT
|
||||
git init --bare "$workspace"
|
||||
fetch_refs=("+refs/heads/$branch:refs/heads/$branch")
|
||||
push_refs=("refs/heads/$branch:refs/heads/$branch")
|
||||
if [[ "$ref_type" == 'tag' ]]; then
|
||||
fetch_refs+=("+refs/tags/$branch:refs/tags/$branch")
|
||||
push_refs+=("refs/tags/$branch:refs/tags/$branch")
|
||||
fi
|
||||
git -C "$workspace" fetch --no-tags "${QL_STATIC_SOURCE:-https://github.com/whyour/qinglong-static.git}" \
|
||||
"${fetch_refs[@]}"
|
||||
git -C "$workspace" push --force "$destination" "${push_refs[@]}"
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
# Observe Pull mirrors; optionally notify them through a small GitHub push.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 3 || $# -gt 4 ]]; then
|
||||
echo 'Usage: wait-for-mirror.sh SOURCE MIRROR REF [branch|tag]' >&2
|
||||
exit 2
|
||||
fi
|
||||
source=$1
|
||||
mirror=$2
|
||||
ref=$3
|
||||
ref_type=${4:-branch}
|
||||
wait_seconds=${QL_MIRROR_WAIT_SECONDS:-600}
|
||||
poll_interval=${QL_MIRROR_POLL_INTERVAL:-30}
|
||||
query_timeout=${QL_MIRROR_QUERY_TIMEOUT:-20}
|
||||
include_version_branch=${QL_MIRROR_INCLUDE_VERSION_BRANCH:-false}
|
||||
notify_repo=${QL_MIRROR_NOTIFY_REPO:-}
|
||||
retry_after=${QL_MIRROR_RETRY_AFTER_SECONDS:-310}
|
||||
notify_on_start=${QL_MIRROR_NOTIFY_ON_START:-true}
|
||||
|
||||
timings=("$wait_seconds" "$poll_interval" "$query_timeout")
|
||||
if [[ -n "$notify_repo" ]]; then timings+=("$retry_after"); fi
|
||||
for value in "${timings[@]}"; do
|
||||
if [[ ! "$value" =~ ^[1-9][0-9]{0,4}$ ]] || (( value > 86400 )); then
|
||||
echo 'Mirror timing values must be integers between 1 and 86400 seconds' >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
case "$ref_type" in
|
||||
branch) refs=("refs/heads/$ref") ;;
|
||||
tag)
|
||||
refs=("refs/tags/$ref")
|
||||
if [[ "$include_version_branch" == true ]]; then
|
||||
refs+=("refs/heads/$ref")
|
||||
fi
|
||||
;;
|
||||
*) echo 'Mirror ref type must be branch or tag' >&2; exit 2 ;;
|
||||
esac
|
||||
if [[ "$include_version_branch" != true && "$include_version_branch" != false ]]; then
|
||||
echo 'QL_MIRROR_INCLUDE_VERSION_BRANCH must be true or false' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$notify_on_start" != true && "$notify_on_start" != false ]]; then
|
||||
echo 'QL_MIRROR_NOTIFY_ON_START must be true or false' >&2
|
||||
exit 2
|
||||
fi
|
||||
for requested_ref in "${refs[@]}"; do
|
||||
git check-ref-format "$requested_ref"
|
||||
done
|
||||
command -v timeout >/dev/null || { echo 'GNU timeout is required' >&2; exit 2; }
|
||||
if [[ -n "$notify_repo" ]]; then
|
||||
if [[ ! "$notify_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo 'QL_MIRROR_NOTIFY_REPO must be a GitHub owner/repository' >&2
|
||||
exit 2
|
||||
fi
|
||||
command -v gh >/dev/null || { echo 'GitHub CLI is required for notifications' >&2; exit 2; }
|
||||
fi
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
polling=false
|
||||
|
||||
notification_command() {
|
||||
local budget=$((notification_deadline - SECONDS))
|
||||
if (( budget <= 0 )); then return 1; fi
|
||||
if (( budget > 10 )); then budget=10; fi
|
||||
timeout --signal=KILL "${budget}s" "$@"
|
||||
}
|
||||
|
||||
notify_static_mirror() {
|
||||
local notification_deadline=$((SECONDS + 30))
|
||||
if [[ "$polling" == true ]] && (( notification_deadline > wait_seconds )); then
|
||||
notification_deadline=$wait_seconds
|
||||
fi
|
||||
local trigger_ref=refs/heads/gitee-sync-trigger
|
||||
local attempt remote_refs source_sha parent tree commit delay remaining
|
||||
local commit_args
|
||||
if ! remote_refs=$(notification_command git ls-remote --refs "$source" "${refs[0]}"); then
|
||||
echo '::warning::Could not read the published source; continuing mirror verification'
|
||||
return 1
|
||||
fi
|
||||
source_sha=${remote_refs%%$'\t'*}
|
||||
if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Published source ref ${refs[0]} does not exist"
|
||||
return 2
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
if (( attempt > 1 )); then
|
||||
delay=$((attempt * 2 - 2))
|
||||
remaining=$((notification_deadline - SECONDS))
|
||||
if (( remaining <= delay )); then break; fi
|
||||
sleep "$delay"
|
||||
fi
|
||||
# Refresh the parent on every attempt. REST avoids the Git push endpoint's
|
||||
# transient 500s, and non-forced updates preserve concurrent notifications.
|
||||
if parent=$(notification_command gh api "repos/$notify_repo/git/ref/heads/gitee-sync-trigger" --jq '.object.sha' 2>&1); then
|
||||
if ! tree=$(notification_command gh api "repos/$notify_repo/git/commits/$parent" --jq '.tree.sha'); then
|
||||
echo "::warning::Could not read notification history (attempt $attempt)"
|
||||
continue
|
||||
fi
|
||||
elif [[ "$parent" == *'(HTTP 404)'* ]]; then
|
||||
parent=''
|
||||
# A new notification branch starts with one tiny marker, never artifacts.
|
||||
# Later commits reuse this tree and have no changed files in the webhook.
|
||||
if ! tree=$(printf '%s' '{"tree":[{"path":".gitee-sync-trigger","mode":"100644","type":"blob","content":"Gitee Pull mirror notifications.\n"}]}' |
|
||||
notification_command gh api --method POST "repos/$notify_repo/git/trees" --input - --jq '.sha'); then
|
||||
echo "::warning::Could not create notification tree (attempt $attempt)"
|
||||
continue
|
||||
fi
|
||||
else
|
||||
echo "::warning::Could not read notification ref (attempt $attempt)"
|
||||
continue
|
||||
fi
|
||||
commit_args=(-f "tree=$tree" -f "message=Trigger Gitee mirror for ${refs[0]} at $source_sha")
|
||||
if [[ -n "$parent" ]]; then commit_args+=(-f "parents[]=$parent"); fi
|
||||
if ! commit=$(notification_command gh api --method POST "repos/$notify_repo/git/commits" "${commit_args[@]}" --jq '.sha'); then
|
||||
echo "::warning::Could not create notification commit (attempt $attempt)"
|
||||
continue
|
||||
fi
|
||||
if [[ -n "$parent" ]]; then
|
||||
if notification_command gh api --method PATCH "repos/$notify_repo/git/refs/heads/gitee-sync-trigger" -f "sha=$commit" -F force=false --silent; then
|
||||
echo '::notice::Sent a small GitHub push event via REST to trigger the Gitee Pull mirror'
|
||||
return 0
|
||||
fi
|
||||
elif notification_command gh api --method POST "repos/$notify_repo/git/refs" -f "ref=$trigger_ref" -f "sha=$commit" --silent; then
|
||||
echo '::notice::Sent a small GitHub push event via REST to trigger the Gitee Pull mirror'
|
||||
return 0
|
||||
fi
|
||||
echo "::warning::Mirror notification update failed (attempt $attempt)"
|
||||
done
|
||||
echo '::warning::Could not send the mirror notification; continuing mirror verification'
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -n "$notify_repo" && "$notify_on_start" == true ]]; then
|
||||
if notify_static_mirror; then :; elif [[ $? -eq 2 ]]; then exit 1; fi
|
||||
fi
|
||||
|
||||
SECONDS=0
|
||||
polling=true
|
||||
attempt=0
|
||||
retried=false
|
||||
last_source='(not read)'
|
||||
last_mirror='(not read)'
|
||||
while (( SECONDS < wait_seconds )); do
|
||||
attempt=$((attempt + 1))
|
||||
outputs=()
|
||||
ready=true
|
||||
# Re-read the source after the mirror so a moving source cannot pass with
|
||||
# the previous SHA. --refs compares annotated tag objects as well as branches.
|
||||
urls=("$source" "$mirror" "$source")
|
||||
labels=('source' 'mirror' 'source confirmation')
|
||||
for index in 0 1 2; do
|
||||
remaining=$((wait_seconds - SECONDS))
|
||||
if (( remaining <= 0 )); then
|
||||
ready=false
|
||||
break
|
||||
fi
|
||||
budget=$query_timeout
|
||||
if (( budget > remaining )); then budget=$remaining; fi
|
||||
if ! output=$(timeout --signal=KILL "${budget}s" git ls-remote --refs \
|
||||
"${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then
|
||||
echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying"
|
||||
ready=false
|
||||
break
|
||||
fi
|
||||
if (( index == 0 )); then last_source=$output; fi
|
||||
if (( index == 1 )); then last_mirror=$output; fi
|
||||
for requested_ref in "${refs[@]}"; do
|
||||
found=false
|
||||
while IFS=$'\t' read -r sha returned_ref; do
|
||||
if [[ "$returned_ref" == "$requested_ref" && "$sha" =~ ^[0-9a-f]{40}([0-9a-f]{24})?$ ]]; then
|
||||
found=true
|
||||
fi
|
||||
done <<< "$output"
|
||||
if [[ "$found" == false ]]; then
|
||||
echo "::notice::${labels[$index]} is missing $requested_ref; waiting"
|
||||
ready=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$ready" == false ]]; then break; fi
|
||||
outputs+=("$output")
|
||||
# Only confirm a source when the initial source and mirror reads match.
|
||||
if (( index == 1 )) && [[ "${outputs[0]}" != "${outputs[1]}" ]]; then
|
||||
ready=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$ready" == true && "${outputs[0]}" == "${outputs[2]}" ]]; then
|
||||
echo "::notice::Mirror matches the latest source refs after ${SECONDS}s"
|
||||
printf '%s\n' "${outputs[2]}"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for mirror (attempt $attempt, ${SECONDS}/${wait_seconds}s)"
|
||||
remaining=$((wait_seconds - SECONDS))
|
||||
if (( remaining <= 0 )); then break; fi
|
||||
# Gitee requires five minutes between sync requests. Retry once after that
|
||||
# interval without restarting the polling deadline.
|
||||
if [[ -n "$notify_repo" && "$retried" == false ]] && (( SECONDS >= retry_after )); then
|
||||
echo '::notice::Retrying the GitHub notification after the Gitee sync interval'
|
||||
if notify_static_mirror; then :; elif [[ $? -eq 2 ]]; then exit 1; fi
|
||||
retried=true
|
||||
continue
|
||||
fi
|
||||
delay=$poll_interval
|
||||
if (( delay > remaining )); then delay=$remaining; fi
|
||||
if [[ -n "$notify_repo" && "$retried" == false ]] && (( SECONDS < retry_after && delay > retry_after - SECONDS )); then
|
||||
delay=$((retry_after - SECONDS))
|
||||
fi
|
||||
sleep "$delay"
|
||||
done
|
||||
|
||||
echo "::error::Mirror did not match the latest source refs within ${wait_seconds}s"
|
||||
printf 'Last source refs:\n%s\nLast mirror refs:\n%s\n' "$last_source" "$last_mirror"
|
||||
exit 1
|
||||
+56
-70
@@ -26,6 +26,28 @@ get_token() {
|
||||
create_token
|
||||
}
|
||||
|
||||
# Check the cached token before every authenticated API request. A rejected
|
||||
# token is refreshed and retried once; other failures are never retried.
|
||||
# Arguments are curl options, including the URL, method and optional body.
|
||||
ql_api_request() {
|
||||
local api=""
|
||||
local attempt
|
||||
get_token || return $?
|
||||
for attempt in 1 2; do
|
||||
api=$(curl -s --noproxy "*" \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
"$@" \
|
||||
--compressed)
|
||||
if [[ "$attempt" == 2 || "$api" == '{"code":200}' ]] ||
|
||||
[[ "$(jq -r '.code' <<< "$api" 2>/dev/null)" != 401 ]]; then
|
||||
break
|
||||
fi
|
||||
create_token || return $?
|
||||
done
|
||||
printf '%s' "$api"
|
||||
}
|
||||
|
||||
# Read the status response once; preserve multiline error messages and the
|
||||
# legacy code/message variables used by the shell API callers.
|
||||
ql_parse_status_response() {
|
||||
@@ -63,13 +85,10 @@ add_cron_api() {
|
||||
sub_id="null"
|
||||
fi
|
||||
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
|
||||
--compressed
|
||||
)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
|
||||
-X POST)
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code == 200 ]]; then
|
||||
@@ -93,14 +112,10 @@ update_cron_api() {
|
||||
local id="$4"
|
||||
fi
|
||||
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-X 'PUT' \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}" \
|
||||
--compressed
|
||||
)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-X PUT \
|
||||
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}")
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code == 200 ]]; then
|
||||
@@ -120,14 +135,10 @@ update_cron_command_api() {
|
||||
local id="$2"
|
||||
fi
|
||||
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-X 'PUT' \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}" \
|
||||
--compressed
|
||||
)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-X PUT \
|
||||
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}")
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code == 200 ]]; then
|
||||
@@ -140,14 +151,10 @@ update_cron_command_api() {
|
||||
del_cron_api() {
|
||||
local ids="$1"
|
||||
local currentTimeStamp=$(date +%s)
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-X 'DELETE' \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--data-raw "[$ids]" \
|
||||
--compressed
|
||||
)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-X DELETE \
|
||||
--data-raw "[$ids]")
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code == 200 ]]; then
|
||||
@@ -171,14 +178,10 @@ update_cron() {
|
||||
dataRaw="${dataRaw},\"exit_code\":$exitCode"
|
||||
fi
|
||||
dataRaw="${dataRaw}}"
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/status?t=$currentTimeStamp" \
|
||||
-X 'PUT' \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--data-raw "$dataRaw" \
|
||||
--compressed
|
||||
)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons/status?t=$currentTimeStamp" \
|
||||
-X PUT \
|
||||
--data-raw "$dataRaw")
|
||||
ql_parse_status_response "$api" || true
|
||||
if [[ $code != 200 ]]; then
|
||||
if [[ ! $message ]]; then
|
||||
@@ -192,14 +195,10 @@ notify_api() {
|
||||
local title="$1"
|
||||
local content="$2"
|
||||
local currentTimeStamp=$(date +%s)
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port}/open/system/notify?t=$currentTimeStamp" \
|
||||
-X 'PUT' \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}" \
|
||||
--compressed
|
||||
)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/system/notify?t=$currentTimeStamp" \
|
||||
-X PUT \
|
||||
--data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}")
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code == 200 ]]; then
|
||||
@@ -212,12 +211,9 @@ notify_api() {
|
||||
find_cron_api() {
|
||||
local params="$1"
|
||||
local currentTimeStamp=$(date +%s)
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--compressed
|
||||
)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \
|
||||
-X GET)
|
||||
data=$(echo "$api" | jq -r .data)
|
||||
if [[ $data == 'null' ]]; then
|
||||
echo -e ""
|
||||
@@ -231,14 +227,10 @@ update_auth_config() {
|
||||
local body="$1"
|
||||
local tip="$2"
|
||||
local currentTimeStamp=$(date +%s)
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
|
||||
-X 'PUT' \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--data-raw "{$body}" \
|
||||
--compressed
|
||||
)
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
|
||||
-X PUT \
|
||||
--data-raw "{$body}")
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code == 200 ]]; then
|
||||
@@ -254,14 +246,10 @@ record_cron_stat() {
|
||||
local elapsed="${3:-0}"
|
||||
[[ $ref_id ]] && [[ $ref_id -gt 0 ]] 2>/dev/null || return
|
||||
|
||||
local api=$(
|
||||
curl -s --noproxy "*" "http://localhost:${ql_port:-5700}/open/dashboard/record" \
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port:-5700}/open/dashboard/record" \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${__ql_token__}" \
|
||||
-H "Content-Type: application/json;charset=UTF-8" \
|
||||
--data-raw "{\"ref_id\":$ref_id,\"code\":$exit_code,\"elapsed\":$elapsed}" \
|
||||
--compressed
|
||||
)
|
||||
--data-raw "{\"ref_id\":$ref_id,\"code\":$exit_code,\"elapsed\":$elapsed}")
|
||||
ql_parse_status_response "$api" || true
|
||||
if [[ $code != 200 ]]; then
|
||||
if [[ ! $message ]]; then
|
||||
@@ -270,5 +258,3 @@ record_cron_stat() {
|
||||
echo -e "${message}"
|
||||
fi
|
||||
}
|
||||
|
||||
get_token
|
||||
|
||||
+7
-9
@@ -84,7 +84,9 @@ env_str_to_array() {
|
||||
}
|
||||
|
||||
clear_non_sh_env() {
|
||||
if [[ $file_param != *.sh ]]; then
|
||||
# Only automatic Node/Python preloads reconstruct the generated environment.
|
||||
# Explicit interpreters and other commands must keep the shell-loaded values.
|
||||
if [[ $isJsOrPythonFile == 'true' ]]; then
|
||||
clear_env
|
||||
fi
|
||||
}
|
||||
@@ -162,7 +164,7 @@ run_normal() {
|
||||
if [[ $isJsOrPythonFile == 'false' ]]; then
|
||||
clear_non_sh_env
|
||||
fi
|
||||
$timeoutCmd $which_program $file_param "${script_params[@]}"
|
||||
run_task_command $which_program $file_param "${script_params[@]}"
|
||||
}
|
||||
|
||||
handle_env_split() {
|
||||
@@ -201,7 +203,7 @@ run_concurrent() {
|
||||
export "${env_param}=${array[$i - 1]}"
|
||||
clear_non_sh_env
|
||||
fi
|
||||
eval envParam="${env_param}" numParam="${i}" $timeoutCmd $which_program $file_param "${script_params[@]}" &>$single_log_path &
|
||||
eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path &
|
||||
done
|
||||
|
||||
wait
|
||||
@@ -241,7 +243,7 @@ run_designated() {
|
||||
|
||||
enter_script_workdir
|
||||
|
||||
envParam="${env_param}" numParam="${num_param}" $timeoutCmd $which_program $file_param "${script_params[@]}"
|
||||
envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}"
|
||||
}
|
||||
|
||||
## 运行其他命令
|
||||
@@ -297,7 +299,7 @@ run_else() {
|
||||
fi
|
||||
|
||||
clear_non_sh_env
|
||||
$timeoutCmd $which_program $file_param "$@"
|
||||
run_task_command $which_program $file_param "$@"
|
||||
}
|
||||
|
||||
check_file() {
|
||||
@@ -334,10 +336,6 @@ check_nounset() {
|
||||
|
||||
main() {
|
||||
if [[ $1 == *.js ]] || [[ $1 == *.mjs ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.sh ]] || [[ $1 == *.ts ]]; then
|
||||
if [[ $1 == *.sh ]]; then
|
||||
timeoutCmd=""
|
||||
fi
|
||||
|
||||
case $# in
|
||||
1)
|
||||
run_normal "$1"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Fork from the current Bash state: an external `bash -c` would lose private
|
||||
# before-hook variables, arrays and functions used by sourced Shell scripts.
|
||||
run_task_command() {
|
||||
local timeout_seconds
|
||||
if [[ -z ${command_timeout_time:-} ]]; then
|
||||
"$@"
|
||||
return $?
|
||||
fi
|
||||
timeout_seconds=$(awk -v value="$command_timeout_time" 'BEGIN {
|
||||
if (value !~ /^([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?[smhd]?$/) exit 1
|
||||
unit = substr(value, length(value), 1)
|
||||
factor = unit == "d" ? 86400 : unit == "h" ? 3600 : unit == "m" ? 60 : 1
|
||||
printf "%.9f", (value + 0) * factor
|
||||
}') || { printf 'Invalid task timeout: %s\n' "$command_timeout_time" >&2; return 125; }
|
||||
if [[ $timeout_seconds == '0.000000000' ]]; then
|
||||
"$@"
|
||||
return $?
|
||||
fi
|
||||
(
|
||||
timeout_dir=$(mktemp -d "${TMPDIR:-/tmp}/ql-task-timeout.XXXXXXXX") || exit 125
|
||||
# Both children get private process groups. The timer and the wrapper must
|
||||
# survive terminating the task, and descendants must not outlive a timeout.
|
||||
set -m
|
||||
( set +m; "$@" ) <&0 &
|
||||
timeout_worker=$!
|
||||
(
|
||||
set +m
|
||||
: > "$timeout_dir/ready"
|
||||
sleep "$timeout_seconds" || exit 125
|
||||
: > "$timeout_dir/expired"
|
||||
kill -TERM -- "-$timeout_worker" 2>/dev/null || :
|
||||
sleep 0.5
|
||||
kill -KILL -- "-$timeout_worker" 2>/dev/null || :
|
||||
) &
|
||||
timeout_watcher=$!
|
||||
set +m
|
||||
trap 'kill -KILL -- "-$timeout_watcher" 2>/dev/null || :
|
||||
if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi
|
||||
kill -KILL -- "-$timeout_worker" 2>/dev/null || :
|
||||
wait "$timeout_worker" 2>/dev/null || :
|
||||
wait "$timeout_watcher" 2>/dev/null || :
|
||||
rm -rf -- "$timeout_dir"' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 131' QUIT
|
||||
# Do not clean up a timer whose process group has not initialized yet.
|
||||
while [[ ! -f "$timeout_dir/ready" ]]; do
|
||||
kill -0 "$timeout_watcher" 2>/dev/null || exit 125
|
||||
sleep 0.001
|
||||
done
|
||||
wait "$timeout_worker"
|
||||
timeout_code=$?
|
||||
if [[ -f "$timeout_dir/expired" ]]; then
|
||||
# Let the timer finish escalation even if the task leader exited first.
|
||||
wait "$timeout_watcher" 2>/dev/null || :
|
||||
exit 124
|
||||
fi
|
||||
exit "$timeout_code"
|
||||
)
|
||||
}
|
||||
+8
-6
@@ -3,6 +3,7 @@
|
||||
dir_shell=$QL_DIR/shell
|
||||
. $dir_shell/share.sh
|
||||
. $dir_shell/api.sh
|
||||
. $dir_shell/task-timeout.sh
|
||||
|
||||
trap 'single_hanle SIGINT' INT
|
||||
trap 'single_hanle SIGTERM' TERM
|
||||
@@ -18,6 +19,12 @@ single_hanle() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_shell_script() {
|
||||
# Preserve inherited variables/functions, but contain exit, exec and shell
|
||||
# options so the task wrapper can run after hooks and report completion.
|
||||
( . "$@" )
|
||||
}
|
||||
|
||||
define_program() {
|
||||
local file_param=$1
|
||||
if [[ $file_param == *.js ]] || [[ $file_param == *.mjs ]]; then
|
||||
@@ -25,7 +32,7 @@ define_program() {
|
||||
elif [[ $file_param == *.py ]] || [[ $file_param == *.pyc ]]; then
|
||||
which_program="python3"
|
||||
elif [[ $file_param == *.sh ]]; then
|
||||
which_program="."
|
||||
which_program="run_shell_script"
|
||||
elif [[ $file_param == *.ts ]]; then
|
||||
which_program="ts-node-transpile-only"
|
||||
else
|
||||
@@ -106,11 +113,6 @@ format_params() {
|
||||
mtime_format="%Y-%m-%d %H:%M:%S.%3N"
|
||||
fi
|
||||
timeoutCmd=""
|
||||
if [[ $command_timeout_time ]]; then
|
||||
if type timeout &>/dev/null; then
|
||||
timeoutCmd="timeout --foreground -s 2 -k 10s $command_timeout_time "
|
||||
fi
|
||||
fi
|
||||
# params=$(echo "$@" | sed -E 's/([^ ])&([^ ])/\1\\\&\2/g')
|
||||
|
||||
# 分割 task 内置参数和脚本参数
|
||||
|
||||
+42
-97
@@ -283,98 +283,44 @@ usage() {
|
||||
}
|
||||
|
||||
reload_qinglong() {
|
||||
echo -e "[reload_qinglong] deleting Triggered at $(date)" >>${dir_log}/reload.log
|
||||
local reload_target="${1}"
|
||||
if [[ "$reload_target" == 'system' ]]; then
|
||||
QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}" node "$dir_shell/upgrade.cjs" reload
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Data import and a plain service restart retain their existing behavior.
|
||||
echo -e "[reload_qinglong] deleting Triggered at $(date)" >>"${dir_log}/reload.log"
|
||||
sleep 3
|
||||
delete_pm2
|
||||
echo -e "[reload_qinglong] deleted Triggered at $(date)" >>${dir_log}/reload.log
|
||||
|
||||
local reload_target="${1}"
|
||||
local primary_branch="master"
|
||||
if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then
|
||||
primary_branch="${QL_BRANCH}"
|
||||
fi
|
||||
|
||||
if [[ "$reload_target" == 'system' ]]; then
|
||||
rm -rf ${dir_root}/back ${dir_root}/cli ${dir_root}/docker ${dir_root}/sample ${dir_root}/shell ${dir_root}/src
|
||||
mv -f ${dir_tmp}/qinglong-${primary_branch}/* ${dir_root}/
|
||||
rm -rf $dir_static/*
|
||||
mv -f ${dir_tmp}/qinglong-static-${primary_branch}/* ${dir_static}/
|
||||
cp -f $file_config_sample $dir_config/config.sample.sh
|
||||
fi
|
||||
|
||||
if [[ "$reload_target" == 'data' ]]; then
|
||||
rm -rf ${dir_data}/*
|
||||
mv -f ${dir_tmp}/data/* ${dir_data}/
|
||||
rm -rf "${dir_data:?}"/*
|
||||
mv -f "$dir_tmp"/data/* "$dir_data"/ || return $?
|
||||
fi
|
||||
echo -e "[reload_qinglong] starting Triggered at $(date)" >>${dir_log}/reload.log
|
||||
reload_pm2
|
||||
echo -e "[reload_qinglong] started Triggered at $(date)\n" >>${dir_log}/reload.log
|
||||
}
|
||||
|
||||
## 更新 qinglong
|
||||
update_qinglong() {
|
||||
rm -rf ${dir_tmp}/*
|
||||
local mirror="gitee"
|
||||
local downloadQLUrl="https://gitee.com/whyour/qinglong/repository/archive"
|
||||
local downloadStaticUrl="https://gitee.com/whyour/qinglong-static/repository/archive"
|
||||
local githubStatus=$(curl -s --noproxy "*" -m 2 -IL "https://google.com" | grep 200)
|
||||
if [[ ! -z $githubStatus ]]; then
|
||||
if curl --fail --silent --noproxy "*" --max-time 2 --head "https://github.com" >/dev/null; then
|
||||
mirror="github"
|
||||
downloadQLUrl="https://github.com/whyour/qinglong/archive/refs/heads"
|
||||
downloadStaticUrl="https://github.com/whyour/qinglong-static/archive/refs/heads"
|
||||
fi
|
||||
t '使用 %s 源更新...\n' "${mirror}"
|
||||
|
||||
local primary_branch="master"
|
||||
if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then
|
||||
primary_branch="${QL_BRANCH}"
|
||||
fi
|
||||
|
||||
wget -cqO "${dir_tmp}/ql.zip" "${downloadQLUrl}/${primary_branch}.zip"
|
||||
exit_status=$?
|
||||
|
||||
if [[ $exit_status -eq 0 ]]; then
|
||||
t '更新青龙源文件成功...\n'
|
||||
|
||||
unzip -oq ${dir_tmp}/ql.zip -d ${dir_tmp}
|
||||
|
||||
update_qinglong_static
|
||||
else
|
||||
t '更新青龙源文件失败,请检查网络...\n'
|
||||
fi
|
||||
t '使用 %s 源更新...\n' "$mirror"
|
||||
QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}" node "$dir_shell/upgrade.cjs" update "$mirror" "$needRestart"
|
||||
}
|
||||
|
||||
update_qinglong_static() {
|
||||
wget -cqO "${dir_tmp}/static.zip" "${downloadStaticUrl}/${primary_branch}.zip"
|
||||
exit_status=$?
|
||||
|
||||
if [[ $exit_status -eq 0 ]]; then
|
||||
t '更新青龙静态资源成功...\n'
|
||||
unzip -oq ${dir_tmp}/static.zip -d ${dir_tmp}
|
||||
|
||||
check_update_dep
|
||||
# Preserve the updater's status through both tee and the final timing output.
|
||||
run_update_logged() (
|
||||
set -o pipefail
|
||||
if [[ "$real_time" == 'true' ]]; then
|
||||
"$@"
|
||||
elif [[ "$no_tee" == 'true' ]]; then
|
||||
"$@" >>"$file_path" 2>&1
|
||||
else
|
||||
t '更新青龙静态资源失败,请检查网络...\n'
|
||||
"$@" 2>&1 | tee -a "$file_path"
|
||||
fi
|
||||
}
|
||||
|
||||
check_update_dep() {
|
||||
t '\n开始检测依赖...\n'
|
||||
if [[ $(diff $dir_root/package.json ${dir_tmp}/qinglong-${primary_branch}/package.json) ]]; then
|
||||
npm_install_2 "${dir_tmp}/qinglong-${primary_branch}"
|
||||
fi
|
||||
|
||||
if [[ $exit_status -eq 0 ]]; then
|
||||
t '\n依赖检测安装成功...\n'
|
||||
t '更新包下载成功...\n'
|
||||
|
||||
if [[ "$needRestart" == 'true' ]]; then
|
||||
reload_qinglong "system"
|
||||
fi
|
||||
else
|
||||
t '\n依赖检测安装失败,请检查网络...\n'
|
||||
fi
|
||||
}
|
||||
)
|
||||
|
||||
## 对比脚本
|
||||
diff_scripts() {
|
||||
@@ -429,23 +375,19 @@ gen_list_repo() {
|
||||
|
||||
cd ${repo_path}
|
||||
|
||||
local cmd="find ."
|
||||
local index=0
|
||||
if [[ $6 ]]; then
|
||||
file_extensions="$6"
|
||||
if [[ $file_extensions =~ "|" ]]; then
|
||||
file_extensions=$(echo $file_extensions | sed 's/|/ /g')
|
||||
local extension_string="${6:-$file_extensions}"
|
||||
extension_string="${extension_string//|/ }"
|
||||
extension_string="${extension_string//$'\n'/ }"
|
||||
local -a extensions=() find_args=()
|
||||
IFS=$' \t\n' read -r -a extensions <<< "$extension_string"
|
||||
local extension
|
||||
for extension in "${extensions[@]}"; do
|
||||
if [[ ${#find_args[@]} -gt 0 ]]; then
|
||||
find_args+=(-o)
|
||||
fi
|
||||
fi
|
||||
for extension in $file_extensions; do
|
||||
if [[ $index -eq 0 ]]; then
|
||||
cmd="${cmd} -name \"*.${extension}\""
|
||||
else
|
||||
cmd="${cmd} -o -name \"*.${extension}\""
|
||||
fi
|
||||
let index+=1
|
||||
find_args+=(-name "*.${extension}")
|
||||
done
|
||||
files=$(eval $cmd | sed 's/^..//')
|
||||
files=$(find . "${find_args[@]}" | sed 's/^..//')
|
||||
if [[ $path ]]; then
|
||||
files=$(echo "$files" | egrep "$path")
|
||||
fi
|
||||
@@ -458,7 +400,7 @@ gen_list_repo() {
|
||||
|
||||
if [[ $dependence ]]; then
|
||||
cd ${repo_path}
|
||||
results=$(eval $cmd | sed 's/^..//' | egrep "$dependence")
|
||||
results=$(find . "${find_args[@]}" | sed 's/^..//' | egrep "$dependence")
|
||||
for _file in ${results}; do
|
||||
file_path=$(dirname $_file)
|
||||
make_dir "${dir_scripts}/${uniq_path}/${file_path}"
|
||||
@@ -529,7 +471,7 @@ main() {
|
||||
|
||||
if [[ "$p1" == "log" ]]; then
|
||||
show_service_logs "${@:2}"
|
||||
return $?
|
||||
return 0
|
||||
fi
|
||||
|
||||
local log_dir="${p1}"
|
||||
@@ -558,14 +500,17 @@ main() {
|
||||
|
||||
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
|
||||
|
||||
local command_status=0
|
||||
case $p1 in
|
||||
update)
|
||||
fix_config
|
||||
local needRestart=${p2:-"true"}
|
||||
eval update_qinglong $cmd
|
||||
run_update_logged update_qinglong
|
||||
command_status=$?
|
||||
;;
|
||||
reload)
|
||||
eval reload_qinglong "$p2" $cmd
|
||||
run_update_logged reload_qinglong "$p2"
|
||||
command_status=$?
|
||||
;;
|
||||
extra)
|
||||
eval run_extra_shell $cmd
|
||||
@@ -624,9 +569,9 @@ main() {
|
||||
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
|
||||
eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒 " $cmd
|
||||
fi
|
||||
return "$command_status"
|
||||
}
|
||||
|
||||
import_config "$@"
|
||||
main "$@"
|
||||
|
||||
exit 0
|
||||
exit $?
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// The image updater and local CLI share one implementation and build contract.
|
||||
const upgrade = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
module.exports = upgrade;
|
||||
if (require.main === module)
|
||||
upgrade.main().catch((error) => {
|
||||
console.error(`更新失败: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+33
-4
@@ -10,7 +10,15 @@ import {
|
||||
import ProLayout, { PageLoading } from '@ant-design/pro-layout';
|
||||
import { history, Link, Outlet, useLocation } from '@umijs/max';
|
||||
import * as DarkReader from '@umijs/ssr-darkreader';
|
||||
import { Avatar, Badge, Dropdown, Image, MenuProps, Tooltip } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Dropdown,
|
||||
Image,
|
||||
MenuProps,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import vhCheck from 'vh-check';
|
||||
@@ -48,7 +56,8 @@ export default function () {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
|
||||
const [siteTitle, setSiteTitle] = useState(
|
||||
() => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
||||
() =>
|
||||
localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [initLoading, setInitLoading] = useState<boolean>(true);
|
||||
@@ -94,7 +103,7 @@ export default function () {
|
||||
if (code === 200 && data.username) {
|
||||
setUser(data);
|
||||
if (location.pathname === '/') {
|
||||
history.push('/dashboard');
|
||||
history.push(data.role === 1 ? '/env' : '/dashboard');
|
||||
}
|
||||
}
|
||||
needLoading && setLoading(false);
|
||||
@@ -206,7 +215,7 @@ export default function () {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !user.username) return;
|
||||
if (!user || !user.username || user.role === 1) return;
|
||||
const ws = WebSocketManager.getInstance(
|
||||
`${window.location.origin}${
|
||||
config.apiPrefix
|
||||
@@ -390,7 +399,27 @@ export default function () {
|
||||
</span>
|
||||
)}
|
||||
{...defaultProps}
|
||||
{...(user.role === 1
|
||||
? {
|
||||
route: {
|
||||
routes: defaultProps.route!.routes!.filter((route) =>
|
||||
['/crontab', '/env', '/subscription'].includes(
|
||||
route.path || '',
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{user.role === 1 && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={intl.get(
|
||||
'普通用户只读自己的数据,执行和系统修改由管理员负责',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Outlet
|
||||
context={{
|
||||
...ctx,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user