Compare commits

...
Author SHA1 Message Date
whyour 8976447b1e fix: pin Alpine amd64 Node runtime and verify URL recovery 2026-10-23 01:43:56 +08:00
whyour 15d54866a8 feat: migrate SQLite runtime and verify complete upgrade bundles
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon.

Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility.

Keep authentication storage failures distinct from expired sessions with a pinned Keyv runtime, propagated cache errors, and retryable HTTP 503 responses. Preserve genuine 401 failures and verify legacy cache bytes, initialization safety, and session recovery.
2026-10-22 13:47:26 +08:00
whyour 99a4d0eff8 fix: batch notification audits and bump CLI to 0.1.3 2026-10-22 01:25:29 +08:00
whyour b486989399 fix: validate ARMv6 upgrades and publish rolling development bundles 2026-10-21 01:43:44 +08:00
whyour 77fe551425 fix: simplify npm packaging and align frontend Node builds 2026-10-19 18:06:06 +08:00
whyour 2669f251ac fix: preserve npm environment during package replacement 2026-10-19 15:41:42 +08:00
whyour 3f02b04dff fix: ship verified npm panel packages and test production migrations 2026-10-19 14:17:53 +08:00
whyour e8b6a30bd9 feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

Publish release bundles with master images and expose an opt-in develop build
sharing the development index across Alpine and Debian images.
2026-10-18 22:19:49 +08:00
whyour a36a77086a fix: validate extra schedules and localize validation errors
Reject invalid extra_schedules with HTTP 400 before create or update reaches the task service. Preserve omitted, null and empty extras, supported cron syntax, and additional metadata.

Localize missing rules, invalid string and array types, and non-object extra entries through the existing per-request translation middleware. Cover POST/PUT rejection without mutations, dynamic English/Chinese switching, and stable error paths.

Validation: 9 related regression tests passed on current develop; backend build passed. The API validation fix also passed full regression and isolated develop/debian-dev acceptance.

Refs #3099
2026-10-18 01:08:36 +08:00
whyour cb5dfec2b5 fix: preserve cron semantics during node-cron adaptation
Preserve supported expression fields instead of stringifying them, and probe individual fields only when whole-expression validation fails. Convert unsupported syntax and weekday ranges ending in 7 while preserving legacy date/weekday rules and valid OR branches.

Add execution regressions for issue #3099 and 64 calendar boundary cases in UTC and Asia/Shanghai. Ensure timezone child tests actually run under the Node test runner.

Validation: 745 tests passed, 8 skipped; final targeted regressions passed; backend compilation passed. Differential calendar checks matched 142,360 execution timestamps.

Fixes #3099
2026-10-17 20:00:33 +08:00
whyour 286088a32c feat: stage online upgrades before verified restarts
Download updates asynchronously with persistent progress and validate matching source and build artifacts before an explicit restart. Keep upgrade workers alive across service restarts, verify the new runtime, and recover the browser with a one-time success notice.

Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and show the sidebar notice only for active, ready or failed jobs. Constrain long account names with ellipsis while preserving avatar and collapse controls.
2026-10-17 17:14:15 +08:00
whyour a1104ef47c fix: report page load timing after load handlers complete 2026-10-17 09:03:50 +08:00
whyour 1a152575cc fix: preserve active system log files when clearing logs 2026-10-17 07:42:10 +08:00
whyour ef348b445b fix: synchronize runtime locales and harden online updates
Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
2026-10-17 07:07:09 +08:00
whyour 8305825ffd Merge Python dependency and Shell regression fixes
fix: Python dependency status, GNU awk compatibility and dev regressions
2026-10-15 23:41:16 +08:00
whyour e555fcbb0b fix: honor Python requirement specifiers and portable script discovery 2026-10-15 23:36:21 +08:00
whyour 83e8f6bccd Merge tested dev regression fixes for integration review 2026-10-15 23:28:56 +08:00
whyour 5801f735bc fix: improve shell compatibility and process cleanup
Preserve literal paths and arguments, use portable command options, validate startup configuration, and retain scripts when downloads fail.

Ensure task watchdogs and process groups are cleaned up across Bash versions, with compatibility and process lifecycle regression coverage.
2026-10-15 23:20:15 +08:00
whyour 53e1129a12 fix: preserve Python source state and repair dev regressions 2026-10-15 09:53:22 +08:00
whyour 13c0a4d21b fix: handle mobile INI editing and silence timeout cleanup
Map INI to the CodeMirror properties language and fall back to plain text for unsupported modes. Reap the timeout watchdog immediately under stderr redirection to prevent Bash job notifications during cleanup.

Refs #3095, #3096
2026-10-15 01:17:43 +08:00
whyour 126fe8eb67 feat(notify): audit system and JavaScript/Python script notifications 2026-10-14 10:28:43 +08:00
whyour 73c41eed22 fix(i18n): retain Python dependency and notification audit translations 2026-10-14 10:25:13 +08:00
whyour 40149b6f17 feat(deps): support Python Git URLs and requirements files 2026-10-14 10:21:19 +08:00
whyour 6532775a25 feat(python): support isolated venv environments for native deployment 2026-10-14 10:19:50 +08:00
whyour 6469792546 chore(deps): update moment and brace-expansion security patches 2026-10-14 10:18:16 +08:00
whyour 0aa521a288 fix(python): integrate venv startup with source dependency support 2026-10-13 02:24:12 +08:00
whyour 1ac04a8062 fix(python): keep source dependency installs and removals in selected venv 2026-10-13 02:21:10 +08:00
whyour e435f6e886 fix(notify): audit system and JS/Python script notification results 2026-10-13 02:20:59 +08:00
whyour db88d037db fix(python): integrate latest develop and support older venv pip 2026-10-13 02:20:59 +08:00
whyour a0c09046c6 fix(deps): keep security updates within compatible dependency lines 2026-10-13 02:17:55 +08:00
dependabot[bot] 5a9e53c771 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [moment](https://github.com/moment/moment) and [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `moment` from 2.30.1 to 2.31.0
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.30.1...2.31.0)

Updates `brace-expansion` from 1.1.12 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.21)

---
updated-dependencies:
- dependency-name: moment
  dependency-version: 2.31.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 17:28:13 +00:00
whyour be877cfddc fix: 修复完整镜像源地址及 CRLF 任务导入 (#3093)
* fix: address six verified legacy issue regressions

* fix: limit issue patch to mirror URLs and CRLF imports

* fix: preserve APT formats and third-party repositories
2026-10-13 01:11:35 +08:00
Copilotwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
fdeffebba8 Fix catch-all route serving HTML for API requests (#2885)
* Initial plan

* Fix env.js loading error by preventing catch-all from matching API routes

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-13 01:07:50 +08:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>whyour
3c61e933a1 chore(deps): bump the npm_and_yarn group across 1 directory with 8 updates (#3075)
* chore(deps): bump the npm_and_yarn group across 1 directory with 8 updates

Bumps the npm_and_yarn group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [body-parser](https://github.com/expressjs/body-parser) | `1.20.3` | `1.20.6` |
| [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) | `3.0.3` | `3.0.7` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.1.0` | `4.3.2` |
| [multer](https://github.com/expressjs/multer) | `2.3.0` | `2.4.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.1` | `10.0.9` |
| [undici](https://github.com/nodejs/undici) | `7.9.0` | `7.29.1` |
| [axios](https://github.com/axios/axios) | `1.7.8` | `1.20.0` |
| [brace-expansion](https://github.com/juliangruber/brace-expansion) | `1.1.12` | `1.1.21` |



Updates `body-parser` from 1.20.3 to 1.20.6
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.3...1.20.6)

Updates `http-proxy-middleware` from 3.0.3 to 3.0.7
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v3.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v3.0.3...v3.0.7)

Updates `js-yaml` from 4.1.0 to 4.3.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.3.2)

Updates `multer` from 2.3.0 to 2.4.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.3.0...v2.4.0)

Updates `nodemailer` from 8.0.1 to 10.0.9
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.1...v10.0.9)

Updates `undici` from 7.9.0 to 7.29.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.9.0...v7.29.1)

Updates `axios` from 1.7.8 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.8...v1.20.0)

Updates `brace-expansion` from 1.1.12 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.21)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:development
- dependency-name: body-parser
  dependency-version: 1.20.6
  dependency-type: direct:production
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
- dependency-name: http-proxy-middleware
  dependency-version: 3.0.7
  dependency-type: direct:production
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: direct:production
- dependency-name: multer
  dependency-version: 2.3.0
  dependency-type: direct:production
- dependency-name: nodemailer
  dependency-version: 9.1.1
  dependency-type: direct:production
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(ci): verify the updated multer runtime version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-13 01:06:29 +08:00
whyour cf236f8324 fix(dependencies): require existing sources and translate install hints 2026-10-12 01:13:53 +08:00
whyour 7334566d3c fix(notification): align audit route and WPUSH fixtures 2026-10-12 01:13:53 +08:00
whyour 4cd6c6ad2e fix(dependencies): quote and validate Python source installs 2026-10-12 00:23:48 +08:00
whyour 81a60254a2 fix(python): share safe startup and honor explicit virtual environments 2026-10-12 00:23:20 +08:00
whyour 1503f07c75 fix(notification): bound audit logs without breaking delivery 2026-10-12 00:23:20 +08:00
whyour 70636900b2 chore: finalize 2.23.0 publication metadata 2026-10-11 12:27:51 +08:00
whyour 331f4f9138 Prepare Qinglong 2.23.0 and CLI 0.1.2 release (#3090)
* fix: patch reachable release-blocking dependencies

* docs: keep release risk notes in PR description

* chore: prepare Qinglong 2.23.0 and CLI 0.1.2 release metadata
2026-10-11 12:24:50 +08:00
whyour b06cb72334 fix: apply minimal production dependency security patches 2026-10-11 02:09:20 +08:00
whyour 89c6355635 fix: renew system tokens and serialize authentication cache updates 2026-10-11 01:56:50 +08:00
whyour 68ffa106f8 fix: resolve Debian regression findings (#3087)
* fix: resolve Debian regression findings

* fix: reject failed APT index refreshes and simplify SSH home lookup
2026-10-09 01:41:29 +08:00
whyour 9d5f58ef99 ci: verify Gitee Pull mirrors with resilient GitHub notifications
Use native Gitee Pull mirrors for code and static artifacts, and verify branch and tag SHAs alongside image builds. Trigger stale mirrors through small GitHub REST ref updates, retry transient errors with backoff, and keep polling when a notification fails.
2026-10-08 01:16:43 +08:00
whyour ef1c54a620 fix: 补齐前端缺失的中英文翻译 2026-10-07 20:35:59 +08:00
whyour 0218404d9f fix: 修复页面升级、ql update 与静态资源版本同步 (#3086)
* fix: validate panel upgrades and synchronize versioned static builds

* fix: add static-only mirror backfill workflow

* fix: avoid lifecycle self-kill and stop cancelled upgrade groups

* fix: build images when the shared upgrade engine changes

* fix: dispatch upgrade lifecycle through a fixed script

* fix: reclaim upgrade locks safely and normalize health probes

* fix: match backend base URL normalization exactly
2026-10-07 17:43:03 +08:00
whyour 24f7d1a46f fix: resolve dev panel and CLI regressions
Synchronize scheduler concurrency across API and gRPC processes and restore persisted limits during recovery.

Serialize subscription mutations and preserve shared storage while cleaning Shell and CLI namespaces, historical references, and filesystem-specific naming candidates.

Handle CLI text responses correctly and include script history in backups. Add regression coverage for concurrency, cleanup ownership, long URLs, Unicode paths, and history exports.
2026-10-07 12:35:10 +08:00
whyour fa8910b3d6 feat: add script version history (#3085)
* feat: add script version history

* fix: validate canonical save-as paths before backup

* fix: stack script history footer on small screens
2026-10-06 22:00:11 +08:00
whyour 2cab112391 fix: 加固安全输入边界并修复订阅兼容性 (#3084)
* fix: harden scoped API inputs and isolate login throttling

* fix: close script path gaps and harden security boundaries

* fix: preserve subscription aliases and avoid extension eval

* fix: allow cancelling invalid historical subscriptions

* fix: make script parent validation explicit for CodeQL

* fix: limit command execution and clarify trusted path branches

* fix: separate diff tool script access from config APIs

* fix: clean up obsolete subscription SSH credentials
2026-10-06 18:21:54 +08:00
whyour 2ab9c702e6 ci: skip Docker builds for CLI-only changes 2026-10-01 12:25:16 +08:00
whyour 6dd98a8565 fix(ci): isolate CLI release concurrency from validation 2026-10-01 12:17:26 +08:00
whyour 06c8d8b112 fix: report resource conflicts and accurate CLI API errors 2026-10-01 11:26:40 +08:00
whyour a5406bedca ci: allow verified CLI prereleases from develop 2026-10-01 00:26:42 +08:00
whyour 3d2b98a442 fix: scope file access restrictions and honor custom data directories 2026-09-30 21:21:35 +08:00
whyour 93d7ec69dc fix: scope config file blacklist to config directory (#3082) 2026-09-30 20:31:20 +08:00
whyour a303e33b4e fix: invalidate queued cron revisions and enforce task process timeouts 2026-09-29 23:37:00 +08:00
whyour 44129ca088 fix: align CLI shell completion and cancellation semantics 2026-09-29 01:06:47 +08:00
whyour def4917447 fix: finalize exited shell tasks and preserve command environments 2026-09-29 00:52:33 +08:00
whyour bc0f35e3f7 fix: reduce task startup overhead and lazy-load notifications (#3080) 2026-09-28 23:41:31 +08:00
whyour f168efcdae refactor: migrate cron scheduling to node-cron (#3079)
* refactor: migrate cron scheduling to node-cron

* feat: support annually midnight and minutely cron macros

* fix: harden cron recovery and system scheduler compatibility
2026-09-27 22:18:39 +08:00
jiandanc 0af546cd1d feat: 支持 Python venv 隔离环境,适配 Node.js 原生部署
## 背景
原方案使用系统 Python + pip --prefix 安装依赖到 data/dep_cache/python3,
系统 Python 环境被污染,且无法隔离不同项目的依赖。

## 改动内容

### 核心:Python venv 支持
- shell/start.sh: 使用 python3 -m venv 创建虚拟环境,替代 --prefix 模式
  - 从 .env 文件读取 PYTHON_VENV_DIR 配置
  - 首次启动自动创建 .venv,已存在则跳过
  - 仅将 .venv/bin 加入 PATH 优先级,不设置 PYTHONHOME/PYTHONPATH(避免破坏 venv 机制)
  - .env 不再强制覆盖,仅首次从 .env.example 复制
  - 启动时自动修复 task/ql 软链接指向当前部署目录

### 后端适配
- back/config/const.ts: 新增 PYTHON_VENV_DIR 常量
- back/config/util.ts: venv 模式下跳过 pip --prefix,直接使用 venv 的 pip3

### 开发模式支持
- shell/dev-env.sh: pnpm start 时自动 source,将 .venv/bin 加入 PATH
- package.json: start:back 加入 source dev-env.sh

### 新增文件
- shell/start-simplify.sh: 精简版启动脚本(跳过系统依赖安装,适用于已预装环境的服务器)
- README-NODE.md: Node.js 原生部署完整文档

### 配置
- .env.example: 新增 PYTHON_VENV_DIR=./.venv 配置项(默认注释状态)

## 兼容性
- Docker 模式不受影响(使用独立的 docker-entrypoint.sh,走原有 --prefix 逻辑)
- 未配置 PYTHON_VENV_DIR 时默认使用系统 Python(向后兼容)
2026-06-07 21:03:51 +08:00
copilot-swe-agent[bot]andwhyour 0d33d2321a fix: address code review feedback - improve notifyType logic and typing
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/4c9f0ab1-8b0e-4b94-b295-39c90ed942c2

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:50:47 +00:00
copilot-swe-agent[bot]andwhyour 79964f149c feat: add notification audit log feature
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/4c9f0ab1-8b0e-4b94-b295-39c90ed942c2

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:49:18 +00:00
copilot-swe-agent[bot] bcb2471768 Initial plan 2026-04-25 06:44:54 +00:00
copilot-swe-agent[bot]andwhyour fcf0ee619b Improve URL pattern matching and remove redundant checks
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:51:12 +00:00
copilot-swe-agent[bot]andwhyour f3ec352066 Address code review feedback: improve pattern matching and error handling
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:49:41 +00:00
copilot-swe-agent[bot]andwhyour 023d0f1cc4 Fix pyproject.toml path handling and add error handling
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:46:12 +00:00
copilot-swe-agent[bot]andwhyour f526d3f972 Add support for GitHub URLs and requirements files in Python dependencies
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-27 08:41:43 +00:00
copilot-swe-agent[bot] e28f746294 Initial plan 2025-12-27 08:35:03 +00:00
352 changed files with 37544 additions and 4614 deletions
+3
View File
@@ -21,3 +21,6 @@ JWT_EXPIRES_IN=
QINIU_AK= QINIU_AK=
QINIU_SK= QINIU_SK=
QINIU_SCOPE= QINIU_SCOPE=
# Python 虚拟环境路径,留空则使用系统 Python
# PYTHON_VENV_DIR=./.venv
+323 -91
View File
@@ -2,23 +2,56 @@ name: Build And Push Docker Image
on: on:
push: push:
paths-ignore: paths:
- "*.md" - "**"
- "!*.md"
# CLI is verified separately; its shared Bash upgrade engine ships in images.
- "!cli/**"
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
- "cli/src/internal/maintenance/platformBundle.cjs"
- "cli/src/internal/maintenance/upgradePrestart.cjs"
- "cli/src/internal/maintenance/panelUpgrade.cjs"
- "cli/src/internal/maintenance/nodeRuntime.cjs"
- "!.github/workflows/cli-package.yml"
branches: branches:
- "master" - "master"
- "develop" - "develop"
tags: tags:
- "v*" - "v*"
workflow_dispatch: 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: permissions:
contents: read contents: read
# Keep master image tags and their bundle indexes in one serialized release.
# A rerun may intentionally replace a version, including from the same commit.
# Develop image builds and their shared bundle index serialize separately.
# Git-tag builds and static synchronization remain independent.
concurrency:
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.ref == 'refs/heads/develop' && !inputs.static_sync_only && 'develop') || format('{0}-{1}', github.run_id, github.run_attempt) }}
cancel-in-progress: false
jobs: jobs:
validate: validate:
if: ${{ !inputs.static_sync_only }}
uses: ./.github/workflows/validate.yml uses: ./.github/workflows/validate.yml
code_gitlab: code_gitlab:
if: ${{ !inputs.static_sync_only }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -35,37 +68,8 @@ jobs:
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed" 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" 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: build-static:
if: ${{ !inputs.static_sync_only }}
needs: validate needs: validate
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -76,7 +80,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "20" node-version-file: ".nvmrc"
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
@@ -87,6 +91,9 @@ jobs:
pnpm build:back pnpm build:back
pnpm build:info pnpm build:info
- name: Pack the npm panel archive from the verified build
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6 - uses: actions/upload-artifact@v6
with: with:
name: qinglong-static-${{ github.sha }} name: qinglong-static-${{ github.sha }}
@@ -94,70 +101,137 @@ jobs:
if-no-files-found: error if-no-files-found: error
include-hidden-files: true include-hidden-files: true
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
retention-days: 14
- name: copy to static repo - name: copy to static repo
env: env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }} GITHUB_BRANCH: ${{ github.ref_name }}
GITHUB_REF_TYPE: ${{ github.ref_type }}
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
run: | run: |
mkdir -p tmp : "${STATIC_TOKEN:?Missing static repository token}"
cd ./tmp bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add . - name: publish verified upgrade download sizes
git config --local user.name 'github-actions[bot]' env:
git config --local user.email 'github-actions[bot]@users.noreply.github.com' GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" GITHUB_BRANCH: ${{ github.ref_name }}
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH} STATIC_TOKEN: ${{ secrets.API_TOKEN }}
run: |
: "${STATIC_TOKEN:?Missing static repository token}"
node scripts/publish-upgrade-info.cjs "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
static_gitlab: static_gitlab:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency:
group: static-gitlab-${{ github.ref_name }}
cancel-in-progress: false
steps: steps:
- uses: actions/checkout@v6
- name: Push qinglong-static to GitLab - 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: | run: |
set +e set -euo pipefail
mkdir -p ~/.ssh 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 chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null 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 bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
cd static-mirror
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
static_gitee: verify-gitee-mirrors:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 14
permissions:
contents: write
strategy:
fail-fast: false
matrix:
repository: [qinglong, qinglong-static]
steps: 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: 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: | run: |
set +e 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"
mkdir -p ~/.ssh - name: Notify and wait for Gitee static mirror
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa if: ${{ matrix.repository == 'qinglong-static' }}
chmod 600 ~/.ssh/id_rsa env:
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null GH_TOKEN: ${{ secrets.API_TOKEN }}
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
cd static-mirror MIRROR_REF: ${{ github.ref_name }}
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null 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 sync-existing-static:
echo "::notice::Gitee static push succeeded" if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
else runs-on: ubuntu-latest
echo "::warning::Push failed, trying to create repo via API..." strategy:
curl -sS --connect-timeout 30 --max-time 60 \ fail-fast: false
-X POST "https://gitee.com/api/v5/user/repos" \ matrix:
-H "Content-Type: application/json" \ mirror: [gitlab]
-d '{"name":"qinglong-static","private":"false"}' \ concurrency:
"?access_token=$GITEE_TOKEN" 2>/dev/null group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry" cancel-in-progress: false
fi steps:
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed" - 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: build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }} if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
@@ -223,6 +297,7 @@ jobs:
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine) - name: Build and push (Alpine)
id: image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
build-args: | build-args: |
@@ -244,6 +319,8 @@ jobs:
build-debian: build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }} if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
@@ -308,6 +385,7 @@ jobs:
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Build and push (Debian) - name: Build and push (Debian)
id: image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
build-args: | build-args: |
@@ -472,30 +550,91 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10 cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
# Unmodified amd64 packages for glibc and musl migration acceptance. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
needs: [build-debian, build-alpine]
strategy:
fail-fast: false
matrix:
libc: [glibc, musl]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Export the official platform package from this run's image digest
env:
IMAGE_DIGEST: ${{ matrix.libc == 'musl' && needs.build-alpine.outputs.image-digest || needs.build-debian.outputs.image-digest }}
run: |
set -euo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
- uses: actions/upload-artifact@v6
with:
name: npm-migration-bundle-${{ matrix.libc }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/*
if-no-files-found: error
retention-days: 3
verify-npm-package:
needs: [build-static, build-npm-migration-bundle, build-alpine]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
node: ['20.18.1', '22.17.0', '24', 'alpine']
container:
image: ${{ matrix.node == 'alpine' && format('ghcr.io/{0}@{1}', github.repository, needs.build-alpine.outputs.image-digest) || format('node:{0}-bookworm', matrix.node) }}
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
- uses: actions/download-artifact@v6
with:
name: npm-migration-bundle-${{ matrix.node == 'alpine' && 'musl' || 'glibc' }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/
- name: Install isolated service test tools
run: |
if [ -f /etc/alpine-release ]; then
apk add --no-cache python3 openssl curl jq nginx procps make g++
else
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
fi
# The published legacy launcher resets PATH to standard directories.
# /usr/local is confined to this disposable job container; PM2_HOME
# remains private to each verification run.
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
- name: Install and upgrade the exact npm archive
if: matrix.node != '20.18.1'
env:
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
- name: Reject an unsupported runtime while preserving the old installation
if: matrix.node == '20.18.1'
run: node scripts/verify-panel-runtime-floor.cjs "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel-results/
if-no-files-found: error
retention-days: 14
publish: publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }} if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310] needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
node-version: "20"
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: Set up Node for trusted publishing - name: Set up Node for trusted publishing
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
@@ -503,18 +642,111 @@ jobs:
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
package-manager-cache: false package-manager-cache: false
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: npm-panel/
- name: Publish npm package with OIDC - name: Publish npm package with OIDC
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
name=$(node -p 'require("./package.json").name') shopt -s nullglob
version=$(node -p 'require("./package.json").version') archives=(npm-panel/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified panel npm archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild." echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
npm publish --access public --registry=https://registry.npmjs.org npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
else else
echo '::error::Could not check the published npm version; refusing to publish.' echo '::error::Could not check the published npm version; refusing to publish.'
exit 1 exit 1
fi fi
build-upgrade-bundles:
# Both channels publish packages from this run's immutable image digests.
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-22.04
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- { platform: linux/amd64, libc: musl, name: x64-musl }
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
- { platform: linux/arm64, libc: musl, name: arm64-musl }
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
- { platform: linux/s390x, libc: musl, name: s390x-musl }
- { platform: linux/386, libc: musl, name: ia32-musl }
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and probe the immutable platform package
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
digest="$ALPINE_DIGEST"
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
path: output/upgrade/*
if-no-files-found: error
publish-upgrade-bundles:
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
needs: build-upgrade-bundles
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: upgrade-channel-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- run: pnpm install --prod --frozen-lockfile
- run: npm --prefix cli ci
- uses: actions/download-artifact@v7
with:
pattern: upgrade-*-${{ github.sha }}
path: output/upgrade
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Publish complete packages before advancing the channel
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+54 -8
View File
@@ -21,16 +21,27 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
publish: publish:
description: Publish the verified CLI package (master only) description: Publish the verified CLI package (master stable / develop prerelease)
type: boolean type: boolean
default: false default: false
prerelease_version:
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
type: string
default: ''
permissions: permissions:
contents: read contents: read
concurrency: concurrency:
group: cli-package-${{ github.workflow }}-${{ github.ref }} # Keep validation runs separate from releases; never interrupt a running release.
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} # 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: jobs:
package: package:
@@ -52,6 +63,20 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip 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 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 ci --prefix cli --no-audit --no-fund
- run: npm run check:cli - run: npm run check:cli
- run: npm run test:cli - run: npm run test:cli
@@ -72,9 +97,11 @@ jobs:
needs: package needs: package
if: >- if: >-
github.repository == 'whyour/qinglong' && github.repository == 'whyour/qinglong' &&
github.ref == 'refs/heads/master' && ((github.ref == 'refs/heads/master' &&
(github.event_name == 'push' || (github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish)) (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 runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
permissions: permissions:
@@ -91,6 +118,8 @@ jobs:
name: qinglong-cli-${{ github.sha }} name: qinglong-cli-${{ github.sha }}
path: cli-package path: cli-package
- name: Publish verified npm archive - name: Publish verified npm archive
env:
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@@ -103,13 +132,30 @@ jobs:
archive="${archives[0]}" archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json) metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata") 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 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 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." echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
exit 0 exit 0
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
echo '::error::Could not check the published CLI version; refusing to publish.' echo '::error::Could not check the published CLI version; refusing to publish.'
exit 1 exit 1
fi 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
+136
View File
@@ -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: '22'
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.4.0"}))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"); assert.equal(typeof require("@mikro-orm/sql").NodeSqliteDialect,"function"); const db=new (require("node:sqlite").DatabaseSync)(":memory:"); assert.equal(db.prepare("SELECT 42 AS value").get().value,42); db.close(); 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
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
version: '8.3.1' version: '8.3.1'
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: '24'
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: cp .env.example .env - run: cp .env.example .env
+10
View File
@@ -29,6 +29,7 @@ __pycache__
/shell/preload/*-notify.json /shell/preload/*-notify.json
/shell/preload/__ql_notify__.* /shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh /shell/preload/lang_env.sh
/shell/upgrade-lock.yaml
.deepseek/ .deepseek/
.claude/ .claude/
@@ -38,3 +39,12 @@ __pycache__
# Local security audit artifacts # Local security audit artifacts
/audit/ /audit/
# Local browser tests, screenshots, reports, and temporary test output
/.playwright-cli/
/output/
/playwright-report/
/test-results/
/blob-report/
/coverage/
/.nyc_output/
-22
View File
@@ -1,22 +0,0 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
+1
View File
@@ -0,0 +1 @@
22
+12
View File
@@ -0,0 +1,12 @@
# Node.js 原生部署与 Python venv
面板要求 Node.js >=22.17,推荐 Node.js 24,并要求该 Node 构建提供 `node:sqlite`。应用升级保留现有 Node;旧 Node 20 环境需先升级运行时。远程管理 CLI 的最低版本要求独立于面板。
设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。
如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。
- `qinglong`(`shell/start.sh`)安装系统依赖和 Node 工具,并安装 Python requests,然后调用公共启动脚本。
- `npm run start:simple`(`shell/start-simplify.sh`)使用已准备的 Node、pm2、nginx、jq 等依赖,不安装系统包、全局 npm 包或 requests;显式指定的 venv 不存在时仍会创建。请先在 venv 中安装 requests 和任务所需依赖。
- 两种启动方式均保留已有 `.env`;使用 venv 时清除继承的 `PYTHONPATH`/`PYTHONUSERBASE`,依赖查询、安装和卸载使用该 venv 的 Python。
该模式需具备现有启动流程所要求的 nginx 配置和 pm2 服务权限。生产环境设置变量应通过 systemd 的 `Environment` 或相应进程配置完成。已有系统 Python 依赖不自动迁移至 venv,需在 venv 中重新安装。
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextFunction, Request, Response, Router } from 'express'; import { NextFunction, Request, Response, Router } from 'express';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { t } from '../shared/i18n';
import { import {
diagnoseClientIp, diagnoseClientIp,
getTrustProxyConfig, getTrustProxyConfig,
@@ -36,7 +37,7 @@ export default (app: Router) => {
} catch (error) { } catch (error) {
res.send({ res.send({
code: 400, code: 400,
message: error instanceof Error ? error.message : '配置更新失败', message: error instanceof Error ? error.message : t('配置更新失败'),
}); });
} }
}, },
+3 -7
View File
@@ -4,7 +4,6 @@ import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { join, basename } from 'path';
import { SAMPLE_FILES } from '../config/const'; import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import ConfigService from '../services/config'; import ConfigService from '../services/config';
@@ -73,15 +72,12 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const { name, content } = req.body; const { name, content } = req.body;
// Resolve path first to prevent traversal attacks
let basePath = config.configPath;
if (name.startsWith('data/scripts/')) { 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( const normalized = resolveFileAccess(
basePath, config.configPath,
[cleanName], [name],
config.blackFileList, config.blackFileList,
); );
if (!normalized) { if (!normalized) {
+19 -13
View File
@@ -5,10 +5,7 @@ import CronService from '../services/cron';
import CronViewService from '../services/cronView'; import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule'; import { commonCronSchema } from '../validation/schedule';
import { import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import cronClient from '../schedule/client'; import cronClient from '../schedule/client';
@@ -19,8 +16,17 @@ export default (app: Router) => {
route.use(async (req, res, next) => { route.use(async (req, res, next) => {
// Keep stop/status callbacks available even when the scheduler is down. // Keep stop/status callbacks available even when the scheduler is down.
if (['POST', 'PUT', 'DELETE'].includes(req.method) && if (
['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) { ['POST', 'PUT', 'DELETE'].includes(req.method) &&
[
'/',
'/run',
'/enable',
'/disable',
'/views/enable',
'/views/disable',
].includes(req.path)
) {
try { try {
await cronClient.readiness.ensureReady(); await cronClient.readiness.ensureReady();
} catch (error) { } catch (error) {
@@ -497,12 +503,8 @@ export default (app: Router) => {
}), }),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try { try {
const instances = await RunningInstanceModel.findAll({ const instances = await RunningInstanceModel.find({
where: { cron_id: req.params.id,
cron_id: req.params.id,
},
order: [['started_at', 'DESC']],
raw: true,
}); });
return res.send({ code: 200, data: instances }); return res.send({ code: 200, data: instances });
} catch (e) { } catch (e) {
@@ -519,7 +521,11 @@ export default (app: Router) => {
instanceId: Joi.number().required(), instanceId: Joi.number().required(),
}), }),
}), }),
async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => { async (
req: Request<{ id: number; instanceId: number }>,
res: Response,
next: NextFunction,
) => {
try { try {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.stopInstance(req.params.instanceId); const data = await cronService.stopInstance(req.params.instanceId);
+93 -157
View File
@@ -1,38 +1,32 @@
import { statistics } from '../data/statistics';
import { Query, QuerySpec, orderBy } from '../data/queries';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { fn, col, where, Op } from 'sequelize';
import { CrontabModel } from '../data/cron'; import { CrontabModel } from '../data/cron';
import { CrontabStatModel } from '../data/cronStats'; import { CrontabStatModel } from '../data/cronStats';
import { import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import os from 'os'; import os from 'os';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { t, tf } from '../shared/i18n'; import { t, tf } from '../shared/i18n';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
app.use('/dashboard', route); app.use('/dashboard', route);
route.post('/record', async (req: Request, res: Response) => { route.post('/record', async (req: Request, res: Response) => {
try { try {
const { ref_id, code, elapsed } = req.body; const { ref_id, code, elapsed } = req.body;
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' }); if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
const today = dayjs().format('YYYY-MM-DD'); const today = dayjs().format('YYYY-MM-DD');
const isSuccess = code === 0 ? 1 : 0; const isSuccess = code === 0 ? 1 : 0;
const isFail = code !== 0 ? 1 : 0; const isFail = code !== 0 ? 1 : 0;
const elapsedMs = (Number(elapsed) || 0) * 1000; const elapsedMs = (Number(elapsed) || 0) * 1000;
const existing = await CrontabStatModel.findOne(
const existing = await CrontabStatModel.findOne({ { ref_id: Number(ref_id), date: today },
where: { ref_id: Number(ref_id), date: today }, {},
}); );
if (existing) { if (existing) {
await CrontabStatModel.update( await CrontabStatModel.nativeUpdate(
{ id: existing.id },
{ {
run_count: (existing.run_count || 0) + 1, run_count: (existing.run_count || 0) + 1,
success_count: (existing.success_count || 0) + isSuccess, success_count: (existing.success_count || 0) + isSuccess,
@@ -40,10 +34,9 @@ export default (app: Router) => {
total_time: (existing.total_time || 0) + elapsedMs, total_time: (existing.total_time || 0) + elapsedMs,
max_time: Math.max(existing.max_time || 0, elapsedMs), max_time: Math.max(existing.max_time || 0, elapsedMs),
}, },
{ where: { id: existing.id } },
); );
} else { } else {
await CrontabStatModel.create({ await CrontabStatModel.insert({
ref_id: Number(ref_id), ref_id: Number(ref_id),
date: today, date: today,
run_count: 1, run_count: 1,
@@ -53,41 +46,27 @@ export default (app: Router) => {
max_time: elapsedMs, max_time: elapsedMs,
}); });
} }
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
res.send({ code: 500 }); res.send({ code: 500 });
} }
}); });
route.get( route.get(
'/overview', '/overview',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const today = dayjs().format('YYYY-MM-DD'); const today = dayjs().format('YYYY-MM-DD');
const [total, enabled, disabled, stats] = await Promise.all([ const [total, enabled, disabled, stats] = await Promise.all([
CrontabModel.count(), CrontabModel.count({}),
CrontabModel.count({ where: { isDisabled: 0 } }), CrontabModel.count({ isDisabled: 0 }),
CrontabModel.count({ where: { isDisabled: 1 } }), CrontabModel.count({ isDisabled: 1 }),
CrontabStatModel.findOne({ statistics.totals(today),
attributes: [
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
[fn('SUM', col('total_time')), 'total_time'],
],
where: { date: today },
raw: true,
}),
]); ]);
const row = stats as any; const row = stats as any;
const totalRuns = Number(row?.total_runs) || 0; const totalRuns = Number(row?.total_runs) || 0;
const totalSuccess = Number(row?.total_success) || 0; const totalSuccess = Number(row?.total_success) || 0;
const totalFail = Number(row?.total_fail) || 0; const totalFail = Number(row?.total_fail) || 0;
const totalTime = Number(row?.total_time) || 0; const totalTime = Number(row?.total_time) || 0;
res.send({ res.send({
code: 200, code: 200,
data: { data: {
@@ -97,7 +76,10 @@ export default (app: Router) => {
todayRuns: totalRuns, todayRuns: totalRuns,
todaySuccess: totalSuccess, todaySuccess: totalSuccess,
todayFail: totalFail, todayFail: totalFail,
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0', successRate:
totalRuns > 0
? ((totalSuccess / totalRuns) * 100).toFixed(1)
: '0',
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0, avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
}, },
}); });
@@ -106,25 +88,25 @@ export default (app: Router) => {
} }
}, },
); );
for (const isSuccess of [false, true]) { for (const isSuccess of [false, true]) {
route.get( route.get(
isSuccess ? '/successes' : '/failures', isSuccess ? '/successes' : '/failures',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const countColumn = isSuccess ? 'success_count' : 'fail_count'; const countColumn = isSuccess ? 'success_count' : 'fail_count';
const rows = (await CrontabStatModel.findAll({ const rows = (await statistics.results(
attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']], dayjs().format('YYYY-MM-DD'),
where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } }, countColumn,
group: ['ref_id'], )) as any[];
order: [[fn('SUM', col(countColumn)), 'DESC'], ['ref_id', 'ASC']], const crons =
raw: true, rows.length > 0
})) as any[]; ? await CrontabModel.find(
const crons = rows.length > 0 ? await CrontabModel.findAll({ { id: { ['$in']: rows.map((row) => Number(row.ref_id)) } },
attributes: ['id', 'name', 'command'], {
where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } }, fields: ['id', 'name', 'command'],
raw: true, },
}) : []; )
: [];
const cronMap = new Map(crons.map((cron) => [cron.id, cron])); const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
res.send({ res.send({
code: 200, code: 200,
@@ -135,7 +117,9 @@ export default (app: Router) => {
id, id,
name: cron?.name || cron?.command || tf('任务#%s', id), name: cron?.name || cron?.command || tf('任务#%s', id),
command: cron?.command || '', command: cron?.command || '',
[isSuccess ? 'successCount' : 'failCount']: Number(row.result_count), [isSuccess ? 'successCount' : 'failCount']: Number(
row.result_count,
),
deleted: !cron, deleted: !cron,
}; };
}), }),
@@ -146,7 +130,6 @@ export default (app: Router) => {
}, },
); );
} }
route.get( route.get(
'/trend', '/trend',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -156,22 +139,7 @@ export default (app: Router) => {
for (let i = days - 1; i >= 0; i--) { for (let i = days - 1; i >= 0; i--) {
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD')); dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
} }
const rows = (await statistics.trend(dates)) as any[];
const rows = (await CrontabStatModel.findAll({
attributes: [
'date',
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
],
where: {
date: { [Op.in]: dates },
},
group: ['date'],
order: [['date', 'ASC']],
raw: true,
})) as any[];
const dataMap: Record<string, any> = {}; const dataMap: Record<string, any> = {};
rows.forEach((r: any) => { rows.forEach((r: any) => {
dataMap[r.date] = { dataMap[r.date] = {
@@ -180,89 +148,52 @@ export default (app: Router) => {
fail: Number(r.total_fail) || 0, fail: Number(r.total_fail) || 0,
}; };
}); });
const data = dates.map((d) => ({ const data = dates.map((d) => ({
date: dayjs(d).format('MM-DD'), date: dayjs(d).format('MM-DD'),
...(dataMap[d] || { total: 0, success: 0, fail: 0 }), ...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
})); }));
res.send({ code: 200, data }); res.send({ code: 200, data });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}, },
); );
route.get( route.get(
'/top-time', '/top-time',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const today = dayjs().format('YYYY-MM-DD'); const today = dayjs().format('YYYY-MM-DD');
const rows = (await statistics.ranking(today, 'total_time')) as any[];
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('run_count')), 'run_count'],
[fn('MAX', col('max_time')), 'max_time'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('total_time')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id)); const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({ const crons = await CrontabModel.find({ id: { ['$in']: ids } }, {});
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {}; const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; }); crons.forEach((c: any) => {
nameMap[c.id] = c.name || c.command;
});
const data = rows.map((r: any, i) => ({ const data = rows.map((r: any, i) => ({
rank: i + 1, rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id), name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)), avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
maxTime: Number(r.max_time), maxTime: Number(r.max_time),
})); }));
res.send({ code: 200, data }); res.send({ code: 200, data });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}, },
); );
route.get( route.get(
'/top-count', '/top-count',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const today = dayjs().format('YYYY-MM-DD'); const today = dayjs().format('YYYY-MM-DD');
const rows = (await statistics.ranking(today, 'run_count')) as any[];
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('run_count')), 'run_count'],
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('success_count')), 'success_count'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('run_count')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id)); const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({ const crons = await CrontabModel.find({ id: { ['$in']: ids } }, {});
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {}; const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; }); crons.forEach((c: any) => {
nameMap[c.id] = c.name || c.command;
});
const data = rows.map((r: any, i) => ({ const data = rows.map((r: any, i) => ({
rank: i + 1, rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id), name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
@@ -270,48 +201,42 @@ export default (app: Router) => {
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)), avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
successRate: successRate:
Number(r.run_count) > 0 Number(r.run_count) > 0
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1) ? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(
1,
)
: '0', : '0',
})); }));
res.send({ code: 200, data }); res.send({ code: 200, data });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}, },
); );
route.get( route.get(
'/runtime', '/runtime',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const runningInstances = await RunningInstanceModel.findAll({ const runningInstances = await RunningInstanceModel.find(
where: { {
status: InstanceStatus.running, status: InstanceStatus.running,
}, },
raw: true, {},
}); );
const queuedCrons = await CrontabModel.find(
const queuedCrons = await CrontabModel.findAll({ {
where: {
status: 3, // queued status: 3, // queued
}, },
raw: true, {},
}); );
// Fetch cron names for running instances // Fetch cron names for running instances
const cronIds = [ const cronIds = [
...new Set(runningInstances.map((i: any) => i.cron_id)), ...new Set(runningInstances.map((i: any) => i.cron_id)),
]; ];
const crons = const crons =
cronIds.length > 0 cronIds.length > 0
? await CrontabModel.findAll({ ? await CrontabModel.find({ id: cronIds }, {})
where: { id: cronIds },
raw: true,
})
: []; : [];
const cronMap = new Map(crons.map((c: any) => [c.id, c])); const cronMap = new Map(crons.map((c: any) => [c.id, c]));
const now = dayjs().unix(); const now = dayjs().unix();
const running = runningInstances.map((inst: any) => { const running = runningInstances.map((inst: any) => {
const cron = cronMap.get(inst.cron_id); const cron = cronMap.get(inst.cron_id);
@@ -324,19 +249,18 @@ export default (app: Router) => {
logPath: inst.log_path, logPath: inst.log_path,
}; };
}); });
const dayAgo = dayjs().subtract(24, 'hour').unix(); const dayAgo = dayjs().subtract(24, 'hour').unix();
const idleTasks = await CrontabModel.findAll({ const idleTasks = await CrontabModel.find(
where: { {
isDisabled: 0, isDisabled: 0,
status: 1, status: 1,
last_execution_time: { [Op.lt]: dayAgo }, last_execution_time: { ['$lt']: dayAgo },
}, },
order: [['last_execution_time', 'ASC']], {
limit: 5, orderBy: orderBy([['last_execution_time', 'ASC']]),
raw: true, limit: 5,
}); },
);
res.send({ res.send({
code: 200, code: 200,
data: { data: {
@@ -357,33 +281,45 @@ export default (app: Router) => {
} }
}, },
); );
route.get( route.get(
'/labels', '/labels',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const today = dayjs().format('YYYY-MM-DD'); const today = dayjs().format('YYYY-MM-DD');
const [crons, stats] = (await Promise.all([ const [crons, stats] = await Promise.all([
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }), CrontabModel.find({ isDisabled: 0 }, {}),
CrontabStatModel.findAll({ where: { date: today }, raw: true }), CrontabStatModel.find({ date: today }, {}),
])); ]);
const statMap: Record<number, any> = {}; const statMap: Record<number, any> = {};
stats.forEach((s: any) => { statMap[s.ref_id] = s; }); stats.forEach((s: any) => {
statMap[s.ref_id] = s;
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {}; });
const labelMap: Record<
string,
{
count: number;
runs: number;
success: number;
totalTime: number;
}
> = {};
crons.forEach((c) => { crons.forEach((c) => {
let rawLabels = c.labels; let rawLabels = c.labels;
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels); if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
const labels: string[] = Array.isArray(rawLabels) const labels: string[] = Array.isArray(rawLabels)
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))] ? [
...new Set(
(rawLabels as string[]).filter((l: string) => !isEmpty(l)),
),
]
: []; : [];
if (labels.length === 0) { if (labels.length === 0) {
labels.push(t('未分类')); labels.push(t('未分类'));
} }
const st = statMap[c.id!]; const st = statMap[c.id!];
labels.forEach((label: string) => { labels.forEach((label: string) => {
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 }; if (!labelMap[label])
labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
labelMap[label].count += 1; labelMap[label].count += 1;
if (st) { if (st) {
labelMap[label].runs += Number(st.run_count) || 0; labelMap[label].runs += Number(st.run_count) || 0;
@@ -392,24 +328,22 @@ export default (app: Router) => {
} }
}); });
}); });
const data = Object.entries(labelMap) const data = Object.entries(labelMap)
.map(([label, v]) => ({ .map(([label, v]) => ({
label, label,
count: v.count, count: v.count,
todayRuns: v.runs, todayRuns: v.runs,
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0', successRate:
v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0, avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
})) }))
.sort((a, b) => b.todayRuns - a.todayRuns); .sort((a, b) => b.todayRuns - a.todayRuns);
res.send({ code: 200, data }); res.send({ code: 200, data });
} catch (e) { } catch (e) {
next(e); next(e);
} }
}, },
); );
route.get( route.get(
'/system', '/system',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -422,7 +356,9 @@ export default (app: Router) => {
uptime: Math.floor(process.uptime()), uptime: Math.floor(process.uptime()),
memTotal: os.totalmem(), memTotal: os.totalmem(),
memFree: os.freemem(), memFree: os.freemem(),
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1), memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(
1,
),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024), heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024), heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))), loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
+17 -14
View File
@@ -1,24 +1,16 @@
import { Joi, celebrate } from 'celebrate'; import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express'; import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer'; import multer from 'multer';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import EnvService from '../services/env'; import EnvService from '../services/env';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const upload = multer({
destination: function (req, file, cb) { storage: multer.memoryStorage(),
cb(null, config.scriptPath); limits: { fileSize: 5 * 1024 * 1024, files: 1 },
},
filename: function (req, file, cb) {
cb(null, file.originalname);
},
}); });
const upload = multer({ storage: storage });
const labelSchema = Joi.array() const labelSchema = Joi.array()
.items(Joi.string().trim().required()) .items(Joi.string().trim().required())
.min(1) .min(1)
@@ -282,12 +274,23 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8'); let parseContent: unknown;
const parseContent = safeJSONParse(fileContent); try {
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
} catch {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
const data = Array.isArray(parseContent) const data = Array.isArray(parseContent)
? parseContent ? 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( const result = await envService.create(
data.map((x) => ({ data.map((x) => ({
name: x.name, name: x.name,
+2
View File
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
import OpenService from '../services/open'; import OpenService from '../services/open';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { rateLimit } from 'express-rate-limit';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -100,6 +101,7 @@ export default (app: Router) => {
route.get( route.get(
'/auth/token', '/auth/token',
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
celebrate({ celebrate({
query: { query: {
client_id: Joi.string().required(), client_id: Joi.string().required(),
+199 -14
View File
@@ -7,20 +7,62 @@ import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; 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 ScriptService from '../services/script';
import ScriptHistoryService from '../services/scriptHistory';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import multer from 'multer'; import multer from 'multer';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import {
ScriptHistoryError,
HistoryUnavailableError,
} from '../shared/scriptHistory';
const route = Router(); const route = Router();
function isPathAllowed(targetPath: string): boolean { function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath); const resolved = path.resolve(targetPath);
return config.writePathList.some((x) => 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({ const storage = multer.diskStorage({
destination: function (req, file, cb) { destination: function (req, file, cb) {
cb(null, config.tmpPath); cb(null, config.tmpPath);
@@ -55,11 +97,7 @@ export default (app: Router) => {
]; ];
if (req.query.path) { if (req.query.path) {
if ( if (
!resolveFileAccess( !resolveFileAccess(config.scriptPath, [req.query.path as string])
config.scriptPath,
[req.query.path as string],
config.blackFileList,
)
) { ) {
return res.send({ code: 403, message: t('暂无权限') }); 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) => { route.get('/:file', (req: Request, res: Response) => {
return res.send({ return res.send({
code: 410, code: 410,
@@ -136,6 +250,11 @@ export default (app: Router) => {
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
content: 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(''), originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''), file: Joi.string().optional().allow(''),
@@ -195,8 +314,56 @@ export default (app: Router) => {
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) { if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
return res.send({ code: 403, message: t('暂无权限') }); 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); 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) { if (fileExists) {
await fs.copyFile( await fs.copyFile(
originFilePath, originFilePath,
@@ -209,7 +376,7 @@ export default (app: Router) => {
await writeFileWithLock(filePath, content); await writeFileWithLock(filePath, content);
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return historyError(e, res, next);
} }
}, },
); );
@@ -221,6 +388,11 @@ export default (app: Router) => {
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
content: Joi.string().required().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) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -238,10 +410,14 @@ export default (app: Router) => {
message: t('暂无权限'), message: t('暂无权限'),
}); });
} }
await writeFileWithLock(filePath, content); const service = Container.get(ScriptHistoryService);
return res.send({ code: 200 }); const data = await service.save(path || '', filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return historyError(e, res, next);
} }
}, },
); );
@@ -368,12 +544,21 @@ export default (app: Router) => {
if (!isPathAllowed(filePath)) { if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') }); 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 scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid); const result = await scriptService.stopScript(filePath, pid);
setTimeout(() => { setTimeout(() => {
rmPath(logPath); const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
if (cleanupPath) {
void rmPath(cleanupPath);
}
}, 3000); }, 3000);
res.send(result); res.send(result);
} catch (e) { } catch (e) {
+3 -10
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser'; import { isValidCronSchedule } from '../shared/cronSchedule';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -58,10 +58,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
if ( if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body); const data = await subscriptionService.create(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
@@ -204,11 +201,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
if ( if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body); const data = await subscriptionService.update(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
+38 -9
View File
@@ -8,15 +8,12 @@ import { celebrate, Joi } from 'celebrate';
import UserService from '../services/user'; import UserService from '../services/user';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import { isDefaultAuthInfo } from '../shared/auth'; import { isDefaultAuthInfo } from '../shared/auth';
import { import { getUniqPath, handleLogPath, parseVersion } from '../config/util';
getUniqPath, import { findCronId } from '../shared/cronCommand';
handleLogPath,
parseVersion,
promiseExec,
} from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import multer from 'multer'; import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager'; import { logStreamManager } from '../shared/logStreamManager';
import { rateLimit } from 'express-rate-limit';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -208,6 +205,17 @@ export default (app: Router) => {
}, },
); );
route.get(
'/update-status',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send(await Container.get(SystemService).getUpgradeStatus());
} catch (e) {
return next(e);
}
},
);
route.put( route.put(
'/reload', '/reload',
celebrate({ celebrate({
@@ -249,6 +257,7 @@ export default (app: Router) => {
route.put( route.put(
'/command-run', '/command-run',
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
command: Joi.string().required(), command: Joi.string().required(),
@@ -258,8 +267,13 @@ export default (app: Router) => {
try { try {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const command = req.body.command; 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`; const crontab = await fs
let id = await promiseExec(idStr); .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 uniqPath = await getUniqPath(command, id);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`; const logPath = `${uniqPath}/${logTime}.log`;
@@ -274,7 +288,9 @@ export default (app: Router) => {
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion // Close the stream after task completion
try { try {
await logStreamManager.closeStream(await handleLogPath(logPath)); await logStreamManager.closeStream(
await handleLogPath(logPath),
);
} finally { } finally {
res.end(); res.end();
} }
@@ -378,6 +394,19 @@ export default (app: Router) => {
}, },
); );
route.get(
'/notify-log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.getNotifyLog();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete( route.delete(
'/log', '/log',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
+13 -28
View File
@@ -5,6 +5,7 @@ import { Container } from 'typedi';
import config from './config'; import config from './config';
import Logger from './loaders/logger'; import Logger from './loaders/logger';
import { errStack } from './shared/errors'; import { errStack } from './shared/errors';
import { initializeLanguageSync } from './shared/languageSync';
import { type GrpcServerService } from './services/grpc'; import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http'; import { type HttpServerService } from './services/http';
@@ -27,6 +28,7 @@ class Application {
if (cluster.isPrimary) { if (cluster.isPrimary) {
await this.initializeDatabase(); await this.initializeDatabase();
} }
await initializeLanguageSync();
if (cluster.isPrimary) { if (cluster.isPrimary) {
this.startMasterProcess(); this.startMasterProcess();
} else { } else {
@@ -39,11 +41,18 @@ class Application {
} }
private startMasterProcess() { private startMasterProcess() {
const readyTimeout =
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number }).arm_version,
) === 6
? 120000
: 30000;
// Fork gRPC worker first and wait for it to be ready // Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc'); const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker // Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000) this.waitForWorkerReady(grpcWorker, readyTimeout)
.then(() => { .then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker'); Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http'); this.httpWorker = this.forkWorker('http');
@@ -69,7 +78,7 @@ class Application {
Logger.warn('Unable to notify HTTP worker of scheduler exit'); Logger.warn('Unable to notify HTTP worker of scheduler exit');
} }
const newGrpcWorker = this.forkWorker('grpc'); const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000) this.waitForWorkerReady(newGrpcWorker, readyTimeout)
.then(() => { .then(() => {
Logger.info('gRPC worker restarted and ready'); Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker // Re-register cron jobs by notifying the HTTP worker
@@ -146,32 +155,8 @@ class Application {
} }
private async setupMiddlewares(): Promise<express.Application> { private async setupMiddlewares(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware. const { createHttpApplication } = await import('./loaders/httpApplication');
const [ return createHttpApplication();
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('./middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
} }
private setupMasterShutdown() { private setupMasterShutdown() {
+2 -5
View File
@@ -1,19 +1,16 @@
import { errStack } from '../shared/errors'; import { errStack } from '../shared/errors';
// Also terminate if the primary disappears without forwarding a signal. // Also terminate if the primary disappears without forwarding a signal.
const onParentDisconnect = () => process.exit(1); const onParentDisconnect = () => process.exit(1);
process.once('disconnect', onParentDisconnect); process.once('disconnect', onParentDisconnect);
async function initializeDatabase() { async function initializeDatabase() {
const { sequelize } = await import('../data'); const { database } = await import('../data');
try { try {
const { default: loadDatabase } = await import('../loaders/db'); const { default: loadDatabase } = await import('../loaders/db');
await loadDatabase(); await loadDatabase();
} finally { } finally {
await sequelize.close(); await database.close();
} }
} }
initializeDatabase() initializeDatabase()
.catch((error) => { .catch((error) => {
console.error(`[boot] Database initialization failed:\n${errStack(error)}`); console.error(`[boot] Database initialization failed:\n${errStack(error)}`);
+141
View File
@@ -0,0 +1,141 @@
// A single, disposable backend process. Resolve startup dependencies, but never
// run appLoader: its initData/initTask hooks install dependencies and start user work.
import 'reflect-metadata';
import dns from 'dns';
import http from 'http';
// The first scheduler connection can outlive its short RPC deadline. Let the
// normal single-flight recovery finish within the supervised startup budget.
async function waitForUpgradeScheduler(
readiness: {
recover(): Promise<boolean>;
},
timeoutMs: number,
retryMs = 250,
) {
const deadline = Date.now() + timeoutMs;
do {
if (await readiness.recover()) return;
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await new Promise((resolve) =>
setTimeout(resolve, Math.min(retryMs, remaining)),
);
} while (Date.now() < deadline);
throw new Error('Upgrade scheduler readiness failed');
}
async function probeUpgrade() {
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
throw new Error('Upgrade probe requires its isolated supervisor');
dns.setDefaultResultOrder('ipv4first');
const { default: config } = await import('../config');
const { database } = await import('../data');
const { default: loadDatabase } = await import('../loaders/db');
const { Container } = await import('typedi');
const { GrpcServerService } = await import('../services/grpc');
const { HttpServerService } = await import('../services/http');
const { createHttpApplication } = await import('../loaders/httpApplication');
const { default: loadServer } = await import('../loaders/server');
const { default: loadExpress } = await import('../loaders/express');
await import('../loaders/app'); // Resolve normal startup hooks without running them.
const { default: inject } = await import('../loaders/depInjector');
const grpc = Container.get(GrpcServerService);
const server = Container.get(HttpServerService);
try {
await loadDatabase();
// Exercise an actual on-disk transaction as well as schema initialization.
await database.transaction(async () => {
await database.query('CREATE TABLE upgrade_probe (value INTEGER)');
await database.query('INSERT INTO upgrade_probe VALUES (42)');
const rows = await database.query('SELECT value FROM upgrade_probe');
if (
(
rows[0] as {
value: number;
}
)?.value !== 42
)
throw new Error('Upgrade database readback failed');
});
inject();
config.grpcPort = await grpc.initialize();
const { default: cronClient } = await import('../schedule/client');
cronClient.readiness.configure(async () => {});
await waitForUpgradeScheduler(
cronClient.readiness,
process.arch === 'arm' &&
Number(
(
process.config.variables as {
arm_version?: string | number;
}
).arm_version,
) === 6
? 60000
: 10000,
);
const app = await createHttpApplication();
loadExpress({ app });
const listener = await server.initialize(app, 0);
await loadServer({ server: listener });
const address = listener.address();
if (!address || typeof address === 'string')
throw new Error('No probe HTTP port');
const health = await new Promise<any>((resolve, reject) => {
const request = http.get(
`http://127.0.0.1:${address.port}/api/health`,
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok' ||
!result.data.services?.grpc ||
!result.data.services?.http
)
throw new Error('Upgrade HTTP/gRPC health check failed');
resolve(result.data);
} catch (error) {
reject(error);
}
});
},
);
request.setTimeout(10000, () =>
request.destroy(new Error('Upgrade health request timed out')),
);
request.on('error', reject);
});
process.send!({
type: 'upgrade-probe',
ok: true,
build: health.build,
memory: process.memoryUsage(),
pid: process.pid,
});
} finally {
await server.shutdown();
grpc.getServer().forceShutdown();
await database.close();
}
}
process.once('disconnect', () => process.exit(1));
probeUpgrade()
.then(() => process.exit(0))
.catch((error) => {
process.send?.({
type: 'upgrade-probe',
ok: false,
code: error.code,
message: error.message,
stack: error.stack,
});
console.error(error);
process.exit(1);
});
+1
View File
@@ -27,6 +27,7 @@ export const SAMPLE_FILES = [
]; ];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME; export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const PYTHON_VENV_DIR = process.env.PYTHON_VENV_DIR;
export const NotificationModeStringMap = { export const NotificationModeStringMap = {
0: 'gotify', 0: 'gotify',
+93 -45
View File
@@ -1,6 +1,5 @@
import { execSync } from 'child_process'; import { execFileSync } from 'child_process';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as os from 'os';
import path from 'path'; import path from 'path';
import config from './index'; import config from './index';
import { fileExist } from './util'; import { fileExist } from './util';
@@ -24,71 +23,120 @@ const clientCertPath = path.join(certDir, 'client.crt');
let cachedConfig: GrpcTlsConfig | null = null; let cachedConfig: GrpcTlsConfig | null = null;
function run(cmd: string, execOpts?: Record<string, unknown>): string { function run(args: string[]): string {
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any; return execFileSync('openssl', args, {
return (execSync(cmd, opts) as string).trim(); stdio: 'pipe',
} timeout: 30000,
encoding: 'utf-8',
async function tmpFile(prefix: string): Promise<string> { }).trim();
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
await fs.mkdir(dir, { recursive: true });
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
} }
async function generateAllCerts(): Promise<GrpcTlsConfig> { async function generateAllCerts(): Promise<GrpcTlsConfig> {
Logger.info('[boot] Generating gRPC mTLS certificates...'); Logger.info('[boot] Generating gRPC mTLS certificates...');
await fs.mkdir(certDir, { recursive: true, mode: 0o700 });
const caKeyTmp = await tmpFile('ca_key'); // Atomically reserve a private workspace for keys, CSRs and OpenSSL side files.
const caCertTmp = await tmpFile('ca_cert'); const workspace = await fs.mkdtemp(path.join(certDir, '.generate-'));
const serverKeyTmp = await tmpFile('server_key'); const caKeyTmp = path.join(workspace, 'ca.key');
const serverCsrTmp = await tmpFile('server_csr'); const caCertTmp = path.join(workspace, 'ca.crt');
const serverExtTmp = await tmpFile('server_ext'); const serverKeyTmp = path.join(workspace, 'server.key');
const clientKeyTmp = await tmpFile('client_key'); const serverCsrTmp = path.join(workspace, 'server.csr');
const clientCsrTmp = await tmpFile('client_csr'); const serverExtTmp = path.join(workspace, 'server.ext');
const clientExtTmp = await tmpFile('client_ext'); const clientKeyTmp = path.join(workspace, 'client.key');
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl'); const clientCsrTmp = path.join(workspace, 'client.csr');
const clientExtTmp = path.join(workspace, 'client.ext');
const cleanup = async () => { const cleanup = async () => {
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp, await fs.rm(workspace, { recursive: true, force: true });
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
try { await fs.unlink(f); } catch {}
}
}; };
try { try {
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用) run(['genrsa', '-out', caKeyTmp, '2048']);
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`); run([
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`); '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 caKey = await fs.readFile(caKeyTmp, 'utf-8');
const caCert = await fs.readFile(caCertTmp, 'utf-8'); const caCert = await fs.readFile(caCertTmp, 'utf-8');
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 }); await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
// 2. 服务端 run(['genrsa', '-out', serverKeyTmp, '2048']);
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`); run([
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`); 'req',
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n'); '-new',
const serverCert = run( '-key',
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`, 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'); const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
// 3. 客户端 run(['genrsa', '-out', clientKeyTmp, '2048']);
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`); run([
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`); 'req',
'-new',
'-key',
clientKeyTmp,
'-out',
clientCsrTmp,
'-subj',
'/CN=grpc-client',
]);
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n'); await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
const clientCert = run( const clientCert = run([
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`, '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'); const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
await cleanup();
Logger.info('[boot] gRPC mTLS certificates generated successfully'); Logger.info('[boot] gRPC mTLS certificates generated successfully');
return { caCert, serverCert, serverKey, clientCert, clientKey }; return { caCert, serverCert, serverKey, clientCert, clientKey };
} catch (e) { } finally {
await cleanup(); await cleanup();
throw e;
} }
} }
+23 -26
View File
@@ -1,5 +1,7 @@
import { Subscription } from '../data/subscription'; import { Subscription } from '../data/subscription';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
import { shellQuote } from '../shared/shellQuote';
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
export function formatUrl(doc: Subscription) { export function formatUrl(doc: Subscription) {
let url = doc.url; let url = doc.url;
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
if (doc.type === 'private-repo') { if (doc.type === 'private-repo') {
if (doc.pull_type === 'ssh-key') { if (doc.pull_type === 'ssh-key') {
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1'); host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
url = doc.url!.replace(host, doc.alias); url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
} else { } else {
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1'); host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
const { username, password } = doc.pull_option as any; const { username, password } = doc.pull_option as any;
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
} }
export function formatCommand(doc: Subscription, url?: string) { export function formatCommand(doc: Subscription, url?: string) {
let command = `SUB_ID=${doc.id} ql `; const args =
let _url = url || formatUrl(doc).url; doc.type === 'file'
const { ? ['raw', url || formatUrl(doc).url, doc.proxy || '']
type, : [
whitelist, 'repo',
blacklist, url || formatUrl(doc).url,
dependences, doc.whitelist || '',
branch, doc.blacklist || '',
extensions, doc.dependences || '',
proxy, doc.branch || '',
autoAddCron, doc.extensions || '',
autoDelCron, doc.proxy || '',
} = doc; ];
if (type === 'file') { args.push(
command += `raw "${_url}" "${proxy || ''}" "${ String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
isNil(autoAddCron) ? true : Boolean(autoAddCron) String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; );
} else { return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${ .map((arg) => shellQuote(arg!))
dependences || '' .join(' ')}`;
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
}
return command;
} }
+223 -100
View File
@@ -1,18 +1,25 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { statSync } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree'; import psTreeFun from 'ps-tree';
import { promisify } from 'util'; import { promisify } from 'util';
import { load } from 'js-yaml'; import { load } from 'js-yaml';
import config from './index'; import config from './index';
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const'; import { PYTHON_INSTALL_DIR, PYTHON_VENV_DIR, TASK_COMMAND } from './const';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence'; import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici'; import { FormData } from 'undici';
import os from 'os'; import os from 'os';
import { maybeSudo, isInContainer } from './container'; import { maybeSudo, isInContainer } from './container';
import { shellQuote } from '../shared/shellQuote';
import { resolveFileAccess } from '../shared/fileAccess'; import { resolveFileAccess } from '../shared/fileAccess';
import {
mirrorWriteCommand,
normalizeMirror,
rewriteMirrorSources,
} from '../shared/linuxMirror';
export * from './share'; export * from './share';
@@ -670,7 +677,9 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
throw new Error('Invalid timezone'); throw new Error('Invalid timezone');
} }
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`)); await promiseExec(
maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`),
);
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`); await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
return true; return true;
@@ -680,61 +689,177 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
} }
} }
// Helper function to check if a name is a GitHub URL
function getPythonExecutable(): string {
return PYTHON_VENV_DIR
? shellQuote(path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'))
: 'python3';
}
function getPythonPip(): string {
return PYTHON_VENV_DIR ? `${getPythonExecutable()} -m pip` : 'pip3';
}
function isGitHubUrl(name: string): boolean {
// Support git+https://, git+http://, https://, and http:// URLs
// This covers GitHub URLs and other git-compatible repositories
return !!name.match(/^(git\+https?:\/\/|https?:\/\/)/i);
}
// Helper function to check if a name is a requirements file
function isRequirementsFile(name: string): boolean {
return !!name.match(/requirements.*\.(txt|in)$/i);
}
// Helper function to check if a name is a pyproject.toml file
function isPyprojectToml(name: string): boolean {
return name.endsWith('pyproject.toml');
}
export function isPythonSourceDependency(name: string): boolean {
const source = name.trim();
return (
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
);
}
export function getGetCommand(type: DependenceTypes, name: string): string { export function getGetCommand(type: DependenceTypes, name: string): string {
const trimmedName = name.trim();
// For Python dependencies installed from GitHub or requirements files,
// we can't reliably check if they're installed, so skip the check
if (type === DependenceTypes.python3) {
if (isPythonSourceDependency(trimmedName)) {
// Return a command that will always indicate not installed
// This ensures GitHub URLs and requirements files are always installed
return 'echo ""';
}
}
const baseCommands = { const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`, [DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
[DependenceTypes.python3]: ` [DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
python3 -c "exec(''' `from importlib.metadata import version, PackageNotFoundError
name='${name}' import importlib.util
import sys
try: try:
from importlib.metadata import version try:
print(version(name)) from packaging.requirements import Requirement
except: except ImportError:
import importlib.util as u from pip._vendor.packaging.requirements import Requirement
import importlib.metadata as m requirement = Requirement(sys.argv[1])
spec=u.find_spec(name) if requirement.marker and not requirement.marker.evaluate():
print(name if spec else '') print('not applicable')
''')"`, else:
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine' try:
? `apk info -es ${name}` installed = version(requirement.name)
: maybeSudo(`dpkg-query -s ${name}`), except PackageNotFoundError:
if not requirement.specifier and not requirement.extras and not requirement.url:
if importlib.util.find_spec(requirement.name):
print(requirement.name)
else:
if requirement.specifier.contains(installed, prereleases=True):
print(installed)
except Exception:
pass`,
)} ${shellQuote(trimmedName)}`,
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? `apk info -es ${trimmedName}`
: maybeSudo(`dpkg-query -s ${trimmedName}`),
}; };
return baseCommands[type]; return baseCommands[type];
} }
export function getInstallCommand(type: DependenceTypes, name: string): string { export function getInstallCommand(type: DependenceTypes, name: string): string {
if (type === DependenceTypes.python3) {
const prefix =
PYTHON_INSTALL_DIR && !PYTHON_VENV_DIR
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
: '';
const pip = `${getPythonPip()} install --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
}${prefix}`;
name = name.trim();
if (isRequirementsFile(name) || isPyprojectToml(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return isRequirementsFile(name)
? `${pip} -r ${shellQuote(file)}`
: `${pip} ${shellQuote(path.dirname(file))}`;
}
return `${pip} -- ${shellQuote(name)}`;
}
const baseCommands = { const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g', [DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.python3]: [DependenceTypes.linux]:
'pip3 install --disable-pip-version-check --root-user-action=ignore', getOsTypeSync() === 'Alpine'
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? 'apk add --no-check-certificate'
? 'apk add --no-check-certificate' : maybeSudo('apt-get install -y'),
: maybeSudo('apt-get install -y'),
}; };
return `${baseCommands[type]} ${name.trim()}`;
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
} }
export function getUninstallCommand( export function getUninstallCommand(
type: DependenceTypes, type: DependenceTypes,
name: string, name: string,
): string { ): string {
if (type === DependenceTypes.python3) {
name = name.trim();
if (isRequirementsFile(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -r ${shellQuote(file)}`;
}
if (isPyprojectToml(name) || isGitHubUrl(name)) {
// A source URL/project can install several distributions and has no safe
// inverse. Removing its panel entry leaves installed packages intact.
return `printf '%s\\n' ${shellQuote(
'Remove installed Python distributions separately by package name.',
)}`;
}
const packageName =
name.match(
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
)?.[1] || name;
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -- ${shellQuote(packageName)}`;
}
const baseCommands = { const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g', [DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.python3]: [DependenceTypes.linux]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y', getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: maybeSudo('apt-get remove -y'),
}; };
return `${baseCommands[type]} ${name.trim()}`; return `${baseCommands[type]} ${name.trim()}`;
} }
@@ -806,75 +931,73 @@ export async function detectOS(): Promise<
return osType; return osType;
} }
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror( async function _updateLinuxMirror(
osType: string, osType: string,
mirrorDomainWithScheme: string, mirror: string,
): Promise<string> { ): Promise<string> {
const S = isInContainer() ? 'sudo ' : ''; const defaults: Record<string, string> = {
let filePath: string, currentDomainWithScheme: string | null; Debian: 'http://deb.debian.org/debian',
switch (osType) { Ubuntu: 'http://archive.ubuntu.com/ubuntu',
case 'Debian': Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
filePath = '/etc/apt/sources.list.d/debian.sources'; };
currentDomainWithScheme = await getCurrentMirrorDomain(filePath); if (!defaults[osType])
if (currentDomainWithScheme) { throw new Error('Unsupported OS type for updating mirrors.');
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`; const target = mirror || defaults[osType];
} else { normalizeMirror(target);
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`; const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
osType === 'Alpine'
? '/etc/apk/repositories'
: `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
let content = '';
try {
content = await fs.readFile(file, 'utf8');
} catch (error: any) {
if (error.code !== 'ENOENT') throw error;
if (osType !== 'Alpine') {
try {
file = '/etc/apt/sources.list';
content = await fs.readFile(file, 'utf8');
} catch (legacyError: any) {
if (legacyError.code !== 'ENOENT') throw legacyError;
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
} }
case 'Ubuntu': }
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
} }
if (!content.trim()) {
const release = await fs.readFile('/etc/os-release', 'utf8');
if (osType === 'Alpine') {
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
if (!version)
throw new Error('Cannot determine Alpine repository version');
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
} else {
// The fallback may have read an empty legacy .list. Deb822 stanzas must
// be written to a .sources file, leaving the legacy file untouched.
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
if (!codename)
throw new Error('Cannot determine APT repository codename');
const ubuntu = osType === 'Ubuntu';
content = `Types: deb\nURIs: ${
defaults[osType]
}\nSuites: ${codename} ${codename}-updates${
ubuntu ? ` ${codename}-backports` : ''
}\nComponents: ${
ubuntu ? 'main restricted universe multiverse' : 'main'
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
if (!ubuntu)
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
}
}
return mirrorWriteCommand(
file,
rewriteMirrorSources(content, target),
S,
update,
);
} }
export async function updateLinuxMirrorFile(mirror: string): Promise<string> { export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
+28
View File
@@ -0,0 +1,28 @@
import { EventEmitter } from 'events';
import { database } from '.';
import { DatabaseClient } from './databaseClient';
// Keep the existing Keyv serialization and namespace. Only its SQLite transport
// changes; old cached authentication records remain readable in either version.
export class CacheStore extends EventEmitter {
readonly opts = { dialect: 'sqlite' };
namespace?: string;
constructor(private client: () => DatabaseClient = () => database) {
super();
}
get(key: string): Promise<string | undefined> {
return this.client().request('cache', 'get', key);
}
async set(key: string, value: string) {
return this.client().request('cache', 'set', key, value);
}
async delete(key: string): Promise<boolean> {
return this.client().request('cache', 'delete', key);
}
async clear(): Promise<void> {
throw new Error('Clearing all authentication namespaces is not supported');
}
async disconnect(): Promise<void> {
/* The shared database client owns the worker. */
}
}
+3 -36
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.'; import { Repository, RecordRow } from './repository';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Crontab { export class Crontab {
name?: string; name?: string;
@@ -62,37 +61,5 @@ export enum CrontabStatus {
'queued' = 3, 'queued' = 3,
} }
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {} export type CronInstance = RecordRow<Crontab>;
export const CrontabModel = sequelize.define<CronInstance>('Crontab', { export const CrontabModel = new Repository<Crontab>('Crontab');
name: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
command: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
schedule: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
timestamp: DataTypes.STRING,
saved: DataTypes.BOOLEAN,
status: DataTypes.NUMBER,
isSystem: DataTypes.NUMBER,
pid: DataTypes.NUMBER,
isDisabled: DataTypes.NUMBER,
isPinned: DataTypes.NUMBER,
log_path: DataTypes.STRING,
queued_token: DataTypes.STRING,
labels: DataTypes.JSON,
last_running_time: DataTypes.NUMBER,
last_execution_time: DataTypes.NUMBER,
sub_id: { type: DataTypes.NUMBER, allowNull: true },
extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
work_dir: DataTypes.STRING,
});
+3 -48
View File
@@ -1,5 +1,4 @@
import { DataTypes, Model } from 'sequelize'; import { Repository, RecordRow } from './repository';
import { sequelize } from '.';
export class CrontabStat { export class CrontabStat {
id?: number; id?: number;
@@ -23,49 +22,5 @@ export class CrontabStat {
} }
} }
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {} export type CrontabStatInstance = RecordRow<CrontabStat>;
export const CrontabStatModel = new Repository<CrontabStat>('CrontabStat');
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
'CrontabStat',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
ref_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
date: {
type: DataTypes.STRING,
allowNull: false,
},
run_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
success_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
fail_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
total_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
max_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
},
{
indexes: [
{ unique: true, fields: ['ref_id', 'date'] },
{ fields: ['date'] },
],
},
);
+3 -23
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.'; import { Repository, RecordRow } from './repository';
import { DataTypes, Model } from 'sequelize';
export enum CronViewType { export enum CronViewType {
'系统' = 1, '系统' = 1,
@@ -39,24 +38,5 @@ export class CrontabView {
} }
} }
export interface CronViewInstance export type CronViewInstance = RecordRow<CrontabView>;
extends Model<CrontabView, CrontabView>, export const CrontabViewModel = new Repository<CrontabView>('CrontabView');
CrontabView {}
export const CrontabViewModel = sequelize.define<CronViewInstance>(
'CrontabView',
{
name: {
unique: 'name',
type: DataTypes.STRING,
},
position: DataTypes.NUMBER,
isDisabled: DataTypes.NUMBER,
filters: DataTypes.JSON,
sorts: DataTypes.JSON,
filterRelation: {
type: DataTypes.STRING,
allowNull: true,
},
type: DataTypes.NUMBER,
},
);
+325
View File
@@ -0,0 +1,325 @@
import { AsyncLocalStorage } from 'async_hooks';
import { ChildProcess, fork } from 'child_process';
import { join } from 'path';
import { setTimeout, clearTimeout } from 'timers';
import { DatabaseError } from './databaseErrors';
type Scope = { active: boolean; child: boolean; token: number };
type Reply = {
id: number;
value?: any;
error?: { code: string; message: string };
};
type Limits = { timeoutMs?: number; queueLimit?: number };
// One worker per backend process; SQLite itself coordinates independent processes.
// A transaction owns this client's queue until its callback and commit finish.
export class DatabaseClient {
private worker?: ChildProcess;
private stopped: Promise<void> = Promise.resolve();
private failed?: Error;
private closed = false;
private nextId = 0;
private queued = 0;
private tail: Promise<void> = Promise.resolve();
private pending = new Map<
number,
{
resolve(value: any): void;
reject(error: Error): void;
timer: NodeJS.Timeout;
}
>();
private scope = new AsyncLocalStorage<Scope>();
private readonly timeoutMs: number;
private readonly queueLimit: number;
constructor(
public readonly storage: string,
private cacheStorage?: string,
limits: Limits = {},
) {
this.timeoutMs =
limits.timeoutMs ?? (process.arch === 'arm' ? 300000 : 60000);
this.queueLimit = limits.queueLimit ?? 256;
if (
!Number.isSafeInteger(this.timeoutMs) ||
this.timeoutMs < 1 ||
!Number.isSafeInteger(this.queueLimit) ||
this.queueLimit < 1
)
throw new Error('Invalid database worker limits');
}
private abort(error: Error) {
this.failed ||= error;
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(this.failed);
}
this.pending.clear();
this.unref();
this.worker?.kill('SIGKILL');
}
private unref() {
this.worker?.unref();
this.worker?.channel?.unref();
}
private start() {
if (this.worker) return this.worker;
const [major, minor] = process.versions.node.split('.').map(Number);
if (major < 22 || (major === 22 && minor < 17))
throw new DatabaseError(
'Qinglong requires Node >=22.17; keep the previous installation until Node is upgraded',
'UNSUPPORTED_NODE',
);
// ts-node's CLI places its executable in execArgv, rather than using -r.
// Source workers register their own compiler; compiled workers need none.
const preloads = __filename.endsWith('.ts')
? ['--require', require.resolve('ts-node/register/transpile-only')]
: [];
const armv6 =
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number })
.arm_version,
) === 6;
const worker = fork(require.resolve('./databaseProcess'), [], {
serialization: 'advanced',
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
execArgv: [
...preloads,
...(armv6 ? ['--jitless'] : []),
'--max-old-space-size=128',
],
env: {
...process.env,
NODE_OPTIONS: '',
...(__filename.endsWith('.ts')
? { TS_NODE_PROJECT: join(__dirname, 'tsconfig.worker.json') }
: {}),
},
});
this.stopped = new Promise((resolve) =>
worker.once('close', () => resolve()),
);
const onOwnerExit = () => worker.kill('SIGKILL');
process.once('exit', onOwnerExit);
worker.once('close', () => process.removeListener('exit', onOwnerExit));
this.worker = worker;
worker.on('message', (reply: Reply & { type?: string }) => {
if (reply.type === 'fatal' && reply.error) {
this.abort(new DatabaseError(reply.error.message, reply.error.code));
return;
}
const request = this.pending.get(reply.id);
if (!request) return;
this.pending.delete(reply.id);
clearTimeout(request.timer);
if (!this.pending.size) this.unref();
if (reply.error)
request.reject(
new DatabaseError(reply.error.message, reply.error.code),
);
else request.resolve(reply.value);
});
const fail = () =>
this.abort(
new DatabaseError(
'Database worker stopped; restart the service before retrying a write',
'DATABASE_WORKER_EXIT',
),
);
worker.on('error', fail);
worker.on('exit', () => {
if (!this.closed) fail();
});
worker.send({
storage: this.storage,
cacheStorage: this.cacheStorage,
ownerPid: process.pid,
});
this.unref();
return worker;
}
private send(
operation: string,
args: any[] = [],
token?: number,
): Promise<any> {
if (this.failed) return Promise.reject(this.failed);
if (this.closed)
return Promise.reject(
new DatabaseError('Database is closed', 'DATABASE_CLOSED'),
);
return new Promise((resolve, reject) => {
try {
const worker = this.start();
const id = ++this.nextId;
const timer = setTimeout(
() =>
this.abort(
new DatabaseError(
'Database worker timed out; writes are never automatically replayed',
'DATABASE_WORKER_TIMEOUT',
),
),
this.timeoutMs,
);
timer.unref();
this.pending.set(id, { resolve, reject, timer });
worker.ref();
worker.channel?.ref();
try {
worker.send({ id, operation, args, token }, (error) => {
if (error)
this.abort(
new DatabaseError(
'Database IPC disconnected',
'DATABASE_WORKER_EXIT',
),
);
});
} catch (error) {
this.pending.delete(id);
clearTimeout(timer);
if (!this.pending.size) this.unref();
reject(error);
}
} catch (error) {
reject(error);
}
});
}
private exclusive<T>(
operation: () => Promise<T>,
shutdown = false,
): Promise<T> {
if (!shutdown && this.queued >= this.queueLimit)
return Promise.reject(
new DatabaseError('Database request queue is full', 'DATABASE_BUSY'),
);
this.queued++;
const previous = this.tail;
let release!: () => void;
this.tail = new Promise<void>((resolve) => {
release = resolve;
});
return previous.then(operation).finally(() => {
this.queued--;
release();
});
}
request<T = any>(operation: string, ...args: any[]): Promise<T> {
const scope = this.scope.getStore();
if (scope) {
if (!scope.active || scope.child)
return Promise.reject(
new DatabaseError(
'Transaction scope is no longer available',
'TRANSACTION_SCOPE',
),
);
if (this.pending.size >= this.queueLimit)
return Promise.reject(
new DatabaseError('Database request queue is full', 'DATABASE_BUSY'),
);
return this.send(operation, args, scope.token);
}
return this.exclusive(() => this.send(operation, args));
}
async transaction<T>(operation: () => Promise<T>): Promise<T> {
const parent = this.scope.getStore();
if (parent && (!parent.active || parent.child))
throw new DatabaseError(
'Concurrent or expired nested transaction',
'TRANSACTION_SCOPE',
);
const run = async () => {
const token = ++this.nextId;
if (parent) parent.child = true;
const scope: Scope = { active: true, child: false, token };
let began = false;
let timer: NodeJS.Timeout | undefined;
try {
await this.send('begin', [], token);
began = true;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new DatabaseError(
'Database transaction timed out',
'DATABASE_TRANSACTION_TIMEOUT',
),
),
this.timeoutMs,
);
});
const value = await Promise.race([
this.scope.run(scope, operation),
deadline,
]);
scope.active = false;
await this.send('commit', [], token);
return value;
} catch (error) {
scope.active = false;
if (began) {
try {
await this.send('rollback', [], token);
} catch {
this.abort(
new DatabaseError(
'Transaction rollback failed; restart the service',
'DATABASE_ROLLBACK_FAILED',
),
);
}
}
throw error;
} finally {
clearTimeout(timer);
if (parent) parent.child = false;
}
};
return parent ? run() : this.exclusive(run);
}
query<T = Record<string, unknown>>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
return this.request('query', sql, params);
}
get healthy() {
return !this.failed && !this.closed;
}
async close() {
if (this.scope.getStore())
throw new DatabaseError(
'Cannot close within a transaction',
'TRANSACTION_SCOPE',
);
await this.exclusive(async () => {
if (this.closed) return;
try {
if (this.worker && !this.failed) await this.send('close');
} finally {
this.closed = true;
this.worker?.ref();
this.worker?.kill('SIGKILL');
await this.stopped;
}
}, true);
}
}
+36
View File
@@ -0,0 +1,36 @@
// Error details (SQL, bindings, credentials) stay inside the database worker.
export class DatabaseError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = 'DatabaseError';
}
}
export function databaseStartupFailure(value: unknown) {
const error = (value && typeof value === 'object' ? value : {}) as Error & { code?: string };
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
error.message || '',
)?.[1];
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(
error.message || '',
)?.[1];
const dependency =
/MODULE_NOT_FOUND/.test(error.code || '') &&
Boolean(
missing &&
((!missing.startsWith('.') &&
!missing.startsWith('/') &&
!missing.startsWith('node:')) ||
/node_modules[/\\]/.test(missing) ||
/node_modules[/\\]/.test(importer || '')),
);
return dependency
? {
code: 'MODULE_NOT_FOUND',
message: "Cannot find module '@mikro-orm/sql'",
}
: {
code: 'DATABASE_WORKER_EXIT',
message: 'Database process failed to start',
};
}
+61
View File
@@ -0,0 +1,61 @@
import { databaseStartupFailure } from './databaseErrors';
import { Worker } from 'worker_threads';
// IPC disconnect cannot run while DatabaseSync is executing SQL. Start this
// independent, allocation-light monitor before opening SQLite so a SIGKILLed
// backend cannot leave an orphan holding the old service's database write lock.
async function monitorOwner(ownerPid: number) {
if (!Number.isSafeInteger(ownerPid) || ownerPid < 1)
throw new Error('Invalid database owner');
const monitor = new Worker(
`
const {parentPort,workerData}=require('node:worker_threads');
process.on('uncaughtException',()=>process.kill(process.pid,'SIGKILL'));
const check=()=>{
if(process.ppid!==workerData.ownerPid)process.kill(process.pid,'SIGKILL');
};
check();
setInterval(check,500);
parentPort.postMessage('ready');
`,
{
eval: true,
workerData: { ownerPid },
// V8's process-wide --jitless remains active on ARMv6. Do not load the
// source worker's ts-node compiler into this tiny, plain-JavaScript monitor.
execArgv: [],
resourceLimits: {
maxOldGenerationSizeMb: 8,
maxYoungGenerationSizeMb: 2,
stackSizeMb: 1,
},
},
);
monitor.once('exit', () => process.exit(1));
await new Promise<void>((resolve, reject) => {
monitor.once('message', () => resolve());
monitor.once('error', reject);
});
monitor.unref();
}
// The bootstrap catches dependency import errors before the ORM module loads.
// The advanced IPC serializer preserves Date, Buffer and undefined values.
process.once('disconnect', () => process.exit(1));
process.once('message', async (settings) => {
const pending: unknown[] = [];
const buffer = (request: unknown) => pending.push(request);
// Keep a listener throughout asynchronous monitor startup. Node may flush
// several buffered IPC messages while delivering the initial settings.
process.on('message', buffer);
try {
await monitorOwner((settings as { ownerPid: number }).ownerPid);
require('./databaseWorker').startDatabaseWorker(settings);
process.removeListener('message', buffer);
for (const request of pending) process.emit('message', request, undefined);
} catch (error) {
process.send?.({ type: 'fatal', error: databaseStartupFailure(error) });
} finally {
process.removeListener('message', buffer);
}
});
+280
View File
@@ -0,0 +1,280 @@
import { mkdirSync } from 'fs';
import { dirname } from 'path';
import { EntitySchema, Type } from '@mikro-orm/core';
import { MikroORM, SqliteDriver, NodeSqliteDialect, raw } from '@mikro-orm/sql';
import { entityDefinitions } from './schema';
// Sequelize DATE readers expect TEXT. Numeric epoch dates would break rollback.
class LegacyDateType extends Type<Date, string> {
convertToDatabaseValue(value: Date) {
return value == null
? value
: value.toISOString().replace('T', ' ').replace('Z', ' +00:00');
}
convertToJSValue(value: string | Date) {
return value == null
? value
: value instanceof Date
? value
: new Date(value);
}
getColumnType() {
return 'DATETIME';
}
compareAsType() {
return 'string' as const;
}
get runtimeType() {
return 'Date';
}
}
const entities = Object.values(entityDefinitions).map(
(definition) =>
new EntitySchema({
...definition,
properties: Object.fromEntries(
Object.entries(definition.properties).map(([name, field]) => [
name,
{
...field,
type:
field.type === 'legacyDate' ? new LegacyDateType() : field.type,
},
]),
),
} as any),
);
let orm: Awaited<ReturnType<typeof MikroORM.init<SqliteDriver>>>;
let cache: any;
const transactions: number[] = [];
let ready: Promise<void>;
let workerData: { storage: string; cacheStorage?: string; ownerPid: number };
async function initialize() {
if (workerData.storage !== ':memory:')
mkdirSync(dirname(workerData.storage), { recursive: true });
orm = await MikroORM.init<SqliteDriver>({
driver: SqliteDriver,
driverOptions: new NodeSqliteDialect(workerData.storage),
dbName: workerData.storage,
entities,
debug: false,
allowGlobalContext: false,
discovery: { warnWhenNoEntities: false },
});
await orm.em.getConnection().execute('PRAGMA busy_timeout=10000');
}
function filter(name: keyof typeof entityDefinitions, value: any): any {
if (value === undefined) throw new Error('Undefined database predicate');
if (value == null || typeof value !== 'object' || value instanceof Date)
return value;
if (Array.isArray(value)) return value.map((part) => filter(name, part));
const properties = entityDefinitions[name].properties;
return Object.fromEntries(
Object.entries(value).map(([key, item]: [string, any]) => {
if (item === undefined) throw new Error('Undefined database predicate');
// LIKE searches JSON's serialized text, matching the pre-migration behavior.
if (
key in properties &&
(properties as any)[key].type === 'json' &&
item &&
typeof item === 'object' &&
'$like' in item
)
return [raw(`cast("${key}" as text)`), item];
return [key, filter(name, item)];
}),
);
}
function values(
name: keyof typeof entityDefinitions,
payload: Record<string, any>,
insert = false,
) {
const properties = entityDefinitions[name].properties;
const data = Object.fromEntries(
Object.entries(payload).filter(
([key, value]) => key in properties && value !== undefined,
),
);
for (const [key, value] of Object.entries(data)) {
if ((properties as any)[key].type === 'json' && value !== null)
data[key] = JSON.parse(JSON.stringify(value));
}
const now = new Date();
if (insert && !data.createdAt) data.createdAt = now;
data.updatedAt = now;
return data;
}
function plain(name: keyof typeof entityDefinitions, row: any) {
if (!row) return null;
return Object.fromEntries(
Object.keys(entityDefinitions[name].properties)
.filter((key) => row[key] !== undefined)
.map((key) => [key, row[key]]),
);
}
async function execute(
operation: string,
args: any[],
token?: number,
): Promise<any> {
await ready;
// Reject queued work after owner death; the monitor interrupts running SQL.
if (process.ppid !== workerData.ownerPid) process.exit(1);
const connection = orm.em.getConnection();
if (operation === 'begin') {
await connection.execute(
transactions.length ? `SAVEPOINT "ql_${token}"` : 'BEGIN IMMEDIATE',
);
transactions.push(token!);
return;
}
if (token !== transactions[transactions.length - 1])
throw new Error('Database transaction ownership mismatch');
if (operation === 'commit' || operation === 'rollback') {
if (!transactions.length) throw new Error('No active database transaction');
const nested = transactions.length > 1;
if (operation === 'rollback' && nested)
await connection.execute(`ROLLBACK TO SAVEPOINT "ql_${token}"`);
await connection.execute(
nested ? `RELEASE SAVEPOINT "ql_${token}"` : operation.toUpperCase(),
);
transactions.pop();
return;
}
if (operation === 'close') {
if (transactions.length)
throw new Error('Cannot close an active transaction');
cache?.close();
await orm.close();
return;
}
if (operation === 'query') return connection.execute(args[0], args[1]);
if (operation === 'cache') {
if (!workerData.cacheStorage)
throw new Error('No cache database configured');
if (!cache) {
const { DatabaseSync } = require('node:sqlite');
mkdirSync(dirname(workerData.cacheStorage), { recursive: true });
cache = new DatabaseSync(workerData.cacheStorage);
cache.exec(
'PRAGMA busy_timeout=10000;CREATE TABLE IF NOT EXISTS keyv(key VARCHAR(255) PRIMARY KEY,value TEXT)',
);
}
const [action, key, value] = args;
switch (action) {
case 'get':
return cache.prepare('SELECT value FROM keyv WHERE key=?').get(key)
?.value;
case 'set':
cache
.prepare(
'INSERT INTO keyv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',
)
.run(key, value);
return true;
case 'delete':
return (
cache.prepare('DELETE FROM keyv WHERE key=?').run(key).changes > 0
);
default:
throw new Error('Unsupported cache operation');
}
}
const [name, condition, options = {}] = args as [
keyof typeof entityDefinitions,
any,
any,
];
if (!(name in entityDefinitions)) throw new Error('Unknown entity');
const em = orm.em.fork();
const entityName = entities.find((entity) => entity.meta.className === name)!;
if (operation !== 'insert' && condition == null)
throw new Error('Missing database predicate');
const query = operation === 'insert' ? {} : filter(name, condition);
const readOptions = {
...options,
orderBy: options.orderBy?.map((order: Record<string, string>) =>
Object.fromEntries(
Object.entries(order).map(([field, direction]) => [
name === 'Env' && field === 'isPinned'
? raw('COALESCE("isPinned",0)')
: field,
direction,
]),
),
),
};
switch (operation) {
case 'find':
return (await em.find(entityName, query, readOptions)).map((row) =>
plain(name, row),
);
case 'findOne':
return plain(name, await em.findOne(entityName, query, readOptions));
case 'count':
return em.count(entityName, query);
case 'insert': {
const id = await em.insert(entityName, values(name, condition, true));
return plain(name, await em.findOneOrFail(entityName, { id }));
}
case 'nativeUpdate':
return em.nativeUpdate(entityName, query, values(name, options));
case 'nativeDelete':
return em.nativeDelete(entityName, query);
default:
throw new Error('Unsupported database operation');
}
}
export function startDatabaseWorker(settings: typeof workerData) {
workerData = settings;
ready = initialize();
void ready.catch(() => {});
let queue = Promise.resolve();
process.on('message', ({ id, operation, args, token }) => {
// Continue after a failed statement so the owner can still roll back.
queue = queue.then(async () => {
try {
process.send?.({
id,
value: await execute(operation, args, token),
});
} catch (error: any) {
const code =
error.name === 'UniqueConstraintViolationException'
? 'UNIQUE_CONSTRAINT'
: error.name === 'TableNotFoundException'
? 'DATABASE_SCHEMA_MISSING'
: (
{
5: 'SQLITE_BUSY',
6: 'SQLITE_LOCKED',
8: 'SQLITE_READONLY',
10: 'SQLITE_IOERR',
11: 'SQLITE_CORRUPT',
13: 'SQLITE_FULL',
14: 'SQLITE_CANTOPEN',
26: 'SQLITE_NOTADB',
} as Record<number, string>
)[Number(error.errcode) & 255] || 'DATABASE_QUERY_FAILED';
process.send?.({
id,
error: {
code,
message:
code === 'UNIQUE_CONSTRAINT'
? 'Resource already exists'
: `Database operation failed (${code})`,
},
});
}
});
});
}
+3 -16
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.'; import { Repository, RecordRow } from './repository';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Dependence { export class Dependence {
timestamp?: string; timestamp?: string;
@@ -47,17 +46,5 @@ export enum versionDependenceCommandTypes {
'=', '=',
} }
export interface DependenceInstance export type DependenceInstance = RecordRow<Dependence>;
extends Model<Dependence, Dependence>, export const DependenceModel = new Repository<Dependence>('Dependence');
Dependence {}
export const DependenceModel = sequelize.define<DependenceInstance>(
'Dependence',
{
name: DataTypes.STRING,
type: DataTypes.NUMBER,
timestamp: DataTypes.STRING,
status: DataTypes.NUMBER,
log: DataTypes.JSON,
remark: DataTypes.STRING,
},
);
+3 -13
View File
@@ -1,5 +1,4 @@
import { DataTypes, Model } from 'sequelize'; import { Repository, RecordRow } from './repository';
import { sequelize } from '.';
export class Env { export class Env {
value?: string; value?: string;
@@ -38,14 +37,5 @@ export const initPosition = 4500000000000000;
export const stepPosition = 10000000000; export const stepPosition = 10000000000;
export const minPosition = 100; export const minPosition = 100;
export interface EnvInstance extends Model<Env, Env>, Env {} export type EnvInstance = RecordRow<Env>;
export const EnvModel = sequelize.define<EnvInstance>('Env', { export const EnvModel = new Repository<Env>('Env');
value: { type: DataTypes.STRING, unique: 'compositeIndex' },
timestamp: DataTypes.STRING,
status: DataTypes.NUMBER,
position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
labels: DataTypes.JSON,
});
+5 -18
View File
@@ -1,23 +1,10 @@
import { Sequelize, Transaction } from 'sequelize';
import config from '../config/index'; import config from '../config/index';
import { join } from 'path'; import { join } from 'path';
import { DatabaseClient } from './databaseClient';
export const sequelize = new Sequelize({ export const database = new DatabaseClient(
dialect: 'sqlite', join(config.dbPath, 'database.sqlite'),
storage: join(config.dbPath, 'database.sqlite'), join(config.dbPath, 'keyv.sqlite'),
logging: false, );
retry: {
max: 10,
match: ['SQLITE_BUSY: database is locked'],
},
pool: {
max: 5,
min: 2,
idle: 30000,
acquire: 30000,
evict: 10000,
},
transactionType: Transaction.TYPES.IMMEDIATE,
});
export type ResponseType<T> = { code: number; data?: T; message?: string }; export type ResponseType<T> = { code: number; data?: T; message?: string };
+11 -11
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.'; import { Repository, RecordRow } from './repository';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class App { export class App {
name: string; name: string;
@@ -24,13 +23,14 @@ export interface AppToken {
expiration: number; expiration: number;
} }
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard'; export type AppScope =
| 'envs'
| 'crons'
| 'configs'
| 'scripts'
| 'logs'
| 'system'
| 'dashboard';
export interface AppInstance extends Model<App, App>, App {} export type AppInstance = RecordRow<App>;
export const AppModel = sequelize.define<AppInstance>('App', { export const AppModel = new Repository<App>('App');
name: { type: DataTypes.STRING, unique: 'name' },
scopes: DataTypes.JSON,
client_id: DataTypes.STRING,
client_secret: DataTypes.STRING,
tokens: DataTypes.JSON,
});
+25
View File
@@ -0,0 +1,25 @@
export type Query = Record<string, any>;
export type QuerySpec = {
where: Query;
orderBy?: Record<string, string>[];
offset?: number;
limit?: number;
};
export function orderBy(order: string[][]): Record<string, string>[] {
return order.map(([field, direction]) => {
if (!/^(asc|desc)$/i.test(direction))
throw new Error('Invalid sort direction');
return { [field]: direction.toLowerCase() };
});
}
export function textSearch(fields: string[], text: string): Query {
return {
$or: fields.flatMap((field) =>
[...new Set([text, encodeURI(text)])].map((value) => ({
[field]: { $like: `%${value}%` },
})),
),
};
}
+54
View File
@@ -0,0 +1,54 @@
import { database } from '.';
import { DatabaseClient } from './databaseClient';
export type RecordRow<T> = T & { id: number; createdAt: Date; updatedAt: Date };
export type Query = Record<string, any>;
export type ReadOptions = {
fields?: string[];
orderBy?: Record<string, string>[];
limit?: number;
offset?: number;
};
// A small IPC boundary with MikroORM's native query/operation names. No ORM
// entities or request identity maps escape the single database worker.
export class Repository<T> {
constructor(
private name: string,
private client: () => DatabaseClient = () => database,
) {}
find(where: Query = {}, options: ReadOptions = {}): Promise<RecordRow<T>[]> {
return this.client().request('find', this.name, where, options);
}
findOne(
where: Query,
options: ReadOptions = {},
): Promise<RecordRow<T> | null> {
return this.client().request('findOne', this.name, where, options);
}
count(where: Query = {}): Promise<number> {
return this.client().request('count', this.name, where);
}
insert(data: Partial<T>): Promise<RecordRow<T>> {
return this.client().request('insert', this.name, data);
}
nativeUpdate(where: Query, data: Partial<T>): Promise<number> {
return this.client().request('nativeUpdate', this.name, where, data);
}
nativeDelete(where: Query): Promise<number> {
return this.client().request('nativeDelete', this.name, where);
}
async insertMany(data: Partial<T>[]): Promise<RecordRow<T>[]> {
return this.client().transaction(async () => {
const rows = [];
for (const item of data) rows.push(await this.insert(item));
return rows;
});
}
async getOrCreate(where: Query): Promise<RecordRow<T>> {
return this.client().transaction(
async () =>
(await this.findOne(where)) || this.insert(where as Partial<T>),
);
}
}
+3 -38
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.'; import { Repository, RecordRow } from './repository';
import { DataTypes, Model } from 'sequelize';
export enum InstanceStatus { export enum InstanceStatus {
'running' = 0, 'running' = 0,
@@ -41,41 +40,7 @@ export class RunningInstance {
} }
} }
export interface RunningInstanceModel export type RunningInstanceModel = RecordRow<RunningInstanceAttributes>;
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>, export const RunningInstanceModel = new Repository<RunningInstanceAttributes>(
RunningInstanceAttributes {}
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
'RunningInstance', 'RunningInstance',
{
cron_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
pid: {
type: DataTypes.NUMBER,
allowNull: true,
},
log_path: {
type: DataTypes.STRING,
allowNull: true,
},
started_at: {
type: DataTypes.NUMBER,
allowNull: false,
},
finished_at: {
type: DataTypes.NUMBER,
allowNull: true,
},
status: {
type: DataTypes.NUMBER,
allowNull: false,
defaultValue: InstanceStatus.running,
},
exit_code: {
type: DataTypes.NUMBER,
allowNull: true,
},
},
); );
+178
View File
@@ -0,0 +1,178 @@
// Persisted SQLite names and types are part of the rollback contract.
type Field = {
type: string;
fieldName: string;
nullable?: boolean;
primary?: boolean;
autoincrement?: boolean;
};
function entity<Name extends string>(
name: Name,
tableName: string,
groups: Record<string, string[]>,
required: string[] = [],
) {
const properties: Record<string, Field> = {
id: { type: 'number', fieldName: 'id', primary: true, autoincrement: true },
};
for (const [type, fields] of Object.entries(groups))
for (const field of fields)
properties[field] = {
type,
fieldName: field,
nullable: !required.includes(field),
};
for (const field of ['createdAt', 'updatedAt'])
properties[field] = {
type: 'legacyDate',
fieldName: field,
nullable: false,
};
return { name, tableName, properties };
}
export const entityDefinitions = {
App: entity(
'App',
'Apps',
{
string: ['name', 'client_id', 'client_secret'],
json: ['scopes', 'tokens'],
},
[],
),
Auth: entity('Auth', 'Auths', { string: ['ip', 'type'], json: ['info'] }, []),
CrontabStat: entity(
'CrontabStat',
'CrontabStats',
{
number: [
'ref_id',
'run_count',
'success_count',
'fail_count',
'total_time',
'max_time',
],
string: ['date'],
},
['ref_id', 'date'],
),
CrontabView: entity(
'CrontabView',
'CrontabViews',
{
string: ['name', 'filterRelation'],
number: ['position', 'isDisabled', 'type'],
json: ['filters', 'sorts'],
},
[],
),
Crontab: entity(
'Crontab',
'Crontabs',
{
string: [
'name',
'command',
'schedule',
'timestamp',
'log_path',
'queued_token',
'task_before',
'task_after',
'log_name',
'work_dir',
],
boolean: ['saved'],
number: [
'status',
'isSystem',
'pid',
'isDisabled',
'isPinned',
'last_running_time',
'last_execution_time',
'sub_id',
'allow_multiple_instances',
],
json: ['labels', 'extra_schedules'],
},
[],
),
Dependence: entity(
'Dependence',
'Dependences',
{
string: ['name', 'timestamp', 'remark'],
number: ['type', 'status'],
json: ['log'],
},
[],
),
Env: entity(
'Env',
'Envs',
{
string: ['value', 'timestamp', 'name', 'remarks'],
number: ['status', 'position', 'isPinned'],
json: ['labels'],
},
[],
),
RunningInstance: entity(
'RunningInstance',
'RunningInstances',
{
number: [
'cron_id',
'pid',
'started_at',
'finished_at',
'status',
'exit_code',
],
string: ['log_path'],
},
['cron_id', 'started_at', 'status'],
),
Subscription: entity(
'Subscription',
'Subscriptions',
{
string: [
'name',
'url',
'schedule',
'type',
'whitelist',
'blacklist',
'dependences',
'extensions',
'sub_before',
'sub_after',
'branch',
'pull_type',
'log_path',
'schedule_type',
'alias',
'proxy',
],
json: ['interval_schedule', 'pull_option'],
number: ['status', 'pid', 'is_disabled', 'autoAddCron', 'autoDelCron'],
},
[],
),
};
export const initialSchema = [
'CREATE TABLE IF NOT EXISTS `Apps` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `scopes` JSON, `client_id` VARCHAR(255), `client_secret` VARCHAR(255), `tokens` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`))',
'CREATE TABLE IF NOT EXISTS `Auths` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `ip` VARCHAR(255), `type` VARCHAR(255), `info` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
'CREATE TABLE IF NOT EXISTS `CrontabStats` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `ref_id` NUMBER NOT NULL, `date` VARCHAR(255) NOT NULL, `run_count` NUMBER DEFAULT 0, `success_count` NUMBER DEFAULT 0, `fail_count` NUMBER DEFAULT 0, `total_time` NUMBER DEFAULT 0, `max_time` NUMBER DEFAULT 0, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
'CREATE TABLE IF NOT EXISTS `CrontabViews` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `position` NUMBER, `isDisabled` NUMBER, `filters` JSON, `sorts` JSON, `filterRelation` VARCHAR(255), `type` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`))',
'CREATE TABLE IF NOT EXISTS `Crontabs` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `command` VARCHAR(255), `schedule` VARCHAR(255), `timestamp` VARCHAR(255), `saved` TINYINT(1), `status` NUMBER, `isSystem` NUMBER, `pid` NUMBER, `isDisabled` NUMBER, `isPinned` NUMBER, `log_path` VARCHAR(255), `queued_token` VARCHAR(255), `labels` JSON, `last_running_time` NUMBER, `last_execution_time` NUMBER, `sub_id` NUMBER, `extra_schedules` JSON, `task_before` VARCHAR(255), `task_after` VARCHAR(255), `log_name` VARCHAR(255), `allow_multiple_instances` NUMBER, `work_dir` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`, `command`, `schedule`))',
'CREATE TABLE IF NOT EXISTS `Dependences` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `type` NUMBER, `timestamp` VARCHAR(255), `status` NUMBER, `log` JSON, `remark` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
'CREATE TABLE IF NOT EXISTS `Envs` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `value` VARCHAR(255), `timestamp` VARCHAR(255), `status` NUMBER, `position` NUMBER, `name` VARCHAR(255), `remarks` VARCHAR(255), `isPinned` NUMBER, `labels` JSON, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`value`, `name`))',
'CREATE TABLE IF NOT EXISTS `RunningInstances` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `cron_id` NUMBER NOT NULL, `pid` NUMBER, `log_path` VARCHAR(255), `started_at` NUMBER NOT NULL, `finished_at` NUMBER, `status` NUMBER NOT NULL DEFAULT 0, `exit_code` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)',
'CREATE TABLE IF NOT EXISTS `Subscriptions` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255), `url` VARCHAR(255), `schedule` VARCHAR(255), `interval_schedule` JSON, `type` VARCHAR(255), `whitelist` VARCHAR(255), `blacklist` VARCHAR(255), `status` NUMBER, `dependences` VARCHAR(255), `extensions` VARCHAR(255), `sub_before` VARCHAR(255), `sub_after` VARCHAR(255), `branch` VARCHAR(255), `pull_type` VARCHAR(255), `pull_option` JSON, `pid` NUMBER, `is_disabled` NUMBER, `log_path` VARCHAR(255), `schedule_type` VARCHAR(255), `alias` VARCHAR(255), `proxy` VARCHAR(255), `autoAddCron` NUMBER, `autoDelCron` NUMBER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`name`, `url`, `schedule`, `interval_schedule`), UNIQUE (`alias`))',
'CREATE INDEX IF NOT EXISTS `crontab_stats_date` ON `CrontabStats` (`date`)',
'CREATE UNIQUE INDEX IF NOT EXISTS `crontab_stats_ref_id_date` ON `CrontabStats` (`ref_id`, `date`)',
];
+45
View File
@@ -0,0 +1,45 @@
import { database } from '.';
// Aggregate SQL stays explicit and bound; public input is never used as an SQL
// identifier. These queries preserve the dashboard response names.
export const statistics = {
async totals(date: string) {
const rows = await database.query(
`SELECT SUM(run_count) AS total_runs,
SUM(success_count) AS total_success, SUM(fail_count) AS total_fail,
SUM(total_time) AS total_time FROM CrontabStats WHERE date=?`,
[date],
);
return rows[0];
},
results(date: string, column: 'success_count' | 'fail_count') {
if (!['success_count', 'fail_count'].includes(column))
throw new Error('Invalid statistic');
return database.query(
`SELECT ref_id,SUM(${column}) AS result_count FROM CrontabStats
WHERE date=? AND ${column}>0 GROUP BY ref_id ORDER BY result_count DESC,ref_id ASC`,
[date],
);
},
trend(dates: string[]) {
if (!dates.length) return Promise.resolve([]);
return database.query(
`SELECT date,SUM(run_count) AS total_runs,
SUM(success_count) AS total_success,SUM(fail_count) AS total_fail FROM CrontabStats
WHERE date IN (${dates
.map(() => '?')
.join(',')}) GROUP BY date ORDER BY date ASC`,
dates,
);
},
ranking(date: string, metric: 'total_time' | 'run_count') {
if (!['total_time', 'run_count'].includes(metric))
throw new Error('Invalid statistic');
return database.query(
`SELECT ref_id,SUM(total_time) AS total_time,SUM(run_count) AS run_count,
SUM(success_count) AS success_count,MAX(max_time) AS max_time FROM CrontabStats
WHERE date=? AND run_count>0 GROUP BY ref_id ORDER BY SUM(${metric}) DESC LIMIT 5`,
[date],
);
},
};
+3 -45
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.'; import { Repository, RecordRow } from './repository';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { SimpleIntervalSchedule } from 'toad-scheduler'; import { SimpleIntervalSchedule } from 'toad-scheduler';
type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule; type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule;
@@ -70,46 +69,5 @@ export enum SubscriptionStatus {
'queued', 'queued',
} }
export interface SubscriptionInstance export type SubscriptionInstance = RecordRow<Subscription>;
extends Model<Subscription, Subscription>, export const SubscriptionModel = new Repository<Subscription>('Subscription');
Subscription {}
export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
'Subscription',
{
name: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
url: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
schedule: {
unique: 'compositeIndex',
type: DataTypes.STRING,
},
interval_schedule: {
unique: 'compositeIndex',
type: DataTypes.JSON,
},
type: DataTypes.STRING,
whitelist: DataTypes.STRING,
blacklist: DataTypes.STRING,
status: DataTypes.NUMBER,
dependences: DataTypes.STRING,
extensions: DataTypes.STRING,
sub_before: DataTypes.STRING,
sub_after: DataTypes.STRING,
branch: DataTypes.STRING,
pull_type: DataTypes.STRING,
pull_option: DataTypes.JSON,
pid: DataTypes.NUMBER,
is_disabled: DataTypes.NUMBER,
log_path: DataTypes.STRING,
schedule_type: DataTypes.STRING,
alias: { type: DataTypes.STRING, unique: 'alias' },
proxy: { type: DataTypes.STRING, allowNull: true },
autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
autoDelCron: { type: DataTypes.NUMBER, allowNull: true },
},
);
+19 -13
View File
@@ -1,5 +1,4 @@
import { sequelize } from '.'; import { Repository, RecordRow } from './repository';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { NotificationInfo } from './notify'; import { NotificationInfo } from './notify';
export class SystemInfo { export class SystemInfo {
@@ -28,8 +27,12 @@ export enum AuthDataType {
'removeLogFrequency' = 'removeLogFrequency', 'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig', 'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig', 'authConfig' = 'authConfig',
'notifyLog' = 'notifyLog',
} }
export const NotifyStatus = LoginStatus;
export type NotifyStatus = LoginStatus;
export interface SystemConfigInfo { export interface SystemConfigInfo {
lang?: string; lang?: string;
panelTitle?: string; panelTitle?: string;
@@ -53,6 +56,17 @@ export interface LoginLogInfo {
status?: LoginStatus; status?: LoginStatus;
} }
export interface NotifyLogInfo {
timestamp?: number;
title?: string;
content?: string;
status?: NotifyStatus;
notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
}
export interface TokenInfo { export interface TokenInfo {
value: string; value: string;
timestamp: number; timestamp: number;
@@ -88,16 +102,8 @@ export interface AuthInfo {
export type SystemModelInfo = SystemConfigInfo & export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> & Partial<NotificationInfo> &
LoginLogInfo & LoginLogInfo &
Partial<NotifyLogInfo> &
Partial<AuthInfo>; Partial<AuthInfo>;
export interface SystemInstance export type SystemInstance = RecordRow<SystemInfo>;
extends Model<SystemInfo, SystemInfo>, export const SystemModel = new Repository<SystemInfo>('Auth');
SystemInfo {}
export const SystemModel = sequelize.define<SystemInstance>('Auth', {
ip: DataTypes.STRING,
type: DataTypes.STRING,
info: {
type: DataTypes.JSON,
allowNull: true,
},
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "node20",
"moduleResolution": "node16",
"rootDir": "..",
"outDir": "../../static/build"
},
"include": ["./databaseWorker.ts", "./schema.ts"],
"exclude": ["node_modules"]
}
+6 -22
View File
@@ -1,30 +1,14 @@
import Logger from './logger'; import Logger from './logger';
import { EnvModel } from '../data/env'; import { database } from '../data';
import { CrontabModel } from '../data/cron'; import { initialSchema } from '../data/schema';
import { DependenceModel } from '../data/dependence';
import { AppModel } from '../data/open';
import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data';
import { migrateSchema } from '../shared/schemaMigrations'; import { migrateSchema } from '../shared/schemaMigrations';
export default async () => { export default async () => {
try { try {
await CrontabModel.sync(); await database.transaction(async () => {
await DependenceModel.sync(); for (const sql of initialSchema) await database.query(sql);
await AppModel.sync(); await migrateSchema(database);
await SystemModel.sync(); });
await EnvModel.sync();
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
await migrateSchema(sequelize);
Logger.info('[boot] DB loaded'); Logger.info('[boot] DB loaded');
} catch (error) { } catch (error) {
Logger.error('[boot] DB load failed', error); Logger.error('[boot] DB load failed', error);
+97 -102
View File
@@ -6,7 +6,7 @@ import config from '../config';
import { UnauthorizedError, expressjwt } from 'express-jwt'; import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util'; import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite'; import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate'; import validationErrors from '../middlewares/validationErrors';
import { serveEnv } from '../config/serverEnv'; import { serveEnv } from '../config/serverEnv';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth'; import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
@@ -14,8 +14,9 @@ import { AuthInfo } from '../data/system';
import path from 'path'; import path from 'path';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import { AppScope } from '../data/open'; import { AppScope } from '../data/open';
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
import protectedPathCase from '../middlewares/protectedPathCase'; import protectedPathCase from '../middlewares/protectedPathCase';
import { withAuthAvailability } from '../middlewares/authAvailability';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) { function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
const setting = value?.trim(); const setting = value?.trim();
if (!setting) { if (!setting) {
@@ -29,24 +30,20 @@ function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
} }
return setting; return setting;
} }
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass // Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true); app.set('case sensitive routing', true);
app.set('strict routing', true); app.set('strict routing', true);
app.set('trust proxy', resolveTrustProxy()); app.set('trust proxy', resolveTrustProxy());
app.use(cors()); app.use(cors());
// Security: Reject case variations under protected API namespaces before // Security: Reject case variations under protected API namespaces before
// authentication checks can interpret the request differently from routing. // authentication checks can interpret the request differently from routing.
app.use(protectedPathCase); app.use(protectedPathCase);
// Rewrite URLs to strip baseUrl prefix if configured // Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness // This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) { if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1')); app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
} }
app.get(`${config.api.prefix}/env.js`, serveEnv); app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use( app.use(
`${config.api.prefix}/static`, `${config.api.prefix}/static`,
@@ -57,7 +54,6 @@ export default ({ app }: { app: Application }) => {
}, },
}), }),
); );
const credentialPaths = ['/api', '/open'].flatMap((prefix) => const credentialPaths = ['/api', '/open'].flatMap((prefix) =>
['/user/login', '/user/init', '/user/two-factor/login'].map( ['/user/login', '/user/init', '/user/two-factor/login'].map(
(route) => `${prefix}${route}`, (route) => `${prefix}${route}`,
@@ -68,13 +64,10 @@ export default ({ app }: { app: Application }) => {
bodyParser.json({ limit: '16kb' }), bodyParser.json({ limit: '16kb' }),
bodyParser.urlencoded({ limit: '16kb', extended: false }), bodyParser.urlencoded({ limit: '16kb', extended: false }),
); );
app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
const frontendPath = path.join(config.rootPath, 'static/dist'); const frontendPath = path.join(config.rootPath, 'static/dist');
app.use(express.static(frontendPath)); app.use(express.static(frontendPath));
app.use( app.use(
expressjwt({ expressjwt({
secret: config.jwt.secret, secret: config.jwt.secret,
@@ -83,7 +76,6 @@ export default ({ app }: { app: Application }) => {
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i], path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}), }),
); );
app.use((req: Request, res, next) => { app.use((req: Request, res, next) => {
if (!req.headers) { if (!req.headers) {
req.platform = 'desktop'; req.platform = 'desktop';
@@ -93,92 +85,92 @@ export default ({ app }: { app: Application }) => {
} }
return next(); return next();
}); });
app.use(
app.use(async (req: Request, res, next) => { withAuthAvailability(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase(); const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) { if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
return next(err);
}
return next(); return next();
} }
} const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`; const apps = await shareStore.getApps();
if ( const doc = apps?.filter((x) =>
!headerToken && x.tokens?.find((y) => y.value === headerToken),
originPath && )?.[0];
config.apiWhiteList.includes(originPath) if (doc && doc.tokens && doc.tokens.length > 0) {
) { const currentToken = doc.tokens.find((x) => x.value === headerToken);
return next(); const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
} const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const authInfo = await shareStore.getAuthInfo(); const err = new UnauthorizedError('credentials_bad_scheme', {
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) { message: t('暂无权限'),
return next(); });
} return next(err);
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; if (
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录'); !currentToken ||
const err = new UnauthorizedError(errorCode, { message: errorMessage }); currentToken.expiration < Math.round(Date.now() / 1000)
next(err); ) {
}); const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
app.use(async (req, res, next) => { });
const pathLower = req.path.toLowerCase(); return next(err);
if ( }
![ return next();
'/api/user/init', }
'/api/user/notification/init', }
'/open/user/init', const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
'/open/user/notification/init', if (
].includes(pathLower) !headerToken &&
) { originPath &&
return next(); config.apiWhiteList.includes(originPath)
} ) {
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo); return next();
}
let isInitialized = !isDefaultAuthInfo(authInfo); const authInfo = await shareStore.getAuthInfo();
if (
if (isInitialized) { isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)
return res.send({ code: 450, message: t('未知错误') }); ) {
} else { return next();
return next(); }
} const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
}); const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
}),
);
app.use(
withAuthAvailability(async (req, res, next) => {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next();
}
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo);
if (isInitialized) {
return res.send({ code: 450, message: t('未知错误') });
} else {
return next();
}
}),
);
app.use(rewrite('/open/*', '/api/$1')); app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes()); app.use(config.api.prefix, routes());
app.get('*', (req, res, next) => {
app.get('*', (_, res, next) => { // Don't serve index.html for API routes
if (
req.path === config.api.prefix ||
req.path.startsWith(`${config.api.prefix}/`)
) {
return res.status(404).json({ code: 404, message: 'Not Found' });
}
const indexPath = path.join(frontendPath, 'index.html'); const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => { res.sendFile(indexPath, (err) => {
if (err) { if (err) {
@@ -188,12 +180,13 @@ export default ({ app }: { app: Application }) => {
} }
}); });
}); });
app.use(validationErrors);
app.use(errors()); app.use(uniqueConstraintError);
app.use( app.use(
( (
err: Error & { status: number }, err: Error & {
status: number;
},
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
@@ -207,15 +200,16 @@ export default ({ app }: { app: Application }) => {
return next(err); return next(err);
}, },
); );
app.use( app.use(
( (
err: Error & { errors: any[] }, err: Error & {
errors: any[];
},
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
if (err.name.includes('Sequelize')) { if (err.name === 'DatabaseError') {
return res return res
.status(500) .status(500)
.send({ .send({
@@ -228,10 +222,11 @@ export default ({ app }: { app: Application }) => {
return next(err); return next(err);
}, },
); );
app.use( app.use(
( (
err: Error & { status: number }, err: Error & {
status: number;
},
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
+33
View File
@@ -0,0 +1,33 @@
import type express from 'express';
import config from '../config';
export async function createHttpApplication(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('../middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(
helmet({
contentSecurityPolicy: false,
}),
);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
+81 -112
View File
@@ -1,3 +1,5 @@
import { textSearch } from '../data/queries';
import { Query, QuerySpec, orderBy } from '../data/queries';
import DependenceService from '../services/dependence'; import DependenceService from '../services/dependence';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { Container } from 'typedi'; import { Container } from 'typedi';
@@ -5,7 +7,6 @@ import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import CronService from '../services/cron'; import CronService from '../services/cron';
import EnvService from '../services/env'; import EnvService from '../services/env';
import { DependenceModel, DependenceStatus } from '../data/dependence'; import { DependenceModel, DependenceStatus } from '../data/dependence';
import { Op } from 'sequelize';
import config from '../config'; import config from '../config';
import { CrontabViewModel, CronViewType } from '../data/cronView'; import { CrontabViewModel, CronViewType } from '../data/cronView';
import { initPosition } from '../data/env'; import { initPosition } from '../data/env';
@@ -13,15 +14,13 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system'; import SystemService from '../services/system';
import UserService from '../services/user'; import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises'; 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 OpenService from '../services/open';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import Logger from './logger'; import Logger from './logger';
import cronClient from '../schedule/client'; import cronClient from '../schedule/client';
import { AppModel } from '../data/open';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { setLang, systemLang } from '../shared/i18n'; import { setLang, systemLang } from '../shared/i18n';
export default async () => { export default async () => {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
@@ -29,34 +28,14 @@ export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const userService = Container.get(UserService); const userService = Container.get(UserService);
const openService = Container.get(OpenService); const openService = Container.get(OpenService);
// 初始化增加系统配置 // 初始化增加系统配置
let systemApp = ( await openService.initializeSystemApp();
await AppModel.findOne({ const systemConfig = await SystemModel.getOrCreate({
where: { name: 'system' }, type: AuthDataType.systemConfig,
})
)?.get({ plain: true });
if (!systemApp) {
systemApp = await AppModel.create({
name: 'system',
scopes: ['crons', 'system', 'dashboard'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
} else if (!systemApp.scopes.includes('dashboard')) {
await AppModel.update(
{ scopes: [...systemApp.scopes, 'dashboard'] },
{ where: { name: 'system' } },
);
}
const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig },
}); });
await SystemModel.findOrCreate({ await SystemModel.getOrCreate({ type: AuthDataType.notification });
where: { type: AuthDataType.notification }, const authConfig = await SystemModel.getOrCreate({
}); type: AuthDataType.authConfig,
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
}); });
if (!authConfig?.info || isDemoEnv()) { if (!authConfig?.info || isDemoEnv()) {
let authInfo = { let authInfo = {
@@ -72,36 +51,34 @@ export default async () => {
} catch (error) { } catch (error) {
Logger.warn('Failed to read auth config file, using default credentials'); Logger.warn('Failed to read auth config file, using default credentials');
} }
await SystemModel.upsert({ await SystemModel.nativeUpdate(
id: authConfig?.id, { id: authConfig.id },
info: authInfo, {
type: AuthDataType.authConfig, info: authInfo,
}); type: AuthDataType.authConfig,
},
);
} }
const installDependencies = async () => { const installDependencies = async () => {
const docs = await DependenceModel.findAll({ const docs = await DependenceModel.find(
where: {}, {},
order: [ {
['type', 'DESC'], orderBy: orderBy([
['createdAt', 'DESC'], ['type', 'DESC'],
], ['createdAt', 'DESC'],
raw: true, ]),
}); },
);
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ id: docs.map((x) => x.id!) },
{ status: DependenceStatus.queued, log: [] }, { status: DependenceStatus.queued, log: [] },
{ where: { id: docs.map((x) => x.id!) } },
); );
setTimeout(async () => { setTimeout(async () => {
await dependenceService.installDependenceOneByOne(docs); await dependenceService.installDependenceOneByOne(docs);
const bootAfterLoader = await import('./bootAfter'); const bootAfterLoader = await import('./bootAfter');
bootAfterLoader.default(); bootAfterLoader.default();
}, 5000); }, 5000);
}; };
// 初始化更新 linux/python/nodejs 镜像源配置 // 初始化更新 linux/python/nodejs 镜像源配置
if (systemConfig.info?.pythonMirror) { if (systemConfig.info?.pythonMirror) {
systemService.updatePythonMirror({ systemService.updatePythonMirror({
@@ -124,39 +101,40 @@ export default async () => {
nodeMirror: systemConfig.info?.nodeMirror, nodeMirror: systemConfig.info?.nodeMirror,
}); });
} }
// 初始化新增默认全部任务视图 // 初始化新增默认全部任务视图
CrontabViewModel.findAll({ CrontabViewModel.find({ type: CronViewType.系统, name: '全部任务' }, {}).then(
where: { type: CronViewType.系统, name: '全部任务' }, (docs) => {
raw: true, if (docs.length === 0) {
}).then((docs) => { CrontabViewModel.insert({
if (docs.length === 0) { name: '全部任务',
CrontabViewModel.create({ type: CronViewType.系统,
name: '全部任务', position: initPosition / 2,
type: CronViewType.系统, });
position: initPosition / 2, }
});
}
});
// 初始化更新所有任务状态为空闲
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped },
{ where: { status: InstanceStatus.running } },
);
// 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({
where: {
isDisabled: { [Op.ne]: 1 },
command: {
[Op.or]: [{ [Op.like]: `%ql repo%` }, { [Op.like]: `%ql raw%` }],
},
}, },
}).then((docs) => { );
// 初始化更新所有任务状态为空闲
await CrontabModel.nativeUpdate({}, { status: CrontabStatus.idle });
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.nativeUpdate(
{ status: InstanceStatus.running },
{
status: InstanceStatus.stopped,
finished_at: Math.floor(Date.now() / 1000),
// The previous process's exit code cannot be recovered after a restart.
},
);
// 初始化时执行一次所有的 ql repo 任务
CrontabModel.find(
{
isDisabled: { ['$ne']: 1 },
$or: [
...textSearch(['command'], 'ql repo').$or,
...textSearch(['command'], 'ql raw').$or,
],
},
{},
).then((docs) => {
for (let i = 0; i < docs.length; i++) { for (let i = 0; i < docs.length; i++) {
const doc = docs[i]; const doc = docs[i];
if (doc) { if (doc) {
@@ -164,88 +142,79 @@ export default async () => {
} }
} }
}); });
// 更新2.11.3以前的脚本路径 // 更新2.11.3以前的脚本路径
CrontabModel.findAll({ CrontabModel.find(
where: { {
command: { $or: ['scripts', 'config', 'log', 'db'].flatMap(
[Op.or]: [ (folder) =>
{ [Op.like]: `%\/${config.rootPath}\/scripts\/%` }, textSearch(['command'], `/${config.rootPath}/${folder}/`).$or,
{ [Op.like]: `%\/${config.rootPath}\/config\/%` }, ),
{ [Op.like]: `%\/${config.rootPath}\/log\/%` },
{ [Op.like]: `%\/${config.rootPath}\/db\/%` },
],
},
}, },
}).then(async (docs) => { {},
).then(async (docs) => {
for (let i = 0; i < docs.length; i++) { for (let i = 0; i < docs.length; i++) {
const doc = docs[i]; const doc = docs[i];
if (doc) { if (doc) {
if (doc.command.includes(`${config.rootPath}/scripts/`)) { if (doc.command.includes(`${config.rootPath}/scripts/`)) {
await CrontabModel.update( await CrontabModel.nativeUpdate(
{ id: doc.id },
{ command: doc.command.replace(`${config.rootPath}/scripts/`, '') }, { command: doc.command.replace(`${config.rootPath}/scripts/`, '') },
{ where: { id: doc.id } },
); );
} }
if (doc.command.includes(`${config.rootPath}/log/`)) { if (doc.command.includes(`${config.rootPath}/log/`)) {
await CrontabModel.update( await CrontabModel.nativeUpdate(
{ id: doc.id },
{ {
command: `${config.dataPath}/log/${doc.command.replace( command: `${config.dataPath}/log/${doc.command.replace(
`${config.rootPath}/log/`, `${config.rootPath}/log/`,
'', '',
)}`, )}`,
}, },
{ where: { id: doc.id } },
); );
} }
if (doc.command.includes(`${config.rootPath}/config/`)) { if (doc.command.includes(`${config.rootPath}/config/`)) {
await CrontabModel.update( await CrontabModel.nativeUpdate(
{ id: doc.id },
{ {
command: `${config.dataPath}/config/${doc.command.replace( command: `${config.dataPath}/config/${doc.command.replace(
`${config.rootPath}/config/`, `${config.rootPath}/config/`,
'', '',
)}`, )}`,
}, },
{ where: { id: doc.id } },
); );
} }
if (doc.command.includes(`${config.rootPath}/db/`)) { if (doc.command.includes(`${config.rootPath}/db/`)) {
await CrontabModel.update( await CrontabModel.nativeUpdate(
{ id: doc.id },
{ {
command: `${config.dataPath}/db/${doc.command.replace( command: `${config.dataPath}/db/${doc.command.replace(
`${config.rootPath}/db/`, `${config.rootPath}/db/`,
'', '',
)}`, )}`,
}, },
{ where: { id: doc.id } },
); );
} }
} }
} }
}); });
// 初始化语言(必须在 autosave_crontab 之前) // 初始化语言(必须在 autosave_crontab 之前)
const lang = systemConfig.info?.lang || systemLang(); const lang = systemConfig.info?.lang || systemLang();
setLang(lang); setLang(lang);
// 确保 lang_env.sh 存在 // 确保 lang_env.sh 存在
try { try {
const langEnvExist = await fileExist(config.langEnvFile); const langEnvExist = await fileExist(config.langEnvFile);
if (!langEnvExist) { if (!langEnvExist) {
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`); await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
} }
} catch { } } catch {}
// 初始化保存一次ck和定时任务数据 // 初始化保存一次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 cronClient.readiness.recover();
await envService.set_envs(); await envService.set_envs();
const authInfo = await userService.getAuthInfo(); const authInfo = await userService.getAuthInfo();
const apps = await openService.findApps();
await shareStore.updateAuthInfo(authInfo); await shareStore.updateAuthInfo(authInfo);
if (apps?.length) { await openService.refreshApps();
await shareStore.updateApps(apps);
}
}; };
+10 -8
View File
@@ -7,23 +7,23 @@ import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path'; import { join } from 'path';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import Logger from './logger';
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
export default async () => { export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService); const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService); const sshKeyService = Container.get(SshKeyService);
// 生成内置token // 生成内置token
let tokenCommand = `ts-node-transpile-only ${join( let tokenCommand = `ts-node-transpile-only ${join(
config.rootPath, config.rootPath,
'back/token.ts', 'back/token.ts',
)}`; )}`;
const tokenFile = join(config.rootPath, 'static/build/token.js'); const tokenFile = join(config.rootPath, 'static/build/token.js');
if (await fileExist(tokenFile)) { if (await fileExist(tokenFile)) {
tokenCommand = `node ${tokenFile}`; tokenCommand = `node ${tokenFile}`;
} }
tokenCommand += ' --renew';
const cron = { const cron = {
id: NaN, id: NaN,
name: t('生成token'), name: t('生成token'),
@@ -38,7 +38,6 @@ export default async () => {
}, },
true, true,
); );
// 运行删除日志任务 // 运行删除日志任务
const data = await systemService.getSystemConfig(); const data = await systemService.getSystemConfig();
if (data && data.info) { if (data && data.info) {
@@ -58,18 +57,21 @@ export default async () => {
true, true,
); );
} }
systemService.updateTimezone(data.info); systemService.updateTimezone(data.info);
// Apply global SSH key if configured // Apply global SSH key if configured
if (data.info.globalSshKey) { if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global'); await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
} }
} }
await subscriptionService.setSshConfig(); await subscriptionService.setSshConfig();
const subs = await subscriptionService.list(); const subs = await subscriptionService.list();
for (const sub of subs) { for (const sub of subs) {
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled); try {
const doc = sub;
assertSubscriptionAlias(doc.alias);
await subscriptionService.handleTask(doc, !sub.is_disabled);
} catch (error) {
Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error);
}
} }
}; };
+18
View File
@@ -0,0 +1,18 @@
import type { RequestHandler } from 'express';
import { t } from '../shared/i18n';
// Express 4 does not forward rejected async middleware promises itself.
export function withAuthAvailability(handler: RequestHandler): RequestHandler {
return (req, res, next) => {
void Promise.resolve()
.then(() => handler(req, res, next))
.catch(() => {
res.setHeader('Retry-After', '1');
next(
Object.assign(new Error(t('认证服务暂时不可用,请稍后重试')), {
status: 503,
}),
);
});
};
}
+13
View File
@@ -0,0 +1,13 @@
import { ErrorRequestHandler } from 'express';
import { DatabaseError } from '../data/databaseErrors';
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 DatabaseError) || err.code !== 'UNIQUE_CONSTRAINT')
return next(err);
// Constraint details can contain environment values or application credentials.
res
.status(409)
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
};
export default uniqueConstraintError;
+18
View File
@@ -0,0 +1,18 @@
import { errors, isCelebrateError } from 'celebrate';
import { ErrorRequestHandler } from 'express';
import { t } from '../shared/i18n';
const respond = errors();
// Schemas are compiled once at startup; resolve their messages per request so
// changing the system language also changes errors from those existing schemas.
const validationErrors: ErrorRequestHandler = (error, req, res, next) => {
if (isCelebrateError(error)) {
for (const detail of error.details.values()) {
detail.message = detail.details.map((item) => t(item.message)).join('. ');
}
}
return respond(error, req, res, next);
};
export default validationErrors;
+10
View File
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
optional NotificationInfo notificationInfo = 3; optional NotificationInfo notificationInfo = 3;
} }
message RecordNotifyLogRequest {
string title = 1;
string content = 2;
bool success = 3;
string notifyType = 4;
string scriptName = 5;
string taskId = 6;
}
service Api { service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {} rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {} rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -271,6 +280,7 @@ service Api {
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {} rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {} rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {} rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {} rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
+175 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v6.31.1
// source: back/protos/api.proto // source: back/protos/api.proto
/* eslint-disable */ /* eslint-disable */
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
notificationInfo?: NotificationInfo | undefined; notificationInfo?: NotificationInfo | undefined;
} }
export interface RecordNotifyLogRequest {
title: string;
content: string;
success: boolean;
notifyType: string;
scriptName: string;
taskId: string;
}
function createBaseEnvItem(): EnvItem { function createBaseEnvItem(): EnvItem {
return { return {
id: undefined, id: undefined,
@@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
}, },
}; };
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
}
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.title !== "") {
writer.uint32(10).string(message.title);
}
if (message.content !== "") {
writer.uint32(18).string(message.content);
}
if (message.success !== false) {
writer.uint32(24).bool(message.success);
}
if (message.notifyType !== "") {
writer.uint32(34).string(message.notifyType);
}
if (message.scriptName !== "") {
writer.uint32(42).string(message.scriptName);
}
if (message.taskId !== "") {
writer.uint32(50).string(message.taskId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRecordNotifyLogRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.title = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.content = reader.string();
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.success = reader.bool();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.notifyType = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.scriptName = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.taskId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RecordNotifyLogRequest {
return {
title: isSet(object.title) ? globalThis.String(object.title) : "",
content: isSet(object.content) ? globalThis.String(object.content) : "",
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
};
},
toJSON(message: RecordNotifyLogRequest): unknown {
const obj: any = {};
if (message.title !== "") {
obj.title = message.title;
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.success !== false) {
obj.success = message.success;
}
if (message.notifyType !== "") {
obj.notifyType = message.notifyType;
}
if (message.scriptName !== "") {
obj.scriptName = message.scriptName;
}
if (message.taskId !== "") {
obj.taskId = message.taskId;
}
return obj;
},
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
const message = createBaseRecordNotifyLogRequest();
message.title = object.title ?? "";
message.content = object.content ?? "";
message.success = object.success ?? false;
message.notifyType = object.notifyType ?? "";
message.scriptName = object.scriptName ?? "";
message.taskId = object.taskId ?? "";
return message;
},
};
export type ApiService = typeof ApiService; export type ApiService = typeof ApiService;
export const ApiService = { export const ApiService = {
getEnvs: { getEnvs: {
@@ -4388,6 +4537,15 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value), responseDeserialize: (value: Buffer) => Response.decode(value),
}, },
recordNotifyLog: {
path: "/com.ql.api.Api/RecordNotifyLog",
requestStream: false,
responseStream: false,
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
getCronDetail: { getCronDetail: {
path: "/com.ql.api.Api/GetCronDetail", path: "/com.ql.api.Api/GetCronDetail",
requestStream: false, requestStream: false,
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>; updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>; getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>; systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>; getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
createCron: handleUnaryCall<CreateCronRequest, CronResponse>; createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>; updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void, callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall; ): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
getCronDetail( getCronDetail(
request: CronDetailRequest, request: CronDetailRequest,
callback: (error: ServiceError | null, response: CronDetailResponse) => void, callback: (error: ServiceError | null, response: CronDetailResponse) => void,
+4
View File
@@ -5,6 +5,7 @@ package com.ql.cron;
service Cron { service Cron {
rpc addCron(AddCronRequest) returns (AddCronResponse); rpc addCron(AddCronRequest) returns (AddCronResponse);
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse); rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
rpc setConcurrency(SetConcurrencyRequest) returns (AddCronResponse);
} }
message ISchedule { string schedule = 1; } message ISchedule { string schedule = 1; }
@@ -27,3 +28,6 @@ message AddCronResponse {}
message DeleteCronRequest { repeated string ids = 1; } message DeleteCronRequest { repeated string ids = 1; }
message DeleteCronResponse {} message DeleteCronResponse {}
// Zero restores the default limit.
message SetConcurrencyRequest { int32 concurrency = 1; }
+89 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v5.28.3
// source: back/protos/cron.proto // source: back/protos/cron.proto
/* eslint-disable */ /* eslint-disable */
@@ -48,6 +48,11 @@ export interface DeleteCronRequest {
export interface DeleteCronResponse { export interface DeleteCronResponse {
} }
/** Zero restores the default limit. */
export interface SetConcurrencyRequest {
concurrency: number;
}
function createBaseISchedule(): ISchedule { function createBaseISchedule(): ISchedule {
return { schedule: "" }; 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 type CronService = typeof CronService;
export const CronService = { export const CronService = {
addCron: { addCron: {
@@ -472,11 +535,21 @@ export const CronService = {
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()), responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value), 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; } as const;
export interface CronServer extends UntypedServiceImplementation { export interface CronServer extends UntypedServiceImplementation {
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>; addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>; delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
setConcurrency: handleUnaryCall<SetConcurrencyRequest, AddCronResponse>;
} }
export interface CronClient extends Client { export interface CronClient extends Client {
@@ -510,6 +583,21 @@ export interface CronClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void, callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall; ): 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 { export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
+53 -74
View File
@@ -1,11 +1,11 @@
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js'; import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron'; import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule'; import { createCronJob, CronJob } from '../shared/cronScheduler';
import { isValidCronSchedule } from '../shared/cronSchedule'; import { isValidCronSchedule } from '../shared/cronSchedule';
import { scheduleStacks } from './data'; import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron'; import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { tf } from '../shared/i18n'; import { t, tf } from '../shared/i18n';
// Validate the entire batch before replacing any existing jobs. // Validate the entire batch before replacing any existing jobs.
const isValidCronField = (cron: string): boolean => { const isValidCronField = (cron: string): boolean => {
@@ -24,11 +24,7 @@ const addCron = (
if (!isValidCronField(schedule)) { if (!isValidCronField(schedule)) {
validationErrors.push( validationErrors.push(
tf( tf('任务ID %s: 无效的 cron 表达式 "%s"', String(id), schedule),
'任务ID %s: 无效的 cron 表达式 "%s"',
String(id),
schedule,
),
); );
} }
@@ -56,79 +52,62 @@ const addCron = (
return; return;
} }
// Recovery replaces the whole snapshot, including deletions and disabled jobs. // Prepare stopped jobs first: construction errors must preserve the old snapshot.
// Validation above must finish before touching the previous schedule. const prepared = new Map<string, CronJob[]>();
if (call.request.replace) { try {
for (const jobs of scheduleStacks.values()) { for (const item of call.request.crons) {
for (const job of jobs) job?.cancel(); const jobs: CronJob[] = [];
prepared.get(item.id)?.forEach((job) => job.cancel());
prepared.set(item.id, jobs);
for (const schedule of [
item.schedule,
...(item.extra_schedules || []).map((x) => x.schedule),
]) {
jobs.push(
createCronJob(
schedule,
async () => {
Logger.info(t('[schedule][准备运行任务] 命令: %s'), item.command);
await runCron(
item.command,
item,
() => scheduleStacks.get(item.id) === jobs,
);
},
{
name: `${item.id}: ${item.name || ''}`,
logger: Logger,
start: false,
},
),
);
}
} }
scheduleStacks.clear(); } catch (error) {
for (const jobs of prepared.values()) jobs.forEach((job) => job.cancel());
const err: any = new Error(
error instanceof Error ? error.message : String(error),
);
err.code = status.INVALID_ARGUMENT;
err.details = err.message;
callback(err, null);
return;
} }
// ===== 第二遍:注册所有任务 ===== if (call.request.replace) {
for (const item of call.request.crons) { for (const jobs of scheduleStacks.values())
const { id, schedule, command, extra_schedules, name } = item; jobs.forEach((job) => job?.cancel());
scheduleStacks.clear();
// 取消该 id 已有的旧任务 }
if (scheduleStacks.has(id)) { for (const [id, jobs] of prepared) {
scheduleStacks.get(id)?.forEach((x) => x.cancel()); scheduleStacks.get(id)?.forEach((job) => job.cancel());
} scheduleStacks.set(id, jobs);
jobs.forEach((job) => job.start());
Logger.info( Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'),
id, id,
name, jobs.length,
schedule,
command,
); );
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
command,
);
});
}
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!mainJob) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
schedule,
);
}
const extraJobs = extra_schedules?.length
? extra_schedules.map((x) => {
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!job) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
x.schedule,
);
}
return job;
})
: [];
// 过滤 null(兜底保护,正常情况下预校验已拦截)
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
if (jobs.length > 0) {
scheduleStacks.set(id, jobs);
}
} }
callback(null, null); callback(null, null);
+23 -38
View File
@@ -16,6 +16,7 @@ import {
MoveEnvRequest, MoveEnvRequest,
Response, Response,
SystemNotifyRequest, SystemNotifyRequest,
RecordNotifyLogRequest,
UpdateEnvNamesRequest, UpdateEnvNamesRequest,
UpdateEnvRequest, UpdateEnvRequest,
} from '../protos/api'; } from '../protos/api';
@@ -38,10 +39,7 @@ import {
RunCronsRequest, RunCronsRequest,
} from '../protos/api'; } from '../protos/api';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import { Model } from 'sequelize';
Container.set('logger', LoggerInstance); Container.set('logger', LoggerInstance);
export const getEnvs = async ( export const getEnvs = async (
call: ServerUnaryCall<GetEnvsRequest, EnvsResponse>, call: ServerUnaryCall<GetEnvsRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>, callback: sendUnaryData<EnvsResponse>,
@@ -61,7 +59,6 @@ export const getEnvs = async (
}); });
} }
}; };
export const createEnv = async ( export const createEnv = async (
call: ServerUnaryCall<CreateEnvRequest, EnvsResponse>, call: ServerUnaryCall<CreateEnvRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>, callback: sendUnaryData<EnvsResponse>,
@@ -74,7 +71,6 @@ export const createEnv = async (
callback(e); callback(e);
} }
}; };
export const updateEnv = async ( export const updateEnv = async (
call: ServerUnaryCall<UpdateEnvRequest, EnvResponse>, call: ServerUnaryCall<UpdateEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>, callback: sendUnaryData<EnvResponse>,
@@ -87,7 +83,6 @@ export const updateEnv = async (
message: 'id parameter is required', message: 'id parameter is required',
}); });
} }
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
const data = await envService.update( const data = await envService.update(
pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem, pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem,
@@ -97,7 +92,6 @@ export const updateEnv = async (
callback(e); callback(e);
} }
}; };
export const deleteEnvs = async ( export const deleteEnvs = async (
call: ServerUnaryCall<DeleteEnvsRequest, Response>, call: ServerUnaryCall<DeleteEnvsRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -109,7 +103,6 @@ export const deleteEnvs = async (
message: 'ids parameter is required', message: 'ids parameter is required',
}); });
} }
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
await envService.remove(call.request.ids); await envService.remove(call.request.ids);
callback(null, { code: 200 }); callback(null, { code: 200 });
@@ -117,7 +110,6 @@ export const deleteEnvs = async (
callback(e); callback(e);
} }
}; };
export const moveEnv = async ( export const moveEnv = async (
call: ServerUnaryCall<MoveEnvRequest, EnvResponse>, call: ServerUnaryCall<MoveEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>, callback: sendUnaryData<EnvResponse>,
@@ -130,7 +122,6 @@ export const moveEnv = async (
message: 'id parameter is required', message: 'id parameter is required',
}); });
} }
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
const data = await envService.move(call.request.id, { const data = await envService.move(call.request.id, {
fromIndex: call.request.fromIndex, fromIndex: call.request.fromIndex,
@@ -141,7 +132,6 @@ export const moveEnv = async (
callback(e); callback(e);
} }
}; };
export const disableEnvs = async ( export const disableEnvs = async (
call: ServerUnaryCall<DisableEnvsRequest, Response>, call: ServerUnaryCall<DisableEnvsRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -153,7 +143,6 @@ export const disableEnvs = async (
message: 'ids parameter is required', message: 'ids parameter is required',
}); });
} }
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
await envService.disabled(call.request.ids); await envService.disabled(call.request.ids);
callback(null, { code: 200 }); callback(null, { code: 200 });
@@ -161,7 +150,6 @@ export const disableEnvs = async (
callback(e); callback(e);
} }
}; };
export const enableEnvs = async ( export const enableEnvs = async (
call: ServerUnaryCall<EnableEnvsRequest, Response>, call: ServerUnaryCall<EnableEnvsRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -173,7 +161,6 @@ export const enableEnvs = async (
message: 'ids parameter is required', message: 'ids parameter is required',
}); });
} }
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
await envService.enabled(call.request.ids); await envService.enabled(call.request.ids);
callback(null, { code: 200 }); callback(null, { code: 200 });
@@ -181,7 +168,6 @@ export const enableEnvs = async (
callback(e); callback(e);
} }
}; };
export const updateEnvNames = async ( export const updateEnvNames = async (
call: ServerUnaryCall<UpdateEnvNamesRequest, Response>, call: ServerUnaryCall<UpdateEnvNamesRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -193,7 +179,6 @@ export const updateEnvNames = async (
message: 'ids parameter is required', message: 'ids parameter is required',
}); });
} }
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
await envService.updateNames({ await envService.updateNames({
ids: call.request.ids, ids: call.request.ids,
@@ -204,7 +189,6 @@ export const updateEnvNames = async (
callback(e); callback(e);
} }
}; };
export const getEnvById = async ( export const getEnvById = async (
call: ServerUnaryCall<GetEnvByIdRequest, EnvResponse>, call: ServerUnaryCall<GetEnvByIdRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>, callback: sendUnaryData<EnvResponse>,
@@ -217,7 +201,6 @@ export const getEnvById = async (
message: 'id parameter is required', message: 'id parameter is required',
}); });
} }
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
const data = await envService.getDb({ id: call.request.id }); const data = await envService.getDb({ id: call.request.id });
callback(null, { callback(null, {
@@ -228,7 +211,6 @@ export const getEnvById = async (
callback(e); callback(e);
} }
}; };
export const systemNotify = async ( export const systemNotify = async (
call: ServerUnaryCall<SystemNotifyRequest, Response>, call: ServerUnaryCall<SystemNotifyRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -238,18 +220,36 @@ export const systemNotify = async (
const data = await systemService.notify({ const data = await systemService.notify({
title: call.request.title, title: call.request.title,
content: call.request.content, content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo, notificationInfo: call.request
.notificationInfo as unknown as NotificationInfo,
}); });
callback(null, data); callback(null, data);
} catch (e: any) { } catch (e: any) {
callback(e); callback(e);
} }
}; };
export const recordNotifyLog = async (
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
await Container.get(SystemService).recordNotifyLog({
title: call.request.title,
content: call.request.content,
status: call.request.success ? 0 : 1,
notifyType: call.request.notifyType,
source: 'script',
scriptName: call.request.scriptName,
taskId: call.request.taskId,
});
callback(null, { code: 200 });
} catch (error: any) {
callback(error);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => { const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined; if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters. const cron = data;
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
return { return {
...cron, ...cron,
labels: cron.labels ?? [], labels: cron.labels ?? [],
@@ -260,7 +260,6 @@ const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
task_after: cron.task_after ?? undefined, task_after: cron.task_after ?? undefined,
}; };
}; };
export const getCronDetail = async ( export const getCronDetail = async (
call: ServerUnaryCall<CronDetailRequest, CronDetailResponse>, call: ServerUnaryCall<CronDetailRequest, CronDetailResponse>,
callback: sendUnaryData<CronDetailResponse>, callback: sendUnaryData<CronDetailResponse>,
@@ -282,7 +281,6 @@ export const getCronDetail = async (
callback(e); callback(e);
} }
}; };
export const createCron = async ( export const createCron = async (
call: ServerUnaryCall<CreateCronRequest, CronResponse>, call: ServerUnaryCall<CreateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>, callback: sendUnaryData<CronResponse>,
@@ -295,7 +293,6 @@ export const createCron = async (
callback(e); callback(e);
} }
}; };
export const updateCron = async ( export const updateCron = async (
call: ServerUnaryCall<UpdateCronRequest, CronResponse>, call: ServerUnaryCall<UpdateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>, callback: sendUnaryData<CronResponse>,
@@ -303,7 +300,6 @@ export const updateCron = async (
try { try {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const { id, ...fields } = call.request; const { id, ...fields } = call.request;
const updateRequest = { const updateRequest = {
id, id,
...Object.entries(fields).reduce((acc: any, [key, value]) => { ...Object.entries(fields).reduce((acc: any, [key, value]) => {
@@ -313,14 +309,12 @@ export const updateCron = async (
return acc; return acc;
}, {}), }, {}),
} as UpdateCronRequest; } as UpdateCronRequest;
const data = (await cronService.update(updateRequest)) as CronItem; const data = (await cronService.update(updateRequest)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) }); callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) { } catch (e: any) {
callback(e); callback(e);
} }
}; };
export const deleteCrons = async ( export const deleteCrons = async (
call: ServerUnaryCall<DeleteCronsRequest, Response>, call: ServerUnaryCall<DeleteCronsRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -333,7 +327,6 @@ export const deleteCrons = async (
callback(e); callback(e);
} }
}; };
export const getCrons = async ( export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>, call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>, callback: sendUnaryData<CronsResponse>,
@@ -361,7 +354,6 @@ export const getCrons = async (
}); });
} }
}; };
export const getCronById = async ( export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>, call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>, callback: sendUnaryData<CronResponse>,
@@ -374,7 +366,6 @@ export const getCronById = async (
message: 'id parameter is required', message: 'id parameter is required',
}); });
} }
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem; const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) }); callback(null, { code: 200, data: normalizeCronData(data) });
@@ -386,7 +377,6 @@ export const getCronById = async (
}); });
} }
}; };
export const enableCrons = async ( export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>, call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -398,7 +388,6 @@ export const enableCrons = async (
message: 'ids parameter is required', message: 'ids parameter is required',
}); });
} }
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids); await cronService.enabled(call.request.ids);
callback(null, { code: 200 }); callback(null, { code: 200 });
@@ -406,7 +395,6 @@ export const enableCrons = async (
callback(e); callback(e);
} }
}; };
export const disableCrons = async ( export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>, call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -418,7 +406,6 @@ export const disableCrons = async (
message: 'ids parameter is required', message: 'ids parameter is required',
}); });
} }
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids); await cronService.disabled(call.request.ids);
callback(null, { code: 200 }); callback(null, { code: 200 });
@@ -426,7 +413,6 @@ export const disableCrons = async (
callback(e); callback(e);
} }
}; };
export const runCrons = async ( export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>, call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
@@ -438,7 +424,6 @@ export const runCrons = async (
message: 'ids parameter is required', message: 'ids parameter is required',
}); });
} }
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
await cronService.run(call.request.ids); await cronService.run(call.request.ids);
callback(null, { code: 200 }); callback(null, { code: 200 });
+26 -1
View File
@@ -62,16 +62,41 @@ class Client {
return this._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( async addCron(
request: AddCronRequest['crons'], request: AddCronRequest['crons'],
replace = false replace = false
): Promise<AddCronResponse> { ): Promise<AddCronResponse> {
await this.waitForReady(2000); await this.waitForReady(2000);
// Include every rule in a recovery snapshot, but bound a stuck worker.
const ruleCount = request.reduce(
(count, item) => count + 1 + (item.extra_schedules?.length || 0),
0,
);
const registrationTimeoutMs = Math.min(120000, 5000 + ruleCount * 5);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.client.addCron( this.client.addCron(
{ crons: request, replace }, { crons: request, replace },
new Metadata(), new Metadata(),
{ deadline: Date.now() + 5000 }, { deadline: Date.now() + registrationTimeoutMs },
(err, res) => { (err, res) => {
if (err) { if (err) {
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) { if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
+2 -2
View File
@@ -1,6 +1,6 @@
import nodeSchedule from 'node-schedule'; import { CronJob } from '../shared/cronScheduler';
import { ToadScheduler } from 'toad-scheduler'; import { ToadScheduler } from 'toad-scheduler';
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>(); export const scheduleStacks = new Map<string, CronJob[]>();
export const intervalSchedule = new ToadScheduler(); export const intervalSchedule = new ToadScheduler();
+17 -16
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron'; import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data'; import { scheduleStacks } from './data';
@@ -9,24 +10,24 @@ const delCron = (
) => { ) => {
for (const id of call.request.ids) { for (const id of call.request.ids) {
if (scheduleStacks.has(id)) { if (scheduleStacks.has(id)) {
Logger.info( Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id);
'[schedule][取消定时任务] 任务ID: %s', // 防御性过滤历史调度栈中的空任务,
id,
);
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误, // 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。 // 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => { scheduleStacks
try { .get(id)
x.cancel(); ?.filter((x) => x != null)
} catch (error: any) { .forEach((x) => {
Logger.warn( try {
'[schedule][取消任务失败] 任务ID: %s, 错误: %s', x.cancel();
id, } catch (error: any) {
error?.message || error, Logger.warn(
); t('[schedule][取消任务失败] 任务ID: %s, 错误: %s'),
} id,
}); error?.message || error,
);
}
});
scheduleStacks.delete(id); scheduleStacks.delete(id);
} }
} }
+2 -1
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { request } from 'undici'; import { request } from 'undici';
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health'; import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
import config from '../config'; import config from '../config';
import { database } from '../data';
function formatError(error: unknown): string { function formatError(error: unknown): string {
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
@@ -54,7 +55,7 @@ const check = async (
switch (call.request.service) { switch (call.request.service) {
// Local scheduler liveness only: never call HTTP from this probe. // Local scheduler liveness only: never call HTTP from this probe.
case 'scheduler': case 'scheduler':
return callback(null, { status: 1 }); return callback(null, { status: database.healthy ? 1 : 2 });
case 'cron': { case 'cron': {
const healthUrl = `http://localhost:${config.port}${ const healthUrl = `http://localhost:${config.port}${
config.baseUrl || '' config.baseUrl || ''
+26
View File
@@ -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);
}
}
+3 -4
View File
@@ -12,13 +12,12 @@ export default class ConfigService {
public async getFile(filePath: string, res: Response) { public async getFile(filePath: string, res: Response) {
let content = ''; let content = '';
if (!filePath) { if (!filePath || filePath.startsWith('data/scripts/')) {
return res.send({ code: 403, message: t('文件无法访问') }); return res.send({ code: 403, message: t('文件无法访问') });
} }
const scriptFile = filePath.startsWith('data/scripts/');
const resolved = resolveFileAccess( const resolved = resolveFileAccess(
scriptFile ? config.scriptPath : config.configPath, config.configPath,
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath], [filePath],
config.blackFileList, config.blackFileList,
); );
if (!resolved) { if (!resolved) {
+258 -295
View File
File diff suppressed because it is too large Load Diff
+19 -31
View File
@@ -1,3 +1,4 @@
import { Query, QuerySpec, orderBy } from '../data/queries';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import { CrontabView, CrontabViewModel } from '../data/cronView'; import { CrontabView, CrontabViewModel } from '../data/cronView';
@@ -7,12 +8,12 @@ import {
minPosition, minPosition,
stepPosition, stepPosition,
} from '../data/env'; } from '../data/env';
import { FindOptions } from 'sequelize';
@Service() @Service()
export default class CronViewService { export default class CronViewService {
constructor(@Inject('logger') private logger: winston.Logger) {} constructor(
@Inject('logger')
private logger: winston.Logger,
) {}
public async create(payload: CrontabView): Promise<CrontabView> { public async create(payload: CrontabView): Promise<CrontabView> {
let position = initPosition; let position = initPosition;
const views = await this.list(); const views = await this.list();
@@ -22,61 +23,51 @@ export default class CronViewService {
position = position / 2; position = position / 2;
const tab = new CrontabView({ ...payload, position }); const tab = new CrontabView({ ...payload, position });
const doc = await this.insert(tab); const doc = await this.insert(tab);
await this.checkPosition(tab.position!); await this.checkPosition(tab.position!);
return doc; return doc;
} }
public async insert(payload: CrontabView): Promise<CrontabView> { public async insert(payload: CrontabView): Promise<CrontabView> {
return await CrontabViewModel.create(payload, { returning: true }); return await CrontabViewModel.insert(payload);
} }
public async update(payload: CrontabView): Promise<CrontabView> { public async update(payload: CrontabView): Promise<CrontabView> {
const doc = await this.getDb({ id: payload.id }); const doc = await this.getDb({ id: payload.id });
const tab = new CrontabView({ ...doc, ...payload }); const tab = new CrontabView({ ...doc, ...payload });
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
return newDoc; return newDoc;
} }
public async updateDb(payload: CrontabView): Promise<CrontabView> { public async updateDb(payload: CrontabView): Promise<CrontabView> {
await CrontabViewModel.update(payload, { where: { id: payload.id } }); await CrontabViewModel.nativeUpdate({ id: payload.id }, payload);
return await this.getDb({ id: payload.id }); return await this.getDb({ id: payload.id });
} }
public async remove(ids: number[]) { public async remove(ids: number[]) {
await CrontabViewModel.destroy({ where: { id: ids } }); await CrontabViewModel.nativeDelete({ id: ids });
} }
public async list(): Promise<CrontabView[]> { public async list(): Promise<CrontabView[]> {
try { try {
const result = await CrontabViewModel.findAll({ const result = await CrontabViewModel.find(
where: {}, {},
order: [['position', 'DESC']], {
}); orderBy: orderBy([['position', 'DESC']]),
},
);
return result; return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
} }
public async getDb(query: QuerySpec['where']): Promise<CrontabView> {
public async getDb( const doc: any = await CrontabViewModel.findOne({ ...query }, {});
query: FindOptions<CrontabView>['where'],
): Promise<CrontabView> {
const doc: any = await CrontabViewModel.findOne({ where: { ...query } });
if (!doc) { if (!doc) {
throw new Error(`CronView ${JSON.stringify(query)} not found`); throw new Error(`CronView ${JSON.stringify(query)} not found`);
} }
return doc.get({ plain: true }); return doc;
} }
public async disabled(ids: number[]) { public async disabled(ids: number[]) {
await CrontabViewModel.update({ isDisabled: 1 }, { where: { id: ids } }); await CrontabViewModel.nativeUpdate({ id: ids }, { isDisabled: 1 });
} }
public async enabled(ids: number[]) { public async enabled(ids: number[]) {
await CrontabViewModel.update({ isDisabled: 0 }, { where: { id: ids } }); await CrontabViewModel.nativeUpdate({ id: ids }, { isDisabled: 0 });
} }
private async checkPosition(position: number) { private async checkPosition(position: number) {
const precisionPosition = parseFloat(position.toPrecision(16)); const precisionPosition = parseFloat(position.toPrecision(16));
if (precisionPosition < minPosition || precisionPosition > maxPosition) { if (precisionPosition < minPosition || precisionPosition > maxPosition) {
@@ -88,11 +79,9 @@ export default class CronViewService {
} }
} }
} }
private getPrecisionPosition(position: number): number { private getPrecisionPosition(position: number): number {
return parseFloat(position.toPrecision(16)); return parseFloat(position.toPrecision(16));
} }
public async move({ public async move({
id, id,
fromIndex, fromIndex,
@@ -118,7 +107,6 @@ export default class CronViewService {
id, id,
position: this.getPrecisionPosition(targetPosition), position: this.getPrecisionPosition(targetPosition),
}); });
await this.checkPosition(targetPosition); await this.checkPosition(targetPosition);
return newDoc; return newDoc;
} }
+114 -114
View File
@@ -1,3 +1,5 @@
import { textSearch } from '../data/queries';
import { Query, QuerySpec, orderBy } from '../data/queries';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
@@ -10,7 +12,6 @@ import {
} from '../data/dependence'; } from '../data/dependence';
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
import SockService from './sock'; import SockService from './sock';
import { FindOptions, Op } from 'sequelize';
import { import {
fileExist, fileExist,
getPid, getPid,
@@ -20,22 +21,23 @@ import {
getInstallCommand, getInstallCommand,
getUninstallCommand, getUninstallCommand,
getGetCommand, getGetCommand,
isPythonSourceDependency,
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util'; import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const'; import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n'; import { t, tf } from '../shared/i18n';
@Service() @Service()
export default class DependenceService { export default class DependenceService {
constructor( constructor(
@Inject('logger') private logger: winston.Logger, @Inject('logger')
private logger: winston.Logger,
private sockService: SockService, private sockService: SockService,
) { } ) {}
public async create(payloads: Dependence[]): Promise<Dependence[]> { public async create(payloads: Dependence[]): Promise<Dependence[]> {
const tabs = payloads.map((x) => { const tabs = payloads.map((x) => {
getInstallCommand(x.type, x.name);
const tab = new Dependence({ ...x, status: DependenceStatus.queued }); const tab = new Dependence({ ...x, status: DependenceStatus.queued });
return tab; return tab;
}); });
@@ -43,14 +45,14 @@ export default class DependenceService {
this.installDependenceOneByOne(docs); this.installDependenceOneByOne(docs);
return docs; return docs;
} }
public async insert(payloads: Dependence[]): Promise<Dependence[]> { public async insert(payloads: Dependence[]): Promise<Dependence[]> {
const docs = await DependenceModel.bulkCreate(payloads); const docs = await DependenceModel.insertMany(payloads);
return docs; return docs;
} }
public async update( public async update(
payload: Dependence & { id: string }, payload: Dependence & {
id: string;
},
): Promise<Dependence> { ): Promise<Dependence> {
const { id, ...other } = payload; const { id, ...other } = payload;
const doc = await this.getDb({ id }); const doc = await this.getDb({ id });
@@ -59,44 +61,43 @@ export default class DependenceService {
...other, ...other,
status: DependenceStatus.queued, status: DependenceStatus.queued,
}); });
getInstallCommand(tab.type, tab.name);
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
this.installDependenceOneByOne([newDoc]); this.installDependenceOneByOne([newDoc]);
return newDoc; return newDoc;
} }
private async updateDb(payload: Dependence): Promise<Dependence> { private async updateDb(payload: Dependence): Promise<Dependence> {
await DependenceModel.update(payload, { where: { id: payload.id } }); await DependenceModel.nativeUpdate({ id: payload.id }, payload);
return await this.getDb({ id: payload.id }); return await this.getDb({ id: payload.id });
} }
public async remove(ids: number[], force = false): Promise<Dependence[]> { public async remove(ids: number[], force = false): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({ where: { id: ids } }); const docs = await DependenceModel.find({ id: ids }, {});
for (const doc of docs) { for (const doc of docs) {
taskLimit.removeQueuedDependency(doc); taskLimit.removeQueuedDependency(doc);
} }
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter( const installedDeps = docs.filter(
(x) => x.status === DependenceStatus.installed, (x) =>
x.status === DependenceStatus.installed ||
// Older list refreshes marked unprobeable sources as failed. They may
// still have installed distributions (including partial pip installs).
(x.status === DependenceStatus.installFailed &&
x.type === DependenceTypes.python3 &&
isPythonSourceDependency(x.name)),
); );
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
await this.removeDb(unInstalledDeps.map((x) => x.id!)); await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) { if (installedDeps.length) {
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ id: installedDeps.map((x) => x.id!) },
{ status: DependenceStatus.queued, log: [] }, { status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
); );
this.installDependenceOneByOne(installedDeps, false, force);
this.installDependenceOneByOne(docs, false, force);
} }
return docs; return docs;
} }
public async removeDb(ids: number[]) { public async removeDb(ids: number[]) {
await DependenceModel.destroy({ where: { id: ids } }); await DependenceModel.nativeDelete({ id: ids });
} }
public async dependencies( public async dependencies(
{ {
searchValue, searchValue,
@@ -123,13 +124,7 @@ export default class DependenceService {
condition.status = status.split(',').map(Number); condition.status = status.split(',').map(Number);
} }
if (searchValue) { if (searchValue) {
const encodeText = encodeURI(searchValue); Object.assign(condition, textSearch(['name'], searchValue));
condition.name = {
[Op.or]: [
{ [Op.like]: `%${searchValue}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
} }
try { try {
return await this.find(condition, sort); return await this.find(condition, sort);
@@ -137,7 +132,6 @@ export default class DependenceService {
throw error; throw error;
} }
} }
private async refreshInstalledStatuses(type?: DependenceTypes) { private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [ const cacheDependenceTypes = [
DependenceTypes.nodejs, DependenceTypes.nodejs,
@@ -146,36 +140,43 @@ export default class DependenceService {
if (type !== undefined && !cacheDependenceTypes.includes(type)) { if (type !== undefined && !cacheDependenceTypes.includes(type)) {
return; return;
} }
const docs = await DependenceModel.find(
const docs = await DependenceModel.findAll({ {
where: {
status: DependenceStatus.installed, status: DependenceStatus.installed,
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type, type: type === undefined ? { ['$in']: cacheDependenceTypes } : type,
}, },
}); {},
);
const checks = await concurrentRun( const checks = await concurrentRun(
docs.map((doc) => async () => { docs
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id; .filter(
}), (doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5, 5,
); );
const missingIds = (checks || []).filter( const missingIds = (checks || []).filter(
(id): id is number => id !== undefined, (id): id is number => id !== undefined,
); );
if (missingIds.length) { if (missingIds.length) {
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ id: missingIds },
{ status: DependenceStatus.installFailed }, { status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
); );
} }
} }
private async isDependenceInstalled(dependency: Dependence) { private async isDependenceInstalled(dependency: Dependence) {
let depName = dependency.name.trim(); let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type]; const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = ''; let depVersion = '';
if (depName.includes(depVersionStr)) { if (
dependency.type !== DependenceTypes.python3 &&
depName.includes(depVersionStr)
) {
const symbolRegx = new RegExp( const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`, `(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
); );
@@ -185,7 +186,6 @@ export default class DependenceService {
depVersion = parsedVersion; depVersion = parsedVersion;
} }
} }
const depInfo = ( const depInfo = (
await promiseExecSuccess(getGetCommand(dependency.type, depName)) await promiseExecSuccess(getGetCommand(dependency.type, depName))
) )
@@ -195,12 +195,10 @@ export default class DependenceService {
(dependency.type === DependenceTypes.nodejs && (dependency.type === DependenceTypes.nodejs &&
depInfo.split(' ')?.[0] === depName) || depInfo.split(' ')?.[0] === depName) ||
dependency.type === DependenceTypes.python3; dependency.type === DependenceTypes.python3;
return Boolean( return Boolean(
depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)), depInfo && nameMatches && (!depVersion || depInfo.includes(depVersion)),
); );
} }
public installDependenceOneByOne( public installDependenceOneByOne(
docs: Dependence[], docs: Dependence[],
isInstall: boolean = true, isInstall: boolean = true,
@@ -209,32 +207,27 @@ export default class DependenceService {
docs.forEach((dep) => { docs.forEach((dep) => {
this.installOrUninstallDependency(dep, isInstall, force); this.installOrUninstallDependency(dep, isInstall, force);
}); });
return taskLimit.waitDependencyQueueDone(); return taskLimit.waitDependencyQueueDone();
} }
public async reInstall(ids: number[]): Promise<Dependence[]> { public async reInstall(ids: number[]): Promise<Dependence[]> {
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ id: ids },
{ status: DependenceStatus.queued, log: [] }, { status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
); );
const docs = await DependenceModel.find({ id: ids }, {});
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
taskLimit.removeQueuedDependency(doc); taskLimit.removeQueuedDependency(doc);
} }
this.installDependenceOneByOne(docs, true, true); this.installDependenceOneByOne(docs, true, true);
return docs; return docs;
} }
public async cancel(ids: number[]) { public async cancel(ids: number[]) {
const docs = await DependenceModel.findAll({ where: { id: ids } }); const docs = await DependenceModel.find({ id: ids }, {});
for (const doc of docs) { for (const doc of docs) {
taskLimit.removeQueuedDependency(doc); taskLimit.removeQueuedDependency(doc);
let depInstallCommand = getInstallCommand(doc.type, doc.name); let depInstallCommand = getInstallCommand(doc.type, doc.name);
let depUnInstallCommand = getUninstallCommand(doc.type, doc.name); let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const isLinuxDependence = doc.type === DependenceTypes.linux; const isLinuxDependence = doc.type === DependenceTypes.linux;
if (isLinuxDependence) { if (isLinuxDependence) {
const osType = await detectOS(); const osType = await detectOS();
if (!osType) { if (!osType) {
@@ -252,50 +245,63 @@ export default class DependenceService {
pid && (await killTask(pid)); pid && (await killTask(pid));
} }
} }
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ id: ids },
{ status: DependenceStatus.cancelled }, { status: DependenceStatus.cancelled },
{ where: { id: ids } },
); );
} }
private async find(query: any, sort: any = []): Promise<Dependence[]> { private async find(query: any, sort: any = []): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({ const docs = await DependenceModel.find(
where: { ...query }, { ...query },
order: [...sort, ['createdAt', 'DESC']], {
}); orderBy: orderBy([...sort, ['createdAt', 'DESC']]),
},
);
return docs; return docs;
} }
public async getDb(query: QuerySpec['where']): Promise<Dependence> {
public async getDb( const doc: any = await DependenceModel.findOne({ ...query }, {});
query: FindOptions<Dependence>['where'],
): Promise<Dependence> {
const doc: any = await DependenceModel.findOne({ where: { ...query } });
if (!doc) { if (!doc) {
throw new Error(`Dependency ${JSON.stringify(query)} not found`); throw new Error(`Dependency ${JSON.stringify(query)} not found`);
} }
return doc.get({ plain: true }); return doc;
} }
private async updateLog(ids: number[], log: string): Promise<void> { private async updateLog(ids: number[], log: string): Promise<void> {
taskLimit.updateDepLog(async () => { taskLimit.updateDepLog(async () => {
const docs = await DependenceModel.findAll({ where: { id: ids } }); const docs = await DependenceModel.find({ id: ids }, {});
for (const doc of docs) { for (const doc of docs) {
const newLog = doc?.log ? [...doc.log, log] : [log]; const newLog = doc?.log ? [...doc.log, log] : [log];
await DependenceModel.update( await DependenceModel.nativeUpdate({ id: doc.id }, { log: newLog });
{ log: newLog },
{ where: { id: doc.id } },
);
} }
return null; return null;
}); });
} }
public installOrUninstallDependency( public installOrUninstallDependency(
dependency: Dependence, dependency: Dependence,
isInstall: boolean = true, isInstall: boolean = true,
force: boolean = false, force: boolean = false,
) { ) {
return taskLimit.runDependeny(dependency, () => { return taskLimit.runDependeny(dependency, () => {
let validatedCommand: string;
try {
validatedCommand = isInstall
? getInstallCommand(dependency.type, dependency.name.trim())
: getUninstallCommand(dependency.type, dependency.name.trim());
} catch (error) {
taskLimit.removeQueuedDependency(dependency);
if (!isInstall && force) {
return this.removeDb([dependency.id!]).then(() => null);
}
return DependenceModel.nativeUpdate(
{ id: dependency.id },
{
status: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed,
log: [String(error)],
},
).then(() => null);
}
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (taskLimit.firstDependencyId !== dependency.id) { if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null); return resolve(null);
@@ -314,9 +320,9 @@ export default class DependenceService {
taskLimit.removeQueuedDependency(dependency); taskLimit.removeQueuedDependency(dependency);
if (isLinuxDependence) { if (isLinuxDependence) {
if (!osType) { if (!osType) {
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ id: depIds },
{ status: DependenceStatus.installFailed }, { status: DependenceStatus.installFailed },
{ where: { id: depIds } },
); );
const startTime = dayjs(); const startTime = dayjs();
const message = tf( const message = tf(
@@ -339,22 +345,17 @@ export default class DependenceService {
} }
linuxCommand = LINUX_DEPENDENCE_COMMAND[osType]; linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
} }
const status = isInstall const status = isInstall
? DependenceStatus.installing ? DependenceStatus.installing
: DependenceStatus.removing; : DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } }); await DependenceModel.nativeUpdate({ id: depIds }, { status });
let command = validatedCommand;
let command = isInstall
? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName);
if (isLinuxDependence) { if (isLinuxDependence) {
command = isInstall command = isInstall
? `${linuxCommand.install} ${depName.trim()}` ? `${linuxCommand.install} ${depName.trim()}`
: `${linuxCommand.uninstall} ${depName.trim()}`; : `${linuxCommand.uninstall} ${depName.trim()}`;
} }
const startTime = dayjs(); const startTime = dayjs();
const message = tf( const message = tf(
'开始%s依赖 %s,开始时间 %s\n\n', '开始%s依赖 %s,开始时间 %s\n\n',
actionText, actionText,
@@ -368,17 +369,21 @@ export default class DependenceService {
status, status,
}); });
this.updateLog(depIds, message); this.updateLog(depIds, message);
// 判断是否已经安装过依赖 // 判断是否已经安装过依赖
if (isInstall && !force) { // pip must resolve optional dependencies even if the base distribution
// is already present; its version alone cannot prove extras are ready.
if (
isInstall &&
!force &&
!(isPythonDependence && depName.includes('['))
) {
let getCommand = getGetCommand(dependency.type, depName); let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type]; const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) { if (isLinuxDependence) {
getCommand = `${linuxCommand.info} ${depName}`; getCommand = `${linuxCommand.info} ${depName}`;
} }
let depVersion = ''; let depVersion = '';
if (depName.includes(depVersionStr)) { if (!isPythonDependence && depName.includes(depVersionStr)) {
const symbolRegx = new RegExp( const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`, `(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
); );
@@ -391,7 +396,6 @@ export default class DependenceService {
const depInfo = (await promiseExecSuccess(getCommand)) const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ') .replace(/\s{2,}/, ' ')
.replace(/\s+$/, ''); .replace(/\s+$/, '');
if ( if (
depInfo && depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) || ((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
@@ -416,9 +420,9 @@ export default class DependenceService {
status: DependenceStatus.installed, status: DependenceStatus.installed,
}); });
this.updateLog(depIds, _message); this.updateLog(depIds, _message);
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ id: depIds },
{ status: DependenceStatus.installed }, { status: DependenceStatus.installed },
{ where: { id: depIds } },
); );
return resolve(null); return resolve(null);
} }
@@ -432,7 +436,6 @@ export default class DependenceService {
const cp = spawn(`${proxyStr} ${command}`, { const cp = spawn(`${proxyStr} ${command}`, {
shell: '/bin/bash', shell: '/bin/bash',
}); });
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
@@ -442,7 +445,6 @@ export default class DependenceService {
}); });
this.updateLog(depIds, data.toString()); this.updateLog(depIds, data.toString());
}); });
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
@@ -452,7 +454,6 @@ export default class DependenceService {
}); });
this.updateLog(depIds, data.toString()); this.updateLog(depIds, data.toString());
}); });
cp.on('error', async (err) => { cp.on('error', async (err) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
@@ -462,23 +463,26 @@ export default class DependenceService {
}); });
this.updateLog(depIds, JSON.stringify(err)); this.updateLog(depIds, JSON.stringify(err));
}); });
cp.on('exit', async (code) => { cp.on('exit', async (code) => {
const endTime = dayjs(); const endTime = dayjs();
const isSucceed = code === 0; const isSucceed = code === 0;
const resultText = isSucceed ? t('成功') : t('失败'); const resultText = isSucceed ? t('成功') : t('失败');
const message = const message =
'\n' + '\n' +
tf('依赖%s%s,结束时间 %s,耗时 %s 秒', tf(
actionText, '依赖%s%s,结束时间 %s,耗时 %s 秒',
resultText, actionText,
endTime.format('YYYY-MM-DD HH:mm:ss'), resultText,
String(endTime.diff(startTime, 'second')), endTime.format('YYYY-MM-DD HH:mm:ss'),
); String(endTime.diff(startTime, 'second')),
);
const exitStatus = isSucceed const exitStatus = isSucceed
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed) ? isInstall
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed); ? DependenceStatus.installed
: DependenceStatus.removed
: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed;
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
message, message,
@@ -486,24 +490,20 @@ export default class DependenceService {
status: exitStatus, status: exitStatus,
}); });
this.updateLog(depIds, message); this.updateLog(depIds, message);
const docs = await DependenceModel.find({ id: depIds }, {});
const docs = await DependenceModel.findAll({ where: { id: depIds } });
const _docIds = docs const _docIds = docs
.filter((x) => x.status !== DependenceStatus.cancelled) .filter((x) => x.status !== DependenceStatus.cancelled)
.map((x) => x.id!); .map((x) => x.id!);
if (_docIds.length > 0) { if (_docIds.length > 0) {
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ id: _docIds },
{ status: exitStatus }, { status: exitStatus },
{ where: { id: _docIds } },
); );
} }
// 如果删除依赖成功或者强制删除 // 如果删除依赖成功或者强制删除
if ((isSucceed || force) && !isInstall) { if ((isSucceed || force) && !isInstall) {
this.removeDb(depIds); this.removeDb(depIds);
} }
resolve(null); resolve(null);
}); });
}); });
+41 -86
View File
@@ -1,5 +1,6 @@
import { textSearch } from '../data/queries';
import { Query, QuerySpec, orderBy } from '../data/queries';
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
@@ -13,12 +14,13 @@ import {
stepPosition, stepPosition,
} from '../data/env'; } from '../data/env';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data'; import { database } from '../data';
@Service() @Service()
export default class EnvService { export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { } constructor(
@Inject('logger')
private logger: winston.Logger,
) {}
public async create(payloads: Env[]): Promise<Env[]> { public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs(); const envs = await this.envs();
let position = initPosition; let position = initPosition;
@@ -39,16 +41,14 @@ export default class EnvService {
await this.checkPosition(tabs[tabs.length - 1].position!); await this.checkPosition(tabs[tabs.length - 1].position!);
return docs; return docs;
} }
public async insert(payloads: Env[]): Promise<Env[]> { public async insert(payloads: Env[]): Promise<Env[]> {
const result: Env[] = []; const result: Env[] = [];
for (const env of payloads) { for (const env of payloads) {
const doc = await EnvModel.create(env, { returning: true }); const doc = await EnvModel.insert(env);
result.push(doc); result.push(doc);
} }
return result; return result;
} }
public async update(payload: Env): Promise<Env> { public async update(payload: Env): Promise<Env> {
const doc = await this.getDb({ id: payload.id }); const doc = await this.getDb({ id: payload.id });
const tab = new Env({ ...doc, ...payload }); const tab = new Env({ ...doc, ...payload });
@@ -56,17 +56,14 @@ export default class EnvService {
await this.set_envs(); await this.set_envs();
return newDoc; return newDoc;
} }
private async updateDb(payload: Env): Promise<Env> { private async updateDb(payload: Env): Promise<Env> {
await EnvModel.update({ ...payload }, { where: { id: payload.id } }); await EnvModel.nativeUpdate({ id: payload.id }, { ...payload });
return await this.getDb({ id: payload.id }); return await this.getDb({ id: payload.id });
} }
public async remove(ids: number[]) { public async remove(ids: number[]) {
await EnvModel.destroy({ where: { id: ids } }); await EnvModel.nativeDelete({ id: ids });
await this.set_envs(); await this.set_envs();
} }
public async move( public async move(
id: number, id: number,
{ {
@@ -89,16 +86,13 @@ export default class EnvService {
? (envs[toIndex].position! + envs[toIndex - 1].position!) / 2 ? (envs[toIndex].position! + envs[toIndex - 1].position!) / 2
: (envs[toIndex].position! + envs[toIndex + 1].position!) / 2; : (envs[toIndex].position! + envs[toIndex + 1].position!) / 2;
} }
const newDoc = await this.update({ const newDoc = await this.update({
id, id,
position: this.getPrecisionPosition(targetPosition), position: this.getPrecisionPosition(targetPosition),
}); });
await this.checkPosition(targetPosition, envs[toIndex].position!); await this.checkPosition(targetPosition, envs[toIndex].position!);
return newDoc; return newDoc;
} }
private async checkPosition(position: number, edge: number = 0) { private async checkPosition(position: number, edge: number = 0) {
const precisionPosition = this.getPrecisionPosition(position); const precisionPosition = this.getPrecisionPosition(position);
if ( if (
@@ -114,40 +108,20 @@ export default class EnvService {
} }
} }
} }
private getPrecisionPosition(position: number): number { private getPrecisionPosition(position: number): number {
return Math.trunc(parseFloat(position.toPrecision(16))); return Math.trunc(parseFloat(position.toPrecision(16)));
} }
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> { public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
let condition = { ...query }; let condition = { ...query };
if (searchText) { if (searchText) {
const encodeText = encodeURI(searchText);
const reg = {
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
condition = { condition = {
...condition, ...condition,
[Op.or]: [ ...textSearch(['name', 'value', 'remarks'], searchText),
{
name: reg,
},
{
value: reg,
},
{
remarks: reg,
},
],
}; };
} }
try { try {
const result = await this.find(condition, [ const result = await this.find(condition, [
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'], ['isPinned', 'DESC'],
['position', 'DESC'], ['position', 'DESC'],
['createdAt', 'ASC'], ['createdAt', 'ASC'],
]); ]);
@@ -156,90 +130,73 @@ export default class EnvService {
throw error; throw error;
} }
} }
private async find(query: any, sort: any = []): Promise<Env[]> { private async find(query: any, sort: any = []): Promise<Env[]> {
const docs = await EnvModel.findAll({ const docs = await EnvModel.find(
where: { ...query }, { ...query },
order: [...sort], {
}); orderBy: orderBy([...sort]),
return docs.map((x) => x.get({ plain: true })); },
);
return docs.map((x) => x);
} }
public async getDb(query: QuerySpec['where']): Promise<Env> {
public async getDb(query: FindOptions<Env>['where']): Promise<Env> { const doc: any = await EnvModel.findOne({ ...query }, {});
const doc: any = await EnvModel.findOne({ where: { ...query } });
if (!doc) { if (!doc) {
throw new Error(`Env ${JSON.stringify(query)} not found`); throw new Error(`Env ${JSON.stringify(query)} not found`);
} }
return doc.get({ plain: true }); return doc;
} }
public async disabled(ids: number[]) { public async disabled(ids: number[]) {
await EnvModel.update( await EnvModel.nativeUpdate({ id: ids }, { status: EnvStatus.disabled });
{ status: EnvStatus.disabled },
{ where: { id: ids } },
);
await this.set_envs(); await this.set_envs();
} }
public async enabled(ids: number[]) { public async enabled(ids: number[]) {
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } }); await EnvModel.nativeUpdate({ id: ids }, { status: EnvStatus.normal });
await this.set_envs(); await this.set_envs();
} }
public async updateNames({ ids, name }: { ids: number[]; name: string }) { public async updateNames({ ids, name }: { ids: number[]; name: string }) {
await EnvModel.update({ name }, { where: { id: ids } }); await EnvModel.nativeUpdate({ id: ids }, { name });
await this.set_envs(); await this.set_envs();
} }
public async pin(ids: number[]) { public async pin(ids: number[]) {
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } }); await EnvModel.nativeUpdate({ id: ids }, { isPinned: 1 });
} }
public async unPin(ids: number[]) { public async unPin(ids: number[]) {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } }); await EnvModel.nativeUpdate({ id: ids }, { isPinned: 0 });
} }
public async addLabels(ids: number[], labels: string[]) { public async addLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => { await database.transaction(async () => {
const docs = await EnvModel.findAll({ const docs = await EnvModel.find({ id: ids }, {});
where: { id: ids },
transaction,
});
for (const doc of docs) { for (const doc of docs) {
const env = doc.get({ plain: true }); const env = doc;
await EnvModel.update( await EnvModel.nativeUpdate(
{ id: env.id },
{ labels: Array.from(new Set([...(env.labels || []), ...labels])) }, { labels: Array.from(new Set([...(env.labels || []), ...labels])) },
{ where: { id: env.id }, transaction },
); );
} }
}); });
return await this.find({ id: ids }); return await this.find({ id: ids });
} }
public async removeLabels(ids: number[], labels: string[]) { public async removeLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => { await database.transaction(async () => {
const docs = await EnvModel.findAll({ const docs = await EnvModel.find({ id: ids }, {});
where: { id: ids },
transaction,
});
for (const doc of docs) { for (const doc of docs) {
const env = doc.get({ plain: true }); const env = doc;
await EnvModel.update( await EnvModel.nativeUpdate(
{ id: env.id },
{ {
labels: (env.labels || []).filter( labels: (env.labels || []).filter(
(label: string) => !labels.includes(label), (label: string) => !labels.includes(label),
), ),
}, },
{ where: { id: env.id }, transaction },
); );
} }
}); });
return await this.find({ id: ids }); return await this.find({ id: ids });
} }
public async set_envs() { public async set_envs() {
const envs = await this.envs('', { const envs = await this.envs('', {
name: { [Op.not]: null }, name: { ['$ne']: null },
status: EnvStatus.normal, status: EnvStatus.normal,
}); });
const groups = groupBy(envs, 'name'); const groups = groupBy(envs, 'name');
@@ -249,7 +206,6 @@ export default class EnvService {
for (const key in groups) { for (const key in groups) {
if (Object.prototype.hasOwnProperty.call(groups, key)) { if (Object.prototype.hasOwnProperty.call(groups, key)) {
const group = groups[key]; const group = groups[key];
// 忽略不符合bash要求的环境变量名称 // 忽略不符合bash要求的环境变量名称
if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) { if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) {
let value = group let value = group
@@ -262,10 +218,9 @@ export default class EnvService {
.map((x) => x.value) .map((x) => x.value)
.join('&') .join('&')
.replace(/\\/g, '\\\\')}`; .replace(/\\/g, '\\\\')}`;
js_env_string += `process.env.${key}=\`${_env_value.replace( js_env_string += `process.env[${JSON.stringify(
/\`/g, key,
'\\`', )}]=${JSON.stringify(group.map((x) => x.value).join('&'))};\n`;
)}\`;\n`;
py_env_string += `os.environ['${key}']='''${_env_value.replace( py_env_string += `os.environ['${key}']='''${_env_value.replace(
/\'/g, /\'/g,
"\\'", "\\'",
+4 -3
View File
@@ -4,6 +4,7 @@ import { HealthService } from '../protos/health';
import { ApiService } from '../protos/api'; import { ApiService } from '../protos/api';
import { addCron } from '../schedule/addCron'; import { addCron } from '../schedule/addCron';
import { delCron } from '../schedule/delCron'; import { delCron } from '../schedule/delCron';
import { setConcurrency } from '../schedule/setConcurrency';
import { check } from '../schedule/health'; import { check } from '../schedule/health';
import * as Api from '../schedule/api'; import * as Api from '../schedule/api';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
@@ -27,7 +28,7 @@ export class GrpcServerService {
async initialize() { async initialize() {
try { try {
this.server.addService(HealthService, { check }); this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron }); this.server.addService(CronService, { addCron, delCron, setConcurrency });
this.server.addService(ApiService, Api); this.server.addService(ApiService, Api);
const tlsConfig = await initGrpcCerts(); const tlsConfig = await initGrpcCerts();
@@ -49,13 +50,13 @@ export class GrpcServerService {
for (const host of hostsToTry) { for (const host of hostsToTry) {
try { try {
const address = this.formatGrpcAddress(host, grpcPort); const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, credentials); const boundPort = await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`); Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, { metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(), port: grpcPort.toString(),
host host
}); });
return grpcPort; return boundPort;
} catch (err) { } catch (err) {
lastError = err as Error; lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err); Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
+8 -3
View File
@@ -1,13 +1,17 @@
import { database } from '../data';
import { Service } from 'typedi'; import { Service } from 'typedi';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import cronClient from '../schedule/client'; import cronClient from '../schedule/client';
import { HttpServerService } from './http'; import { HttpServerService } from './http';
import { runtimeBuild } from '../shared/runtimeBuild';
interface HealthStatus { interface HealthStatus {
build: typeof runtimeBuild;
status: 'ok' | 'error'; status: 'ok' | 'error';
services: { services: {
http: boolean; http: boolean;
grpc: boolean; grpc: boolean;
database: boolean;
}; };
metrics: { metrics: {
uptime: number; uptime: number;
@@ -22,16 +26,16 @@ interface HealthStatus {
export class HealthService { export class HealthService {
private startTime = Date.now(); private startTime = Date.now();
constructor( constructor(private httpServerService: HttpServerService) {}
private httpServerService: HttpServerService,
) {}
async check(): Promise<HealthStatus> { async check(): Promise<HealthStatus> {
const status: HealthStatus = { const status: HealthStatus = {
build: runtimeBuild,
status: 'ok', status: 'ok',
services: { services: {
http: true, http: true,
grpc: true, grpc: true,
database: database.healthy,
}, },
metrics: { metrics: {
uptime: Math.floor((Date.now() - this.startTime) / 1000), uptime: Math.floor((Date.now() - this.startTime) / 1000),
@@ -65,6 +69,7 @@ export class HealthService {
Logger.error('gRPC server check failed:', err); Logger.error('gRPC server check failed:', err);
} }
if (!status.services.database) status.status = 'error';
return status; return status;
} }
} }
+1 -6
View File
@@ -1,5 +1,4 @@
import { resolveFileAccess } from '../shared/fileAccess'; import { resolveFileAccess } from '../shared/fileAccess';
import path from 'path';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
@@ -9,10 +8,6 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {} constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) { public checkFilePath(filePath: string, fileName: string) {
return resolveFileAccess( return resolveFileAccess(config.logPath, [filePath || '', fileName]);
config.logPath,
[filePath || '', fileName],
config.blackFileList,
);
} }
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
@@ -126,7 +127,7 @@ export class MetricsService {
timestamp: Date.now(), timestamp: Date.now(),
metrics: this.getMetrics(), metrics: this.getMetrics(),
}; };
Logger.info('性能指标报告:', report); Logger.info(t('性能指标报告:'), report);
return report; return report;
} }
} }
+41 -25
View File
@@ -1,3 +1,4 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import crypto from 'crypto'; import crypto from 'crypto';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
@@ -55,27 +56,33 @@ export default class NotificationService {
content: string, content: string,
notificationInfo?: NotificationInfo, notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
let { type, ...rest } = await this.userService.getNotificationMode(); let type: string | undefined;
if (notificationInfo?.type) { let success = false;
type = notificationInfo?.type; try {
} const saved = await this.userService.getNotificationMode();
if (type) { type = notificationInfo?.type || saved.type;
this.title = title; if (type) {
this.content = content; this.title = title;
let params = rest; this.content = content;
if (notificationInfo) { const { type: _, ...overrides } = notificationInfo || {};
const { type: _, ...others } = notificationInfo; const { type: _saved, ...rest } = saved;
params = { ...rest, ...others }; this.params = { ...rest, ...overrides };
} try {
this.params = params; success = !!(await this.modeMap.get(type)?.call(this));
const notificationModeAction = this.modeMap.get(type); } catch (error) {
try { console.error(error);
return await notificationModeAction?.call(this); }
} catch (error: any) {
console.error(error);
} }
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
} }
return false;
} }
public async testNotify( public async testNotify(
@@ -84,14 +91,23 @@ export default class NotificationService {
content: string, content: string,
) { ) {
const { type, ...rest } = info; const { type, ...rest } = info;
if (type) { if (!type) return true;
let success = false;
try {
this.title = title; this.title = title;
this.content = content; this.content = content;
this.params = rest; this.params = rest;
const notificationModeAction = this.modeMap.get(type); success = !!(await this.modeMap.get(type)?.call(this));
return await notificationModeAction?.call(this); return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
} }
return true;
} }
private parseMailRecipients(value?: string) { private parseMailRecipients(value?: string) {
@@ -921,7 +937,8 @@ export default class NotificationService {
private async openiLink() { private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } = const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params; this.params;
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com'; const baseUrl =
openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`; const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = { const body: Record<string, string> = {
type: 'text', type: 'text',
@@ -974,5 +991,4 @@ export default class NotificationService {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
} }
} }
+126 -72
View File
@@ -1,36 +1,81 @@
import { textSearch } from '../data/queries';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import { createRandomString } from '../config/util'; import { createRandomString } from '../config/util';
import { App, AppModel } from '../data/open'; import { App, AppModel } from '../data/open';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import sequelize, { Op } from 'sequelize';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { t } from '../shared/i18n'; 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() @Service()
export default class OpenService { export default class OpenService {
constructor(@Inject('logger') private logger: winston.Logger) {} 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: 10000,
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({ name: 'system' }, {});
const app = doc;
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.nativeUpdate(
{ id: app.id },
{ scopes: [...app.scopes, 'dashboard'] },
);
}
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> { public async findApps(): Promise<App[] | null> {
const docs = await this.find({}); const docs = await this.find({});
return docs; return docs;
} }
public async create(payload: App): Promise<App> { public async create(payload: App): Promise<App> {
const tab = { ...payload }; return this.withAppsLock(async () => {
tab.client_id = createRandomString(12, 12); const tab = { ...payload };
tab.client_secret = createRandomString(24, 24); tab.client_id = createRandomString(12, 12);
const doc = await this.insert(tab); tab.client_secret = createRandomString(24, 24);
const apps = await this.find({}); const doc = await this.insert(tab);
await shareStore.updateApps(apps); const apps = await this.find({});
return { ...doc, tokens: [] }; await shareStore.updateApps(apps);
return { ...doc, tokens: [] };
});
} }
private async insert(payload: App): Promise<App> {
public async insert(payload: App): Promise<App> { const doc = await AppModel.insert(payload);
const doc = await AppModel.create(payload, { returning: true }); return doc;
return doc.get({ plain: true });
} }
public async update(payload: App): Promise<App> { public async update(payload: App): Promise<App> {
const newDoc = await this.updateDb({ const newDoc = await this.updateDb({
name: payload.name, name: payload.name,
@@ -39,28 +84,28 @@ export default class OpenService {
} as App); } as App);
return { ...newDoc, tokens: [] }; return { ...newDoc, tokens: [] };
} }
private async updateDb(payload: Partial<App>): Promise<App> { private async updateDb(payload: Partial<App>): Promise<App> {
await AppModel.update(payload, { where: { id: payload.id } }); return this.withAppsLock(async () => {
const apps = await this.find({}); await AppModel.nativeUpdate({ id: payload.id }, payload);
await shareStore.updateApps(apps); const apps = await this.find({});
return apps?.find((x) => x.id === payload.id) as App; await shareStore.updateApps(apps);
return apps?.find((x) => x.id === payload.id) as App;
});
} }
public async getDb(query: Record<string, any>): Promise<App> { public async getDb(query: Record<string, any>): Promise<App> {
const doc = await AppModel.findOne({ where: query }); const doc = await AppModel.findOne(query, {});
if (!doc) { if (!doc) {
throw new Error(`App ${JSON.stringify(query)} not found`); throw new Error(`App ${JSON.stringify(query)} not found`);
} }
return doc.get({ plain: true }); return doc;
} }
public async remove(ids: number[]) { public async remove(ids: number[]) {
await AppModel.destroy({ where: { id: ids } }); return this.withAppsLock(async () => {
const apps = await this.find({}); await AppModel.nativeDelete({ id: ids });
await shareStore.updateApps(apps); const apps = await this.find({});
await shareStore.updateApps(apps);
});
} }
public async resetSecret(id: number): Promise<App> { public async resetSecret(id: number): Promise<App> {
const tab: Partial<App> = { const tab: Partial<App> = {
client_secret: createRandomString(24, 24), client_secret: createRandomString(24, 24),
@@ -76,7 +121,6 @@ export default class OpenService {
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
return newDoc; return newDoc;
} }
public async list( public async list(
searchText: string = '', searchText: string = '',
sort: any = {}, sort: any = {},
@@ -84,18 +128,7 @@ export default class OpenService {
): Promise<App[]> { ): Promise<App[]> {
let condition = { ...query }; let condition = { ...query };
if (searchText) { if (searchText) {
const encodeText = encodeURI(searchText); condition = { ...condition, ...textSearch(['name'], searchText) };
const reg = {
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
condition = {
...condition,
name: reg,
};
} }
try { try {
const result = await this.find(condition); const result = await this.find(condition);
@@ -106,13 +139,18 @@ export default class OpenService {
throw error; throw error;
} }
} }
private async find(query: Record<string, any>, sort?: any): Promise<App[]> { private async find(query: Record<string, any>, sort?: any): Promise<App[]> {
const docs = await AppModel.findAll({ where: { ...query } }); const docs = await AppModel.find({ ...query }, {});
return docs.map((x) => x.get({ plain: true })); return docs.map((x) => x);
} }
public async authToken(credentials: {
public async authToken({ 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_id,
client_secret, client_secret,
}: { }: {
@@ -121,7 +159,7 @@ export default class OpenService {
}): Promise<any> { }): Promise<any> {
let token = uuidV4(); let token = uuidV4();
const expiration = Math.round(Date.now() / 1000) + 2592000; // 2592000 30天 const expiration = Math.round(Date.now() / 1000) + 2592000; // 2592000 30天
const doc = await AppModel.findOne({ where: { client_id, client_secret } }); const doc = await AppModel.findOne({ client_id, client_secret }, {});
if (doc) { if (doc) {
const timestamp = Math.round(Date.now() / 1000); const timestamp = Math.round(Date.now() / 1000);
const invalidTokens = (doc.tokens || []).filter( const invalidTokens = (doc.tokens || []).filter(
@@ -137,10 +175,7 @@ export default class OpenService {
} else { } else {
tokens = [...invalidTokens, { value: token, expiration }]; tokens = [...invalidTokens, { value: token, expiration }];
} }
await AppModel.update( await AppModel.nativeUpdate({ client_id, client_secret }, { tokens });
{ tokens },
{ where: { client_id, client_secret } },
);
const apps = await this.find({}); const apps = await this.find({});
await shareStore.updateApps(apps); await shareStore.updateApps(apps);
return { return {
@@ -155,28 +190,47 @@ export default class OpenService {
return { code: 400, message: t('client_id 或 client_seret 有误') }; return { code: 400, message: t('client_id 或 client_seret 有误') };
} }
} }
public async generateSystemToken(renew = false): Promise<{
public async generateSystemToken(): Promise<{
value: string; value: string;
expiration: number; expiration: number;
}> { }> {
const apps = await shareStore.getApps(); return this.withAppsLock(async () => {
const systemApp = apps?.find((x) => x.name === 'system'); // Read after acquiring the lock; a concurrent issuer may have renewed.
if (!systemApp) { const systemApp = await this.getDb({ name: 'system' });
throw new Error('system app not found'); const now = Math.round(Date.now() / 1000);
} let appToken = systemApp.tokens
const now = Math.round(Date.now() / 1000); ?.filter((token) => token.expiration > now)
const currentToken = systemApp.tokens?.find((x) => x.expiration > now); .sort((left, right) => right.expiration - left.expiration)[0];
if (currentToken) { if (renew || !appToken) {
return currentToken; const { data } = await this.issueToken({
} client_id: systemApp.client_id,
const { data } = await this.authToken({ client_secret: systemApp.client_secret,
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,
};
} }
} }
+25 -41
View File
@@ -1,11 +1,10 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Op } from 'sequelize';
import { Service } from 'typedi'; import { Service } from 'typedi';
import config from '../config'; import config from '../config';
import { CrontabStatModel } from '../data/cronStats'; import { CrontabStatModel } from '../data/cronStats';
import { sequelize } from '../data'; import { database } from '../data';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { AuthDataType, SystemModel } from '../data/system'; import { AuthDataType, SystemModel } from '../data/system';
import { import {
@@ -15,54 +14,50 @@ import {
normalizeRetentionPolicy, normalizeRetentionPolicy,
RetentionPolicy, RetentionPolicy,
} from '../shared/retention'; } from '../shared/retention';
export interface StorageCleanupRequest extends RetentionPolicy { export interface StorageCleanupRequest extends RetentionPolicy {
dependenceCacheTypes?: DependenceCacheType[]; dependenceCacheTypes?: DependenceCacheType[];
compactDatabase?: boolean; compactDatabase?: boolean;
} }
function runningInstanceWhere(days: number) { function runningInstanceWhere(days: number) {
const cutoff = dayjs().subtract(days, 'day').unix(); const cutoff = dayjs().subtract(days, 'day').unix();
return { return {
status: { [Op.ne]: InstanceStatus.running }, status: { ['$ne']: InstanceStatus.running },
[Op.or]: [ ['$or']: [
{ finished_at: { [Op.lt]: cutoff } }, { finished_at: { ['$lt']: cutoff } },
{ {
finished_at: { [Op.is]: null }, finished_at: { ['$eq']: null },
started_at: { [Op.lt]: cutoff }, started_at: { ['$lt']: cutoff },
}, },
], ],
}; };
} }
function cronStatWhere(days: number) { function cronStatWhere(days: number) {
return { return {
date: { [Op.lt]: dayjs().subtract(days, 'day').format('YYYY-MM-DD') }, date: { ['$lt']: dayjs().subtract(days, 'day').format('YYYY-MM-DD') },
}; };
} }
@Service() @Service()
export default class RetentionService { export default class RetentionService {
public async updatePolicy(policy: Partial<RetentionPolicy>) { public async updatePolicy(policy: Partial<RetentionPolicy>) {
const normalized = normalizeRetentionPolicy(policy); const normalized = normalizeRetentionPolicy(policy);
const systemConfig = await SystemModel.findOne({ const systemConfig = await SystemModel.findOne(
where: { type: AuthDataType.systemConfig }, { type: AuthDataType.systemConfig },
}); {},
);
if (!systemConfig) { if (!systemConfig) {
throw new Error('System config not found'); throw new Error('System config not found');
} }
await SystemModel.update( await SystemModel.nativeUpdate(
{ id: systemConfig.id },
{ {
info: { info: {
...systemConfig.info, ...systemConfig.info,
...normalized, ...normalized,
}, },
}, },
{ where: { id: systemConfig.id } },
); );
return normalized; return normalized;
} }
public async preview(request: StorageCleanupRequest) { public async preview(request: StorageCleanupRequest) {
const policy = normalizeRetentionPolicy(request); const policy = normalizeRetentionPolicy(request);
const dependenceCacheTypes = (request.dependenceCacheTypes || []).filter( const dependenceCacheTypes = (request.dependenceCacheTypes || []).filter(
@@ -70,14 +65,12 @@ export default class RetentionService {
); );
const [runningInstances, cronStats, dependenceCaches] = await Promise.all([ const [runningInstances, cronStats, dependenceCaches] = await Promise.all([
policy.runningInstanceRetentionDays > 0 policy.runningInstanceRetentionDays > 0
? RunningInstanceModel.count({ ? RunningInstanceModel.count(
where: runningInstanceWhere(policy.runningInstanceRetentionDays), runningInstanceWhere(policy.runningInstanceRetentionDays),
}) )
: 0, : 0,
policy.cronStatRetentionDays > 0 policy.cronStatRetentionDays > 0
? CrontabStatModel.count({ ? CrontabStatModel.count(cronStatWhere(policy.cronStatRetentionDays))
where: cronStatWhere(policy.cronStatRetentionDays),
})
: 0, : 0,
Promise.all( Promise.all(
dependenceCacheTypes.map(async (type) => ({ dependenceCacheTypes.map(async (type) => ({
@@ -88,7 +81,6 @@ export default class RetentionService {
})), })),
), ),
]); ]);
return { return {
policy, policy,
runningInstances, runningInstances,
@@ -101,29 +93,23 @@ export default class RetentionService {
compactDatabase: Boolean(request.compactDatabase), compactDatabase: Boolean(request.compactDatabase),
}; };
} }
public async cleanup(request: StorageCleanupRequest) { public async cleanup(request: StorageCleanupRequest) {
const preview = await this.preview(request); const preview = await this.preview(request);
const deleted = await sequelize.transaction(async (transaction) => { const deleted = await database.transaction(async () => {
const runningInstances = const runningInstances =
preview.policy.runningInstanceRetentionDays > 0 preview.policy.runningInstanceRetentionDays > 0
? await RunningInstanceModel.destroy({ ? await RunningInstanceModel.nativeDelete(
where: runningInstanceWhere( runningInstanceWhere(preview.policy.runningInstanceRetentionDays),
preview.policy.runningInstanceRetentionDays, )
),
transaction,
})
: 0; : 0;
const cronStats = const cronStats =
preview.policy.cronStatRetentionDays > 0 preview.policy.cronStatRetentionDays > 0
? await CrontabStatModel.destroy({ ? await CrontabStatModel.nativeDelete(
where: cronStatWhere(preview.policy.cronStatRetentionDays), cronStatWhere(preview.policy.cronStatRetentionDays),
transaction, )
})
: 0; : 0;
return { runningInstances, cronStats }; return { runningInstances, cronStats };
}); });
const dependenceCaches = []; const dependenceCaches = [];
for (const cache of preview.dependenceCaches) { for (const cache of preview.dependenceCaches) {
await fs.rm(path.join(config.dependenceCachePath, cache.type), { await fs.rm(path.join(config.dependenceCachePath, cache.type), {
@@ -132,14 +118,12 @@ export default class RetentionService {
}); });
dependenceCaches.push(cache); dependenceCaches.push(cache);
} }
if ( if (
request.compactDatabase && request.compactDatabase &&
(deleted.runningInstances || deleted.cronStats) (deleted.runningInstances || deleted.cronStats)
) { ) {
await sequelize.query('VACUUM'); await database.query('VACUUM');
} }
return { return {
deleted: { deleted: {
...deleted, ...deleted,
+45 -25
View File
@@ -1,6 +1,7 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import nodeSchedule from 'node-schedule'; import { createCronJob, CronJob } from '../shared/cronScheduler';
import { ChildProcessWithoutNullStreams } from 'child_process'; import { ChildProcessWithoutNullStreams } from 'child_process';
import { import {
ToadScheduler, ToadScheduler,
@@ -11,7 +12,11 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
import { observeChildProcess, asError, ProcessResult } from '../shared/childProcess'; import {
observeChildProcess,
asError,
ProcessResult,
} from '../shared/childProcess';
export interface ScheduleTaskType { export interface ScheduleTaskType {
id?: number; id?: number;
@@ -38,7 +43,7 @@ export interface TaskCallbacks {
@Service() @Service()
export default class ScheduleService { export default class ScheduleService {
private scheduleStacks = new Map<string, nodeSchedule.Job>(); private scheduleStacks = new Map<string, CronJob>();
private intervalSchedule = new ToadScheduler(); private intervalSchedule = new ToadScheduler();
@@ -79,7 +84,7 @@ export default class ScheduleService {
let cp: ChildProcessWithoutNullStreams | undefined; let cp: ChildProcessWithoutNullStreams | undefined;
let result: ProcessResult = { code: null, signal: null }; let result: ProcessResult = { code: null, signal: null };
try { try {
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id); this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id);
await callbacks.onBefore?.(startTime); await callbacks.onBefore?.(startTime);
cp = spawn(command, { shell: '/bin/bash' }); cp = spawn(command, { shell: '/bin/bash' });
const child = cp; const child = cp;
@@ -99,7 +104,7 @@ export default class ScheduleService {
if (result.error) { if (result.error) {
this.logger.error( this.logger.error(
'[panel][执行任务失败] 任务ID: %s, 错误: %s', t('[panel][执行任务失败] 任务ID: %s, 错误: %s'),
others.id, others.id,
result.error.message, result.error.message,
); );
@@ -107,7 +112,7 @@ export default class ScheduleService {
await callbacks.onError?.(result.error.message); await callbacks.onError?.(result.error.message);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
'[panel][任务错误回调失败] %s', t('[panel][任务错误回调失败] %s'),
asError(error).message, asError(error).message,
); );
} }
@@ -123,12 +128,12 @@ export default class ScheduleService {
} catch (error) { } catch (error) {
result.error ??= asError(error); result.error ??= asError(error);
this.logger.error( this.logger.error(
'[panel][任务结束回调失败] %s', t('[panel][任务结束回调失败] %s'),
asError(error).message, asError(error).message,
); );
} }
this.logger.info( this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j', t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'),
others.id, others.id,
result.code, result.code,
); );
@@ -137,7 +142,7 @@ export default class ScheduleService {
).catch((error) => { ).catch((error) => {
// Queue/setup failures must not become unhandled rejections in detached callers. // Queue/setup failures must not become unhandled rejections in detached callers.
rejectStart(asError(error)); rejectStart(asError(error));
this.logger.error('[panel][任务队列失败] %s', asError(error).message); this.logger.error(t('[panel][任务队列失败] %s'), asError(error).message);
return { ...others, code: null, signal: null, error: asError(error) }; return { ...others, code: null, signal: null, error: asError(error) };
}); });
@@ -152,25 +157,40 @@ export default class ScheduleService {
) { ) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s', t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'),
_id, _id,
schedule, schedule,
name, name,
command, command,
); );
this.scheduleStacks.set( if (schedule) {
_id, try {
nodeSchedule.scheduleJob(_id, schedule, async () => { const job = createCronJob(
this.runTask(command, callbacks, {
name,
schedule, schedule,
command, () =>
id: _id, this.runTask(command, callbacks, {
runOrigin, name,
}); schedule,
}), command,
); id: _id,
runOrigin,
}),
{ name: `${_id}: ${name || ''}`, logger: this.logger, start: false },
);
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.set(_id, job);
job.start();
} catch (error) {
// A persisted invalid subscription must not become an unhandled startup rejection.
this.logger.warn(
t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'),
_id,
schedule,
asError(error).message,
);
}
}
if (runImmediately) { if (runImmediately) {
this.runTask(command, callbacks, { this.runTask(command, callbacks, {
@@ -185,7 +205,7 @@ export default class ScheduleService {
async cancelCronTask({ id = 0, name }: ScheduleTaskType) { async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info('[panel][取消定时任务] 任务名: %s', name); this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name);
if (this.scheduleStacks.has(_id)) { if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel(); this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id); this.scheduleStacks.delete(_id);
@@ -200,7 +220,7 @@ export default class ScheduleService {
) { ) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s', t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'),
_id, _id,
name, name,
command, command,
@@ -217,7 +237,7 @@ export default class ScheduleService {
}, },
(err) => { (err) => {
this.logger.error( this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j', t('[panel][执行任务失败] 命令: %s, 错误信息: %j'),
command, command,
err, err,
); );
@@ -245,7 +265,7 @@ export default class ScheduleService {
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) { async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[panel][取消interval任务] 任务ID: %s, 任务名: %s', t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'),
_id, _id,
name, name,
); );
+1 -5
View File
@@ -66,11 +66,7 @@ export default class ScriptService {
} }
public checkFilePath(filePath: string, fileName: string) { public checkFilePath(filePath: string, fileName: string) {
return resolveFileAccess( return resolveFileAccess(config.scriptPath, [filePath || '', fileName]);
config.scriptPath,
[filePath || '', fileName],
config.blackFileList,
);
} }
public async getFile(filePath: string, fileName: string) { public async getFile(filePath: string, fileName: string) {
+12
View File
@@ -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'));
}
}
+52 -21
View File
@@ -1,3 +1,4 @@
import { t } from '../shared/i18n';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import fs from 'fs/promises'; import fs from 'fs/promises';
@@ -8,10 +9,22 @@ import { formatUrl } from '../config/subscription';
import config from '../config'; import config from '../config';
import { fileExist, rmPath } from '../config/util'; import { fileExist, rmPath } from '../config/util';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import {
getSubscriptionSshAlias,
resolveSubscriptionPath,
} from '../shared/subscriptionPath';
@Service() @Service()
export default class SshKeyService { 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 sshPath = config.sshdPath;
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config'); private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`; private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
@@ -21,6 +34,10 @@ export default class SshKeyService {
} }
private async initSshConfigFile() { private async initSshConfigFile() {
await fs.mkdir(path.dirname(this.sshConfigFilePath), {
recursive: true,
mode: 0o700,
});
let config = ''; let config = '';
const _exist = await fileExist(this.sshConfigFilePath); const _exist = await fileExist(this.sshConfigFilePath);
if (_exist) { if (_exist) {
@@ -42,22 +59,22 @@ export default class SshKeyService {
key: string, key: string,
): Promise<void> { ): Promise<void> {
try { try {
const filePath = path.join(this.sshPath, alias); const filePath = resolveSubscriptionPath(this.sshPath, alias);
try { try {
await rmPath(filePath); await rmPath(filePath);
} catch { } } catch {}
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' }); await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
} catch (error) { } catch (error) {
this.logger.error('生成私钥文件失败', error); this.logger.error(t('生成私钥文件失败'), error);
} }
} }
private async removePrivateKeyFile(alias: string): Promise<void> { private async removePrivateKeyFile(alias: string): Promise<void> {
try { try {
const filePath = path.join(this.sshPath, alias); const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath); await rmPath(filePath);
} catch (error) { } catch (error) {
this.logger.error('删除私钥文件失败', error); this.logger.error(t('删除私钥文件失败'), error);
} }
} }
@@ -66,18 +83,26 @@ export default class SshKeyService {
host: string, host: string,
proxy?: 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') { if (host === 'github.com') {
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`; host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
} }
const proxyStr = proxy const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n` ? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
: ''; : '';
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join( const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(
this.sshPath, this.sshPath,
alias, alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`; )}\n StrictHostKeyChecking no\n${proxyStr}`;
await writeFileWithLock( await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`, `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config, config,
{ {
encoding: 'utf8', encoding: 'utf8',
@@ -88,10 +113,10 @@ export default class SshKeyService {
private async removeSshConfig(alias: string) { private async removeSshConfig(alias: string) {
try { try {
const filePath = path.join(this.sshPath, `${alias}.config`); const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath); await rmPath(filePath);
} catch (error) { } catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error); this.logger.error(t('删除ssh配置文件%s失败'), alias, error);
} }
} }
@@ -101,6 +126,7 @@ export default class SshKeyService {
host: string, host: string,
proxy?: string, proxy?: string,
): Promise<void> { ): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.generatePrivateKeyFile(alias, key); await this.generatePrivateKeyFile(alias, key);
await this.generateSingleSshConfig(alias, host, proxy); await this.generateSingleSshConfig(alias, host, proxy);
} }
@@ -110,6 +136,7 @@ export default class SshKeyService {
host: string, host: string,
proxy?: string, proxy?: string,
): Promise<void> { ): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.removePrivateKeyFile(alias); await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias); await this.removeSshConfig(alias);
} }
@@ -117,15 +144,19 @@ export default class SshKeyService {
public async setSshConfig(docs: Subscription[]) { public async setSshConfig(docs: Subscription[]) {
for (const doc of docs) { for (const doc of docs) {
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') { if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
const { alias, proxy } = doc; try {
const { host } = formatUrl(doc); const alias = getSubscriptionSshAlias(doc.alias);
await this.removePrivateKeyFile(alias); const { host } = formatUrl(doc);
await this.removeSshConfig(alias); await this.removePrivateKeyFile(alias);
await this.generatePrivateKeyFile( await this.removeSshConfig(alias);
alias, await this.generatePrivateKeyFile(
(doc.pull_option as any).private_key, alias,
); (doc.pull_option as any).private_key,
await this.generateSingleSshConfig(alias, host, proxy); );
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error);
}
} }
} }
} }
@@ -144,12 +175,12 @@ export default class SshKeyService {
private async generateGlobalSshConfig(alias: string) { private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available // Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join( const config = `Host *\n IdentityFile ${resolveSubscriptionPath(
this.sshPath, this.sshPath,
alias, alias,
)}\n StrictHostKeyChecking no\n`; )}\n StrictHostKeyChecking no\n`;
await writeFileWithLock( await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`, `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config, config,
{ {
encoding: 'utf8', encoding: 'utf8',
+163 -119
View File
@@ -1,3 +1,5 @@
import { textSearch } from '../data/queries';
import { Query, QuerySpec, orderBy } from '../data/queries';
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
@@ -19,8 +21,7 @@ import {
rmPath, rmPath,
} from '../config/util'; } from '../config/util';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { FindOptions, Op } from 'sequelize'; import path from 'path';
import path, { join } from 'path';
import ScheduleService, { TaskCallbacks } from './schedule'; import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler'; import { SimpleIntervalSchedule } from 'toad-scheduler';
import SockService from './sock'; import SockService from './sock';
@@ -30,21 +31,28 @@ import dayjs from 'dayjs';
import { LOG_END_SYMBOL } from '../config/const'; import { LOG_END_SYMBOL } from '../config/const';
import { formatCommand, formatUrl } from '../config/subscription'; import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron'; 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 CrontabService from './cron';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager'; import { logStreamManager } from '../shared/logStreamManager';
import { LogReadOptions, readLogChunk } from '../shared/logReader'; import { LogReadOptions, readLogChunk } from '../shared/logReader';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
constructor( constructor(
@Inject('logger') private logger: winston.Logger, @Inject('logger')
private logger: winston.Logger,
private scheduleService: ScheduleService, private scheduleService: ScheduleService,
private sockService: SockService, private sockService: SockService,
private sshKeyService: SshKeyService, private sshKeyService: SshKeyService,
private crontabService: CrontabService, private crontabService: CrontabService,
) {} ) {}
public async list( public async list(
searchText?: string, searchText?: string,
ids?: string, ids?: string,
@@ -52,46 +60,33 @@ export default class SubscriptionService {
let query = {}; let query = {};
const subIds = JSON.parse(ids || '[]'); const subIds = JSON.parse(ids || '[]');
if (searchText) { if (searchText) {
const reg = { query = textSearch(['name', 'url'], searchText);
[Op.or]: [
{ [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeURI(searchText)}%` },
],
};
query = {
[Op.or]: [
{
name: reg,
},
{
url: reg,
},
],
};
} }
try { try {
const result = await SubscriptionModel.findAll({ const result = await SubscriptionModel.find(
where: { ...query, ...(ids ? { id: subIds } : undefined) }, { ...query, ...(ids ? { id: subIds } : undefined) },
order: [ {
['is_disabled', 'ASC'], orderBy: orderBy([
['createdAt', 'DESC'], ['is_disabled', 'ASC'],
], ['createdAt', 'DESC'],
}); ]),
},
);
return result; return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
} }
public async handleTask( public async handleTask(
doc: Subscription, doc: Subscription,
needCreate = true, needCreate = true,
runImmediately = false, runImmediately = false,
) { ) {
const { url } = formatUrl(doc); // Cancelling by ID must also work for invalid historical subscriptions.
if (needCreate) {
doc.command = formatCommand(doc, url as string); const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
}
if (doc.schedule_type === 'crontab') { if (doc.schedule_type === 'crontab') {
this.scheduleService.cancelCronTask(doc as any); this.scheduleService.cancelCronTask(doc as any);
needCreate && needCreate &&
@@ -112,34 +107,34 @@ export default class SubscriptionService {
)); ));
} }
} }
public async setSshConfig() { public async setSshConfig() {
const docs = await SubscriptionModel.findAll(); const docs = await SubscriptionModel.find({}, {});
await this.sshKeyService.setSshConfig(docs); await this.sshKeyService.setSshConfig(docs);
} }
private taskCallbacks(doc: Subscription): TaskCallbacks { private taskCallbacks(doc: Subscription): TaskCallbacks {
return { return {
onBefore: async (startTime) => { onBefore: async (startTime) => {
const logTime = startTime.format('YYYY-MM-DD-HH-mm-ss'); const logTime = startTime.format('YYYY-MM-DD-HH-mm-ss');
const logPath = `${doc.alias}/${logTime}.log`; const logPath = `${doc.alias}/${logTime}.log`;
await SubscriptionModel.update( await SubscriptionModel.nativeUpdate(
{ id: doc.id },
{ {
status: SubscriptionStatus.running, status: SubscriptionStatus.running,
log_path: logPath, log_path: logPath,
}, },
{ where: { id: doc.id } },
); );
const absolutePath = await handleLogPath( const absolutePath = await handleLogPath(
logPath as string, logPath as string,
tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')), tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')),
); );
// 执行sub_before // 执行sub_before
let beforeStr = ''; let beforeStr = '';
try { try {
if (doc.sub_before) { if (doc.sub_before) {
await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`); await logStreamManager.write(
absolutePath,
`\n## ${t('执行before命令...')}\n\n`,
);
beforeStr = await promiseExec(doc.sub_before); beforeStr = await promiseExec(doc.sub_before);
} }
} catch (error: any) { } catch (error: any) {
@@ -151,11 +146,11 @@ export default class SubscriptionService {
} }
}, },
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => { onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
await SubscriptionModel.update( await SubscriptionModel.nativeUpdate(
{ id: doc.id },
{ {
pid: cp.pid, pid: cp.pid,
}, },
{ where: { id: doc.id } },
); );
}, },
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
@@ -163,7 +158,6 @@ export default class SubscriptionService {
try { try {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
absolutePath = await handleLogPath(sub.log_path as string); absolutePath = await handleLogPath(sub.log_path as string);
// 执行 sub_after // 执行 sub_after
let afterStr = ''; let afterStr = '';
try { try {
@@ -176,12 +170,12 @@ export default class SubscriptionService {
} }
} catch (error: any) { } catch (error: any) {
afterStr = afterStr =
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) ||
JSON.stringify(error);
} }
if (afterStr) { if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`); await logStreamManager.write(absolutePath, `${afterStr}\n`);
} }
await logStreamManager.write( await logStreamManager.write(
absolutePath, absolutePath,
'\n' + '\n' +
@@ -196,10 +190,10 @@ export default class SubscriptionService {
try { try {
if (absolutePath) await logStreamManager.closeStream(absolutePath); if (absolutePath) await logStreamManager.closeStream(absolutePath);
} finally { } finally {
await SubscriptionModel.update( await SubscriptionModel.nativeUpdate({ id: doc.id }, {
{ status: SubscriptionStatus.idle, pid: null } as any, status: SubscriptionStatus.idle,
{ where: { id: doc.id } }, pid: null,
); } as any);
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'runSubscriptionEnd', type: 'runSubscriptionEnd',
message: t('订阅执行完成'), message: t('订阅执行完成'),
@@ -220,33 +214,45 @@ export default class SubscriptionService {
}, },
}; };
} }
public async create(payload: Subscription): Promise<Subscription> { public async create(payload: Subscription): Promise<Subscription> {
const tab = new Subscription(payload); assertSubscriptionAlias(payload.alias);
const doc = await this.insert(tab); return withSubscriptionMutation(async () => {
await this.handleTask(doc.get({ plain: true })); const tab = new Subscription(payload);
await this.setSshConfig(); const doc = await this.insert(tab);
return doc; await this.handleTask(doc);
await this.setSshConfig();
return doc;
});
} }
public async insert(payload: Subscription): Promise<SubscriptionInstance> { public async insert(payload: Subscription): Promise<SubscriptionInstance> {
return await SubscriptionModel.create(payload, { returning: true }); return await SubscriptionModel.insert(payload);
} }
public async update(payload: Subscription): Promise<Subscription> { public async update(payload: Subscription): Promise<Subscription> {
const doc = await this.getDb({ id: payload.id }); assertSubscriptionAlias(payload.alias);
const tab = new Subscription({ ...doc, ...payload }); return withSubscriptionMutation(async () => {
const newDoc = await this.updateDb(tab); const doc = await this.getDb({ id: payload.id });
await this.handleTask(newDoc, !newDoc.is_disabled); const tab = new Subscription({ ...doc, ...payload });
await this.setSshConfig(); const newDoc = await this.updateDb(tab);
return newDoc; 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> { public async updateDb(payload: Subscription): Promise<Subscription> {
await SubscriptionModel.update(payload, { where: { id: payload.id } }); await SubscriptionModel.nativeUpdate({ id: payload.id }, payload);
return await this.getDb({ id: payload.id }); return await this.getDb({ id: payload.id });
} }
public async status({ public async status({
ids, ids,
status, status,
@@ -262,6 +268,7 @@ export default class SubscriptionService {
last_running_time: number; last_running_time: number;
last_execution_time: number; last_execution_time: number;
}) { }) {
if (log_path) resolveLogPath(config.logPath, log_path);
const options: any = { const options: any = {
status, status,
pid, pid,
@@ -271,66 +278,112 @@ export default class SubscriptionService {
if (last_running_time > 0) { if (last_running_time > 0) {
options.last_running_time = last_running_time; options.last_running_time = last_running_time;
} }
return await SubscriptionModel.nativeUpdate({ id: ids }, { ...options });
return await SubscriptionModel.update(
{ ...options },
{ where: { id: ids } },
);
} }
public async remove(
public async remove(ids: number[], query: { force?: boolean }) { ids: number[],
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); query: {
for (const doc of docs) { force?: boolean;
await this.handleTask(doc.get({ plain: true }), false); },
} ) {
await SubscriptionModel.destroy({ where: { id: ids } }); return withSubscriptionMutation(async () => {
await this.setSshConfig(); const docs = await SubscriptionModel.find({ id: ids }, {});
const storagePaths: string[] = [];
if (query?.force === true) { const retainedPaths: string[] = [];
const crons = await CrontabModel.findAll({ where: { sub_id: ids } }); const collectPaths = (
if (crons?.length) { doc: Subscription,
await this.crontabService.remove(crons.map((x) => x.id!)); 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.find({}, {});
for (const doc of remaining) {
if (!ids.includes(doc.id!)) collectPaths(doc, retainedPaths);
}
} }
for (const doc of docs) { for (const doc of docs) {
const filePath = join(config.scriptPath, doc.alias); await this.handleTask(doc, false);
const repoPath = join(config.repoPath, doc.alias);
await rmPath(filePath);
await rmPath(repoPath);
} }
} await SubscriptionModel.nativeDelete({ id: ids });
for (const doc of docs) {
await this.removeSshConfigForSubscription(doc);
}
await this.setSshConfig();
if (query?.force === true) {
const crons = await CrontabModel.find({ 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(query: QuerySpec['where']): Promise<Subscription> {
public async getDb( const doc = await SubscriptionModel.findOne({ ...query }, {});
query: FindOptions<Subscription>['where'],
): Promise<Subscription> {
const doc = await SubscriptionModel.findOne({ where: { ...query } });
if (!doc) { if (!doc) {
throw new Error(`Subscription ${JSON.stringify(query)} not found`); throw new Error(`Subscription ${JSON.stringify(query)} not found`);
} }
return doc.get({ plain: true }); return doc;
} }
public async run(ids: number[]) { public async run(ids: number[]) {
await SubscriptionModel.update( await SubscriptionModel.nativeUpdate(
{ id: ids },
{ status: SubscriptionStatus.queued }, { status: SubscriptionStatus.queued },
{ where: { id: ids } },
); );
ids.forEach((id) => { ids.forEach((id) => {
this.runSingle(id); this.runSingle(id);
}); });
} }
public async stop(ids: number[]) { public async stop(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); const docs = await SubscriptionModel.find({ id: ids }, {});
let failure: unknown; let failure: unknown;
for (const doc of docs) { for (const doc of docs) {
try { try {
if (doc.pid) { if (doc.pid) {
await killTask(doc.pid, true); await killTask(doc.pid, true);
} }
await SubscriptionModel.update( await SubscriptionModel.nativeUpdate(
{ id: doc.id, pid: doc.pid ?? null },
{ status: SubscriptionStatus.idle, pid: null } as any, { status: SubscriptionStatus.idle, pid: null } as any,
{ where: { id: doc.id, pid: doc.pid ?? null } },
); );
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);
@@ -339,15 +392,12 @@ export default class SubscriptionService {
} }
if (failure) throw failure; if (failure) throw failure;
} }
private async runSingle(subscriptionId: number) { private async runSingle(subscriptionId: number) {
const subscription = await this.getDb({ id: subscriptionId }); const subscription = await this.getDb({ id: subscriptionId });
if (subscription.status !== SubscriptionStatus.queued) { if (subscription.status !== SubscriptionStatus.queued) {
return; return;
} }
const command = formatCommand(subscription); const command = formatCommand(subscription);
this.scheduleService.runTask(command, this.taskCallbacks(subscription), { this.scheduleService.runTask(command, this.taskCallbacks(subscription), {
name: subscription.name, name: subscription.name,
schedule: subscription.schedule, schedule: subscription.schedule,
@@ -356,25 +406,22 @@ export default class SubscriptionService {
runOrigin: 'subscription', runOrigin: 'subscription',
}); });
} }
public async disabled(ids: number[]) { public async disabled(ids: number[]) {
await SubscriptionModel.update({ is_disabled: 1 }, { where: { id: ids } }); await SubscriptionModel.nativeUpdate({ id: ids }, { is_disabled: 1 });
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); const docs = await SubscriptionModel.find({ id: ids }, {});
await this.setSshConfig(); await this.setSshConfig();
for (const doc of docs) { for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false); await this.handleTask(doc, false);
} }
} }
public async enabled(ids: number[]) { public async enabled(ids: number[]) {
await SubscriptionModel.update({ is_disabled: 0 }, { where: { id: ids } }); await SubscriptionModel.nativeUpdate({ id: ids }, { is_disabled: 0 });
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); const docs = await SubscriptionModel.find({ id: ids }, {});
await this.setSshConfig(); await this.setSshConfig();
for (const doc of docs) { for (const doc of docs) {
await this.handleTask(doc.get({ plain: true })); await this.handleTask(doc);
} }
} }
public async log(id: number, options: LogReadOptions = {}) { public async log(id: number, options: LogReadOptions = {}) {
const doc = await this.getDb({ id }); const doc = await this.getDb({ id });
if (!doc || !doc.log_path) { if (!doc || !doc.log_path) {
@@ -386,20 +433,17 @@ export default class SubscriptionService {
truncated: false, truncated: false,
}; };
} }
const absolutePath = await handleLogPath(doc.log_path as string); const absolutePath = await handleLogPath(doc.log_path as string);
return await readLogChunk(absolutePath, options); return await readLogChunk(absolutePath, options);
} }
public async logs(id: number) { public async logs(id: number) {
const doc = await this.getDb({ id }); const doc = await this.getDb({ id });
if (!doc) { if (!doc) {
return []; return [];
} }
if (doc.log_path) { if (doc.log_path) {
const relativeDir = path.dirname(`${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); const _exist = await fileExist(dir);
if (_exist) { if (_exist) {
let files = await fs.readdir(dir); let files = await fs.readdir(dir);
+190 -135
View File
@@ -1,7 +1,12 @@
import { Query, QuerySpec, orderBy } from '../data/queries';
import { persistNotificationAudit } from '../shared/notificationAudit';
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express'; import { Response } from 'express';
import fs from 'fs'; import fs from 'fs';
import { Agent, request } from 'undici'; import { request } from 'undici';
import path from 'path'; import path from 'path';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import winston from 'winston'; import winston from 'winston';
@@ -29,8 +34,12 @@ import {
SystemInstance, SystemInstance,
SystemModel, SystemModel,
SystemModelInfo, SystemModelInfo,
NotifyStatus,
NotifyLogInfo,
} from '../data/system'; } from '../data/system';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import cronClient from '../schedule/client';
import { withSchedulerMutation } from '../shared/schedulerMutationLock';
import NotificationService from './notify'; import NotificationService from './notify';
import ScheduleService, { TaskCallbacks } from './schedule'; import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock'; import SockService from './sock';
@@ -38,18 +47,16 @@ import os from 'os';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { t, setLang } from '../shared/i18n'; import { t, setLang } from '../shared/i18n';
import { updateLinuxMirrorFile } from '../config/util'; import { updateLinuxMirrorFile } from '../config/util';
@Service() @Service()
export default class SystemService { export default class SystemService {
@Inject((type) => NotificationService) @Inject((type) => NotificationService)
private notificationService!: NotificationService; private notificationService!: NotificationService;
constructor( constructor(
@Inject('logger') private logger: winston.Logger, @Inject('logger')
private logger: winston.Logger,
private scheduleService: ScheduleService, private scheduleService: ScheduleService,
private sockService: SockService, private sockService: SockService,
) { } ) {}
public async getSystemConfig() { public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig }); const doc = await this.getDb({ type: AuthDataType.systemConfig });
return { return {
@@ -57,22 +64,19 @@ export default class SystemService {
info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' }, info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' },
}; };
} }
private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> { private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> {
const { id, ...others } = payload; const { id, ...others } = payload;
await SystemModel.update(others, { where: { id } }); await SystemModel.nativeUpdate({ id }, others);
const doc = await this.getDb({ id }); const doc = await this.getDb({ id });
return doc; return doc;
} }
public async getDb(query: any): Promise<SystemInfo> { public async getDb(query: any): Promise<SystemInfo> {
const doc = await SystemModel.findOne({ where: query }); const doc = await SystemModel.findOne(query, {});
if (!doc) { if (!doc) {
throw new Error(`System ${JSON.stringify(query)} not found`); throw new Error(`System ${JSON.stringify(query)} not found`);
} }
return doc.get({ plain: true }); return doc;
} }
public async updateNotificationMode(notificationInfo: NotificationInfo) { public async updateNotificationMode(notificationInfo: NotificationInfo) {
const code = Math.random().toString().slice(-6); const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify( const isSuccess = await this.notificationService.testNotify(
@@ -90,7 +94,6 @@ export default class SystemService {
return { code: 400, message: t('通知发送失败,请检查参数') }; return { code: 400, message: t('通知发送失败,请检查参数') };
} }
} }
public async updateLogRemoveFrequency(info: SystemModelInfo) { public async updateLogRemoveFrequency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
@@ -117,19 +120,30 @@ export default class SystemService {
} }
return { code: 200, data: info }; return { code: 200, data: info };
} }
public async updateCronConcurrency(info: SystemModelInfo) { public async updateCronConcurrency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const limit = info.cronConcurrency ?? 0;
await this.updateAuthDb({ if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
...oDoc, throw Object.assign(new Error(t('参数错误')), { status: 400 });
info: { ...oDoc.info, ...info },
});
if (info.cronConcurrency) {
await taskLimit.setCustomLimit(info.cronConcurrency);
} }
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) { public async updateDependenceProxy(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
await this.updateAuthDb({ await this.updateAuthDb({
@@ -139,14 +153,15 @@ export default class SystemService {
if (info.dependenceProxy) { if (info.dependenceProxy) {
await fs.promises.writeFile( await fs.promises.writeFile(
config.dependenceProxyFile, 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 { } else {
await fs.promises.rm(config.dependenceProxyFile); await fs.promises.rm(config.dependenceProxyFile);
} }
return { code: 200, data: info }; return { code: 200, data: info };
} }
public async updateNodeMirror(info: SystemModelInfo, res?: Response) { public async updateNodeMirror(info: SystemModelInfo, res?: Response) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
await this.updateAuthDb({ await this.updateAuthDb({
@@ -155,15 +170,16 @@ export default class SystemService {
}); });
let cmd = 'pnpm config delete registry'; let cmd = 'pnpm config delete registry';
if (info.nodeMirror) { if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`; cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
} }
let command = `cd && ${cmd}`; let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({ const docs = await DependenceModel.find(
where: { {
type: DependenceTypes.nodejs, type: DependenceTypes.nodejs,
status: DependenceStatus.installed, status: DependenceStatus.installed,
}, },
}); {},
);
if (docs.length > 0) { if (docs.length > 0) {
command += ` && pnpm i -g`; command += ` && pnpm i -g`;
} }
@@ -195,7 +211,6 @@ export default class SystemService {
}, },
); );
} }
public async updatePythonMirror(info: SystemModelInfo) { public async updatePythonMirror(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
await this.updateAuthDb({ await this.updateAuthDb({
@@ -204,12 +219,11 @@ export default class SystemService {
}); });
let cmd = 'pip config unset global.index-url'; let cmd = 'pip config unset global.index-url';
if (info.pythonMirror) { 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); await promiseExec(cmd);
return { code: 200, data: info }; return { code: 200, data: info };
} }
public async updateLinuxMirror( public async updateLinuxMirror(
info: SystemModelInfo, info: SystemModelInfo,
res?: Response, res?: Response,
@@ -220,7 +234,6 @@ export default class SystemService {
return; return;
} }
const command = await updateLinuxMirrorFile(info.linuxMirror || ''); const command = await updateLinuxMirrorFile(info.linuxMirror || '');
let hasError = false;
this.scheduleService.runTask( this.scheduleService.runTask(
command, command,
{ {
@@ -228,14 +241,15 @@ export default class SystemService {
res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end(); res?.end();
}, },
onEnd: async () => { onEnd: async (cp) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'updateLinuxMirror', type: 'updateLinuxMirror',
message: 'update linux mirror end', message: 'update linux mirror end',
status: 'completed', status: 'completed',
}); });
onEnd?.(); 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({ await this.updateAuthDb({
...oDoc, ...oDoc,
info: { ...oDoc.info, ...info }, info: { ...oDoc.info, ...info },
@@ -243,7 +257,6 @@ export default class SystemService {
} }
}, },
onError: async (message: string) => { onError: async (message: string) => {
hasError = true;
this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
@@ -257,30 +270,25 @@ export default class SystemService {
}, },
); );
} }
public async checkUpdate() { public async checkUpdate() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try { try {
const currentVersionContent = await parseVersion(config.versionFile); const currentVersionContent = await parseVersion(config.versionFile);
const { body, statusCode } = await request(
let lastVersionContent; `${config.lastVersionFile}?t=${Date.now()}`,
try { { signal: controller.signal },
const { body } = await request( );
`${config.lastVersionFile}?t=${Date.now()}`, const text = await body.text();
{ if (statusCode !== 200) throw new Error('Version request failed');
dispatcher: new Agent({ const lastVersionContent = parseContentVersion(text);
keepAliveTimeout: 30000, if (
keepAliveMaxTimeout: 30000, !lastVersionContent ||
}), typeof lastVersionContent.version !== 'string' ||
}, !/^\d+\.\d+\.\d+$/.test(lastVersionContent.version)
); ) {
const text = await body.text(); throw new Error('Invalid version response');
lastVersionContent = parseContentVersion(text);
} catch (error) { }
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
} }
return { return {
code: 200, code: 200,
data: { data: {
@@ -296,11 +304,12 @@ export default class SystemService {
} catch (error: any) { } catch (error: any) {
return { return {
code: 400, code: 400,
message: error.message, message: t('检查更新失败,请检查网络后重试'),
}; };
} finally {
clearTimeout(timeout);
} }
} }
private checkHasNewVersion(curVersion: string, lastVersion: string) { private checkHasNewVersion(curVersion: string, lastVersion: string) {
const curArr = curVersion.split('.').map((x) => parseInt(x, 10)); const curArr = curVersion.split('.').map((x) => parseInt(x, 10));
const lastArr = lastVersion.split('.').map((x) => parseInt(x, 10)); const lastArr = lastVersion.split('.').map((x) => parseInt(x, 10));
@@ -319,45 +328,49 @@ export default class SystemService {
} }
return false; return false;
} }
public async updateSystem() { public async updateSystem() {
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' }); return this.startUpgradeJob('download');
}
cp.stdout.on('data', (data) => { private upgradeJobs() {
this.sockService.sendMessage({ return require(path.join(
type: 'updateSystemVersion', config.rootPath,
message: data.toString(), 'cli/src/internal/maintenance/panelUpgrade.cjs',
}); ));
}); }
public async getUpgradeStatus() {
cp.stderr.on('data', (data) => { return {
this.sockService.sendMessage({ code: 200,
type: 'updateSystemVersion', data: await this.upgradeJobs().getStatus(config.tmpPath),
message: data.toString(), };
}); }
}); private async startUpgradeJob(action: 'download' | 'apply') {
try {
cp.on('error', (err) => { const data = await this.upgradeJobs().startJob(
this.sockService.sendMessage({ {
type: 'updateSystemVersion', root: config.rootPath,
message: JSON.stringify(err), tmp: config.tmpPath,
status: 'failed', env: {
}); ...process.env,
}); dir_data: config.dataPath,
dir_config: config.configPath,
cp.on('exit', (code) => { QlPort: String(config.port),
this.sockService.sendMessage({ QlBaseUrl: config.baseUrl,
type: 'updateSystemVersion', },
message: '', },
status: code === 0 ? 'success' : 'failed', action,
}); );
}); return { code: 200, data };
} catch (error: any) {
return { code: 200 }; this.logger.error('[upgrade] %s', error.message);
return { code: 400, message: t('升级任务启动失败,请检查日志后重试') };
}
} }
public async reloadSystem(target?: 'system' | 'data') { 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('参数错误') };
}
if (target === 'system') return this.startUpgradeJob('apply');
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
const cp = spawn(cmd, { const cp = spawn(cmd, {
shell: '/bin/bash', shell: '/bin/bash',
detached: true, detached: true,
@@ -369,7 +382,6 @@ export default class SystemService {
}); });
return { code: 200 }; return { code: 200 };
} }
public async notify({ public async notify({
title, title,
content, content,
@@ -397,29 +409,51 @@ export default class SystemService {
return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') }; return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
} }
} }
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) { await persistNotificationAudit(info);
}
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.find(
{ type: AuthDataType.notifyLog },
{
orderBy: orderBy([['id', 'DESC']]),
limit: 200,
},
);
return docs.map((x) => ({ ...x.info, id: x.id }));
}
public async run(
{
command,
logPath,
}: {
command: string;
logPath?: string;
},
callback: TaskCallbacks,
) {
if (!command.startsWith(TASK_COMMAND)) { if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`; command = `${TASK_COMMAND} ${command}`;
} }
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '' const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, { this.scheduleService.runTask(
command, `${logPathPrefix} real_time=true ${command}`,
id: command.replace(/ /g, '-'), callback,
runOrigin: 'system', {
}); command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
},
);
} }
public async stop({ command, pid }: { command: string; pid: number }) { public async stop({ command, pid }: { command: string; pid: number }) {
if (!pid && !command) { if (!pid && !command) {
return { code: 400, message: t('参数错误') }; return { code: 400, message: t('参数错误') };
} }
if (pid) { if (pid) {
await killTask(pid); await killTask(pid);
return { code: 200 }; return { code: 200 };
} }
if (!command.startsWith(TASK_COMMAND)) { if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`; command = `${TASK_COMMAND} ${command}`;
} }
@@ -431,24 +465,47 @@ export default class SystemService {
return { code: 400, message: t('任务未找到') }; return { code: 400, message: t('任务未找到') };
} }
} }
public async exportData(res: Response, type?: string[]) { public async exportData(res: Response, type?: string[]) {
try { try {
let dataDirs = ['db', 'upload']; let dataDirs = ['db', 'upload'];
if (type && type.length) { if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base')); dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
} }
const dataPaths = dataDirs.map((dir) => `data/${dir}`); const allowed = new Set([
await promiseExec( 'db',
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile 'upload',
} ${dataPaths.join(' ')}`, '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); res.download(config.dataTgzFile);
} catch (error: any) { } catch (error: any) {
return res.send({ code: 400, message: error.message }); return res.send({ code: 400, message: error.message });
} }
} }
public async importData() { public async importData() {
try { try {
await promiseExec(`rm -rf ${path.join(config.tmpPath, 'data')}`); await promiseExec(`rm -rf ${path.join(config.tmpPath, 'data')}`);
@@ -460,7 +517,6 @@ export default class SystemService {
return { code: 400, message: error.message }; return { code: 400, message: error.message };
} }
} }
public async getSystemLog( public async getSystemLog(
res: Response, res: Response,
query: { query: {
@@ -480,7 +536,6 @@ export default class SystemService {
.reverse() .reverse()
.filter((x) => x.title.endsWith('.log')) .filter((x) => x.title.endsWith('.log'))
.filter((x) => x.createTime >= startTime && x.createTime <= endTime); .filter((x) => x.createTime >= startTime && x.createTime <= endTime);
const limit = Math.min( const limit = Math.min(
Math.max(Number(query.limit) || 1024 * 1024, 1), Math.max(Number(query.limit) || 1024 * 1024, 1),
1024 * 1024, 1024 * 1024,
@@ -488,7 +543,10 @@ export default class SystemService {
const total = logs.reduce((size, log) => size + (log.size || 0), 0); const total = logs.reduce((size, log) => size + (log.size || 0), 0);
let remaining = limit; let remaining = limit;
const selected: Array< const selected: Array<
(typeof logs)[number] & { start: number; length: number } (typeof logs)[number] & {
start: number;
length: number;
}
> = []; > = [];
for (let index = logs.length - 1; index >= 0 && remaining > 0; index--) { for (let index = logs.length - 1; index >= 0 && remaining > 0; index--) {
const log = logs[index]; const log = logs[index];
@@ -499,7 +557,6 @@ export default class SystemService {
remaining -= length; remaining -= length;
} }
} }
const contentLength = selected.reduce((size, log) => size + log.length, 0); const contentLength = selected.reduce((size, log) => size + log.length, 0);
res.set({ res.set({
'Content-Length': contentLength, 'Content-Length': contentLength,
@@ -511,7 +568,6 @@ export default class SystemService {
res.end(); res.end();
return; return;
} }
const currentLog = fileNames.shift(); const currentLog = fileNames.shift();
if (currentLog) { if (currentLog) {
const currentFileStream = fs.createReadStream( const currentFileStream = fs.createReadStream(
@@ -528,15 +584,23 @@ export default class SystemService {
} }
})(res, selected); })(res, selected);
} }
public async deleteSystemLog() { public async deleteSystemLog() {
const result = await readDirs(config.systemLogPath, config.systemLogPath); const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result.reverse().filter((x) => x.title.endsWith('.log')); const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
for (const log of logs) { for (const log of logs) {
await rmPath(path.join(config.systemLogPath, log.title)); // All workers keep append handles open; unlinking strands future writes
// in a deleted inode until rotation. Preserve the file while clearing it.
const file = await fs.promises.open(
path.join(config.systemLogPath, log.title),
fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
);
try {
await file.truncate(0);
} finally {
await file.close();
}
} }
} }
public async updateTimezone(info: SystemModelInfo) { public async updateTimezone(info: SystemModelInfo) {
if (!info.timezone) { if (!info.timezone) {
info.timezone = 'Asia/Shanghai'; info.timezone = 'Asia/Shanghai';
@@ -553,7 +617,6 @@ export default class SystemService {
return { code: 400, message: t('设置时区失败') }; return { code: 400, message: t('设置时区失败') };
} }
} }
public async updateLanguage(info: SystemModelInfo) { public async updateLanguage(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
const lang = info.lang || 'zh'; const lang = info.lang || 'zh';
@@ -565,7 +628,7 @@ export default class SystemService {
try { try {
await fs.promises.writeFile( await fs.promises.writeFile(
config.langEnvFile, config.langEnvFile,
`export QL_LANG='${lang}'\n`, `export QL_LANG=${shellQuote(lang)}\n`,
); );
} catch (error) { } catch (error) {
this.logger.error(`Failed to write lang_env.sh: ${error}`); this.logger.error(`Failed to write lang_env.sh: ${error}`);
@@ -573,7 +636,6 @@ export default class SystemService {
setLang(lang); setLang(lang);
return { code: 200, data: { lang } }; return { code: 200, data: { lang } };
} }
public async updatePanelTitle(info: SystemModelInfo) { public async updatePanelTitle(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
const panelTitle = info.panelTitle?.trim() || ''; const panelTitle = info.panelTitle?.trim() || '';
@@ -583,43 +645,36 @@ export default class SystemService {
}); });
return { code: 200, data: { panelTitle } }; return { code: 200, data: { panelTitle } };
} }
public async updateGlobalSshKey(info: SystemModelInfo) { public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
...oDoc, ...oDoc,
info: { ...oDoc.info, ...info }, info: { ...oDoc.info, ...info },
}); });
// Apply the global SSH key // Apply the global SSH key
const SshKeyService = require('./sshKey').default; const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container; const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService); const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) { if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global'); await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else { } else {
await sshKeyService.removeGlobalSSHKey('global'); await sshKeyService.removeGlobalSSHKey('global');
} }
return { code: 200, data: result }; return { code: 200, data: result };
} }
public async cleanDependence(type: 'node' | 'python3') { public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: t('参数错误') }; return { code: 400, message: t('参数错误') };
} }
const finalPath = path.join(config.dependenceCachePath, type); const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true, force: true }); await fs.promises.rm(finalPath, { recursive: true, force: true });
await DependenceModel.update( await DependenceModel.nativeUpdate(
{ status: DependenceStatus.installFailed },
{ {
where: { type:
type: type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3,
type === 'node' ? DependenceTypes.nodejs : DependenceTypes.python3, status: DependenceStatus.installed,
status: DependenceStatus.installed,
},
}, },
{ status: DependenceStatus.installFailed },
); );
return { code: 200 }; return { code: 200 };
} }
+37 -85
View File
@@ -33,26 +33,28 @@ import {
verifyPassword, verifyPassword,
} from '../shared/password'; } from '../shared/password';
import { serializeAuthMutation } from '../shared/authMutation'; import { serializeAuthMutation } from '../shared/authMutation';
import { LoginThrottle } from '../shared/loginThrottle';
@Service() @Service()
export default class UserService { export default class UserService {
private loginThrottle = new LoginThrottle();
@Inject((type) => NotificationService) @Inject((type) => NotificationService)
private notificationService!: NotificationService; private notificationService!: NotificationService;
constructor( constructor(
@Inject('logger') private logger: winston.Logger, @Inject('logger')
private logger: winston.Logger,
private scheduleService: ScheduleService, private scheduleService: ScheduleService,
private sockService: SockService, private sockService: SockService,
) {} ) {}
@serializeAuthMutation @serializeAuthMutation
public async login( public async login(
payloads: { username: string; password: string }, payloads: {
username: string;
password: string;
},
req: Request, req: Request,
): Promise<any> { ): Promise<any> {
return this.authenticate(payloads, req); return this.authenticate(payloads, req);
} }
private async authenticate( private async authenticate(
payloads: { payloads: {
username: string; username: string;
@@ -87,7 +89,6 @@ export default class UserService {
platform, platform,
blockedIps = [], blockedIps = [],
} = content; } = content;
if ( if (
ip && ip &&
blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip) blockedIps.some((blockedIp) => normalizeClientIp(blockedIp) === ip)
@@ -105,22 +106,16 @@ export default class UserService {
this.getLoginLog(); this.getLoginLog();
return { code: 403, message: t('该 IP 已被列入黑名单') }; return { code: 403, message: t('该 IP 已被列入黑名单') };
} }
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
const retriesTime = Math.pow(3, retries) * 1000; if (waitTime > 0) {
if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil(
(retriesTime - (timestamp - lastlogon)) / 1000,
);
return { return {
code: 410, code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime), message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime, data: waitTime,
}; };
} }
const passwordMatches = const passwordMatches =
username === cUsername && (await verifyPassword(password, cPassword)); username === cUsername && (await verifyPassword(password, cPassword));
if (passwordMatches && twoFactorActivated && needTwoFactor) { if (passwordMatches && twoFactorActivated && needTwoFactor) {
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
isTwoFactorChecking: true, isTwoFactorChecking: true,
@@ -131,15 +126,14 @@ export default class UserService {
message: '', message: '',
}; };
} }
if (passwordMatches) { if (passwordMatches) {
this.loginThrottle.reset(ip);
const data = createRandomString(50, 100); const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d'; const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, { let token = jwt.sign({ data }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn || expiration, expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384', algorithm: 'HS384',
}); });
const tokenInfo: TokenInfo = { const tokenInfo: TokenInfo = {
value: token, value: token,
timestamp, timestamp,
@@ -147,13 +141,11 @@ export default class UserService {
address, address,
platform: req.platform, platform: req.platform,
}; };
const updatedTokens = this.addTokenToList( const updatedTokens = this.addTokenToList(
tokens, tokens,
req.platform, req.platform,
tokenInfo, tokenInfo,
); );
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
password: isPasswordHash(cPassword) password: isPasswordHash(cPassword)
? cPassword ? cPassword
@@ -173,7 +165,7 @@ export default class UserService {
t('你于') + t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') + t('在') +
address + t(address) +
' ' + ' ' +
req.platform + req.platform +
t('端') + t('端') +
@@ -206,8 +198,9 @@ export default class UserService {
}, },
}; };
} else { } else {
const failures = this.loginThrottle.fail(ip, timestamp);
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
retries: retries + 1, retries: failures,
lastlogon: timestamp, lastlogon: timestamp,
lastip: ip, lastip: ip,
lastaddr: address, lastaddr: address,
@@ -218,7 +211,7 @@ export default class UserService {
t('你于') + t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') + t('在') +
address + t(address) +
' ' + ' ' +
req.platform + req.platform +
t('端') + t('端') +
@@ -239,8 +232,8 @@ export default class UserService {
}, },
}); });
this.getLoginLog(); this.getLoginLog();
if (retries > 2) { if (failures > 3) {
const waitTime = Math.round(Math.pow(3, retries + 1)); const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
return { return {
code: 410, code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime), message: tf('失败次数过多,请%s秒后重试', waitTime),
@@ -251,16 +244,13 @@ export default class UserService {
} }
} }
} }
@serializeAuthMutation @serializeAuthMutation
public async logout(platform: string, tokenValue: string): Promise<any> { public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) { if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token'); this.logger.warn('Invalid logout parameters - empty platform or token');
return; return;
} }
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it // Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList( const tokenExists = this.findTokenInList(
authInfo.tokens, authInfo.tokens,
@@ -274,45 +264,36 @@ export default class UserService {
); );
return; return;
} }
const updatedTokens = this.removeTokenFromList( const updatedTokens = this.removeTokenFromList(
authInfo.tokens, authInfo.tokens,
platform, platform,
tokenValue, tokenValue,
); );
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
token: authInfo.token === tokenValue ? '' : authInfo.token, token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: updatedTokens, tokens: updatedTokens,
}); });
} }
public async getLoginLog(): Promise<Array<SystemModelInfo | undefined>> { public async getLoginLog(): Promise<Array<SystemModelInfo | undefined>> {
const docs = await SystemModel.findAll({ const docs = await SystemModel.find({ type: AuthDataType.loginLog }, {});
where: { type: AuthDataType.loginLog },
});
if (docs && docs.length > 0) { if (docs && docs.length > 0) {
const result = docs.sort( const result = docs.sort(
(a, b) => b.info!.timestamp! - a.info!.timestamp!, (a, b) => b.info!.timestamp! - a.info!.timestamp!,
); );
if (result.length > 100) { if (result.length > 100) {
const ids = result.slice(100).map((x) => x.id!); const ids = result.slice(100).map((x) => x.id!);
await SystemModel.destroy({ await SystemModel.nativeDelete({ id: ids });
where: { id: ids },
});
} }
return result.map((x) => x.info); return result.map((x) => x.info);
} }
return []; return [];
} }
public async getIpBlacklist(): Promise<string[]> { public async getIpBlacklist(): Promise<string[]> {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter( return uniq((authInfo.blockedIps || []).map(normalizeClientIp)).filter(
Boolean, Boolean,
); );
} }
@serializeAuthMutation @serializeAuthMutation
public async blockIp(ip: string): Promise<string[]> { public async blockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
@@ -323,7 +304,6 @@ export default class UserService {
await this.updateAuthInfo(authInfo, { blockedIps }); await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps; return blockedIps;
} }
@serializeAuthMutation @serializeAuthMutation
public async unblockIp(ip: string): Promise<string[]> { public async unblockIp(ip: string): Promise<string[]> {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
@@ -334,12 +314,10 @@ export default class UserService {
await this.updateAuthInfo(authInfo, { blockedIps }); await this.updateAuthInfo(authInfo, { blockedIps });
return blockedIps; return blockedIps;
} }
private async insertDb(payload: SystemInfo): Promise<SystemInfo> { private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await SystemModel.create({ ...payload }, { returning: true }); const doc = await SystemModel.insert({ ...payload });
return doc; return doc;
} }
@serializeAuthMutation @serializeAuthMutation
public async initializeUser({ public async initializeUser({
username, username,
@@ -365,7 +343,6 @@ export default class UserService {
}); });
return { code: 200, message: t('更新成功') }; return { code: 200, message: t('更新成功') };
} }
@serializeAuthMutation @serializeAuthMutation
public async updateUsernameAndPassword({ public async updateUsernameAndPassword({
username, username,
@@ -388,14 +365,12 @@ export default class UserService {
}); });
return { code: 200, message: t('更新成功') }; return { code: 200, message: t('更新成功') };
} }
@serializeAuthMutation @serializeAuthMutation
public async updateAvatar(avatar: string) { public async updateAvatar(avatar: string) {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar }); await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: t('更新成功') }; return { code: 200, data: avatar, message: t('更新成功') };
} }
@serializeAuthMutation @serializeAuthMutation
public async initTwoFactor() { public async initTwoFactor() {
const secret = authenticator.generateSecret(); const secret = authenticator.generateSecret();
@@ -407,7 +382,6 @@ export default class UserService {
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret }); await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
return { secret, url: otpauth }; return { secret, url: otpauth };
} }
@serializeAuthMutation @serializeAuthMutation
public async activeTwoFactor(code: string) { public async activeTwoFactor(code: string) {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
@@ -426,21 +400,24 @@ export default class UserService {
} }
return isValid; return isValid;
} }
@serializeAuthMutation @serializeAuthMutation
public async twoFactorLogin( public async twoFactorLogin(
{ {
username, username,
password, password,
code, code,
}: { username: string; password: string; code: string }, }: {
username: string;
password: string;
code: string;
},
req: any, req: any,
) { ) {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo; const { isTwoFactorChecking, twoFactorSecret } = authInfo;
const now = Date.now(); const now = Date.now();
const retries = authInfo.retries || 0; const source = getClientIp(req);
if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) { if (this.loginThrottle.retryAfter(source, now) > 0) {
return { code: 410, message: t('失败次数过多,请稍后重试') }; return { code: 410, message: t('失败次数过多,请稍后重试') };
} }
if ( if (
@@ -472,9 +449,8 @@ export default class UserService {
.join(' '); .join(' ');
} }
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
retries: retries + 1, retries: this.loginThrottle.fail(source, now),
lastlogon: now, lastlogon: now,
isTwoFactorChecking: retries + 1 < 5,
lastip: ip, lastip: ip,
lastaddr: address, lastaddr: address,
platform: req.platform, platform: req.platform,
@@ -482,7 +458,6 @@ export default class UserService {
return { code: 430, message: t('验证失败') }; return { code: 430, message: t('验证失败') };
} }
} }
@serializeAuthMutation @serializeAuthMutation
public async deactivateTwoFactor() { public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
@@ -496,7 +471,6 @@ export default class UserService {
}); });
return true; return true;
} }
public async getAuthInfo() { public async getAuthInfo() {
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
if (authInfo) { if (authInfo) {
@@ -505,7 +479,6 @@ export default class UserService {
const doc = await this.getDb({ type: AuthDataType.authConfig }); const doc = await this.getDb({ type: AuthDataType.authConfig });
return (doc.info || {}) as AuthInfo; return (doc.info || {}) as AuthInfo;
} }
private async updateAuthInfo(authInfo: AuthInfo, info: Partial<AuthInfo>) { private async updateAuthInfo(authInfo: AuthInfo, info: Partial<AuthInfo>) {
const result = { ...authInfo, ...info }; const result = { ...authInfo, ...info };
await shareStore.updateAuthInfo(result); await shareStore.updateAuthInfo(result);
@@ -517,34 +490,27 @@ export default class UserService {
this.sockService.getClients().forEach((conn) => conn.close('401')); this.sockService.getClients().forEach((conn) => conn.close('401'));
} }
} }
public async getNotificationMode(): Promise<NotificationInfo> { public async getNotificationMode(): Promise<NotificationInfo> {
const doc = await this.getDb({ type: AuthDataType.notification }); const doc = await this.getDb({ type: AuthDataType.notification });
return (doc.info || {}) as NotificationInfo; return (doc.info || {}) as NotificationInfo;
} }
private async updateAuthDb(payload: SystemInfo): Promise<any> { private async updateAuthDb(payload: SystemInfo): Promise<any> {
let doc = await SystemModel.findOne({ where: { type: payload.type } }); let doc = await SystemModel.findOne({ type: payload.type }, {});
if (doc) { if (doc) {
const updateResult = await SystemModel.update(payload, { await SystemModel.nativeUpdate({ id: doc.id }, payload);
where: { id: doc.id }, doc = await SystemModel.findOne({ id: doc.id });
returning: true,
});
doc = updateResult[1][0];
} else { } else {
doc = await SystemModel.create(payload, { returning: true }); doc = await SystemModel.insert(payload);
} }
return doc; return doc;
} }
public async getDb(query: any): Promise<SystemInfo> { public async getDb(query: any): Promise<SystemInfo> {
const doc = await SystemModel.findOne({ where: { ...query } }); const doc = await SystemModel.findOne({ ...query }, {});
if (!doc) { if (!doc) {
throw new Error(`${JSON.stringify(query)} not found`); throw new Error(`${JSON.stringify(query)} not found`);
} }
return doc.get({ plain: true }); return doc;
} }
public async updateNotificationMode(notificationInfo: NotificationInfo) { public async updateNotificationMode(notificationInfo: NotificationInfo) {
const code = Math.random().toString().slice(-6); const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify( const isSuccess = await this.notificationService.testNotify(
@@ -562,12 +528,10 @@ export default class UserService {
return { code: 400, message: t('通知发送失败,请检查参数') }; return { code: 400, message: t('通知发送失败,请检查参数') };
} }
} }
private normalizeTokens( private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>, tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> { ): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {}; const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) { for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') { if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array // Legacy format: convert string token to TokenInfo array
@@ -589,10 +553,8 @@ export default class UserService {
normalized[platform] = value || []; normalized[platform] = value || [];
} }
} }
return normalized; return normalized;
} }
private addTokenToList( private addTokenToList(
tokens: Record<string, string | TokenInfo[]>, tokens: Record<string, string | TokenInfo[]>,
platform: string, platform: string,
@@ -606,16 +568,12 @@ export default class UserService {
); );
maxTokensPerPlatform = config.maxTokensPerPlatform; maxTokensPerPlatform = config.maxTokensPerPlatform;
} }
const normalized = this.normalizeTokens(tokens); const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) { if (!normalized[platform]) {
normalized[platform] = []; normalized[platform] = [];
} }
// Add new token // Add new token
normalized[platform].unshift(tokenInfo); normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform // Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) { if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice( normalized[platform] = normalized[platform].slice(
@@ -623,40 +581,32 @@ export default class UserService {
maxTokensPerPlatform, maxTokensPerPlatform,
); );
} }
return normalized; return normalized;
} }
private removeTokenFromList( private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>, tokens: Record<string, string | TokenInfo[]>,
platform: string, platform: string,
tokenValue: string, tokenValue: string,
): Record<string, TokenInfo[]> { ): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens); const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) { if (normalized[platform]) {
normalized[platform] = normalized[platform].filter( normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue, (t) => t.value !== tokenValue,
); );
} }
return normalized; return normalized;
} }
private findTokenInList( private findTokenInList(
tokens: Record<string, string | TokenInfo[]>, tokens: Record<string, string | TokenInfo[]>,
platform: string, platform: string,
tokenValue: string, tokenValue: string,
): TokenInfo | undefined { ): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens); const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) { if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue); return normalized[platform].find((t) => t.value === tokenValue);
} }
return undefined; return undefined;
} }
@serializeAuthMutation @serializeAuthMutation
public async resetAuthInfo(info: Partial<AuthInfo>) { public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info; const { retries, twoFactorActivated, password, username } = info;
@@ -673,7 +623,6 @@ export default class UserService {
}, },
(x) => !isNil(x), (x) => !isNil(x),
); );
if (password !== undefined) { if (password !== undefined) {
payload.password = await hashPassword(password); payload.password = await hashPassword(password);
} }
@@ -690,5 +639,8 @@ export default class UserService {
}); });
} }
await this.updateAuthInfo(authInfo, payload); await this.updateAuthInfo(authInfo, payload);
if (retries === 0 || password !== undefined || username !== undefined) {
this.loginThrottle.clear();
}
} }
} }
+11
View File
@@ -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 '';
}
+149 -7
View File
@@ -1,12 +1,152 @@
import cronParser from 'cron-parser-v4'; import CronExpressionParser from 'cron-parser';
import { validate } from 'node-cron';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
// Keep validation aligned with node-schedule's locked cron-parser version. const aliases: Record<string, string> = {
// cron-parser 5 accepts syntax (e.g. H) that the scheduler cannot execute. '@yearly': '0 0 0 1 1 *',
'@annually': '0 0 0 1 1 *',
'@monthly': '0 0 0 1 * *',
'@weekly': '0 0 0 * * 0',
'@daily': '0 0 0 * * *',
'@midnight': '0 0 0 * * *',
'@hourly': '0 0 * * * *',
'@minutely': '0 * * * * *',
};
export interface CronSchedule {
patterns: string[];
dayCount: number;
nthDay: number;
}
// Validation and registration repeatedly see identical rules in large snapshots.
// Cache only immutable syntax, never dates or running tasks, and bound user input.
const scheduleCache = new Map<string, CronSchedule>();
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
export function parseCronSchedule(schedule: unknown): CronSchedule {
if (typeof schedule !== 'string' || !schedule.trim()) {
throw new Error('Invalid cron schedule');
}
let source = schedule.trim();
const cacheKey = source;
const cached = scheduleCache.get(cacheKey);
if (cached) return cached;
if (source.startsWith('@')) {
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
source = aliases[source];
}
let parts = source.split(/\s+/);
if (
parts.length > 6 ||
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
) {
throw new Error('Unsupported cron syntax');
}
parts = [
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
...parts,
];
if (
parts.some(
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
)
) {
throw new Error('Question mark is only valid in day fields');
}
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
let normalized = parts.map((part) => part.replace(/\?/g, '*'));
// cron-parser includes Sunday in stepped ranges ending in 7 even when the
// step does not land on 7. node-cron accepts those ranges with different days.
if (/(^|,)(?:\d+|[a-z]{3})-0*7\/\d+(?=,|$)/i.test(normalized[5])) {
normalized[5] = expression.fields.dayOfWeek.values.join(',');
}
// Most rules are already supported; only inspect individual fields on failure.
if (!validate(normalized.join(' '))) {
const fields = [
expression.fields.second,
expression.fields.minute,
expression.fields.hour,
expression.fields.dayOfMonth,
expression.fields.month,
expression.fields.dayOfWeek,
];
normalized = normalized.map((value, index) => {
// Preserve supported lists, ranges and names. Expand only fields node-cron
// cannot consume, such as numeric/name-start steps; never compress them.
const probe = ['0', '0', '0', '*', '*', '*'];
probe[index] = value;
if (validate(probe.join(' '))) return value;
const values = fields[index].values;
if (index === 5 && expression.fields.dayOfWeek.nthDay) {
// The legacy normalizer treated *#N / ?#N as an unrestricted weekday.
if (values.length === 8) return '*';
// # applies to a single weekday; normalize its numeric spelling only.
return values.length === 1
? `${values[0]}#${expression.fields.dayOfWeek.nthDay}`
: value;
}
return values.join(',');
});
}
const months = expression.fields.month.values;
// cron-parser 4 clipped dates outside a single selected month's maximum
// before deciding whether the day field covered the whole month.
const dayCount = expression.fields.dayOfMonth.values.filter(
(day) =>
months.length !== 1 ||
typeof day !== 'number' ||
day <= daysInMonth[months[0] - 1],
).length;
const weekCount = expression.fields.dayOfWeek.values.length;
// Preserve the legacy count-based wildcard rules, including repeated Sunday
// and L tokens: an ignored day field must not remain an AND constraint.
const weekdayPattern = [
...normalized.slice(0, 3),
'*',
...normalized.slice(4),
].join(' ');
const patterns =
weekCount === 8
? [[...normalized.slice(0, 5), '*'].join(' ')]
: dayCount < 31
? [[...normalized.slice(0, 5), '*'].join(' '), weekdayPattern]
: [weekdayPattern];
const valid = patterns.map((pattern) => validate(pattern));
if (patterns.length === 2 && !valid[0] && valid[1]) {
// A date branch can be impossible for all chosen months while the weekday
// branch still runs. Drop it only if its syntax is valid with any month.
if (validate([...normalized.slice(0, 4), '*', '*'].join(' '))) {
patterns.shift();
valid.shift();
}
}
if (!valid.every(Boolean)) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
patterns,
dayCount,
nthDay: expression.fields.dayOfWeek.nthDay,
};
Object.freeze(patterns);
Object.freeze(parsed);
if (scheduleCache.size >= 512) {
const oldest = scheduleCache.keys().next().value;
if (oldest !== undefined) scheduleCache.delete(oldest);
}
scheduleCache.set(cacheKey, parsed);
return parsed;
}
export function isValidCronSchedule(schedule: unknown): schedule is string { export function isValidCronSchedule(schedule: unknown): schedule is string {
if (typeof schedule !== 'string' || !schedule.trim()) return false;
try { try {
return cronParser.parseExpression(schedule).hasNext(); parseCronSchedule(schedule);
return true;
} catch { } catch {
return false; return false;
} }
@@ -22,6 +162,8 @@ export function getInvalidCronSchedules(cron: {
) { ) {
return []; return [];
} }
return [cron.schedule, ...(cron.extra_schedules || []).map((x) => x.schedule)] return [
.filter((schedule) => !isValidCronSchedule(schedule)); cron.schedule,
...(cron.extra_schedules || []).map((x) => x.schedule),
].filter((schedule) => !isValidCronSchedule(schedule));
} }
+90
View File
@@ -0,0 +1,90 @@
import { t } from './i18n';
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
interface SchedulerLogger {
warn(message: string, ...args: unknown[]): unknown;
error(message: string, ...args: unknown[]): unknown;
}
export interface CronJob {
start(): void;
cancel(): void;
}
// node-cron owns timers and calendar calculation; Qinglong owns execution
// concurrency and process lifetime. Missed in-process slots join that same queue.
export function createCronJob(
schedule: string,
callback: (date: Date) => unknown | Promise<unknown>,
options: { name: string; logger: SchedulerLogger; start?: boolean },
): CronJob {
const parsed = parseCronSchedule(schedule);
const tasks: ScheduledTask[] = [];
let cancelled = false;
let started = false;
const accepts = (index: number, date: Date) => {
if (tasks.length === 1) return true;
// Preserve cron-parser 4's day/weekday OR semantics, including its
// month-dependent full-day range and the global nth-week constraint.
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
return false;
if (index === 1) return !tasks[0].match(date);
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
};
const dispatch = async (index: number, date: Date, missed: boolean) => {
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'),
options.name,
date.toISOString(),
);
}
try {
await callback(date);
} catch (error) {
options.logger.error(
t('[schedule][定时回调失败] 任务: %s, 错误: %s'),
options.name,
error instanceof Error ? error.message : String(error),
);
}
};
try {
for (const [index, pattern] of parsed.patterns.entries()) {
const task = createTask(
pattern,
(context) => dispatch(index, context.date, false),
{
noOverlap: false,
missedExecutionTolerance: 5000,
},
);
tasks.push(task);
task.on('execution:missed', (context) =>
dispatch(index, context.date, true),
);
// Fail before an existing schedule is cancelled, including impossible dates.
task.getNextRuns(1);
}
} catch (error) {
tasks.forEach((task) => task.destroy());
throw error;
}
const job: CronJob = {
start() {
if (started || cancelled) return;
started = true;
tasks.forEach((task) => task.start());
},
cancel() {
if (cancelled) return;
cancelled = true;
tasks.forEach((task) => task.destroy());
},
};
if (options.start !== false) job.start();
return job;
}
+226 -91
View File
@@ -1,111 +1,236 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = { const messages: Record<string, Record<string, string>> = {
zh: {}, zh: {},
en: { en: {
'暂无权限': 'Access denied', '认证服务暂时不可用,请稍后重试':
'参数错误': 'Invalid parameter', 'Authentication service is temporarily unavailable; try again shortly.',
'参数不正确': 'Invalid parameter', '升级任务启动失败,请检查日志后重试':
'文件无法访问': 'File not accessible', 'Failed to start the update. Check the logs and try again.',
'文件不存在': 'File not found', '[schedule][停止已运行任务] 任务ID: %s, PID: %s':
'路径不存在': 'Path does not exist', '[schedule][Stopping running task] Task ID: %s, PID: %s',
'路径不正确': 'Invalid path',
'接口已下线,请使用 /configs/detail 接口':
'This endpoint has been retired. Use /configs/detail instead.',
'接口已下线,请使用 /logs/detail 接口':
'This endpoint has been retired. Use /logs/detail instead.',
'接口已下线,请使用 /scripts/detail 接口':
'This endpoint has been retired. Use /scripts/detail instead.',
'仅支持 PNG、JPEG、GIF、WebP、AVIF 图片':
'Only PNG, JPEG, GIF, WebP and AVIF images are supported',
请先初始化: 'Please initialize the panel first',
请先关闭两步验证: 'Please disable two-factor authentication first',
'失败次数过多,请稍后重试':
'Too many failed attempts. Please try again later.',
配置更新失败: 'Failed to update configuration',
'trust proxy 配置格式无效': 'Invalid trust proxy configuration',
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
系统配置不存在: 'System configuration does not exist',
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
'Version history only supports UTF-8 text scripts up to 1 MiB',
脚本不存在: 'Script does not exist',
请选择脚本文件: 'Please select a script file',
'历史版本读取失败,请检查历史文件':
'Failed to read version history. Check the history file.',
历史版本不存在或已清理:
'This history version does not exist or has been removed',
'文件已变化,请刷新历史版本后重试':
'The file has changed. Refresh version history and try again.',
'历史版本容量不足,无法安全保存':
'Version history storage is full. The file cannot be saved safely.',
'跳过订阅 %s 的无效定时配置: %s':
'Skipping invalid schedule for subscription %s: %s',
'[schedule][准备运行任务] 命令: %s':
'[schedule][Preparing task] Command: %s',
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s':
'[schedule][Creating scheduled task] Task ID: %s, schedules: %s',
'[schedule][取消定时任务] 任务ID: %s':
'[schedule][Cancelling scheduled task] Task ID: %s',
'[schedule][取消任务失败] 任务ID: %s, 错误: %s':
'[schedule][Failed to cancel task] Task ID: %s, error: %s',
'[panel][停止任务失败] PID: %s, 错误: %s':
'[panel][Failed to stop task] PID: %s, error: %s',
'[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s':
'[panel][Failed to stop instance] Instance ID: %s, PID: %s, error: %s',
'[panel][执行任务失败] 任务ID: %s, 错误: %s':
'[panel][Task failed] Task ID: %s, error: %s',
'[panel][执行任务结束] 任务ID: %s, 退出码: %j':
'[panel][Task finished] Task ID: %s, exit code: %j',
'[panel][创建任务失败] 任务ID: %s, 错误: %s':
'[panel][Failed to create task] Task ID: %s, error: %s',
'[panel][关闭任务日志失败] %s': '[panel][Failed to close task log] %s',
'[panel][清理任务状态失败] %s': '[panel][Failed to clear task status] %s',
'性能指标报告:': 'Performance metrics:',
'[panel][开始执行任务] 任务ID: %s': '[panel][Starting task] Task ID: %s',
'[panel][任务错误回调失败] %s': '[panel][Task error callback failed] %s',
'[panel][任务结束回调失败] %s':
'[panel][Task completion callback failed] %s',
'[panel][任务队列失败] %s': '[panel][Task queue failed] %s',
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s':
'[panel][Creating cron task] Task ID: %s, cron: %s, name: %s, command: %s',
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s':
'[panel][Skipping invalid schedule] Task ID: %s, cron: %s, error: %s',
'[panel][取消定时任务] 任务名: %s':
'[panel][Cancelling scheduled task] Name: %s',
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s':
'[panel][Creating interval task] Task ID: %s, name: %s, command: %s',
'[panel][执行任务失败] 命令: %s, 错误信息: %j':
'[panel][Task failed] Command: %s, error: %j',
'[panel][取消interval任务] 任务ID: %s, 任务名: %s':
'[panel][Cancelling interval task] Task ID: %s, name: %s',
生成私钥文件失败: 'Failed to generate private key file',
删除私钥文件失败: 'Failed to delete private key file',
'删除ssh配置文件%s失败': 'Failed to delete SSH configuration file %s',
'跳过订阅 %s 的无效 SSH 配置: %s':
'Skipping invalid SSH configuration for subscription %s: %s',
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s':
'[schedule][Running late task] Task: %s, scheduled time: %s',
'[schedule][定时回调失败] 任务: %s, 错误: %s':
'[schedule][Scheduled callback failed] Task: %s, error: %s',
'[schedule][任务队列] 空闲中...': '[schedule][Task queue] Idle...',
'[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Task queued] Running: %s, waiting: %s',
'[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Processing task] Running: %s, waiting: %s',
'[schedule][任务处理成功] 参数 %s':
'[schedule][Task succeeded] Arguments: %s',
'[schedule][任务处理错误] 参数 %s': '[schedule][Task failed] Arguments: %s',
'[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s':
'[schedule][Task finished] Running: %s, waiting: %s',
'[schedule][任务重复运行] 通知失败 %s':
'[schedule][Duplicate task] Notification failed: %s',
'[schedule][任务重复运行] 参数 %s':
'[schedule][Duplicate task] Arguments: %s',
'[schedule][停止已运行任务] 任务ID: %s':
'[schedule][Stopping running task] Task ID: %s',
'[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s':
'[schedule][Failed to check running task] Task ID: %s, error: %s',
'[schedule][开始执行任务] 参数 %s':
'[schedule][Starting task] Arguments: %s',
'[schedule][任务标准错误] 命令: %s, 信息: %s':
'[schedule][Task stderr] Command: %s, message: %s',
'[schedule][执行任务失败] 命令: %s, 错误: %s':
'[schedule][Task failed] Command: %s, error: %s',
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j':
'[schedule][Task finished] Task ID: %s, exit code: %j',
'[schedule][创建任务失败] 命令: %s, 错误: %s':
'[schedule][Failed to create task] Command: %s, error: %s',
'检查更新失败,请检查网络后重试':
'Failed to check for updates. Check your network and try again.',
'资源已存在,请检查重复的名称或值':
'Resource already exists; check for duplicate names or values',
暂无权限: 'Access denied',
参数错误: 'Invalid parameter',
参数不正确: 'Invalid parameter',
文件无法访问: 'File not accessible',
文件不存在: 'File not found',
路径不存在: 'Path does not exist',
路径不正确: 'Invalid path',
'通知发送失败,请检查参数': 'Notification failed, check parameters', '通知发送失败,请检查参数': 'Notification failed, check parameters',
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings', '通知发送失败,请检查系统设置/通知配置':
'通知发送成功': 'Notification sent successfully', 'Notification failed, check system settings',
'设置时区失败': 'Failed to set timezone', 通知发送成功: 'Notification sent successfully',
'任务未找到': 'Task not found', 设置时区失败: 'Failed to set timezone',
'密码不能设置为admin': 'Password cannot be admin', 任务未找到: 'Task not found',
'更新成功': 'Update successful', 密码不能设置为admin: 'Password cannot be admin',
'未知错误': 'Unknown error', 更新成功: 'Update successful',
'验证失败': 'Verification failed', 未知错误: 'Unknown error',
'实例不存在或已停止': 'Instance does not exist or stopped', 验证失败: 'Verification failed',
'实例已停止': 'Instance stopped', 实例不存在或已停止: 'Instance does not exist or stopped',
'确认停止实例': 'Confirm to stop instance', 实例已停止: 'Instance stopped',
'确认停止运行实例': 'Confirm to stop running instance', 确认停止实例: 'Confirm to stop instance',
'确认停止': 'Confirm to stop', 确认停止运行实例: 'Confirm to stop running instance',
'确认停止定时任务': 'Confirm to stop scheduled task', 确认停止: 'Confirm to stop',
'确认删除': 'Confirm to delete', 确认停止定时任务: 'Confirm to stop scheduled task',
'确认删除定时任务': 'Confirm to delete scheduled task', 确认删除: 'Confirm to delete',
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?', 确认删除定时任务: 'Confirm to delete scheduled task',
'确认运行': 'Confirm to run', 确认删除选中的定时任务吗: 'Confirm to delete selected tasks?',
'确认运行定时任务': 'Confirm to run scheduled task', 确认运行: 'Confirm to run',
'确认保存': 'Confirm to save', 确认运行定时任务: 'Confirm to run scheduled task',
'确认保存文件': 'Confirm to save file', 确认保存: 'Confirm to save',
'确认重启': 'Confirm restart', 确认保存文件: 'Confirm to save file',
'确认启用': 'Confirm to enable', 确认重启: 'Confirm restart',
'确认禁用': 'Confirm to disable', 确认启用: 'Confirm to enable',
'确认': 'Confirm', 确认禁用: 'Confirm to disable',
'删除成功': 'Deleted successfully', 确认: 'Confirm',
'操作成功': 'Operation successful', 删除成功: 'Deleted successfully',
'参数不完整': 'Incomplete parameters', 操作成功: 'Operation successful',
'默认路径不支持删除': 'Default path cannot be deleted', 参数不完整: 'Incomplete parameters',
'必须在日志目录下': 'Must be within log directory', 默认路径不支持删除: 'Default path cannot be deleted',
必须在日志目录下: 'Must be within log directory',
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite', '备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:', '如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
'系统将在': 'System will', 系统将在: 'System will',
'秒后自动刷新': 'refresh in seconds', 秒后自动刷新: 'refresh in seconds',
'生成数据中...': 'Generating data...', '生成数据中...': 'Generating data...',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format', '每条数据 name 或者 value 字段不能为空,参考导出文件格式':
'不支持当前依赖类型': 'Unsupported dependency type', 'Each entry must have name and value, see export format',
'依赖已存在': 'Dependency already exists', 不支持当前依赖类型: 'Unsupported dependency type',
'依赖不存在': 'Dependency does not exist', 依赖已存在: 'Dependency already exists',
'该脚本正在运行中': 'Script is running', 依赖不存在: 'Dependency does not exist',
'该脚本未在运行中': 'Script is not running', 该脚本正在运行中: 'Script is running',
'文件内容为空': 'File content is empty', 该脚本未在运行中: 'Script is not running',
'文件名不能为空': 'File name cannot be empty', 文件内容为空: 'File content is empty',
'标签不能为空': 'Label cannot be empty', 文件名不能为空: 'File name cannot be empty',
'名称不能为空': 'Name cannot be empty', 标签不能为空: 'Label cannot be empty',
'名称不能为保留关键字': 'Name cannot be reserved keyword', 名称不能为空: 'Name cannot be empty',
'名称已存在': 'Name already exists', 名称不能为保留关键字: 'Name cannot be reserved keyword',
'密码错误': 'Incorrect password', 名称已存在: 'Name already exists',
'用户不存在': 'User does not exist', 密码错误: 'Incorrect password',
'请输入用户名和密码': 'Please enter username and password', 用户不存在: 'User does not exist',
'无权访问': 'Access denied', 请输入用户名和密码: 'Please enter username and password',
'登录成功': 'Login successful', 无权访问: 'Access denied',
'退出成功': 'Logout successful', 登录成功: 'Login successful',
退出成功: 'Logout successful',
'Token 已失效': 'Token expired', 'Token 已失效': 'Token expired',
'Token 无效': 'Invalid token', 'Token 无效': 'Invalid token',
'两步骤验证已开启': '2FA enabled', 两步骤验证已开启: '2FA enabled',
'两步骤验证已关闭': '2FA disabled', 两步骤验证已关闭: '2FA disabled',
'验证码错误': 'Invalid verification code', 验证码错误: 'Invalid verification code',
'验证码已过期': 'Verification code expired', 验证码已过期: 'Verification code expired',
'请先开启两步骤验证': 'Please enable 2FA first', 请先开启两步骤验证: 'Please enable 2FA first',
'两步骤验证密钥不能为空': '2FA secret cannot be empty', 两步骤验证密钥不能为空: '2FA secret cannot be empty',
'用户已存在': 'User already exists', 用户已存在: 'User already exists',
'用户名不能为admin': 'Username cannot be admin', 用户名不能为admin: 'Username cannot be admin',
'不能删除自己': 'Cannot delete yourself', 不能删除自己: 'Cannot delete yourself',
'不能禁用自己': 'Cannot disable yourself', 不能禁用自己: 'Cannot disable yourself',
'文件路径无效': 'Invalid file path', 文件路径无效: 'Invalid file path',
'保存成功': 'Saved successfully', 保存成功: 'Saved successfully',
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret', 'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
'订阅执行完成': 'Subscription completed', 订阅执行完成: 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids', 'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行':
'wxPusher requires at least one of TopicIds or Uids',
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty', 'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens', 'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title', 'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
'绝对路径必须在日志目录内或使用 /dev/null': '绝对路径必须在日志目录内或使用 /dev/null':
'Absolute path must be within log directory or use /dev/null', 'Absolute path must be within log directory or use /dev/null',
'请先登录': 'Please login first', 请先登录: 'Please login first',
'运行中...': 'Running...', '运行中...': 'Running...',
'日志不存在...': 'Log does not exist...', '日志不存在...': 'Log does not exist...',
'未分类': 'Uncategorized', 未分类: 'Uncategorized',
'任务重复运行': 'Duplicate task execution', 任务重复运行: 'Duplicate task execution',
'日志设置为忽略': 'Log set to ignore', 日志设置为忽略: 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty', 定时规则不能为空: 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule', 无效的定时规则: 'Invalid schedule rule',
定时规则必须是字符串: 'Schedule rule must be a string',
额外定时规则必须是对象: 'Extra schedule must be an object',
额外定时规则必须是数组: 'Extra schedules must be an array',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符': '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators', 'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters', 日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again', '错误的用户名密码,请重试':
'Incorrect username or password, please try again',
'该 IP 已被列入黑名单': 'This IP address has been blocked', '该 IP 已被列入黑名单': 'This IP address has been blocked',
'已加入 IP 黑名单': 'IP address added to the blacklist', '已加入 IP 黑名单': 'IP address added to the blacklist',
'已移出 IP 黑名单': 'IP address removed from the blacklist', '已移出 IP 黑名单': 'IP address removed from the blacklist',
'青龙快讯': 'QingLong', 青龙快讯: 'QingLong',
'登录通知': 'Login Notification', 登录通知: 'Login Notification',
'你于': 'You at ', 你于: 'You at ',
'在': ' in ', 在: ' in ',
端: '', 端: ' client',
内网IP: 'Private IP',
登录失败: 'login failed', 登录失败: 'login failed',
',ip地址': ', IP: ', ',ip地址': ', IP: ',
'任务#%s': 'Task#%s', '任务#%s': 'Task#%s',
@@ -130,12 +255,13 @@ const messages: Record<string, Record<string, string>> = {
青龙: 'QingLong', 青龙: 'QingLong',
'【蛟龙】测试通知 https://t.me/jiao_long': '【蛟龙】测试通知 https://t.me/jiao_long':
'[JiaoLong] Test notification https://t.me/jiao_long', '[JiaoLong] Test notification https://t.me/jiao_long',
'生成token': 'Generate token', 生成token: 'Generate token',
'删除日志': 'Delete logs', 删除日志: 'Delete logs',
'## 开始执行... %s\n': '## Start executing... %s\n', '## 开始执行... %s\n': '## Start executing... %s\n',
'执行before命令...': 'Execute before command...', '执行before命令...': 'Execute before command...',
'执行after命令...': 'Execute after command...', '执行after命令...': 'Execute after command...',
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds', '## 执行结束... %s 耗时 %s 秒':
'## Execution finished... %s elapsed %s seconds',
'调度器注册失败,任务创建已回滚': '调度器注册失败,任务创建已回滚':
'Scheduler registration failed, task creation rolled back', 'Scheduler registration failed, task creation rolled back',
'调度器注册失败,任务更新已回滚': '调度器注册失败,任务更新已回滚':
@@ -151,9 +277,18 @@ const messages: Record<string, Record<string, string>> = {
let currentLang: string = 'zh'; let currentLang: string = 'zh';
export function setLang(lang: string) { export function setLang(lang: string, persist = true) {
currentLang = lang || 'zh'; currentLang = lang || 'zh';
shareStore.setLang(currentLang); if (!persist) return;
// Translation is also used by standalone scheduler and CLI helpers. Load
// persistent storage only when changing the language, not when reading it.
const { shareStore } = require('./store') as typeof import('./store');
const language = currentLang;
return shareStore.setLang(language).then(() => {
if (currentLang === language && process.connected && process.send) {
process.send({ type: 'language-changed', lang: language }, () => {});
}
});
} }
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */ /** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */
+1
View File
@@ -18,6 +18,7 @@ export interface IDependencyFn<T> {
export interface ICronFn<T> { export interface ICronFn<T> {
(): Promise<T>; (): Promise<T>;
cron?: TCron; cron?: TCron;
isCurrent?: () => boolean;
} }
export interface ISchedule { export interface ISchedule {
+32
View File
@@ -0,0 +1,32 @@
import cluster from 'cluster';
import { setLang, systemLang } from './i18n';
function languageMessage(
message: unknown,
): message is { type: string; lang: string } {
const value = message as { type?: string; lang?: string } | null;
return (
value?.type === 'language-changed' && ['zh', 'en'].includes(value.lang!)
);
}
// Each worker owns a translation cache. Relay saved settings to live workers;
// a replacement worker restores the persisted language before serving requests.
export async function initializeLanguageSync() {
if (cluster.isPrimary) {
cluster.on('message', (_worker, message) => {
if (!languageMessage(message)) return;
setLang(message.lang, false);
for (const worker of Object.values(cluster.workers || {})) {
if (worker?.isConnected()) worker.send(message, () => {});
}
});
return;
} else {
process.on('message', (message) => {
if (languageMessage(message)) setLang(message.lang, false);
});
}
const { shareStore } = await import('./store');
setLang((await shareStore.getLang()) || systemLang(), false);
}
+121
View File
@@ -0,0 +1,121 @@
import { shellQuote } from './shellQuote';
const repositoryNames =
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
export function normalizeMirror(mirror: string): URL {
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
const url = new URL(mirror);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error(
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
);
}
return url;
}
export function replaceMirrorUrl(current: string, mirror: string): string {
const target = normalizeMirror(mirror);
const previous = new URL(current);
const oldSegments = previous.pathname.split('/').filter(Boolean);
const newSegments = target.pathname.split('/').filter(Boolean);
const repositoryIndex = oldSegments.findIndex((part) =>
repositoryNames.test(part),
);
const targetIndex = newSegments.findIndex((part) =>
repositoryNames.test(part),
);
const prefix =
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
if (repositoryIndex >= 0) {
const repository = oldSegments[repositoryIndex];
let suffix = oldSegments.slice(repositoryIndex + 1);
// Repair paths already corrupted by repeated authority-only replacement.
while (suffix[0] === repository) suffix.shift();
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
}
// Unknown repository paths: a full URL replaces the old root, a hostname
// preserves it. Never append the newly entered root to the existing root.
return `${target.origin}/${(newSegments.length
? newSegments
: oldSegments
).join('/')}`;
}
export function rewriteMirrorSources(content: string, mirror: string): string {
normalizeMirror(mirror);
return content
.split(/(\r?\n[ \t]*\r?\n)/)
.map((stanza) => {
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
return stanza
.split('\n')
.map((line) => {
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
return line;
const legacy = /^\s*deb(?:-src)?\s/.test(line);
const key = legacy
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
: signedBy;
// In .list files a # starts an inline comment; its URLs are metadata.
const comment = legacy ? line.indexOf('#') : -1;
const source = comment < 0 ? line : line.slice(0, comment);
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
);
return rewritten + (comment < 0 ? '' : line.slice(comment));
})
.join('\n');
})
.join('');
}
function isSystemRepository(url: string, signedBy?: string): boolean {
const keys =
signedBy
?.trim()
.split(/[\s,]+/)
.filter(Boolean) || [];
const archiveKeyring =
keys.length > 0 &&
keys.every((key) =>
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
);
// A separate signing key identifies a third-party archive, even if its
// repository happens to be called /debian or /ubuntu.
if (keys.length > 0 && !archiveKeyring) return false;
const segments = new URL(url).pathname.split('/').filter(Boolean);
const repositoryIndex = segments.findIndex((part) =>
repositoryNames.test(part),
);
if (repositoryIndex < 0) return false;
// Without archive metadata, accept only conventional archive roots. A
// vendor path such as Docker's /linux/debian is not the Debian archive.
return (
archiveKeyring ||
repositoryIndex === 0 ||
segments
.slice(0, repositoryIndex)
.every((part) => /^mirrors?$/.test(part)) ||
segments[repositoryIndex] === 'alpine'
);
}
export function mirrorWriteCommand(
file: string,
content: string,
prefix: string,
update: string,
): string {
return `${prefix}mkdir -p ${shellQuote(
file.slice(0, file.lastIndexOf('/')),
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
file,
)} >/dev/null && ${update}`;
}
+11
View File
@@ -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;
}
+47
View File
@@ -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();
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Query, QuerySpec, orderBy } from '../data/queries';
import {
SystemModel,
AuthDataType,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
// Both backend sends and script reports use the same bounded, best-effort sink.
export async function persistNotificationAudit(
info: NotifyLogInfo,
): Promise<void> {
try {
await SystemModel.insert({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
title: String(info.title || '').slice(0, 512),
content: String(info.content || '').slice(0, 8192),
status:
info.status === NotifyStatus.success
? NotifyStatus.success
: NotifyStatus.fail,
notifyType: String(info.notifyType || '').slice(0, 64),
source: info.source === 'script' ? 'script' : 'system',
scriptName: String(info.scriptName || '').slice(0, 512),
taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined,
},
});
const oldest = await SystemModel.find(
{ type: AuthDataType.notifyLog },
{
fields: ['id'],
orderBy: orderBy([['id', 'DESC']]),
offset: 200,
limit: 1,
},
);
if (oldest[0]?.id) {
await SystemModel.nativeDelete({
type: AuthDataType.notifyLog,
id: { ['$lte']: oldest[0].id },
});
}
} catch (error) {
console.error('Failed to persist notification audit log');
}
}
+48 -26
View File
@@ -50,11 +50,9 @@ class TaskLimit {
Buffer.from(tlsConfig.clientCert), Buffer.from(tlsConfig.clientCert),
) )
: credentials.createInsecure(); : credentials.createInsecure();
this._client = new ApiClient( this._client = new ApiClient(`localhost:${config.grpcPort}`, creds, {
`localhost:${config.grpcPort}`, 'grpc.enable_http_proxy': 0,
creds, });
{ 'grpc.enable_http_proxy': 0 },
);
} }
return this._client; return this._client;
} }
@@ -81,29 +79,36 @@ class TaskLimit {
private handleEvents() { private handleEvents() {
this.cronLimit.on('add', () => { this.cronLimit.on('add', () => {
Logger.info( Logger.info(
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`, t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
); );
}); });
this.cronLimit.on('active', () => { this.cronLimit.on('active', () => {
Logger.info( Logger.info(
`[schedule][开始处理任务] 运行中任务数: ${ t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount + 1 this.cronLimitActiveCount + 1,
}, 等待中任务数: ${this.cronLimitPendingCount}`, this.cronLimitPendingCount,
); );
}); });
this.cronLimit.on('completed', (param) => { this.cronLimit.on('completed', (param) => {
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`); Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param));
}); });
this.cronLimit.on('error', (error) => { this.cronLimit.on('error', (error) => {
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`); Logger.error(
t('[schedule][任务处理错误] 参数 %s'),
JSON.stringify(error),
);
}); });
this.cronLimit.on('next', () => { this.cronLimit.on('next', () => {
Logger.info( Logger.info(
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`, t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'),
this.cronLimitActiveCount,
this.cronLimitPendingCount,
); );
}); });
this.cronLimit.on('idle', () => { this.cronLimit.on('idle', () => {
Logger.info(`[schedule][任务队列] 空闲中...`); Logger.info(t(`[schedule][任务队列] 空闲中...`));
}); });
} }
@@ -113,12 +118,15 @@ class TaskLimit {
} }
} }
public removeQueuedCron(id: string) { public removeQueuedCron(id: string, completed?: ICronFn<any>) {
if (this.queuedCrons.has(id)) { if (this.queuedCrons.has(id)) {
const runs = this.queuedCrons.get(id); const runs = this.queuedCrons.get(id);
if (runs && runs.length > 0) { if (runs && runs.length > 0) {
runs.pop(); const remaining = completed
this.queuedCrons.set(id, runs); ? runs.filter((run) => run !== completed)
: runs.slice(0, -1);
if (remaining.length) this.queuedCrons.set(id, remaining);
else this.queuedCrons.delete(id);
} }
} }
} }
@@ -129,13 +137,20 @@ class TaskLimit {
this.manualCronoLimit.concurrency = limit; this.manualCronoLimit.concurrency = limit;
return; return;
} }
await SystemModel.sync();
const doc = await SystemModel.findOne({ try {
where: { type: AuthDataType.systemConfig }, const doc = await SystemModel.findOne({
}); type: AuthDataType.systemConfig,
if (doc?.info?.cronConcurrency) { });
this.cronLimit.concurrency = doc.info.cronConcurrency; if (doc?.info?.cronConcurrency) {
this.manualCronoLimit.concurrency = doc.info.cronConcurrency; this.cronLimit.concurrency = doc.info.cronConcurrency;
this.manualCronoLimit.concurrency = doc.info.cronConcurrency;
}
} catch (error) {
// Imports can precede the isolated initializer on a fresh installation.
// Use defaults until that initializer creates Auths; never create schema here.
if ((error as { code?: string }).code !== 'DATABASE_SCHEMA_MISSING')
throw error;
} }
} }
@@ -143,9 +158,15 @@ class TaskLimit {
cron: TCron, cron: TCron,
fn: ICronFn<T>, fn: ICronFn<T>,
options?: Partial<QueueAddOptions>, options?: Partial<QueueAddOptions>,
isCurrent: () => boolean = () => true,
): Promise<T | void> { ): Promise<T | void> {
if (!isCurrent()) return;
fn.cron = cron; 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 result = runs?.length ? [...runs, fn] : [fn];
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0; const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
if (result?.length > 5) { if (result?.length > 5) {
@@ -165,13 +186,14 @@ class TaskLimit {
(err, res) => { (err, res) => {
if (err) { if (err) {
Logger.error( Logger.error(
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`, t('[schedule][任务重复运行] 通知失败 %s'),
JSON.stringify(err),
); );
} }
}, },
); );
} }
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`); Logger.warn(t('[schedule][任务重复运行] 参数 %s'), JSON.stringify(cron));
return; return;
} }
this.queuedCrons.set(cron.id, result); this.queuedCrons.set(cron.id, result);
+36 -21
View File
@@ -1,3 +1,4 @@
import { t } from './i18n';
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
import taskLimit from './pLimit'; import taskLimit from './pLimit';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
@@ -8,15 +9,23 @@ import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { observeChildProcess, asError } from './childProcess'; import { observeChildProcess, asError } from './childProcess';
export function runCron(cmd: string, cron: ICron): Promise<number | void> { export function runCron(
return taskLimit.runWithCronLimit(cron, async () => { cmd: string,
cron: ICron,
isCurrent: () => boolean = () => true,
): Promise<number | void> {
const execute = async () => {
try { try {
if (!isCurrent()) return;
// Check if the cron is already running and stop it (only if multiple instances are not allowed) // Check if the cron is already running and stop it (only if multiple instances are not allowed)
try { try {
const existingCron = await CrontabModel.findOne({ const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) }, 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 // Default to single instance mode (0) for backward compatibility
const allowSingleInstances = const allowSingleInstances =
existingCron?.allow_multiple_instances === 0; existingCron?.allow_multiple_instances === 0;
@@ -29,45 +38,50 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
existingCron.status === CrontabStatus.queued) existingCron.status === CrontabStatus.queued)
) { ) {
Logger.info( Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`, t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
cron.id,
existingCron.pid,
); );
await killTask(existingCron.pid, true); await killTask(existingCron.pid, true);
// Mark old running instances as stopped // Mark old running instances as stopped
const stoppedAt = dayjs().unix(); const stoppedAt = dayjs().unix();
await RunningInstanceModel.update( await RunningInstanceModel.nativeUpdate(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{ {
where: { cron_id: Number(cron.id),
cron_id: Number(cron.id), status: InstanceStatus.running,
status: InstanceStatus.running,
},
}, },
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
); );
// Update the status to idle after killing // Update the status to idle after killing
await CrontabModel.update( await CrontabModel.nativeUpdate(
{ id: Number(cron.id) },
{ status: CrontabStatus.idle, pid: undefined }, { status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
); );
} }
} catch (error) { } catch (error) {
Logger.error( Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`, t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
cron.id,
error,
); );
throw error; throw error;
} }
// In particular, invalidation can happen while stopping a previous run.
if (!isCurrent()) return;
Logger.info( Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({ t('[schedule][开始执行任务] 参数 %s'),
JSON.stringify({
...cron, ...cron,
command: cmd, command: cmd,
})}`, }),
); );
const cp = spawn(cmd, { shell: '/bin/bash' }); const cp = spawn(cmd, { shell: '/bin/bash' });
const { completed } = observeChildProcess(cp, { const { completed } = observeChildProcess(cp, {
onStderr: async (message) => { onStderr: async (message) => {
Logger.info( Logger.info(
'[schedule][任务标准错误] 命令: %s, 信息: %s', t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
cmd, cmd,
message, message,
); );
@@ -76,25 +90,26 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
const result = await completed; const result = await completed;
if (result.error) { if (result.error) {
Logger.error( Logger.error(
'[schedule][执行任务失败] 命令: %s, 错误: %s', t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
cmd, cmd,
result.error.message, result.error.message,
); );
} }
Logger.info( Logger.info(
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j', t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
cron.id, cron.id,
result.code, result.code,
); );
return { ...cron, command: cmd, pid: cp.pid, ...result } as any; return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
} catch (error) { } catch (error) {
Logger.error( Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误: %s', t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
cmd, cmd,
asError(error).message, asError(error).message,
); );
} finally { } finally {
taskLimit.removeQueuedCron(cron.id); taskLimit.removeQueuedCron(cron.id, execute);
} }
}); };
return taskLimit.runWithCronLimit(cron, execute, undefined, isCurrent);
} }
+41
View File
@@ -0,0 +1,41 @@
import { readFileSync } from 'fs';
import path from 'path';
import { createHash, randomUUID } from 'crypto';
function read(filename: string): Buffer | undefined {
try {
return readFileSync(filename);
} catch {
return undefined;
}
}
function manifest(): { sourceCommit?: string } {
try {
const value = JSON.parse(
read(path.resolve(__dirname, '../../build-info.json'))?.toString() ||
'{}',
);
return value && typeof value === 'object' ? value : {};
} catch {
return {};
}
}
// Capture before serving requests. Reading the installed files on each request
// would let an old process claim the identity of a newly copied build.
const backend = read(path.resolve(__dirname, '../app.js'));
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
const version = read(path.join(root, 'version.yaml'))
?.toString()
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
export const runtimeBuild = Object.freeze({
instanceId: randomUUID(),
parentPid: process.ppid,
version,
sourceCommit: manifest().sourceCommit,
backendSha256: backend
? createHash('sha256').update(backend).digest('hex')
: undefined,
});
+5 -15
View File
@@ -1,4 +1,4 @@
import { QueryTypes, Sequelize } from 'sequelize'; import { DatabaseClient } from '../data/databaseClient';
// Append new entries; IDs are persisted and must not be renumbered or reused. // Append new entries; IDs are persisted and must not be renumbered or reused.
const columns = [ const columns = [
@@ -27,25 +27,19 @@ const columns = [
{ table: 'Crontabs', column: 'queued_token', type: 'VARCHAR(255)' }, { table: 'Crontabs', column: 'queued_token', type: 'VARCHAR(255)' },
]; ];
export async function migrateSchema(database: Sequelize): Promise<void> { export async function migrateSchema(database: DatabaseClient): Promise<void> {
await database.transaction(async (transaction) => { await database.transaction(async () => {
await database.query( await database.query(
'CREATE TABLE IF NOT EXISTS "SchemaMigrations" ("id" TEXT PRIMARY KEY, "applied_at" TEXT NOT NULL)', 'CREATE TABLE IF NOT EXISTS "SchemaMigrations" ("id" TEXT PRIMARY KEY, "applied_at" TEXT NOT NULL)',
{ transaction },
); );
const applied = await database.query<{ id: string }>( const applied = await database.query<{ id: string }>(
'SELECT "id" FROM "SchemaMigrations"', 'SELECT "id" FROM "SchemaMigrations"',
{ type: QueryTypes.SELECT, transaction },
); );
const appliedIds = new Set(applied.map(({ id }) => id)); const appliedIds = new Set(applied.map(({ id }) => id));
for (const { table, column, type } of columns) { for (const { table, column, type } of columns) {
const id = `add-${table}-${column}`; const id = `add-${table}-${column}`;
const fields = await database.query<{ name: string }>( const fields = await database.query<{ name: string }>(
`PRAGMA table_info("${table}")`, `PRAGMA table_info("${table}")`,
{
type: QueryTypes.SELECT,
transaction,
},
); );
if (fields.length === 0) if (fields.length === 0)
throw new Error(`Migration table is missing: ${table}`); throw new Error(`Migration table is missing: ${table}`);
@@ -53,16 +47,12 @@ export async function migrateSchema(database: Sequelize): Promise<void> {
// table/column/type come only from the static migration manifest above. // table/column/type come only from the static migration manifest above.
await database.query( await database.query(
`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`, `ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`,
{ transaction },
); );
} }
if (!appliedIds.has(id)) { if (!appliedIds.has(id)) {
await database.query( await database.query(
'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (:id, :appliedAt)', 'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (?, ?)',
{ [id, new Date().toISOString()],
replacements: { id, appliedAt: new Date().toISOString() },
transaction,
},
); );
} }
} }
+467
View File
@@ -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);
}
}
}
+5
View File
@@ -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, "'\\''") + "'";
}
+7 -5
View File
@@ -1,9 +1,7 @@
import { AuthInfo } from '../data/system'; import { AuthInfo } from '../data/system';
import { App } from '../data/open'; import { App } from '../data/open';
import Keyv from 'keyv'; import Keyv from 'keyv';
import KeyvSqlite from '@keyv/sqlite'; import { CacheStore } from '../data/cacheStore';
import config from '../config';
import path from 'path';
export enum EKeyv { export enum EKeyv {
'apps' = 'apps', 'apps' = 'apps',
@@ -17,8 +15,12 @@ export interface IKeyvStore {
lang: string; lang: string;
} }
const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite')); const keyvSqlite = new CacheStore();
export const keyvStore = new Keyv<IKeyvStore>({ store: keyvSqlite }); // A stopped database is unavailable, not a cache miss or an expired session.
export const keyvStore = new Keyv<IKeyvStore>({
store: keyvSqlite,
throwOnErrors: true,
});
export const shareStore = { export const shareStore = {
getAuthInfo() { getAuthInfo() {

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