Compare commits

..
164 Commits
Author SHA1 Message Date
whyour 33b2f2da77 更新 npm 版本 v2.20.0 2025-12-11 01:57:01 +08:00
whyour 4bdcb1a1e9 移除 init_nginx 2025-12-11 01:56:52 +08:00
whyour f99e92f28d 更新 pipeline 2025-12-11 01:53:51 +08:00
whyour ffa187cb36 增加用户 qinglong 2025-12-11 01:53:51 +08:00
whyour b1ca19bdd7 增加 packageManager 2025-12-11 01:53:51 +08:00
whyour 644ad1737b 更新 npm 版本 v2.19.2-2 2025-12-11 01:53:51 +08:00
whyour d3e6fdf018 更新 npm 版本 v2.19.1-0 2025-12-11 01:53:51 +08:00
whyour 5673bc635e 修复 apt 命令 2025-12-11 01:53:51 +08:00
whyour 88ae00580a 更新 npm 版本 v2.19.0-10 2025-12-11 01:53:50 +08:00
whyour 7d4d871d5a 修改 linux 启动文件逻辑 2025-12-11 01:53:50 +08:00
whyour 0d02e85cfc 修复发布 npm包依赖文件 2025-12-11 01:53:50 +08:00
whyour ba81732996 修复 npm 启动脚本 2025-12-11 01:53:50 +08:00
whyour 5679857025 移除自动清除 deb 2025-12-11 01:53:50 +08:00
whyour 3b0dc2e76e 修复 command 变量 2025-12-11 01:53:50 +08:00
whyour 360dbac033 更新 npm 版本 v2.18.3-3 2025-12-11 01:53:50 +08:00
whyour d351a1844e 更新 nodejs 版本 2025-12-11 01:53:50 +08:00
whyour db66691cf9 更新 npm 版本 v2.18.2-6 2025-12-11 01:53:50 +08:00
whyour 671be89446 修复系统安装依赖提示 2025-12-11 01:53:50 +08:00
whyour 4fa6adc590 写入文件增加文件锁 2025-12-11 01:53:50 +08:00
whyour 77bb9fd8a9 更新 npm 版本 v2.17.13 2025-12-11 01:53:50 +08:00
whyour 2080ced834 修改本地服务启动提示 2025-12-11 01:53:50 +08:00
whyour 7f99cb6e7d 更新 npm 版本 v2.17.12 2025-12-11 01:53:50 +08:00
whyour fd340806b1 修改 debian 版本为 12 bookworm 2025-12-11 01:53:50 +08:00
whyour 9ab3d9367e 更新 npm 版本 v2.17.11 2025-12-11 01:53:50 +08:00
whyour 54ed0fd0fa 更新 npm 版本 v2.17.10 2025-12-11 01:53:50 +08:00
whyour e13c5052b0 更新 npm 版本 v2.17.9 2025-12-11 01:53:50 +08:00
whyour 32495911a1 修复 qinglong 命令 2025-12-11 01:53:50 +08:00
whyour b93ce700f8 更新 npm 版本 v2.17.8 2025-12-11 01:53:50 +08:00
whyour 78591dc2bc npm 启动增加 reload 逻辑 2025-12-11 01:53:50 +08:00
whyour b4f5a416ac 修改 ts 文件执行依赖 2025-12-11 01:53:50 +08:00
whyour 28d03cb3a9 更新 npm 版本 v0.21.2 2025-12-11 01:53:50 +08:00
whyour 9853a368b4 修改 apt 命令 2025-12-11 01:53:50 +08:00
whyour b9380c53d6 安装 linux 依赖自动识别 alpine 和 debian 2025-12-11 01:53:50 +08:00
whyour f942b83eb6 更新 npm 版本 v0.20.4 2025-12-11 01:53:50 +08:00
whyour e81f633d1c 修复 debian netcat 包名 2025-12-11 01:53:50 +08:00
whyour f76d3ef15c 更新 npm 版本 v0.19.9 2025-12-11 01:53:50 +08:00
whyour eef6ff8dcf 修改 npm 安装启动命令 2025-12-11 01:53:49 +08:00
whyour 91c77342ee 更新 npm 版本 v0.18.0 2025-12-11 01:53:49 +08:00
whyour 5a8ec34453 更新 npm 版本 v0.17.0 2025-12-11 01:53:49 +08:00
whyour 6200a7d2b7 修复 linux 镜像源 2025-12-11 01:53:49 +08:00
whyour 41ad55f42b 更新 npm 版本 v0.16.0 2025-12-11 01:53:49 +08:00
whyour 635bc7ce65 更新 workflow action 版本 2025-12-11 01:53:49 +08:00
whyour 3747681a70 增加 npx 命令 2025-12-11 01:53:49 +08:00
whyour 830f312853 更新 npm 版本 v0.14.5 2025-12-11 01:53:49 +08:00
whyour 81dc3926df 修复 workflow 2025-12-11 01:53:49 +08:00
whyour db150d8c75 移除 qinglong 命令 npm 默认镜像源 2025-12-11 01:53:49 +08:00
whyour e466caee42 修复 qinglong 命令 2025-12-11 01:53:49 +08:00
whyour 4c6d820481 修改切换 linux 镜像源 2025-12-11 01:53:49 +08:00
whyour 9719584e7d 增加 debian 开发版本 2025-12-11 01:53:49 +08:00
whyour bcfa5ab075 更新 npm 版本 v0.13.2 2025-12-11 01:53:49 +08:00
whyour 5079cd39c6 修复 qinglong 命令 2025-12-11 01:53:49 +08:00
whyour f9951ba7d5 修复 shell check_server 2025-12-11 01:53:49 +08:00
whyour 91d9df3df8 修复拉取私有仓库 2025-12-11 01:53:49 +08:00
dream10201andwhyour cea7f5132e 修复linux依赖检测 (#2082) 2025-12-11 01:53:49 +08:00
whyour f7c6dc999d 更新 npm v0.8.4 2025-12-11 01:53:49 +08:00
whyour 153789e670 更新 npm 版本 0.7.7 2025-12-11 01:53:48 +08:00
whyour 85c03ce960 修复 debian apt 命令,支持 qinglong 命令 2025-12-11 01:53:48 +08:00
whyour 9753618480 增加 debian-slim 基础镜像 2025-12-11 01:53:48 +08:00
whyour 7a580e816f 修改获取示例文件 api path 2025-12-11 01:53:48 +08:00
whyour 33fa3aca99 更新版本 2.20.0 2025-12-11 01:53:17 +08:00
whyour c772fc9527 修复脚本调试保存文件错误 2025-12-11 01:52:47 +08:00
whyour c5d2aa3aba 更新 pipeline 2025-12-10 00:34:35 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
02a05f06bd Add signature verification support for Feishu bot notifications (#2856)
* Initial plan

* Add signature verification support for Feishu bot notifications

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

* Add clarifying comments about Feishu signature algorithm

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

* Add i18n translations for larkSecret configuration field

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>
2025-11-27 01:10:04 +08:00
whyour 3b0f55caf4 修复任务实例默认值 2025-11-23 12:45:02 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
6a3dd4f83c Fix null log_name issue by omitting it from shell command when not set (#2849)
* Initial plan

* Fix null log_name handling in runSingle method

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

* Update cron.log_name before makeCommand to avoid passing null to shell

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

* Fix: Only pass log_name to shell when it has a value

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

* Fix uniqPath calculation in runSingle for null log_name

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

* Improve comment clarity in makeCommand

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

* Refactor: Move no_tee and ID to initial commandVariable declaration

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

* Refactor: Simplify uniqPath ternary expression

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>
2025-11-22 12:06:01 +08:00
whyour 177cd3de81 更新 docker 日志 2025-11-22 01:05:28 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
d473c3ae88 Fix SSH global private key matching before subscription-specific keys (#2845)
* Initial plan

* Fix SSH global private key loading order by using zzz_ prefix

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

* Use tilde (~) prefix for global SSH config to ensure it loads last

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>
2025-11-21 01:53:58 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
ee2fbe5335 Add global SSH key configuration in system settings (#2840)
* Initial plan

* Add backend support for global SSH keys

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

* Add frontend UI for global SSH keys management

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

* Add SshKeyModel to database initialization

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

* Add SSH config generation for global SSH keys

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

* Add internationalization support for SSH key management UI

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

* Simplify to single global SSH key in system settings

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>
2025-11-20 10:09:01 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
48abf44ceb feat: Support multiple concurrent login sessions per platform (#2816)
* Initial plan

* Implement multi-device login support - allow multiple concurrent sessions

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

* Address code review feedback - extract constants and utility functions

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

* Add validation and logging improvements based on code review

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

* Revert unnecessary file changes - keep only multi-device login feature files

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>
2025-11-19 00:18:29 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
03c7031a3c Fix task duplication: add single/multi-instance support with UI configuration and stop all running instances (#2837)
* Initial plan

* Stop running tasks before starting new scheduled instance

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

* Add multi-instance support and fix stop to kill all running instances

- Add allow_multiple_instances field to Crontab model (default: 0 for single instance)
- Add validation for new field in commonCronSchema
- Add getAllPids and killAllTasks utility functions
- Update stop method to kill ALL running instances of a task
- Update runCron to respect allow_multiple_instances config
- Backward compatible: defaults to single instance mode

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

* Add UI support for allow_multiple_instances configuration

- Add allow_multiple_instances field to ICrontab interface
- Add instance mode selector in task creation/edit modal
- Add translations for instance mode in Chinese and English
- Default to single instance mode for backward compatibility

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

* Add allow_multiple_instances column migration and optimize db.ts

- Add allow_multiple_instances column to Crontabs table migration
- Refactor migration code to use data-driven approach
- Replace 11 individual try-catch blocks with single loop
- Improve code maintainability and readability

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>
2025-11-19 00:10:27 +08:00
whyour 0e5de4a824 更新启动日志 2025-11-16 21:31:52 +08:00
whyour af96bd98ac 修复系统提示 2025-11-16 21:26:14 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
08ef509e27 Optimize log file writes using stream pooling (#2835)
* Initial plan

* Implement LogStreamManager for optimized log writing

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

* Fix error handler in LogStreamManager to avoid race conditions

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>
2025-11-16 21:11:10 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
fbeb4f4a6c Fix PM2 startup failures on ARM routers with Node.js incompatibility (#2828)
* Initial plan

* Add fallback mechanism for PM2 startup failures on incompatible hardware

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>
2025-11-16 20:29:32 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
eb09a417a1 Fix DNS resolution failures in Alpine containers by setting ndots:0 (#2827)
* Initial plan

* Fix DNS resolution issue in Alpine containers by adding ndots:0 option

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>
2025-11-16 20:28:12 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
e84ddb6cfc Fix environment variable copy reliability by validating clipboard operation (#2833)
* Initial plan

* Fix env variable copy issue by using onCopy callback

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>
2025-11-16 18:23:45 +08:00
whyour d01ec3b310 修复 parse cron 2025-11-15 19:09:07 +08:00
whyour 45fac29804 移除用户判断 2025-11-15 17:02:00 +08:00
whyour 72d4c8bfd4 修复 homedir 权限 2025-11-15 02:27:27 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
8f23c61fee Add non-root Docker user support with automatic command setup (#2830)
* Initial plan

* Add non-root user guide and improve error messages

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

* Add PM2_HOME configuration to fix non-root user permission errors

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

* Fix PM2 socket error by using /tmp for PM2_HOME instead of mounted volume

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

* Document symlink permission limitation for non-root users

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

* Clarify that scheduled tasks auto-adapt to use full paths when symlinks unavailable

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

* Add error handling for symlink creation to prevent worker crashes

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

* Add practical solutions for non-root users to use ql and task commands

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

* Modify linkCommand to create symlinks in ~/bin for non-root users

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

* 修复链接 task 命令

---------

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>
2025-11-15 01:46:24 +08:00
whyour e41eed81ae 移除说明文档 2025-11-14 23:23:01 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
58f5fe763f Add cron task management to QLAPI (#2826)
* Initial plan

* Add cron management capabilities to QLAPI

- Added GetCrons, GetCronById, EnableCrons, DisableCrons, RunCrons to proto
- Implemented gRPC handlers for new cron operations
- Updated client.js to expose new methods to scripts
- Updated sample to demonstrate new API usage

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

* Add documentation for QLAPI cron management features

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

* Specify pnpm version 8.3.1 in package.json

Add packageManager field to enforce pnpm@8.3.1 as requested

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

* Revert pnpm-lock.yaml to original state

The lockfile was inadvertently updated during dependency installation
for proto generation. Reverting to maintain consistency with pnpm@8.3.1.

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

* Regenerate proto files with correct tool versions

Regenerated using protoc v3.17.3 and protoc-gen-ts_proto v2.6.1
to match the original project configuration.

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>
2025-11-14 23:20:56 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
214241797d Fix QlPort and QlGrpcPort environment variables ignored in host network mode and pm2 reload (#2825)
* Initial plan

* Fix host mode port configuration by using QlPort environment variable

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

* Fix GRPC_PORT conflict in host network mode

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

* Ensure BACK_PORT and GRPC_PORT survive pm2 reload with --update-env

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

* Move env.sh sourcing after fix_config to preserve more environment variables

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

* Refactor: Extract export_ql_envs function and move env.sh sourcing earlier

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

* Extract load_ql_envs function and reorder initialization in docker-entrypoint.sh and update.sh

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>
2025-11-14 22:56:39 +08:00
whyour aedd48c9c4 修改错误日志 2025-11-14 22:23:30 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
90ddf0fb57 Fix race condition preventing scheduled tasks from executing in clustered mode (#2819)
* Initial plan

* Fix race condition causing scheduled tasks not to run

Added synchronization to ensure gRPC worker is ready before HTTP worker starts. This prevents the race condition where autosave_crontab() tries to register cron jobs before the gRPC server is ready to accept them.

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

* Add timeout protection for gRPC worker initialization

Added 30-second timeout to prevent system hang if gRPC worker fails to start. This provides better error handling and prevents indefinite waiting.

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

* Refactor worker ready logic and improve restart handling

- Extracted waitForWorkerReady() method for better code reusability
- Improved worker restart logic to wait for gRPC worker readiness
- This addresses code review feedback for better maintainability

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

* Improve logging accuracy for worker restarts

- Fixed log messages to accurately reflect worker state
- Added proper logging after gRPC worker is confirmed ready
- Improved HTTP worker restart logging with PID
- Addresses code review feedback for better clarity

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

* Revert unnecessary pnpm-lock.yaml changes

The pnpm-lock.yaml was unintentionally updated when installing dependencies for testing. No package dependencies were actually changed - only existing code was modified in back/app.ts. Reverting to original state.

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

* Re-register cron jobs when gRPC worker restarts

When the gRPC worker restarts, the scheduled tasks need to be re-added to the new gRPC server instance. This fix:

1. Tracks the HTTP worker reference in the master process
2. Sends a 'reregister-crons' message to the HTTP worker after gRPC restarts
3. HTTP worker calls autosave_crontab() to re-register all cron jobs with the new gRPC server

This ensures scheduled tasks continue to work after a gRPC worker restart.

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>
2025-11-12 00:59:22 +08:00
whyour c9fc9b4b45 定时任务支持订阅筛选 2025-11-11 01:24:28 +08:00
whyour 8fdc69421c 修改定时任务支持的排序顺序 2025-11-11 01:02:36 +08:00
whyour 1deb264913 升级 cron-parser 2025-11-11 00:37:03 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1f2fd8ac02 Fix URIError from malformed cookies causing white screen on load (#2811)
* Initial plan

* Fix decodeURIComponent error in cookie parsing by adding try-catch

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

* Add type annotation and logging to catch block per code review

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

* Fix TypeScript errors in deps.ts - remove unused path parameter

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

* Revert pnpm-lock.yaml to avoid unnecessary lockfile version upgrade

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>
2025-11-09 21:53:06 +08:00
whyour 06aa07329f 修复日志目录逻辑 2025-11-09 21:42:45 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
4cb9f57479 环境变量支持置顶 (#2822)
* Initial plan

* Add pin to top feature for environment variables

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

* Format code with prettier

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

* Add database migration for isPinned column in Envs table

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

* Use snake_case naming (is_pinned) for database column

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>
2025-11-09 19:43:33 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
c369514741 定时任务支持自定义日志文件或者 /dev/null (#2823)
* Initial plan

* Add log_name field to enable custom log folder naming

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

* Add database migration for log_name column

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

* Add security validation to prevent path traversal attacks

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

* Apply prettier formatting to modified files

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

* Support absolute paths like /dev/null for log redirection

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

* Restrict absolute paths to log directory except /dev/null

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>
2025-11-09 19:32:40 +08:00
whyour 0e28e1b6c4 修复 pm2 日志目录 2025-11-09 17:52:38 +08:00
whyour 73f8f3c5fa 增加 agent
Create a new agent configuration file for QL agent.
2025-11-08 00:05:56 +08:00
whyour 494ccb3c08 更新 workflow 2025-11-03 00:09:36 +08:00
whyour 399728b433 修复 jwt 认证 2025-11-02 22:28:58 +08:00
whyour 52a1de5063 更新 demo 地址 2025-11-02 21:54:19 +08:00
whyour 18f27a9a69 移除 nginx 2025-11-02 19:29:59 +08:00
whyour 07951964a1 修复模块注入 2025-10-26 22:32:03 +08:00
涛之雨andGitHub a1f888af59 Add validation to dependencies GET endpoint and update service logic (#2778)
* Add validation to dependencies GET endpoint and update service logic

* fix https://github.com/whyour/qinglong/pull/2778/files/6063bc3a67fb329de9b90f7c93524b862bd9eb93#r2266494581

* remove default condition type

* fix query mistakes
2025-10-11 23:23:13 +08:00
涛之雨andGitHub f7472b6e74 Add input validation to script API routes (#2777)
* Add input validation to script API routes

* 优化脚本 API 路由的错误处理逻辑

* Fix optional path compatibility checks

* remove file
2025-10-11 23:20:26 +08:00
whyour a7baeba755 修复 task 命令可能软链失败 2025-10-11 23:15:54 +08:00
涛之雨andGitHub e4f733320d Enable debug backend (#2776) 2025-08-19 11:13:33 +08:00
whyour 55c92dc320 发布版本 v2.19.2 2025-07-12 20:29:49 +08:00
whyour 50769c43dd 修复 command-run 接口日志绑定 2025-07-12 20:29:44 +08:00
whyour 0587644a6b command-run 增加返回 QL-Task-Log 日志路径 2025-06-28 01:05:05 +08:00
whyour 87b934aafe QLAPI.systemNotify 支持自定义通知类型和参数 2025-06-24 02:00:51 +08:00
whyour 7a92e7c6ab 修复取消安装依赖 2025-06-22 21:47:39 +08:00
whyour 1d8403c0ec 修复环境变量过大解析 2025-06-22 21:10:55 +08:00
whyour ef9e38f167 备份数据支持选择模块,支持清除依赖缓存 2025-06-22 14:25:19 +08:00
whyour c9bd053fbd 修改服务启动方式 2025-06-11 00:42:29 +08:00
憶夣andGitHub 57939391b9 ntfy 增加可选的认证与用户动作 (#2741)
* feat:ntfy增加可选的认证

* feat:ntfy增加可选的用户动作

* fix:ntfy动作包含中文报错
2025-06-07 00:26:27 +08:00
whyour 394e96bbf8 修复 health 接口报错 2025-06-07 00:25:47 +08:00
whyour 47c194c1f4 更新版本 v2.19.1 2025-05-24 15:03:08 +08:00
whyour 7d65d96ebd 修复 demo 环境提示 2025-05-24 14:56:49 +08:00
whyour 224000b63b 修复依赖是否安装检查逻辑 2025-05-23 23:45:43 +08:00
whyour 1c18668bad 修复文件下载参数 2025-05-22 00:09:19 +08:00
whyour f94582b68d 修复查询 python 依赖存在逻辑 2025-05-21 01:25:24 +08:00
whyour eb1c00984c 修复任务视图状态包含筛选 2025-05-20 23:40:18 +08:00
whyour 1a185f5682 修复创建脚本可能失败 2025-05-20 01:00:08 +08:00
whyour b6ea8565ec 更新版本 v2.19.0 2025-05-18 22:02:57 +08:00
whyour 2e94d58758 修改启动命令环境变量 2025-05-18 22:02:55 +08:00
whyour 472a3088df 修复启动逻辑 2025-05-17 17:25:50 +08:00
whyour ec3d61a713 修复 /:file 获取日志接口 2025-05-16 00:57:27 +08:00
whyour 95459c33ed 修改 import undici 2025-05-15 01:19:24 +08:00
whyour 5459b72f63 更新 pnpm-lock 2025-05-15 01:15:55 +08:00
whyour 05db2b1df8 修复重置用户名失败 2025-05-15 01:12:01 +08:00
whyour c3072e7712 got 替换为 uudici 2025-05-15 01:01:39 +08:00
whyour 3fafe4d24d 修复依赖强制删除未移除队列 2025-05-13 02:00:37 +08:00
whyour 32bccb3f3e 修复无法识别 python 依赖安装的命令 2025-05-13 00:22:15 +08:00
whyour ac04478d1d 修复重用户名 2025-05-11 18:59:15 +08:00
whyour 8a18baa921 修复登录通知失败造成服务重启 2025-05-11 14:36:16 +08:00
whyour 9a399f8de8 更新 workflow 2025-05-09 23:36:14 +08:00
whyour da639a1f8f 移除部分历史兼容逻辑 2025-05-09 15:37:05 +08:00
whyour 425e49675a 修复删除日志 2025-05-09 00:20:53 +08:00
whyour 8174762c18 demo 环境不自动运行任务 2025-05-08 01:43:14 +08:00
whyour 710a107e73 邮箱通知支持多个收件人 2025-05-08 01:27:25 +08:00
whyour d871585eee 修改服务启动逻辑 2025-05-07 09:30:00 +08:00
whyour 729b405b0f 修复 bootAfter 目录 2025-04-26 23:26:11 +08:00
whyour 71a7c1b9d3 boot 任务改为在依赖安装完成后执行 2025-04-25 23:52:33 +08:00
whyour 40a831f3a2 修复脚本管理查询逻辑 2025-04-25 01:40:37 +08:00
whyour 1befa1bb8c 缓存 node 和 python 依赖 2025-04-23 02:10:39 +08:00
whyour 8009634b44 修复脚本管理增加文件夹 2025-04-19 16:34:28 +08:00
whyour 03a23f6f31 更新 workflow 运行环境 2025-04-19 14:36:21 +08:00
whyour 124e01e93a 修复 QLAPI 修复环境变量 remarks,esm 依赖查不到 2025-04-19 01:38:43 +08:00
whyour 2370923a75 更新版本 v2.18.3 2025-03-19 23:47:53 +08:00
whyour 256e4e7a83 修复任务打开脚本链接 2025-03-19 23:47:51 +08:00
whyour c0ec063333 修复删除脚本参数 2025-03-18 23:17:03 +08:00
whyour 576408de01 脚本管理增加强制打开文件操作 2025-03-16 17:57:13 +08:00
whyour cf94ecfb11 脚本管理和日志管理支持下载 2025-03-13 00:22:24 +08:00
whyour 118c92d9e5 更新版本 v2.18.2 2025-02-28 00:56:43 +08:00
whyour 280085668e 修复未初始化时区设置 2025-02-28 00:56:40 +08:00
whyour bae4073a64 增加重置密码命令 2025-02-27 23:57:26 +08:00
whyour af3e358a6a 系统设置增加时区设置 2025-02-27 00:45:21 +08:00
whyour 64fcbff715 脚本管理增加可预览检查 2025-02-26 01:17:11 +08:00
whyour f9f78b4e05 修改系统内置通知模块名称,避免重复 2025-02-25 00:32:13 +08:00
whyour fa83761d27 修改定时规则类型 2025-02-21 01:35:08 +08:00
whyour 8173075b67 定时任务支持 @once 和 @boot 任务 2025-02-20 02:18:59 +08:00
whyour 496918131f 修复群晖通知参数,任务视图不属于筛选 2025-02-16 12:15:45 +08:00
whyour 5d64aae452 修复 mirror action 2025-02-07 01:56:23 +08:00
whyour 952ea2859e 修复最后运行时长多语言显示 2025-01-31 23:02:25 +08:00
whyour 25e8e8198d 修改 QLAPI 调用校验 2025-01-30 00:55:48 +08:00
whyour 3ab5b0d86b 修复登录失败没有提示 2025-01-29 23:50:46 +08:00
whyour 6df651aa63 更新 ts-proto 版本 2025-01-29 23:29:45 +08:00
155 changed files with 10485 additions and 3366 deletions
+8 -11
View File
@@ -1,14 +1,11 @@
UPDATE_PORT=5300 GRPC_PORT=5500
PUBLIC_PORT=5400 BACK_PORT=5700
CRON_PORT=5500
BACK_PORT=5600
PORT=5700
LOG_LEVEL='debug' LOG_LEVEL='info'
SECRET='whyour' JWT_SECRET=
JWT_EXPIRES_IN=
QINIU_AK='' QINIU_AK=
QINIU_SK='' QINIU_SK=
QINIU_SCOPE='' QINIU_SCOPE=
TEMP=''
+4
View File
@@ -0,0 +1,4 @@
---
name: Bug Fixer
description: Fix this issue following our error handling pattern.
---
@@ -7,6 +7,8 @@ on:
branches: branches:
- "master" - "master"
- "develop" - "develop"
- "debian"
- "debian-dev"
tags: tags:
- "v*" - "v*"
schedule: schedule:
@@ -14,27 +16,35 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
to_gitlab: code_gitlab:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1 - uses: Yikun/hub-mirror-action@master
with: with:
target_repo_url: git@gitlab.com:whyour/qinglong.git src: github/whyour
ssh_private_key: ${{ secrets.GITLAB_SSH_PK }} dst: gitlab/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITLAB_TOKEN }}
static_list: "qinglong"
force_update: true
to_gitee: code_gitee:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1 - uses: Yikun/hub-mirror-action@master
with: with:
target_repo_url: git@gitee.com:whyour/qinglong.git src: github/whyour
ssh_private_key: ${{ secrets.GITLAB_SSH_PK }} dst: gitee/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "qinglong"
force_update: true
build-static: build-static:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -71,23 +81,42 @@ jobs:
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH} git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
mkdir -p ~/.ssh static_gitlab:
echo "${PRIVATE_KEY}" > ~/.ssh/id_rsa needs: build-static
chmod 600 ~/.ssh/id_rsa runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
with:
src: github/whyour
dst: gitlab/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITLAB_TOKEN }}
static_list: "qinglong-static"
force_update: true
export GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -l git" static_gitee:
git remote add gitee "${REPO_GITEE}" needs: build-static
git remote add gitlab "${REPO_GITLAB}" runs-on: ubuntu-latest
git gc steps:
git push --force --quiet gitee ${GITHUB_BRANCH}:${GITHUB_BRANCH} - uses: actions/checkout@v4
git push --force --quiet gitlab ${GITHUB_BRANCH}:${GITHUB_BRANCH} with:
fetch-depth: 0
- uses: Yikun/hub-mirror-action@master
with:
src: github/whyour
dst: gitee/whyour
dst_key: ${{ secrets.GITLAB_SSH_PK }}
dst_token: ${{ secrets.GITEE_TOKEN }}
static_list: "qinglong-static"
force_update: true
build: build:
needs: build-static needs: build-static
# 由于 ubuntu-latest 中使用 linux6.x 内核 linux/s390x npm 无法使用 runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
# runs-on: self-hosted
permissions: permissions:
packages: write packages: write
@@ -102,6 +131,13 @@ jobs:
with: with:
cache: "pnpm" cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone - name: Setup timezone
uses: szenius/set-timezone@v2.0 uses: szenius/set-timezone@v2.0
with: with:
@@ -127,19 +163,13 @@ jobs:
images: | images: |
${{ github.repository }} ${{ github.repository }}
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: | flavor: |
latest=false latest=false
tags: | tags: |
type=schedule,pattern=nightly type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'debian-dev') }}
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'debian') }}
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -156,25 +186,24 @@ jobs:
QL_BRANCH=${{ github.ref_name }} QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }} SOURCE_COMMIT=${{ github.sha }}
network: host network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
# platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: . context: .
file: ./docker/Dockerfile file: ./docker/Dockerfile
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
- name: Image digest - name: Image digest
run: | run: |
echo ${{ steps.docker_build.outputs.digest }} echo ${{ steps.docker_build.outputs.digest }}
build310: build310:
if: ${{ github.ref_name == 'master' }} if: ${{ github.ref_name == 'debian' }}
needs: build-static needs: build-static
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
@@ -189,6 +218,13 @@ jobs:
with: with:
cache: "pnpm" cache: "pnpm"
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup timezone - name: Setup timezone
uses: szenius/set-timezone@v2.0 uses: szenius/set-timezone@v2.0
with: with:
@@ -222,14 +258,42 @@ jobs:
QL_BRANCH=${{ github.ref_name }} QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }} SOURCE_COMMIT=${{ github.sha }}
network: host network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: . context: .
file: ./docker/310.Dockerfile file: ./docker/310.Dockerfile
push: true push: true
tags: whyour/qinglong:python3.10 tags: |
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10 whyour/qinglong:debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
- name: Image digest - name: Image digest
run: | run: |
echo ${{ steps.docker_build_310.outputs.digest }} echo ${{ steps.docker_build_310.outputs.digest }}
publish:
if: ${{ github.ref_name == 'debian' }}
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: publich npm package
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
npm publish
+1
View File
@@ -27,3 +27,4 @@ __pycache__
/shell/preload/env.* /shell/preload/env.*
/shell/preload/notify.* /shell/preload/notify.*
/shell/preload/*-notify.json /shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
+22
View File
@@ -0,0 +1,22 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
+1 -11
View File
@@ -16,18 +16,8 @@ export default defineConfig({
favicons: [`https://qn.whyour.cn/favicon.svg`], favicons: [`https://qn.whyour.cn/favicon.svg`],
publicPath: process.env.NODE_ENV === 'production' ? './' : '/', publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
proxy: { proxy: {
[`${baseUrl}api/update`]: {
target: 'http://127.0.0.1:5300/',
changeOrigin: true,
pathRewrite: { [`^${baseUrl}api/update`]: '/api' },
},
[`${baseUrl}api/public`]: {
target: 'http://127.0.0.1:5400/',
changeOrigin: true,
pathRewrite: { [`^${baseUrl}api/public`]: '/api' },
},
[`${baseUrl}api`]: { [`${baseUrl}api`]: {
target: 'http://127.0.0.1:5600/', target: 'http://127.0.0.1:5700/',
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
pathRewrite: { [`^${baseUrl}api`]: '/api' }, pathRewrite: { [`^${baseUrl}api`]: '/api' },
+4 -2
View File
@@ -18,9 +18,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat [docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong [docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong) [Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg) [演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div> </div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png) ![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
@@ -41,6 +41,8 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image. The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
+4 -2
View File
@@ -20,9 +20,9 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat [docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong [docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong) [Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg) [演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div> </div>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png) ![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
@@ -43,6 +43,8 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像 `latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
Vendored
-9
View File
@@ -1,9 +0,0 @@
import 'express';
declare global {
namespace Express {
interface Request {
platform: string;
}
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ export default (app: Router) => {
app.use('/configs', route); app.use('/configs', route);
route.get( route.get(
'/sample', '/samples',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
res.send({ res.send({
+5 -31
View File
@@ -4,7 +4,8 @@ import { Logger } from 'winston';
import CronService from '../services/cron'; 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 cron_parser from 'cron-parser'; import { commonCronSchema } from '../validation/schedule';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -170,27 +171,14 @@ export default (app: Router) => {
route.post( route.post(
'/', '/',
celebrate({ celebrate({
body: Joi.object({ body: Joi.object(commonCronSchema),
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional(),
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
}),
}), }),
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 (cron_parser.parseExpression(req.body.schedule).hasNext()) {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.create(req.body); const data = await cronService.create(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -331,30 +319,16 @@ export default (app: Router) => {
'/', '/',
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
labels: Joi.array().optional().allow(null), ...commonCronSchema,
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional().allow(null),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
id: Joi.number().required(), id: Joi.number().required(),
}), }),
}), }),
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 (
!req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.update(req.body); const data = await cronService.update(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -418,7 +392,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.import_crontab(); const data = await cronService.importCrontab();
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
+13 -8
View File
@@ -8,7 +8,17 @@ const route = Router();
export default (app: Router) => { export default (app: Router) => {
app.use('/dependencies', route); app.use('/dependencies', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => { route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
@@ -18,7 +28,8 @@ export default (app: Router) => {
logger.error('🔥 error: %o', e); logger.error('🔥 error: %o', e);
return next(e); return next(e);
} }
}); },
);
route.post( route.post(
'/', '/',
@@ -32,7 +43,6 @@ export default (app: Router) => {
), ),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.create(req.body); const data = await dependenceService.create(req.body);
@@ -54,7 +64,6 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.update(req.body); const data = await dependenceService.update(req.body);
@@ -71,7 +80,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()), body: Joi.array().items(Joi.number().required()),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body); const data = await dependenceService.remove(req.body);
@@ -88,7 +96,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()), body: Joi.array().items(Joi.number().required()),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body, true); const data = await dependenceService.remove(req.body, true);
@@ -107,7 +114,6 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.getDb({ id: req.params.id }); const data = await dependenceService.getDb({ id: req.params.id });
@@ -124,7 +130,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()), body: Joi.array().items(Joi.number().required()),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.reInstall(req.body); const data = await dependenceService.reInstall(req.body);
+41 -7
View File
@@ -1,12 +1,12 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Joi, celebrate } from 'celebrate';
import { Container } from 'typedi'; import { NextFunction, Request, Response, Router } from 'express';
import EnvService from '../services/env';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import multer from 'multer';
import config from '../config';
import fs from 'fs'; import fs from 'fs';
import multer from 'multer';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util'; import { safeJSONParse } from '../config/util';
import EnvService from '../services/env';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -196,6 +196,40 @@ export default (app: Router) => {
}, },
); );
route.put(
'/pin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.pin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/unpin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.unPin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post( route.post(
'/upload', '/upload',
upload.single('env'), upload.single('env'),
+27
View File
@@ -0,0 +1,27 @@
import { Router } from 'express';
import Logger from '../loaders/logger';
import { HealthService } from '../services/health';
import Container from 'typedi';
const route = Router();
export default (app: Router) => {
app.use('/', route);
route.get('/health', async (req, res) => {
try {
const healthService = Container.get(HealthService);
const health = await healthService.check();
res.status(200).send({
code: 200,
data: health,
});
} catch (err: any) {
Logger.error('Health check failed:', err);
res.status(500).send({
code: 500,
message: 'Health check failed',
error: err.message,
});
}
});
};
+4
View File
@@ -9,6 +9,8 @@ import open from './open';
import dependence from './dependence'; import dependence from './dependence';
import system from './system'; import system from './system';
import subscription from './subscription'; import subscription from './subscription';
import update from './update';
import health from './health';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -22,6 +24,8 @@ export default () => {
dependence(app); dependence(app);
system(app); system(app);
subscription(app); subscription(app);
update(app);
health(app);
return app; return app;
}; };
+66 -23
View File
@@ -1,10 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express'; import { celebrate, Joi } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import { getFileContentByName, readDirs, removeAnsi, rmPath } from '../config/util'; import {
import { join, resolve } from 'path'; getFileContentByName,
import { celebrate, Joi } from 'celebrate'; readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
const route = Router(); const route = Router();
const blacklist = ['.tmp']; const blacklist = ['.tmp'];
@@ -29,17 +34,16 @@ export default (app: Router) => {
'/detail', '/detail',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const finalPath = resolve( const logService = Container.get(LogService);
config.logPath, const finalPath = logService.checkFilePath(
(req.query.path as string) || '', (req.query.path as string) || '',
(req.query.file as string) || '', (req.query.file as string) || '',
); );
if (!finalPath || blacklist.includes(req.query.path as string)) {
if ( return res.send({
blacklist.includes(req.query.path as string) || code: 403,
!finalPath.startsWith(config.logPath) message: '暂无权限',
) { });
return res.send({ code: 403, message: '暂无权限' });
} }
const content = await getFileContentByName(finalPath); const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: removeAnsi(content) }); res.send({ code: 200, data: removeAnsi(content) });
@@ -53,16 +57,16 @@ export default (app: Router) => {
'/:file', '/:file',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const finalPath = resolve( const logService = Container.get(LogService);
config.logPath, const finalPath = logService.checkFilePath(
(req.query.path as string) || '', (req.query.path as string) || '',
(req.params.file as string) || '', (req.params.file as string) || '',
); );
if ( if (!finalPath || blacklist.includes(req.query.path as string)) {
blacklist.includes(req.path) || return res.send({
!finalPath.startsWith(config.logPath) code: 403,
) { message: '暂无权限',
return res.send({ code: 403, message: '暂无权限' }); });
} }
const content = await getFileContentByName(finalPath); const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
@@ -83,17 +87,56 @@ export default (app: Router) => {
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
let { filename, path, type } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string; path: string;
type: string;
}; };
const filePath = join(config.logPath, path, filename); const logService = Container.get(LogService);
await rmPath(filePath); const finalPath = logService.checkFilePath(path, filename);
if (!finalPath || blacklist.includes(path)) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await rmPath(finalPath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
}, },
); );
route.post(
'/download',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const logService = Container.get(LogService);
const filePath = logService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
}; };
+89 -30
View File
@@ -1,4 +1,4 @@
import { fileExist, readDirs, readDir, rmPath } from '../config/util'; import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
@@ -24,10 +24,17 @@ const upload = multer({ storage: storage });
export default (app: Router) => { export default (app: Router) => {
app.use('/scripts', route); app.use('/scripts', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => { route.get(
'/',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
let result = []; let result: IFile[] = [];
const blacklist = [ const blacklist = [
'node_modules', 'node_modules',
'.git', '.git',
@@ -37,11 +44,11 @@ export default (app: Router) => {
'package-lock.json', 'package-lock.json',
]; ];
if (req.query.path) { if (req.query.path) {
const targetPath = path.join( result = await readDir(
config.scriptPath,
req.query.path as string, req.query.path as string,
config.scriptPath,
blacklist,
); );
result = await readDir(targetPath, config.scriptPath, blacklist);
} else { } else {
result = await readDirs( result = await readDirs(
config.scriptPath, config.scriptPath,
@@ -68,11 +75,17 @@ export default (app: Router) => {
route.get( route.get(
'/detail', '/detail',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
file: Joi.string().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile( const content = await scriptService.getFile(
req.query.path as string, req.query?.path as string || '',
req.query.file as string, req.query.file as string,
); );
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
@@ -84,11 +97,19 @@ export default (app: Router) => {
route.get( route.get(
'/:file', '/:file',
celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile( const content = await scriptService.getFile(
req.query.path as string, req.query?.path as string || '',
req.params.file, req.params.file,
); );
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
@@ -101,8 +122,17 @@ export default (app: Router) => {
route.post( route.post(
'/', '/',
upload.single('file'), upload.single('file'),
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, path, content, originFilename, directory } = let { filename, path, content, originFilename, directory } =
req.body as { req.body as {
@@ -124,8 +154,8 @@ export default (app: Router) => {
} }
if (config.writePathList.every((x) => !path.startsWith(x))) { if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({ return res.send({
code: 430, code: 403,
message: '文件路径禁止访问', message: '暂无权限',
}); });
} }
@@ -146,6 +176,7 @@ export default (app: Router) => {
path, path,
`${originFilename.replace(/\//g, '')}`, `${originFilename.replace(/\//g, '')}`,
); );
await fs.mkdir(path, { recursive: true });
const filePath = join(path, `${filename.replace(/\//g, '')}`); const filePath = join(path, `${filename.replace(/\//g, '')}`);
const fileExists = await fileExist(filePath); const fileExists = await fileExist(filePath);
if (fileExists) { if (fileExists) {
@@ -175,14 +206,20 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, content, path } = req.body as { let { filename, content, path } = req.body as {
filename: string; filename: string;
content: string; content: string;
path: string; path: string;
}; };
const filePath = join(config.scriptPath, path, filename); const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await writeFileWithLock(filePath, content); await writeFileWithLock(filePath, content);
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e) {
@@ -196,19 +233,27 @@ export default (app: Router) => {
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().allow(''), path: Joi.string().optional().allow(''),
type: Joi.string().optional(), type: Joi.string().optional(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, path, type } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string; path: string;
type: string;
}; };
const filePath = join(config.scriptPath, path, filename); if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await rmPath(filePath); await rmPath(filePath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
@@ -222,24 +267,30 @@ export default (app: Router) => {
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string;
}; };
const filePath = join(config.scriptPath, filename); if (!path) {
// const stats = fs.statSync(filePath); path = '';
// res.set({ }
// 'Content-Type': 'application/octet-stream', //告诉浏览器这是一个二进制文件 const scriptService = Container.get(ScriptService);
// 'Content-Disposition': 'attachment; filename=' + filename, //告诉浏览器这是一个需要下载的文件 const filePath = scriptService.checkFilePath(path, filename);
// 'Content-Length': stats.size //文件大小 if (!filePath) {
// }); return res.send({
// fs.createReadStream(filePath).pipe(res); code: 403,
message: '暂无权限',
});
}
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {
if (err) {
return next(err); return next(err);
}
}); });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -260,6 +311,9 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
let { filename, content, path } = req.body; let { filename, content, path } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename); const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
await writeFileWithLock(filePath, content || ''); await writeFileWithLock(filePath, content || '');
@@ -285,6 +339,9 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
let { filename, path, pid } = req.body; let { filename, path, pid } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename); const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
const logPath = join(config.logPath, path, `${name}.swap`); const logPath = join(config.logPath, path, `${name}.swap`);
@@ -312,12 +369,14 @@ export default (app: Router) => {
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
let { filename, path, type, newFilename } = req.body as { let { filename, path, newFilename } = req.body as {
filename: string; filename: string;
path: string; path: string;
type: string;
newFilename: string; newFilename: string;
}; };
if (!path) {
path = '';
}
const filePath = join(config.scriptPath, path, filename); const filePath = join(config.scriptPath, path, filename);
const newPath = join(config.scriptPath, path, newFilename); const newPath = join(config.scriptPath, path, newFilename);
await fs.rename(filePath, newPath); await fs.rename(filePath, newPath);
+3 -3
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 cron_parser from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -60,7 +60,7 @@ export default (app: Router) => {
try { try {
if ( if (
!req.body.schedule || !req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext() 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);
@@ -193,7 +193,7 @@ export default (app: Router) => {
if ( if (
!req.body.schedule || !req.body.schedule ||
typeof req.body.schedule === 'object' || typeof req.body.schedule === 'object' ||
cron_parser.parseExpression(req.body.schedule).hasNext() 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);
+70 -5
View File
@@ -14,6 +14,7 @@ import {
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import multer from 'multer'; import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -273,19 +274,22 @@ export default (app: Router) => {
{ {
onStart: async (cp, startTime) => { onStart: async (cp, startTime) => {
res.setHeader('QL-Task-Pid', `${cp.pid}`); res.setHeader('QL-Task-Pid', `${cp.pid}`);
res.setHeader('QL-Task-Log', `${logPath}`);
}, },
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end(); res.end();
}, },
onError: async (message: string) => { onError: async (message: string) => {
res.write(`\n${message}`); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, `\n${message}`); await logStreamManager.write(absolutePath, message);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
res.write(`\n${message}`); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, `\n${message}`); await logStreamManager.write(absolutePath, message);
}, },
}, },
); );
@@ -316,10 +320,15 @@ export default (app: Router) => {
route.put( route.put(
'/data/export', '/data/export',
celebrate({
body: Joi.object({
type: Joi.array().items(Joi.string()).optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
await systemService.exportData(res); await systemService.exportData(res, req.body.type);
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -384,6 +393,8 @@ export default (app: Router) => {
body: Joi.object({ body: Joi.object({
retries: Joi.number().optional(), retries: Joi.number().optional(),
twoFactorActivated: Joi.boolean().optional(), twoFactorActivated: Joi.boolean().optional(),
password: Joi.string().optional(),
username: Joi.string().optional(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -396,4 +407,58 @@ export default (app: Router) => {
} }
}, },
); );
route.put(
'/config/timezone',
celebrate({
body: Joi.object({
timezone: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateTimezone(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
body: Joi.object({
type: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.cleanDependence(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
}; };
+51
View File
@@ -0,0 +1,51 @@
import { NextFunction, Request, Response, Router } from 'express';
import Container from 'typedi';
import Logger from '../loaders/logger';
import SystemService from '../services/system';
const route = Router();
export default (app: Router) => {
app.use('/update', route);
route.put(
'/reload',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem();
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/system',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem('system');
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.put(
'/data',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem('data');
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
};
+4 -3
View File
@@ -8,6 +8,7 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import config from '../config'; import config from '../config';
import { isDemoEnv, getToken } from '../config/util';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -55,7 +56,8 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
await userService.logout(req.platform); const token = getToken(req);
await userService.logout(req.platform, token);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -72,9 +74,8 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
if (process.env.DeployEnv === 'demo') { if (isDemoEnv()) {
return res.send({ code: 450, message: '未知错误' }); return res.send({ code: 450, message: '未知错误' });
} }
const userService = Container.get(UserService); const userService = Container.get(UserService);
+293 -17
View File
@@ -1,30 +1,306 @@
import 'reflect-metadata'; // We need this in order to use @Decorators import 'reflect-metadata';
import config from './config'; import cluster, { type Worker } from 'cluster';
import compression from 'compression';
import cors from 'cors';
import express from 'express'; import express from 'express';
import helmet from 'helmet';
import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger'; import Logger from './loaders/logger';
import { monitoringMiddleware } from './middlewares/monitoring';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
async function startServer() { interface WorkerMetadata {
const app = express(); id: number;
pid: number;
serviceType: string;
startTime: Date;
}
await require('./loaders/db').default(); class Application {
private app: express.Application;
private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
await require('./loaders/initFile').default(); constructor() {
this.app = express();
// 创建一个全局中间件,删除查询参数中的t
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.t) {
delete req.query.t;
}
next();
});
}
await require('./loaders/app').default({ expressApp: app }); async start() {
try {
if (cluster.isPrimary) {
await this.initializeDatabase();
}
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
await this.startWorkerProcess();
}
} catch (error) {
Logger.error('Failed to start application:', error);
process.exit(1);
}
}
const server = app private startMasterProcess() {
.listen(config.port, '0.0.0.0', () => { // Fork gRPC worker first and wait for it to be ready
Logger.debug(`✌️ 后端服务启动成功!`); const grpcWorker = this.forkWorker('grpc');
console.debug(`✌️ 后端服务启动成功!`);
process.send?.('ready'); // Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
.then(() => {
Logger.info('✌️ gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
}) })
.on('error', (err) => { .catch((error) => {
Logger.error(err); Logger.error('✌️ Failed to wait for gRPC worker:', error);
console.error(err);
process.exit(1); process.exit(1);
}); });
await require('./loaders/server').default({ server }); cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('✌️ gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('✌️ Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error('✌️ Failed to send reregister-crons message:', error);
}
}
})
.catch((error) => {
Logger.error('✌️ Failed to restart gRPC worker:', error);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`✌️ Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
} }
startServer(); this.workerMetadataMap.delete(worker.id);
}
});
this.setupMasterShutdown();
}
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
this.workerMetadataMap.set(worker.id, {
id: worker.id,
pid: worker.process.pid!,
serviceType,
startTime: new Date(),
});
return worker;
}
private async initializeDatabase() {
const dbLoader = await import('./loaders/db');
await dbLoader.default();
}
private setupMiddlewares() {
this.app.use(helmet({
contentSecurityPolicy: false,
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
}
private setupMasterShutdown() {
const shutdown = async () => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
const workers = Object.values(cluster.workers || {});
const workerPromises: Promise<void>[] = [];
workers.forEach((worker) => {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`✌️ Worker ${worker.process.pid} exited`);
resolve();
});
try {
worker.send('shutdown');
} catch (error) {
Logger.warn(
`✌️ Failed to send shutdown to worker ${worker.process.pid}:`,
error,
);
}
});
workerPromises.push(exitPromise);
}
});
try {
await Promise.race([
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('✌️ Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error('✌️ Error during worker shutdown:', error);
process.exit(1);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`);
try {
if (serviceType === 'http') {
await this.startHttpService();
} else {
await this.startGrpcService();
}
process.send?.('ready');
} catch (error) {
Logger.error(`✌️ ${serviceType} worker failed:`, error);
process.exit(1);
}
}
private async startHttpService() {
this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app');
await appLoader.default({ app: this.app });
const server = await this.httpServerService.initialize(
this.app,
config.port,
);
const serverLoader = await import('./loaders/server');
await (serverLoader.default as any)({ server });
this.setupWorkerShutdown('http');
}
private async startGrpcService() {
const { GrpcServerService } = await import('./services/grpc');
this.grpcServerService = Container.get(GrpcServerService);
await this.grpcServerService.initialize();
this.setupWorkerShutdown('grpc');
}
private setupWorkerShutdown(serviceType: string) {
process.on('message', async (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('✌️ Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('✌️ Cron jobs re-registered successfully');
} catch (error) {
Logger.error('✌️ Failed to re-register cron jobs:', error);
}
}
});
const shutdown = () => this.gracefulShutdown(serviceType);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async gracefulShutdown(serviceType: string) {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
try {
if (serviceType === 'http') {
await this.httpServerService?.shutdown();
} else {
await this.grpcServerService?.shutdown();
}
process.exit(0);
} catch (error) {
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error);
process.exit(1);
}
}
}
const app = new Application();
app.start().catch((error) => {
Logger.error('🙅‍♀️ Application failed to start:', error);
process.exit(1);
});
+61
View File
@@ -23,3 +23,64 @@ export const SAMPLE_FILES = [
target: 'data/scripts/notify.py', target: 'data/scripts/notify.py',
}, },
]; ];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const NotificationModeStringMap = {
0: 'gotify',
1: 'goCqHttpBot',
2: 'serverChan',
3: 'pushDeer',
4: 'bark',
5: 'chat',
6: 'telegramBot',
7: 'dingtalkBot',
8: 'weWorkBot',
9: 'weWorkApp',
10: 'aibotk',
11: 'iGot',
12: 'pushPlus',
13: 'wePlusBot',
14: 'email',
15: 'pushMe',
16: 'feishu',
17: 'webhook',
18: 'chronocat',
19: 'ntfy',
20: 'wxPusherBot',
} as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
'Debian' | 'Ubuntu' | 'Alpine',
{
install: string;
uninstall: string;
info: string;
check(info: string): boolean;
}
> = {
Debian: {
install: 'apt-get install -y',
uninstall: 'apt-get remove -y',
info: 'dpkg-query -s',
check(info: string) {
return info.includes('install ok installed');
},
},
Ubuntu: {
install: 'apt-get install -y',
uninstall: 'apt-get remove -y',
info: 'dpkg-query -s',
check(info: string) {
return info.includes('install ok installed');
},
},
Alpine: {
install: 'apk add --no-check-certificate',
uninstall: 'apk del',
info: 'apk info -es',
check(info: string) {
return info.includes('installed');
},
},
};
+68
View File
@@ -0,0 +1,68 @@
import { request as undiciRequest, Dispatcher } from 'undici';
type RequestBaseOptions = {
dispatcher?: Dispatcher;
json?: Record<string, any>;
form?: string;
headers?: Record<string, string>;
} & Omit<Dispatcher.RequestOptions<null>, 'origin' | 'path' | 'method'>;
type RequestOptionsWithOptions = RequestBaseOptions &
Partial<Pick<Dispatcher.RequestOptions, 'method'>>;
type ResponseTypeMap = {
json: Record<string, any>;
text: string;
};
type ResponseTypeKey = keyof ResponseTypeMap;
async function request(
url: string,
options?: RequestOptionsWithOptions,
): Promise<Dispatcher.ResponseData<null>> {
const { json, form, body, headers = {}, ...rest } = options || {};
const finalHeaders = { ...headers } as Record<string, string>;
let finalBody = body;
if (json) {
finalHeaders['content-type'] = 'application/json';
finalBody = JSON.stringify(json);
} else if (form) {
finalBody = form;
delete finalHeaders['content-type'];
}
const res = await undiciRequest(url, {
method: 'POST',
headers: finalHeaders,
body: finalBody,
...rest,
});
return res;
}
async function post<T extends ResponseTypeKey = 'json'>(
url: string,
options?: RequestBaseOptions & { responseType?: T },
): Promise<ResponseTypeMap[T]> {
const resp = await request(url, { ...options, method: 'POST' });
const rawText = await resp.body.text();
if (options?.responseType === 'text') {
return rawText as ResponseTypeMap[T];
}
try {
return JSON.parse(rawText) as ResponseTypeMap[T];
} catch {
return rawText as ResponseTypeMap[T];
}
}
export const httpClient = {
post,
request,
};
+58 -15
View File
@@ -2,12 +2,60 @@ import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { createRandomString } from './share'; import { createRandomString } from './share';
dotenv.config({
path: path.join(__dirname, '../../.env'),
});
interface Config {
port: number;
grpcPort: number;
nodeEnv: string;
isDevelopment: boolean;
isProduction: boolean;
jwt: {
secret: string;
expiresIn?: string;
};
cors: {
origin: string[];
methods: string[];
};
logs: {
level: string;
};
api: {
prefix: string;
};
}
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
jwt: {
secret: process.env.JWT_SECRET || 'whyour-secret',
expiresIn: process.env.JWT_EXPIRES_IN,
},
cors: {
origin: process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',')
: ['*'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
},
};
process.env.NODE_ENV = process.env.NODE_ENV || 'development'; process.env.NODE_ENV = process.env.NODE_ENV || 'development';
if (!process.env.QL_DIR) { if (!process.env.QL_DIR) {
// 声明QL_DIR环境变量
let qlHomePath = path.join(__dirname, '../../'); let qlHomePath = path.join(__dirname, '../../');
// 生产环境
if (qlHomePath.endsWith('/static/')) { if (qlHomePath.endsWith('/static/')) {
qlHomePath = path.join(qlHomePath, '../'); qlHomePath = path.join(qlHomePath, '../');
} }
@@ -38,12 +86,13 @@ const dbPath = path.join(dataPath, 'db/');
const uploadPath = path.join(dataPath, 'upload/'); const uploadPath = path.join(dataPath, 'upload/');
const sshdPath = path.join(dataPath, 'ssh.d/'); const sshdPath = path.join(dataPath, 'ssh.d/');
const systemLogPath = path.join(dataPath, 'syslog/'); const systemLogPath = path.join(dataPath, 'syslog/');
const dependenceCachePath = path.join(dataPath, 'dep_cache/');
const envFile = path.join(preloadPath, 'env.sh'); const envFile = path.join(preloadPath, 'env.sh');
const jsEnvFile = path.join(preloadPath, 'env.js'); const jsEnvFile = path.join(preloadPath, 'env.js');
const pyEnvFile = path.join(preloadPath, 'env.py'); const pyEnvFile = path.join(preloadPath, 'env.py');
const jsNotifyFile = path.join(preloadPath, 'notify.js'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, 'notify.py'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list'); const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json'); const authConfigFile = path.join(configPath, 'auth.json');
@@ -65,17 +114,8 @@ if (envFound.error) {
} }
export default { export default {
port: parseInt(process.env.BACK_PORT as string, 10), ...config,
cronPort: parseInt(process.env.CRON_PORT as string, 10), jwt: config.jwt,
publicPort: parseInt(process.env.PUBLIC_PORT as string, 10),
updatePort: parseInt(process.env.UPDATE_PORT as string, 10),
secret: process.env.SECRET || createRandomString(16, 32),
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
rootPath, rootPath,
tmpPath, tmpPath,
dataPath, dataPath,
@@ -118,6 +158,7 @@ export default {
bakPath, bakPath,
apiWhiteList: [ apiWhiteList: [
'/api/user/login', '/api/user/login',
'/api/health',
'/open/auth/token', '/open/auth/token',
'/api/user/two-factor/login', '/api/user/two-factor/login',
'/api/system', '/api/system',
@@ -134,4 +175,6 @@ export default {
sqliteFile, sqliteFile,
sshdPath, sshdPath,
systemLogPath, systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
}; };
+287 -94
View File
@@ -1,19 +1,21 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import got from 'got';
import iconv from 'iconv-lite';
import { exec } from 'child_process'; import { exec } from 'child_process';
import FormData from 'form-data'; import psTreeFun from 'ps-tree';
import psTreeFun from 'pstree.remy';
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 { TASK_COMMAND } from './const'; import { PYTHON_INSTALL_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 { FormData } from 'undici';
import os from 'os';
export * from './share'; export * from './share';
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
export async function getFileContentByName(fileName: string) { export async function getFileContentByName(fileName: string) {
const _exsit = await fileExist(fileName); const _exsit = await fileExist(fileName);
if (_exsit) { if (_exsit) {
@@ -57,78 +59,6 @@ export function getToken(req: any) {
return ''; return '';
} }
export async function getNetIp(req: any) {
const ipArray = [
...new Set([
...(req.headers['x-real-ip'] || '').split(','),
...(req.headers['x-forwarded-for'] || '').split(','),
req.ip,
...req.ips,
req.socket.remoteAddress,
]),
].filter(Boolean);
let ip = ipArray[0];
if (ipArray.length > 1) {
for (let i = 0; i < ipArray.length; i++) {
const ipNumArray = ipArray[i].split('.');
const tmp = ipNumArray[0] + '.' + ipNumArray[1];
if (
tmp === '192.168' ||
(ipNumArray[0] === '172' &&
ipNumArray[1] >= 16 &&
ipNumArray[1] <= 32) ||
tmp === '10.7' ||
tmp === '127.0'
) {
continue;
}
ip = ipArray[i];
break;
}
}
ip = ip.substr(ip.lastIndexOf(':') + 1, ip.length);
if (ip.includes('127.0') || ip.includes('192.168') || ip.includes('10.7')) {
ip = '';
}
if (!ip) {
return { address: `获取失败`, ip };
}
try {
const csdnApi = got
.get(`https://searchplugin.csdn.net/api/v1/ip/get?ip=${ip}`, {
timeout: 10000,
retry: 0,
})
.text();
const pconlineApi = got
.get(`https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`, {
timeout: 10000,
retry: 0,
})
.buffer();
const [csdnBody, pconlineBody] = await await Promise.all<any>([
csdnApi,
pconlineApi,
]);
const csdnRes = JSON.parse(csdnBody);
const pconlineRes = JSON.parse(iconv.decode(pconlineBody, 'GBK'));
let address = '';
if (csdnBody && csdnRes.code == 200) {
address = csdnRes.data.address;
} else if (pconlineRes && pconlineRes.addr) {
address = pconlineRes.addr;
}
return { address, ip };
} catch (error) {
return { address: `获取失败`, ip };
}
}
export function getPlatform(userAgent: string): 'mobile' | 'desktop' { export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
const ua = userAgent.toLowerCase(); const ua = userAgent.toLowerCase();
const testUa = (regexp: RegExp) => regexp.test(ua); const testUa = (regexp: RegExp) => regexp.test(ua);
@@ -237,7 +167,7 @@ enum FileType {
'file', 'file',
} }
interface IFile { export interface IFile {
title: string; title: string;
key: string; key: string;
type: 'directory' | 'file'; type: 'directory' | 'file';
@@ -305,23 +235,51 @@ export async function readDir(
dir: string, dir: string,
baseDir: string = '', baseDir: string = '',
blacklist: string[] = [], blacklist: string[] = [],
) { ): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir); const absoluteDir = path.join(baseDir, dir);
const files = await fs.readdir(dir); const relativePath = path.relative(baseDir, absoluteDir);
const result: any = files
.filter((x) => !blacklist.includes(x)) try {
.map(async (file: string) => { const files = await fs.readdir(absoluteDir);
const subPath = path.join(dir, file); const result: IFile[] = [];
for (const file of files) {
const subPath = path.join(absoluteDir, file);
const stats = await fs.lstat(subPath); const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file); const key = path.join(relativePath, file);
return {
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) {
result.push({
title: file, title: file,
type: stats.isDirectory() ? 'directory' : 'file', type: 'directory',
key, key,
parent: relativePath, parent: relativePath,
}; createTime: stats.birthtime.getTime(),
children: [],
}); });
} else {
result.push({
title: file,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
});
}
}
return result; return result;
} catch (error: any) {
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
} }
export async function promiseExec(command: string): Promise<string> { export async function promiseExec(command: string): Promise<string> {
@@ -352,9 +310,9 @@ export function parseHeaders(headers: string) {
if (!headers) return {}; if (!headers) return {};
const parsed: any = {}; const parsed: any = {};
let key; let key: string;
let val; let val: string;
let i; let i: number;
headers && headers &&
headers.split('\n').forEach(function parser(line) { headers.split('\n').forEach(function parser(line) {
@@ -433,11 +391,11 @@ export function parseBody(
export function psTree(pid: number): Promise<number[]> { export function psTree(pid: number): Promise<number[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
psTreeFun(pid, (err: any, pids: number[]) => { psTreeFun(pid, (err: any, children) => {
if (err) { if (err) {
reject(err); reject(err);
} }
resolve(pids.filter((x) => !isNaN(x))); resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x)));
}); });
}); });
} }
@@ -462,6 +420,27 @@ export async function getPid(cmd: string) {
return pid ? Number(pid) : undefined; return pid ? Number(pid) : undefined;
} }
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion { interface IVersion {
version: string; version: string;
changeLogLink: string; changeLogLink: string;
@@ -527,7 +506,7 @@ export function safeJSONParse(value?: string) {
try { try {
return JSON.parse(value); return JSON.parse(value);
} catch (error) { } catch (error) {
Logger.error('[JSON.parse失败]', error); Logger.error('[safeJSONParse失败]', error);
return {}; return {};
} }
} }
@@ -542,3 +521,217 @@ export async function rmPath(path: string) {
Logger.error('[rmPath失败]', error); Logger.error('[rmPath失败]', error);
} }
} }
export async function setSystemTimezone(timezone: string): Promise<boolean> {
try {
if (!(await fileExist(`/usr/share/zoneinfo/${timezone}`))) {
throw new Error('Invalid timezone');
}
await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`);
await promiseExec(`echo "${timezone}" > /etc/timezone`);
return true;
} catch (error) {
Logger.error('[setSystemTimezone失败]', error);
return false;
}
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
try:
from importlib.metadata import version
print(version(name))
except:
import importlib.util as u
import importlib.metadata as m
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: `apt-get info ${name}`,
};
return baseCommands[type];
}
export function getInstallCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.python3]:
'pip3 install --disable-pip-version-check --root-user-action=ignore',
[DependenceTypes.linux]: 'apt install -y',
};
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
}
export function getUninstallCommand(
type: DependenceTypes,
name: string,
): string {
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: 'apt remove -y',
};
return `${baseCommands[type]} ${name.trim()}`;
}
export function isDemoEnv() {
return process.env.DeployEnv === 'demo';
}
async function getOSReleaseInfo(): Promise<string> {
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
return osRelease;
}
function isDebian(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Debian');
}
function isUbuntu(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Ubuntu');
}
function isCentOS(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
}
function isAlpine(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Alpine');
}
export async function detectOS(): Promise<
'Debian' | 'Ubuntu' | 'Alpine' | undefined
> {
if (osType) return osType;
const platform = os.platform();
if (platform === 'linux') {
const osReleaseInfo = await getOSReleaseInfo();
if (isDebian(osReleaseInfo)) {
osType = 'Debian';
} else if (isUbuntu(osReleaseInfo)) {
osType = 'Ubuntu';
} else if (isAlpine(osReleaseInfo)) {
osType = 'Alpine';
} else {
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
}
} else if (platform === 'darwin') {
osType = undefined;
} else {
Logger.error(`Unsupported platform: ${platform}`);
console.error(`Unsupported platform: ${platform}`);
}
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
): Promise<string> {
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
await replaceDomainInFile(
filePath,
currentDomainWithScheme,
mirrorDomainWithScheme || 'http://deb.debian.org',
);
return 'apt-get update';
} else {
throw Error(`Current mirror domain not found.`);
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
await replaceDomainInFile(
filePath,
currentDomainWithScheme,
mirrorDomainWithScheme || 'http://archive.ubuntu.com',
);
return 'apt-get update';
} else {
throw Error(`Current mirror domain not found.`);
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
await replaceDomainInFile(
filePath,
currentDomainWithScheme,
mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org',
);
return 'apk update';
} else {
throw Error(`Current mirror domain not found.`);
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
const detectedOS = await detectOS();
if (!detectedOS) {
throw Error(`Unknown Linux Distribution`);
}
return await _updateLinuxMirror(detectedOS, mirror);
}
+6
View File
@@ -21,6 +21,8 @@ export class Crontab {
extra_schedules?: Array<{ schedule: string }>; extra_schedules?: Array<{ schedule: string }>;
task_before?: string; task_before?: string;
task_after?: string; task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
constructor(options: Crontab) { constructor(options: Crontab) {
this.name = options.name; this.name = options.name;
@@ -45,6 +47,8 @@ export class Crontab {
this.extra_schedules = options.extra_schedules; this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before; this.task_before = options.task_before;
this.task_after = options.task_after; this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
} }
} }
@@ -84,4 +88,6 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
extra_schedules: DataTypes.JSON, extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING, task_before: DataTypes.STRING,
task_after: DataTypes.STRING, task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
}); });
-18
View File
@@ -41,30 +41,12 @@ export enum DependenceTypes {
'linux', 'linux',
} }
export enum InstallDependenceCommandTypes {
'pnpm add -g',
'pip3 install --disable-pip-version-check --root-user-action=ignore',
'apk add --no-check-certificate',
}
export enum GetDependenceCommandTypes {
'pnpm ls -g ',
'pip3 show --disable-pip-version-check',
'apk info -es',
}
export enum versionDependenceCommandTypes { export enum versionDependenceCommandTypes {
'@', '@',
'==', '==',
'=', '=',
} }
export enum unInstallDependenceCommandTypes {
'pnpm remove -g',
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
'apk del',
}
export interface DependenceInstance export interface DependenceInstance
extends Model<Dependence, Dependence>, extends Model<Dependence, Dependence>,
Dependence {} Dependence {}
+4 -1
View File
@@ -1,5 +1,5 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.'; import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Env { export class Env {
value?: string; value?: string;
@@ -9,6 +9,7 @@ export class Env {
position?: number; position?: number;
name?: string; name?: string;
remarks?: string; remarks?: string;
isPinned?: 1 | 0;
constructor(options: Env) { constructor(options: Env) {
this.value = options.value; this.value = options.value;
@@ -21,6 +22,7 @@ export class Env {
this.position = options.position; this.position = options.position;
this.name = options.name; this.name = options.name;
this.remarks = options.remarks || ''; this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
} }
} }
@@ -42,4 +44,5 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER, position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' }, name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING, remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
}); });
+10 -7
View File
@@ -1,5 +1,3 @@
import { IncomingHttpHeaders } from 'http';
export enum NotificationMode { export enum NotificationMode {
'gotify' = 'gotify', 'gotify' = 'gotify',
'goCqHttpBot' = 'goCqHttpBot', 'goCqHttpBot' = 'goCqHttpBot',
@@ -49,9 +47,8 @@ export class PushDeerNotification extends NotificationBaseInfo {
public pushDeerUrl = ''; public pushDeerUrl = '';
} }
export class ChatNotification extends NotificationBaseInfo { export class synologyChatNotification extends NotificationBaseInfo {
public chatUrl = ''; public synologyChatUrl = '';
public chatToken = '';
} }
export class BarkNotification extends NotificationBaseInfo { export class BarkNotification extends NotificationBaseInfo {
@@ -61,7 +58,7 @@ export class BarkNotification extends NotificationBaseInfo {
public barkGroup = 'qinglong'; public barkGroup = 'qinglong';
public barkLevel = 'active'; public barkLevel = 'active';
public barkUrl = ''; public barkUrl = '';
public barkArchive="" public barkArchive = '';
} }
export class TelegramBotNotification extends NotificationBaseInfo { export class TelegramBotNotification extends NotificationBaseInfo {
@@ -118,6 +115,7 @@ export class EmailNotification extends NotificationBaseInfo {
public emailService: string = ''; public emailService: string = '';
public emailUser: string = ''; public emailUser: string = '';
public emailPass: string = ''; public emailPass: string = '';
public emailTo: string = '';
} }
export class PushMeNotification extends NotificationBaseInfo { export class PushMeNotification extends NotificationBaseInfo {
@@ -144,12 +142,17 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo { export class LarkNotification extends NotificationBaseInfo {
public larkKey = ''; public larkKey = '';
public larkSecret = '';
} }
export class NtfyNotification extends NotificationBaseInfo { export class NtfyNotification extends NotificationBaseInfo {
public ntfyUrl = ''; public ntfyUrl = '';
public ntfyTopic = ''; public ntfyTopic = '';
public ntfyPriority = ''; public ntfyPriority = '';
public ntfyToken = '';
public ntfyUsername = '';
public ntfyPassword = '';
public ntfyActions = '';
} }
export class WxPusherBotNotification extends NotificationBaseInfo { export class WxPusherBotNotification extends NotificationBaseInfo {
@@ -163,7 +166,7 @@ export interface NotificationInfo
GotifyNotification, GotifyNotification,
ServerChanNotification, ServerChanNotification,
PushDeerNotification, PushDeerNotification,
ChatNotification, synologyChatNotification,
BarkNotification, BarkNotification,
TelegramBotNotification, TelegramBotNotification,
DingtalkBotNotification, DingtalkBotNotification,
+16 -1
View File
@@ -37,6 +37,8 @@ export interface SystemConfigInfo {
nodeMirror?: string; nodeMirror?: string;
pythonMirror?: string; pythonMirror?: string;
linuxMirror?: string; linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
} }
export interface LoginLogInfo { export interface LoginLogInfo {
@@ -47,6 +49,19 @@ export interface LoginLogInfo {
status?: LoginStatus; status?: LoginStatus;
} }
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo { export interface AuthInfo {
username: string; username: string;
password: string; password: string;
@@ -57,7 +72,7 @@ export interface AuthInfo {
platform: string; platform: string;
isTwoFactorChecking: boolean; isTwoFactorChecking: boolean;
token: string; token: string;
tokens: Record<string, string>; tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean; twoFactorActivated: boolean;
twoFactorSecret: string; twoFactorSecret: string;
avatar: string; avatar: string;
-7
View File
@@ -1,7 +0,0 @@
declare namespace Express {
interface Request {
platform: 'desktop' | 'mobile';
}
}
declare module 'pstree.remy';
+13
View File
@@ -0,0 +1,13 @@
export enum ScheduleType {
BOOT = '@boot',
ONCE = '@once',
}
export type ScheduleValidator = (schedule?: string) => boolean;
export type CronSchedulerPayload = {
name: string;
id: string;
schedule: string;
command: string;
extra_schedules: Array<{ schedule: string }>;
};
+9 -10
View File
@@ -5,25 +5,24 @@ import initData from './initData';
import { Application } from 'express'; import { Application } from 'express';
import linkDeps from './deps'; import linkDeps from './deps';
import initTask from './initTask'; import initTask from './initTask';
import initFile from './initFile';
export default async ({ expressApp }: { expressApp: Application }) => { export default async ({ app }: { app: Application }) => {
depInjectorLoader(); depInjectorLoader();
Logger.info('✌️ Dependency loaded'); Logger.info('✌️ Dependency loaded');
console.log('✌️ Dependency loaded');
await initData();
Logger.info('✌️ Init data loaded');
console.log('✌️ Init data loaded');
await linkDeps(); await linkDeps();
Logger.info('✌️ Link deps loaded'); Logger.info('✌️ Link deps loaded');
console.log('✌️ Link deps loaded');
initFile();
Logger.info('✌️ Init file loaded');
await initData();
Logger.info('✌️ Init data loaded');
initTask(); initTask();
Logger.info('✌️ Init task loaded'); Logger.info('✌️ Init task loaded');
console.log('✌️ Init task loaded');
expressLoader({ app: expressApp }); expressLoader({ app });
Logger.info('✌️ Express loaded'); Logger.info('✌️ Express loaded');
console.log('✌️ Express loaded');
}; };
+8
View File
@@ -0,0 +1,8 @@
import Container from 'typedi';
import CronService from '../services/cron';
export default async () => {
const cronService = Container.get(CronService);
await cronService.bootTask();
};
+30 -37
View File
@@ -19,48 +19,41 @@ export default async () => {
await CrontabViewModel.sync(); await CrontabViewModel.sync();
// 初始化新增字段 // 初始化新增字段
const migrations = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
];
for (const migration of migrations) {
try { try {
await sequelize.query( await sequelize.query(
'alter table CrontabViews add column filterRelation VARCHAR(255)', `alter table ${migration.table} add column ${migration.column} ${migration.type}`,
); );
} catch (error) {} } catch (error) {
try { // Column already exists or other error, continue
await sequelize.query( }
'alter table Subscriptions add column proxy VARCHAR(255)', }
);
} catch (error) {}
try {
await sequelize.query('alter table CrontabViews add column type NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoAddCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query(
'alter table Subscriptions add column autoDelCron NUMBER',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column extra_schedules JSON',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) {}
console.log('✌️ DB loaded');
Logger.info('✌️ DB loaded'); Logger.info('✌️ DB loaded');
} catch (error) { } catch (error) {
console.error('✌️ DB load failed'); Logger.error('✌️ DB load failed', error);
Logger.error(error);
} }
}; };
+23 -5
View File
@@ -1,8 +1,9 @@
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import os from 'os';
import chokidar from 'chokidar'; import chokidar from 'chokidar';
import config from '../config/index'; import config from '../config/index';
import { fileExist, promiseExec, rmPath } from '../config/util'; import Logger from './logger';
async function linkToNodeModule(src: string, dst?: string) { async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src); const target = path.join(config.rootPath, 'node_modules', dst || src);
@@ -17,8 +18,18 @@ async function linkToNodeModule(src: string, dst?: string) {
} }
async function linkCommand() { async function linkCommand() {
const commandPath = await promiseExec('which node'); const homeDir = os.homedir();
const commandDir = path.dirname(commandPath); let userBinDir = path.join(homeDir, 'bin');
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const linkShell = [ const linkShell = [
{ {
src: 'update.sh', src: 'update.sh',
@@ -36,6 +47,13 @@ async function linkCommand() {
const source = path.join(config.rootPath, 'shell', link.src); const source = path.join(config.rootPath, 'shell', link.src);
const target = path.join(commandDir, link.dest); const target = path.join(commandDir, link.dest);
const tmpTarget = path.join(commandDir, link.tmp); const tmpTarget = path.join(commandDir, link.tmp);
try {
const stats = await fs.lstat(tmpTarget);
if (stats) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
await fs.symlink(source, tmpTarget); await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target); await fs.rename(tmpTarget, target);
} }
@@ -52,6 +70,6 @@ export default async (src: string = 'deps') => {
}); });
watcher watcher
.on('add', (path) => linkToNodeModule(src)) .on('add', () => linkToNodeModule(src))
.on('change', (path) => linkToNodeModule(src)); .on('change', () => linkToNodeModule(src));
}; };
+19 -20
View File
@@ -7,10 +7,10 @@ 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 { errors } from 'celebrate';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { serveEnv } from '../config/serverEnv'; import { serveEnv } from '../config/serverEnv';
import Logger from './logger';
import { IKeyvStore, shareStore } from '../shared/store'; import { IKeyvStore, shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import path from 'path';
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
app.set('trust proxy', 'loopback'); app.set('trust proxy', 'loopback');
@@ -18,25 +18,18 @@ export default ({ app }: { app: Application }) => {
app.get(`${config.api.prefix}/env.js`, serveEnv); app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
app.use(
'/api/public',
createProxyMiddleware({
target: `http://0.0.0.0:${config.publicPort}/api`,
changeOrigin: true,
pathRewrite: { '/api/public': '' },
logger: Logger,
}),
);
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');
app.use(express.static(frontendPath));
app.use( app.use(
expressjwt({ expressjwt({
secret: config.secret, secret: config.jwt.secret,
algorithms: ['HS384'], algorithms: ['HS384'],
}).unless({ }).unless({
path: [...config.apiWhiteList, /^\/open\//], path: [...config.apiWhiteList, /^\/(?!api\/).*/],
}), }),
); );
@@ -50,7 +43,11 @@ export default ({ app }: { app: Application }) => {
return next(); return next();
}); });
app.use(async (req, res, next) => { app.use(async (req: Request, res, next) => {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {
return next();
}
const headerToken = getToken(req); const headerToken = getToken(req);
if (req.path.startsWith('/open/')) { if (req.path.startsWith('/open/')) {
const apps = await shareStore.getApps(); const apps = await shareStore.getApps();
@@ -81,12 +78,9 @@ export default ({ app }: { app: Application }) => {
} }
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
if (authInfo && headerToken) { if (isValidToken(authInfo, headerToken, req.platform)) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[req.platform] === headerToken) {
return next(); return next();
} }
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken const errorMessage = headerToken
@@ -122,10 +116,15 @@ export default ({ app }: { app: Application }) => {
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.use((req, res, next) => { app.get('*', (_, res, next) => {
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
const err: any = new Error('Not Found'); const err: any = new Error('Not Found');
err['status'] = 404; err['status'] = 404;
next(err); next(err);
}
});
}); });
app.use(errors()); app.use(errors());
+14 -8
View File
@@ -13,7 +13,7 @@ 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, safeJSONParse } from '../config/util'; import { createRandomString, fileExist, 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';
@@ -56,8 +56,11 @@ export default async () => {
password: 'admin', password: 'admin',
}; };
try { try {
const authFileExist = await fileExist(config.authConfigFile);
if (authFileExist) {
const content = await readFile(config.authConfigFile, 'utf8'); const content = await readFile(config.authConfigFile, 'utf8');
authInfo = safeJSONParse(content); authInfo = safeJSONParse(content);
}
} 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');
} }
@@ -68,24 +71,27 @@ export default async () => {
}); });
} }
const installDependencies = () => { const installDependencies = async () => {
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖 const docs = await DependenceModel.findAll({
DependenceModel.findAll({
where: {}, where: {},
order: [ order: [
['type', 'DESC'], ['type', 'DESC'],
['createdAt', 'DESC'], ['createdAt', 'DESC'],
], ],
raw: true, raw: true,
}).then(async (docs) => { });
await DependenceModel.update( await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] }, { status: DependenceStatus.queued, log: [] },
{ where: { id: docs.map((x) => x.id!) } }, { where: { id: docs.map((x) => x.id!) } },
); );
setTimeout(() => {
dependenceService.installDependenceOneByOne(docs); setTimeout(async () => {
await dependenceService.installDependenceOneByOne(docs);
const bootAfterLoader = await import('./bootAfter');
bootAfterLoader.default();
}, 5000); }, 5000);
});
}; };
// 初始化更新 linux/python/nodejs 镜像源配置 // 初始化更新 linux/python/nodejs 镜像源配置
+5 -4
View File
@@ -27,8 +27,8 @@ const sampleNotifyJsFile = path.join(samplePath, 'notify.js');
const sampleNotifyPyFile = path.join(samplePath, 'notify.py'); const sampleNotifyPyFile = path.join(samplePath, 'notify.py');
const scriptNotifyJsFile = path.join(scriptPath, 'sendNotify.js'); const scriptNotifyJsFile = path.join(scriptPath, 'sendNotify.js');
const scriptNotifyPyFile = path.join(scriptPath, 'notify.py'); const scriptNotifyPyFile = path.join(scriptPath, 'notify.py');
const jsNotifyFile = path.join(preloadPath, 'notify.js'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, 'notify.py'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const TaskBeforeFile = path.join(configPath, 'task_before.sh'); const TaskBeforeFile = path.join(configPath, 'task_before.sh');
const TaskBeforeJsFile = path.join(configPath, 'task_before.js'); const TaskBeforeJsFile = path.join(configPath, 'task_before.js');
const TaskBeforePyFile = path.join(configPath, 'task_before.py'); const TaskBeforePyFile = path.join(configPath, 'task_before.py');
@@ -116,11 +116,12 @@ export default async () => {
`Neither content nor source specified for ${item.target}`, `Neither content nor source specified for ${item.target}`,
); );
} }
const content = item.content || (await fs.readFile(item.source!)); const content =
item.content ||
(await fs.readFile(item.source!, { encoding: 'utf-8' }));
await writeFileWithLock(item.target, content); await writeFileWithLock(item.target, content);
} }
} }
Logger.info('✌️ Init file down'); Logger.info('✌️ Init file down');
console.log('✌️ Init file down');
}; };
+12 -1
View File
@@ -2,6 +2,7 @@ import { Container } from 'typedi';
import SystemService from '../services/system'; import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule'; import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config'; import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path'; import { join } from 'path';
@@ -10,6 +11,7 @@ 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);
// 生成内置token // 生成内置token
let tokenCommand = `ts-node-transpile-only ${join( let tokenCommand = `ts-node-transpile-only ${join(
@@ -38,7 +40,8 @@ export default async () => {
// 运行删除日志任务 // 运行删除日志任务
const data = await systemService.getSystemConfig(); const data = await systemService.getSystemConfig();
if (data && data.info && data.info.logRemoveFrequency) { if (data && data.info) {
if (data.info.logRemoveFrequency) {
const rmlogCron = { const rmlogCron = {
id: data.id as number, id: data.id as number,
name: '删除日志', name: '删除日志',
@@ -55,6 +58,14 @@ export default async () => {
); );
} }
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
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) {
+51 -26
View File
@@ -4,35 +4,60 @@ import config from '../config';
import path from 'path'; import path from 'path';
const levelMap: Record<string, string> = { const levelMap: Record<string, string> = {
info: '\ue6f5', info: '️', // info图标
warn: '\ue880', warn: '⚠️', // 警告图标
error: '\ue602', error: '❌', // 错误图标
debug: '\ue67f' debug: '🐛', // debug调试图标
}
const customFormat = winston.format.combine(
winston.format.splat(),
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.align(),
winston.format.printf((i) => `[${levelMap[i.level]}${i.level}] [${[i.timestamp]}]: ${i.message}`),
);
const defaultOptions = {
format: customFormat,
datePattern: "YYYY-MM-DD",
maxSize: "20m",
maxFiles: "7d",
}; };
const LoggerInstance = winston.createLogger({ const baseFormat = [
level: config.logs.level, winston.format.splat(),
levels: winston.config.npm.levels, winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
transports: [ winston.format.align(),
new winston.transports.DailyRotateFile({ ];
const consoleFormat = winston.format.combine(
winston.format.colorize({ level: true }),
...baseFormat,
winston.format.printf((info) => {
return `[${info.level} ${info.timestamp}]:${info.message}`;
}),
);
const plainFormat = winston.format.combine(
winston.format.uncolorize(),
...baseFormat,
winston.format.printf((info) => {
return `[${levelMap[info.level] || ''}${info.level} ${info.timestamp}]:${
info.message
}`;
}),
);
const consoleTransport = new winston.transports.Console({
format: consoleFormat,
level: 'debug',
});
const fileTransport = new winston.transports.DailyRotateFile({
filename: path.join(config.systemLogPath, '%DATE%.log'), filename: path.join(config.systemLogPath, '%DATE%.log'),
...defaultOptions, datePattern: 'YYYY-MM-DD',
}) maxSize: '20m',
], maxFiles: '7d',
format: plainFormat,
level: config.logs.level || 'info',
});
const LoggerInstance = winston.createLogger({
level: 'debug',
levels: winston.config.npm.levels,
transports: [consoleTransport, fileTransport],
exceptionHandlers: [consoleTransport, fileTransport],
rejectionHandlers: [consoleTransport, fileTransport],
});
LoggerInstance.on('error', (error) => {
console.error('Logger error:', error);
}); });
export default LoggerInstance; export default LoggerInstance;
+3 -4
View File
@@ -4,6 +4,7 @@ import { Container } from 'typedi';
import SockService from '../services/sock'; import SockService from '../services/sock';
import { getPlatform } from '../config/util'; import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
export default async ({ server }: { server: Server }) => { export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} }); const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
@@ -17,9 +18,8 @@ export default async ({ server }: { server: Server }) => {
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (authInfo) {
const { token = '', tokens = {} } = authInfo; if (isValidToken(authInfo, headerToken, platform)) {
if (headerToken === token || tokens[platform] === headerToken) {
sockService.addClient(conn); sockService.addClient(conn);
conn.on('data', (message) => { conn.on('data', (message) => {
@@ -32,7 +32,6 @@ export default async ({ server }: { server: Server }) => {
return; return;
} }
}
conn.close('404'); conn.close('404');
}); });
-106
View File
@@ -1,106 +0,0 @@
import bodyParser from 'body-parser';
import { errors } from 'celebrate';
import cors from 'cors';
import { Application, NextFunction, Request, Response } from 'express';
import { expressjwt } from 'express-jwt';
import Container from 'typedi';
import config from '../config';
import SystemService from '../services/system';
import Logger from './logger';
export default ({ app }: { app: Application }) => {
app.set('trust proxy', 'loopback');
app.use(cors());
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
app.use(
expressjwt({
secret: config.secret,
algorithms: ['HS384'],
}),
);
app.put(
'/api/reload',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem();
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
app.put(
'/api/system',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem('system');
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
app.put(
'/api/data',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem('data');
res.send(result);
} catch (e) {
Logger.error('🔥 error: %o', e);
return next(e);
}
},
);
app.use((req, res, next) => {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
});
app.use(errors());
app.use(
(
err: Error & { status: number },
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name === 'UnauthorizedError') {
return res
.status(err.status)
.send({ code: 401, message: err.message })
.end();
}
return next(err);
},
);
app.use(
(
err: Error & { status: number },
req: Request,
res: Response,
next: NextFunction,
) => {
res.status(err.status || 500);
res.json({
code: err.status || 500,
message: err.message,
});
},
);
};
+80
View File
@@ -0,0 +1,80 @@
import { Request, Response, NextFunction } from 'express';
import Logger from '../loaders/logger';
import { performance } from 'perf_hooks';
import { metricsService } from '../services/metrics';
interface RequestMetrics {
method: string;
path: string;
duration: number;
statusCode: number;
timestamp: number;
platform?: string;
}
const requestMetrics: RequestMetrics[] = [];
export const monitoringMiddleware = (
req: Request,
res: Response,
next: NextFunction,
) => {
const start = performance.now();
const originalEnd = res.end;
res.end = function (chunk?: any, encoding?: any, cb?: any) {
const duration = performance.now() - start;
const metric: RequestMetrics = {
method: req.method,
path: req.path,
duration,
statusCode: res.statusCode,
timestamp: Date.now(),
platform: req.platform,
};
requestMetrics.push(metric);
metricsService.record('http_request', duration, {
method: req.method,
path: req.path,
statusCode: res.statusCode.toString(),
...(req.platform && { platform: req.platform }),
});
if (requestMetrics.length > 1000) {
requestMetrics.shift();
}
if (duration > 1000) {
Logger.warn(
`Slow request detected: ${req.method} ${
req.path
} took ${duration.toFixed(2)}ms`,
);
}
return originalEnd.call(this, chunk, encoding, cb);
};
next();
};
export const getMetrics = () => {
return {
totalRequests: requestMetrics.length,
averageDuration:
requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) /
requestMetrics.length,
requestsByMethod: requestMetrics.reduce((acc, curr) => {
acc[curr.method] = (acc[curr.method] || 0) + 1;
return acc;
}, {} as Record<string, number>),
requestsByPlatform: requestMetrics.reduce((acc, curr) => {
if (curr.platform) {
acc[curr.platform] = (acc[curr.platform] || 0) + 1;
}
return acc;
}, {} as Record<string, number>),
recentRequests: requestMetrics.slice(-10),
};
};
+203 -1
View File
@@ -8,7 +8,7 @@ message EnvItem {
optional string value = 3; optional string value = 3;
optional string remarks = 4; optional string remarks = 4;
optional int32 status = 5; optional int32 status = 5;
optional int32 position = 6; optional int64 position = 6;
} }
message GetEnvsRequest { string searchValue = 1; } message GetEnvsRequest { string searchValue = 1; }
@@ -53,9 +53,202 @@ message Response {
optional string message = 2; optional string message = 2;
} }
message ExtraScheduleItem { string schedule = 1; }
message CronItem {
optional int32 id = 1;
optional string command = 2;
optional string schedule = 3;
optional string name = 4;
repeated string labels = 5;
optional int32 sub_id = 6;
repeated ExtraScheduleItem extra_schedules = 7;
optional string task_before = 8;
optional string task_after = 9;
optional int32 status = 10;
optional string log_path = 11;
optional int32 pid = 12;
optional int64 last_running_time = 13;
optional int64 last_execution_time = 14;
}
message CreateCronRequest {
string command = 1;
string schedule = 2;
optional string name = 3;
repeated string labels = 4;
optional int32 sub_id = 5;
repeated ExtraScheduleItem extra_schedules = 6;
optional string task_before = 7;
optional string task_after = 8;
}
message UpdateCronRequest {
int32 id = 1;
optional string command = 2;
optional string schedule = 3;
optional string name = 4;
repeated string labels = 5;
optional int32 sub_id = 6;
repeated ExtraScheduleItem extra_schedules = 7;
optional string task_before = 8;
optional string task_after = 9;
}
message DeleteCronsRequest { repeated int32 ids = 1; }
message GetCronsRequest {
optional string searchValue = 1;
}
message GetCronByIdRequest { int32 id = 1; }
message EnableCronsRequest { repeated int32 ids = 1; }
message DisableCronsRequest { repeated int32 ids = 1; }
message RunCronsRequest { repeated int32 ids = 1; }
message CronsResponse {
int32 code = 1;
repeated CronItem data = 2;
optional string message = 3;
}
message CronResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
message CronDetailRequest { string log_path = 1; }
message CronDetailResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
enum NotificationMode {
gotify = 0;
goCqHttpBot = 1;
serverChan = 2;
pushDeer = 3;
bark = 4;
chat = 5;
telegramBot = 6;
dingtalkBot = 7;
weWorkBot = 8;
weWorkApp = 9;
aibotk = 10;
iGot = 11;
pushPlus = 12;
wePlusBot = 13;
email = 14;
pushMe = 15;
feishu = 16;
webhook = 17;
chronocat = 18;
ntfy = 19;
wxPusherBot = 20;
}
message NotificationInfo {
NotificationMode type = 1;
optional string gotifyUrl = 2;
optional string gotifyToken = 3;
optional int32 gotifyPriority = 4;
optional string goCqHttpBotUrl = 5;
optional string goCqHttpBotToken = 6;
optional string goCqHttpBotQq = 7;
optional string serverChanKey = 8;
optional string pushDeerKey = 9;
optional string pushDeerUrl = 10;
optional string synologyChatUrl = 11;
optional string barkPush = 12;
optional string barkIcon = 13;
optional string barkSound = 14;
optional string barkGroup = 15;
optional string barkLevel = 16;
optional string barkUrl = 17;
optional string barkArchive = 18;
optional string telegramBotToken = 19;
optional string telegramBotUserId = 20;
optional string telegramBotProxyHost = 21;
optional string telegramBotProxyPort = 22;
optional string telegramBotProxyAuth = 23;
optional string telegramBotApiHost = 24;
optional string dingtalkBotToken = 25;
optional string dingtalkBotSecret = 26;
optional string weWorkBotKey = 27;
optional string weWorkOrigin = 28;
optional string weWorkAppKey = 29;
optional string aibotkKey = 30;
optional string aibotkType = 31;
optional string aibotkName = 32;
optional string iGotPushKey = 33;
optional string pushPlusToken = 34;
optional string pushPlusUser = 35;
optional string pushPlusTemplate = 36;
optional string pushplusChannel = 37;
optional string pushplusWebhook = 38;
optional string pushplusCallbackUrl = 39;
optional string pushplusTo = 40;
optional string wePlusBotToken = 41;
optional string wePlusBotReceiver = 42;
optional string wePlusBotVersion = 43;
optional string emailService = 44;
optional string emailUser = 45;
optional string emailPass = 46;
optional string emailTo = 47;
optional string pushMeKey = 48;
optional string pushMeUrl = 49;
optional string chronocatURL = 50;
optional string chronocatQQ = 51;
optional string chronocatToken = 52;
optional string webhookHeaders = 53;
optional string webhookBody = 54;
optional string webhookUrl = 55;
optional string webhookMethod = 56;
optional string webhookContentType = 57;
optional string larkKey = 58;
optional string ntfyUrl = 59;
optional string ntfyTopic = 60;
optional string ntfyPriority = 61;
optional string ntfyToken = 62;
optional string ntfyUsername = 63;
optional string ntfyPassword = 64;
optional string ntfyActions = 65;
optional string wxPusherBotAppToken = 66;
optional string wxPusherBotTopicIds = 67;
optional string wxPusherBotUids = 68;
}
message SystemNotifyRequest { message SystemNotifyRequest {
string title = 1; string title = 1;
string content = 2; string content = 2;
optional NotificationInfo notificationInfo = 3;
} }
service Api { service Api {
@@ -69,4 +262,13 @@ 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 GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
} }
+3383 -112
View File
File diff suppressed because it is too large Load Diff
+68 -51
View File
@@ -1,10 +1,11 @@
// 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 v1.181.2 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v3.17.3
// source: back/protos/cron.proto // source: back/protos/cron.proto
/* eslint-disable */ /* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import { import {
type CallOptions, type CallOptions,
ChannelCredentials, ChannelCredentials,
@@ -17,7 +18,6 @@ import {
type ServiceError, type ServiceError,
type UntypedServiceImplementation, type UntypedServiceImplementation,
} from "@grpc/grpc-js"; } from "@grpc/grpc-js";
import _m0 from "protobufjs/minimal";
export const protobufPackage = "com.ql.cron"; export const protobufPackage = "com.ql.cron";
@@ -29,7 +29,7 @@ export interface ICron {
id: string; id: string;
schedule: string; schedule: string;
command: string; command: string;
extraSchedules: ISchedule[]; extra_schedules: ISchedule[];
name: string; name: string;
} }
@@ -51,22 +51,22 @@ function createBaseISchedule(): ISchedule {
return { schedule: "" }; return { schedule: "" };
} }
export const ISchedule = { export const ISchedule: MessageFns<ISchedule> = {
encode(message: ISchedule, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { encode(message: ISchedule, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.schedule !== "") { if (message.schedule !== "") {
writer.uint32(10).string(message.schedule); writer.uint32(10).string(message.schedule);
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): ISchedule { decode(input: BinaryReader | Uint8Array, length?: number): ISchedule {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseISchedule(); const message = createBaseISchedule();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag !== 10) { if (tag !== 10) {
break; break;
} }
@@ -74,10 +74,11 @@ export const ISchedule = {
message.schedule = reader.string(); message.schedule = reader.string();
continue; continue;
} }
}
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -105,11 +106,11 @@ export const ISchedule = {
}; };
function createBaseICron(): ICron { function createBaseICron(): ICron {
return { id: "", schedule: "", command: "", extraSchedules: [], name: "" }; return { id: "", schedule: "", command: "", extra_schedules: [], name: "" };
} }
export const ICron = { export const ICron: MessageFns<ICron> = {
encode(message: ICron, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { encode(message: ICron, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") { if (message.id !== "") {
writer.uint32(10).string(message.id); writer.uint32(10).string(message.id);
} }
@@ -119,8 +120,8 @@ export const ICron = {
if (message.command !== "") { if (message.command !== "") {
writer.uint32(26).string(message.command); writer.uint32(26).string(message.command);
} }
for (const v of message.extraSchedules) { for (const v of message.extra_schedules) {
ISchedule.encode(v!, writer.uint32(34).fork()).ldelim(); ISchedule.encode(v!, writer.uint32(34).fork()).join();
} }
if (message.name !== "") { if (message.name !== "") {
writer.uint32(42).string(message.name); writer.uint32(42).string(message.name);
@@ -128,42 +129,46 @@ export const ICron = {
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): ICron { decode(input: BinaryReader | Uint8Array, length?: number): ICron {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseICron(); const message = createBaseICron();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag !== 10) { if (tag !== 10) {
break; break;
} }
message.id = reader.string(); message.id = reader.string();
continue; continue;
case 2: }
case 2: {
if (tag !== 18) { if (tag !== 18) {
break; break;
} }
message.schedule = reader.string(); message.schedule = reader.string();
continue; continue;
case 3: }
case 3: {
if (tag !== 26) { if (tag !== 26) {
break; break;
} }
message.command = reader.string(); message.command = reader.string();
continue; continue;
case 4: }
case 4: {
if (tag !== 34) { if (tag !== 34) {
break; break;
} }
message.extraSchedules.push(ISchedule.decode(reader, reader.uint32())); message.extra_schedules.push(ISchedule.decode(reader, reader.uint32()));
continue; continue;
case 5: }
case 5: {
if (tag !== 42) { if (tag !== 42) {
break; break;
} }
@@ -171,10 +176,11 @@ export const ICron = {
message.name = reader.string(); message.name = reader.string();
continue; continue;
} }
}
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -184,8 +190,8 @@ export const ICron = {
id: isSet(object.id) ? globalThis.String(object.id) : "", id: isSet(object.id) ? globalThis.String(object.id) : "",
schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "", schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "",
command: isSet(object.command) ? globalThis.String(object.command) : "", command: isSet(object.command) ? globalThis.String(object.command) : "",
extraSchedules: globalThis.Array.isArray(object?.extraSchedules) extra_schedules: globalThis.Array.isArray(object?.extra_schedules)
? object.extraSchedules.map((e: any) => ISchedule.fromJSON(e)) ? object.extra_schedules.map((e: any) => ISchedule.fromJSON(e))
: [], : [],
name: isSet(object.name) ? globalThis.String(object.name) : "", name: isSet(object.name) ? globalThis.String(object.name) : "",
}; };
@@ -202,8 +208,8 @@ export const ICron = {
if (message.command !== "") { if (message.command !== "") {
obj.command = message.command; obj.command = message.command;
} }
if (message.extraSchedules?.length) { if (message.extra_schedules?.length) {
obj.extraSchedules = message.extraSchedules.map((e) => ISchedule.toJSON(e)); obj.extra_schedules = message.extra_schedules.map((e) => ISchedule.toJSON(e));
} }
if (message.name !== "") { if (message.name !== "") {
obj.name = message.name; obj.name = message.name;
@@ -219,7 +225,7 @@ export const ICron = {
message.id = object.id ?? ""; message.id = object.id ?? "";
message.schedule = object.schedule ?? ""; message.schedule = object.schedule ?? "";
message.command = object.command ?? ""; message.command = object.command ?? "";
message.extraSchedules = object.extraSchedules?.map((e) => ISchedule.fromPartial(e)) || []; message.extra_schedules = object.extra_schedules?.map((e) => ISchedule.fromPartial(e)) || [];
message.name = object.name ?? ""; message.name = object.name ?? "";
return message; return message;
}, },
@@ -229,22 +235,22 @@ function createBaseAddCronRequest(): AddCronRequest {
return { crons: [] }; return { crons: [] };
} }
export const AddCronRequest = { export const AddCronRequest: MessageFns<AddCronRequest> = {
encode(message: AddCronRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { encode(message: AddCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.crons) { for (const v of message.crons) {
ICron.encode(v!, writer.uint32(10).fork()).ldelim(); ICron.encode(v!, writer.uint32(10).fork()).join();
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): AddCronRequest { decode(input: BinaryReader | Uint8Array, length?: number): AddCronRequest {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronRequest(); const message = createBaseAddCronRequest();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag !== 10) { if (tag !== 10) {
break; break;
} }
@@ -252,10 +258,11 @@ export const AddCronRequest = {
message.crons.push(ICron.decode(reader, reader.uint32())); message.crons.push(ICron.decode(reader, reader.uint32()));
continue; continue;
} }
}
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -286,13 +293,13 @@ function createBaseAddCronResponse(): AddCronResponse {
return {}; return {};
} }
export const AddCronResponse = { export const AddCronResponse: MessageFns<AddCronResponse> = {
encode(_: AddCronResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { encode(_: AddCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): AddCronResponse { decode(input: BinaryReader | Uint8Array, length?: number): AddCronResponse {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronResponse(); const message = createBaseAddCronResponse();
while (reader.pos < end) { while (reader.pos < end) {
@@ -302,7 +309,7 @@ export const AddCronResponse = {
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -329,22 +336,22 @@ function createBaseDeleteCronRequest(): DeleteCronRequest {
return { ids: [] }; return { ids: [] };
} }
export const DeleteCronRequest = { export const DeleteCronRequest: MessageFns<DeleteCronRequest> = {
encode(message: DeleteCronRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { encode(message: DeleteCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.ids) { for (const v of message.ids) {
writer.uint32(10).string(v!); writer.uint32(10).string(v!);
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): DeleteCronRequest { decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronRequest {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronRequest(); const message = createBaseDeleteCronRequest();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag !== 10) { if (tag !== 10) {
break; break;
} }
@@ -352,10 +359,11 @@ export const DeleteCronRequest = {
message.ids.push(reader.string()); message.ids.push(reader.string());
continue; continue;
} }
}
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -386,13 +394,13 @@ function createBaseDeleteCronResponse(): DeleteCronResponse {
return {}; return {};
} }
export const DeleteCronResponse = { export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
encode(_: DeleteCronResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { encode(_: DeleteCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): DeleteCronResponse { decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronResponse {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronResponse(); const message = createBaseDeleteCronResponse();
while (reader.pos < end) { while (reader.pos < end) {
@@ -402,7 +410,7 @@ export const DeleteCronResponse = {
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -506,3 +514,12 @@ export type Exact<P, I extends P> = P extends Builtin ? P
function isSet(value: any): boolean { function isSet(value: any): boolean {
return value !== null && value !== undefined; return value !== null && value !== undefined;
} }
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
+27 -16
View File
@@ -1,25 +1,25 @@
// 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 v1.181.2 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v3.17.3
// source: back/protos/health.proto // source: back/protos/health.proto
/* eslint-disable */ /* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import { import {
type CallOptions, type CallOptions,
ChannelCredentials, ChannelCredentials,
Client, Client,
type ClientOptions, type ClientOptions,
ClientReadableStream, type ClientReadableStream,
type ClientUnaryCall, type ClientUnaryCall,
handleServerStreamingCall, type handleServerStreamingCall,
type handleUnaryCall, type handleUnaryCall,
makeGenericClientConstructor, makeGenericClientConstructor,
Metadata, Metadata,
type ServiceError, type ServiceError,
type UntypedServiceImplementation, type UntypedServiceImplementation,
} from "@grpc/grpc-js"; } from "@grpc/grpc-js";
import _m0 from "protobufjs/minimal";
export const protobufPackage = "com.ql.health"; export const protobufPackage = "com.ql.health";
@@ -80,22 +80,22 @@ function createBaseHealthCheckRequest(): HealthCheckRequest {
return { service: "" }; return { service: "" };
} }
export const HealthCheckRequest = { export const HealthCheckRequest: MessageFns<HealthCheckRequest> = {
encode(message: HealthCheckRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { encode(message: HealthCheckRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.service !== "") { if (message.service !== "") {
writer.uint32(10).string(message.service); writer.uint32(10).string(message.service);
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckRequest { decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckRequest {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckRequest(); const message = createBaseHealthCheckRequest();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag !== 10) { if (tag !== 10) {
break; break;
} }
@@ -103,10 +103,11 @@ export const HealthCheckRequest = {
message.service = reader.string(); message.service = reader.string();
continue; continue;
} }
}
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -137,22 +138,22 @@ function createBaseHealthCheckResponse(): HealthCheckResponse {
return { status: 0 }; return { status: 0 };
} }
export const HealthCheckResponse = { export const HealthCheckResponse: MessageFns<HealthCheckResponse> = {
encode(message: HealthCheckResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { encode(message: HealthCheckResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.status !== 0) { if (message.status !== 0) {
writer.uint32(8).int32(message.status); writer.uint32(8).int32(message.status);
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckResponse { decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckResponse {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckResponse(); const message = createBaseHealthCheckResponse();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag !== 8) { if (tag !== 8) {
break; break;
} }
@@ -160,10 +161,11 @@ export const HealthCheckResponse = {
message.status = reader.int32() as any; message.status = reader.int32() as any;
continue; continue;
} }
}
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -262,3 +264,12 @@ export type Exact<P, I extends P> = P extends Builtin ? P
function isSet(value: any): boolean { function isSet(value: any): boolean {
return value !== null && value !== undefined; return value !== null && value !== undefined;
} }
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
-35
View File
@@ -1,35 +0,0 @@
import express from 'express';
import Logger from './loaders/logger';
import config from './config';
import { HealthClient } from './protos/health';
import { credentials } from '@grpc/grpc-js';
const app = express();
const client = new HealthClient(
`0.0.0.0:${config.cronPort}`,
credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 },
);
app.get('/api/health', (req, res) => {
client.check({ service: 'cron' }, (err, response) => {
if (err) {
return res.status(200).send({ code: 500, error: err });
}
return res.status(200).send({ code: 200, data: response });
});
});
app
.listen(config.publicPort, '0.0.0.0', async () => {
await require('./loaders/db').default();
Logger.debug(`✌️ 公共服务启动成功!`);
console.debug(`✌️ 公共服务启动成功!`);
process.send?.('ready');
})
.on('error', (err) => {
Logger.error(err);
console.error(err);
process.exit(1);
});
+7 -7
View File
@@ -10,23 +10,23 @@ const addCron = (
callback: sendUnaryData<AddCronResponse>, callback: sendUnaryData<AddCronResponse>,
) => { ) => {
for (const item of call.request.crons) { for (const item of call.request.crons) {
const { id, schedule, command, extraSchedules, name } = item; const { id, schedule, command, extra_schedules, name } = item;
if (scheduleStacks.has(id)) { if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel()); scheduleStacks.get(id)?.forEach((x) => x.cancel());
} }
Logger.info( Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', '[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id, id,
name, name,
schedule, schedule,
command, command,
); );
if (extraSchedules?.length) { if (extra_schedules?.length) {
extraSchedules.forEach((x) => { extra_schedules.forEach((x) => {
Logger.info( Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', '[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id, id,
name, name,
x.schedule, x.schedule,
@@ -40,8 +40,8 @@ const addCron = (
Logger.info(`[schedule][准备运行任务] 命令: ${command}`); Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item); runCron(command, item);
}), }),
...(extraSchedules?.length ...(extra_schedules?.length
? extraSchedules.map((x) => ? extra_schedules.map((x) =>
nodeSchedule.scheduleJob(id, x.schedule, async () => { nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`); Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item); runCron(command, item);
+273 -2
View File
@@ -4,6 +4,7 @@ import EnvService from '../services/env';
import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js'; import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js';
import { import {
CreateEnvRequest, CreateEnvRequest,
CronItem,
DeleteEnvsRequest, DeleteEnvsRequest,
DisableEnvsRequest, DisableEnvsRequest,
EnableEnvsRequest, EnableEnvsRequest,
@@ -21,6 +22,22 @@ import {
import LoggerInstance from '../loaders/logger'; import LoggerInstance from '../loaders/logger';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import SystemService from '../services/system'; import SystemService from '../services/system';
import CronService from '../services/cron';
import {
CronDetailRequest,
CronDetailResponse,
CreateCronRequest,
UpdateCronRequest,
DeleteCronsRequest,
CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
Container.set('logger', LoggerInstance); Container.set('logger', LoggerInstance);
@@ -62,9 +79,17 @@ export const updateEnv = async (
callback: sendUnaryData<EnvResponse>, callback: sendUnaryData<EnvResponse>,
) => { ) => {
try { try {
if (!call.request.env?.id) {
return callback(null, {
code: 400,
data: undefined,
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', 'remark']) as EnvItem, pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem,
); );
callback(null, { code: 200, data }); callback(null, { code: 200, data });
} catch (e: any) { } catch (e: any) {
@@ -77,6 +102,13 @@ export const deleteEnvs = async (
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
) => { ) => {
try { try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService); 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 });
@@ -90,6 +122,14 @@ export const moveEnv = async (
callback: sendUnaryData<EnvResponse>, callback: sendUnaryData<EnvResponse>,
) => { ) => {
try { try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
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,
@@ -106,6 +146,13 @@ export const disableEnvs = async (
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
) => { ) => {
try { try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService); 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 });
@@ -119,6 +166,13 @@ export const enableEnvs = async (
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
) => { ) => {
try { try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService); 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 });
@@ -132,6 +186,13 @@ export const updateEnvNames = async (
callback: sendUnaryData<Response>, callback: sendUnaryData<Response>,
) => { ) => {
try { try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
await envService.updateNames({ await envService.updateNames({
ids: call.request.ids, ids: call.request.ids,
@@ -148,6 +209,14 @@ export const getEnvById = async (
callback: sendUnaryData<EnvResponse>, callback: sendUnaryData<EnvResponse>,
) => { ) => {
try { try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
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, {
@@ -165,9 +234,211 @@ export const systemNotify = async (
) => { ) => {
try { try {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const data = await systemService.notify(call.request); const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
});
callback(null, data); callback(null, data);
} catch (e: any) { } catch (e: any) {
callback(e); callback(e);
} }
}; };
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
return {
...data,
sub_id: data.sub_id ?? undefined,
extra_schedules: data.extra_schedules ?? undefined,
pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined,
};
};
export const getCronDetail = async (
call: ServerUnaryCall<CronDetailRequest, CronDetailResponse>,
callback: sendUnaryData<CronDetailResponse>,
) => {
try {
if (!call.request.log_path) {
return callback(null, {
code: 400,
data: undefined,
message: 'log_path is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.find({
log_path: call.request.log_path,
})) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const createCron = async (
call: ServerUnaryCall<CreateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const data = (await cronService.create(call.request)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const updateCron = async (
call: ServerUnaryCall<UpdateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const { id, ...fields } = call.request;
const updateRequest = {
id,
...Object.entries(fields).reduce((acc: any, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {}),
} as UpdateCronRequest;
const data = (await cronService.update(updateRequest)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const deleteCrons = async (
call: ServerUnaryCall<DeleteCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const cronService = Container.get(CronService);
await cronService.remove(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
) => {
try {
const cronService = Container.get(CronService);
const result = await cronService.crontabs({
searchValue: call.request.searchValue || '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
});
const data = result.data.map((x) => normalizeCronData(x as CronItem));
callback(null, {
code: 200,
data: data.filter((x): x is CronItem => x !== undefined),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(null, {
code: 404,
data: undefined,
message: e.message,
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+1 -1
View File
@@ -10,7 +10,7 @@ import config from '../config';
class Client { class Client {
private client = new CronClient( private client = new CronClient(
`0.0.0.0:${config.cronPort}`, `0.0.0.0:${config.grpcPort}`,
credentials.createInsecure(), credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 }, { 'grpc.enable_http_proxy': 0 },
); );
+1 -1
View File
@@ -10,7 +10,7 @@ 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(
'[schedule][取消定时任务], 任务ID: %s', '[schedule][取消定时任务] 任务ID: %s',
id, id,
); );
scheduleStacks.get(id)?.forEach(x => x.cancel()); scheduleStacks.get(id)?.forEach(x => x.cancel());
+3 -6
View File
@@ -17,14 +17,11 @@ const check = async (
return callback(null, { status: 1 }); return callback(null, { status: 1 });
} }
const panelErrLog = await promiseExec( const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`, `tail -n 300 ~/.pm2/logs/qinglong-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
); );
return callback( return callback(
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()), new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
); );
default: default:
-27
View File
@@ -1,27 +0,0 @@
import { Server, ServerCredentials } from '@grpc/grpc-js';
import { CronService } from '../protos/cron';
import { addCron } from './addCron';
import { delCron } from './delCron';
import { HealthService } from '../protos/health';
import { check } from './health';
import config from '../config';
import Logger from '../loaders/logger';
import { ApiService } from '../protos/api';
import * as Api from './api';
const server = new Server({ 'grpc.enable_http_proxy': 0 });
server.addService(HealthService, { check });
server.addService(CronService, { addCron, delCron });
server.addService(ApiService, Api);
server.bindAsync(
`0.0.0.0:${config.cronPort}`,
ServerCredentials.createInsecure(),
(err, port) => {
if (err) {
throw err;
}
Logger.debug(`✌️ 定时服务启动成功!`);
console.debug(`✌️ 定时服务启动成功!`);
process.send?.('ready');
},
);
+3 -3
View File
@@ -3,7 +3,7 @@ import path, { join } from 'path';
import config from '../config'; import config from '../config';
import { getFileContentByName } from '../config/util'; import { getFileContentByName } from '../config/util';
import { Response } from 'express'; import { Response } from 'express';
import got from 'got'; import { request } from 'undici';
@Service() @Service()
export default class ConfigService { export default class ConfigService {
@@ -27,10 +27,10 @@ export default class ConfigService {
} }
if (filePath.startsWith('sample/')) { if (filePath.startsWith('sample/')) {
const res = await got.get( const res = await request(
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`, `https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
); );
content = res.body; content = await res.body.text();
} else if (filePath.startsWith('data/scripts/')) { } else if (filePath.startsWith('data/scripts/')) {
content = await getFileContentByName(join(config.rootPath, filePath)); content = await getFileContentByName(join(config.rootPath, filePath));
} else { } else {
+162 -58
View File
@@ -4,13 +4,15 @@ import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs/promises'; import fs from 'fs/promises';
import cron_parser from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { import {
getFileContentByName, getFileContentByName,
fileExist, fileExist,
killTask, killTask,
killAllTasks,
getUniqPath, getUniqPath,
safeJSONParse, safeJSONParse,
isDemoEnv,
} from '../config/util'; } from '../config/util';
import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize'; import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize';
import path from 'path'; import path from 'path';
@@ -22,6 +24,8 @@ import dayjs from 'dayjs';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class CronService { export default class CronService {
@@ -35,22 +39,60 @@ export default class CronService {
return false; return false;
} }
private isOnceSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.ONCE);
}
private isBootSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.BOOT);
}
private isSpecialSchedule(schedule?: string) {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
}
private async getLogName(cron: Crontab) {
const { log_name, command, id } = cron;
if (log_name === '/dev/null') {
return log_name;
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
}
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
return uniqPath;
}
public async create(payload: Crontab): Promise<Crontab> { public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload); const tab = new Crontab(payload);
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab); const doc = await this.insert(tab);
if (this.isNodeCron(doc)) {
if (isDemoEnv()) {
return doc;
}
if (!this.isSpecialSchedule(doc.schedule)) {
await cronClient.addCron([ await cronClient.addCron([
{ {
name: doc.name || '', name: doc.name || '',
id: String(doc.id), id: String(doc.id),
schedule: doc.schedule!, schedule: doc.schedule!,
command: this.makeCommand(doc), command: this.makeCommand(doc),
extraSchedules: doc.extra_schedules || [], extra_schedules: doc.extra_schedules || [],
}, },
]); ]);
} }
await this.set_crontab();
await this.setCrontab();
return doc; return doc;
} }
@@ -58,29 +100,32 @@ export default class CronService {
return await CrontabModel.create(payload, { returning: true }); return await CrontabModel.create(payload, { returning: true });
} }
public async update(payload: Crontab): Promise<Crontab> { public async update(payload: Partial<Crontab>): Promise<Crontab> {
const doc = await this.getDb({ id: payload.id }); const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload }); const tab = new Crontab({ ...doc, ...payload });
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1) {
if (doc.isDisabled === 1 || isDemoEnv()) {
return newDoc; return newDoc;
} }
if (this.isNodeCron(doc)) {
await cronClient.delCron([String(doc.id)]); await cronClient.delCron([String(newDoc.id)]);
}
if (this.isNodeCron(newDoc)) { if (!this.isSpecialSchedule(newDoc.schedule)) {
await cronClient.addCron([ await cronClient.addCron([
{ {
name: doc.name || '', name: doc.name || '',
id: String(newDoc.id), id: String(newDoc.id),
schedule: newDoc.schedule!, schedule: newDoc.schedule!,
command: this.makeCommand(newDoc), command: this.makeCommand(newDoc),
extraSchedules: newDoc.extra_schedules || [], extra_schedules: newDoc.extra_schedules || [],
}, },
]); ]);
} }
await this.set_crontab();
await this.setCrontab();
return newDoc; return newDoc;
} }
@@ -135,7 +180,7 @@ export default class CronService {
public async remove(ids: number[]) { public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } }); await CrontabModel.destroy({ where: { id: ids } });
await cronClient.delCron(ids.map(String)); await cronClient.delCron(ids.map(String));
await this.set_crontab(); await this.setCrontab();
} }
public async pin(ids: number[]) { public async pin(ids: number[]) {
@@ -179,8 +224,8 @@ export default class CronService {
for (const col of viewQuery.filters) { for (const col of viewQuery.filters) {
const { property, value, operation } = col; const { property, value, operation } = col;
let q: any = {}; let q: any = {};
let operate2 = null; let operate2: any = null;
let operate = null; let operate: any = null;
switch (operation) { switch (operation) {
case 'Reg': case 'Reg':
operate = Op.like; operate = Op.like;
@@ -191,22 +236,39 @@ export default class CronService {
operate2 = Op.and; operate2 = Op.and;
break; break;
case 'In': case 'In':
if (
property === 'status' &&
!value.includes(CrontabStatus.disabled)
) {
q[Op.and] = [
{ [property]: Array.isArray(value) ? value : [value] },
{ isDisabled: 0 },
];
} else {
q[Op.or] = [ q[Op.or] = [
{ {
[property]: Array.isArray(value) ? value : [value], [property]: Array.isArray(value) ? value : [value],
}, },
property === 'status' && value.includes(2) property === 'status' && value.includes(CrontabStatus.disabled)
? { isDisabled: 1 } ? { isDisabled: 1 }
: {}, : {},
]; ];
}
break; break;
case 'Nin': case 'Nin':
q[Op.and] = [ q[Op.and] = [
{
[Op.or]: [
{ {
[property]: { [property]: {
[Op.notIn]: Array.isArray(value) ? value : [value], [Op.notIn]: Array.isArray(value) ? value : [value],
}, },
}, },
{
[property]: { [Op.is]: null },
},
],
},
property === 'status' && value.includes(2) property === 'status' && value.includes(2)
? { isDisabled: { [Op.ne]: 1 } } ? { isDisabled: { [Op.ne]: 1 } }
: {}, : {},
@@ -320,10 +382,10 @@ export default class CronService {
log_path, log_path,
}: { }: {
log_path: string; log_path: string;
}): Promise<Crontab | null> { }): Promise<Crontab | undefined> {
try { try {
const result = await CrontabModel.findOne({ where: { log_path } }); const result = await CrontabModel.findOne({ where: { log_path } });
return result; return result?.get({ plain: true });
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -374,7 +436,7 @@ export default class CronService {
try { try {
const result = await CrontabModel.findAll(condition); const result = await CrontabModel.findAll(condition);
const count = await CrontabModel.count({ where: query }); const count = await CrontabModel.count({ where: query });
return { data: result, total: count }; return { data: result.map((x) => x.get({ plain: true })), total: count };
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -401,12 +463,17 @@ export default class CronService {
public async stop(ids: number[]) { public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
if (doc.pid) { // Kill all running instances of this task
try { try {
await killTask(doc.pid); const command = this.makeCommand(doc);
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
);
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(
} `[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
} }
} }
@@ -424,7 +491,7 @@ export default class CronService {
name: cron.name, name: cron.name,
command: cron.command, command: cron.command,
schedule: cron.schedule, schedule: cron.schedule,
extraSchedules: cron.extra_schedules, extra_schedules: cron.extra_schedules,
}; };
if (cron.status !== CrontabStatus.queued) { if (cron.status !== CrontabStatus.queued) {
resolve(params); resolve(params);
@@ -435,13 +502,15 @@ export default class CronService {
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
); );
let { id, command, log_path } = cron; let { id, command, log_name } = cron;
const uniqPath = await getUniqPath(command, `${id}`);
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`); const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
await fs.mkdir(logDirPath, { recursive: true }); await fs.mkdir(logDirPath, { recursive: true });
}
const logPath = `${uniqPath}/${logTime}.log`; const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`); const absolutePath = path.resolve(config.logPath, `${logPath}`);
const cp = spawn( const cp = spawn(
@@ -457,7 +526,7 @@ export default class CronService {
{ where: { id } }, { where: { id } },
); );
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
await fs.appendFile(absolutePath, data.toString()); await logStreamManager.write(absolutePath, data.toString());
}); });
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
this.logger.info( this.logger.info(
@@ -465,7 +534,7 @@ export default class CronService {
command, command,
data.toString(), data.toString(),
); );
await fs.appendFile(absolutePath, data.toString()); await logStreamManager.write(absolutePath, data.toString());
}); });
cp.on('error', async (err) => { cp.on('error', async (err) => {
this.logger.error( this.logger.error(
@@ -473,7 +542,7 @@ export default class CronService {
command, command,
err, err,
); );
await fs.appendFile(absolutePath, JSON.stringify(err)); await logStreamManager.write(absolutePath, JSON.stringify(err));
}); });
cp.on('exit', async (code) => { cp.on('exit', async (code) => {
@@ -482,6 +551,8 @@ export default class CronService {
JSON.stringify(params), JSON.stringify(params),
code, code,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined }, { status: CrontabStatus.idle, pid: undefined },
{ where: { id } }, { where: { id } },
@@ -495,23 +566,26 @@ export default class CronService {
public async disabled(ids: number[]) { public async disabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await cronClient.delCron(ids.map(String)); await cronClient.delCron(ids.map(String));
await this.set_crontab(); await this.setCrontab();
} }
public async enabled(ids: number[]) { public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
const sixCron = docs const crons = docs.map((doc) => ({
.filter((x) => this.isNodeCron(x))
.map((doc) => ({
name: doc.name || '', name: doc.name || '',
id: String(doc.id), id: String(doc.id),
schedule: doc.schedule!, schedule: doc.schedule!,
command: this.makeCommand(doc), command: this.makeCommand(doc),
extraSchedules: doc.extra_schedules || [], extra_schedules: doc.extra_schedules || [],
})); }));
await cronClient.addCron(sixCron);
await this.set_crontab(); if (isDemoEnv()) {
return;
}
await cronClient.addCron(crons);
await this.setCrontab();
} }
public async log(id: number) { public async log(id: number) {
@@ -519,13 +593,18 @@ export default class CronService {
if (!doc) { if (!doc) {
return ''; return '';
} }
if (doc.log_name === '/dev/null') {
return '日志设置为忽略';
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath)); const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) { if (logFileExist) {
return await getFileContentByName(`${absolutePath}`); return await getFileContentByName(`${absolutePath}`);
} else { } else {
return '任务未运行'; return typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? '运行中...'
: '日志不存在...';
} }
} }
@@ -545,7 +624,7 @@ export default class CronService {
files.map(async (x) => ({ files.map(async (x) => ({
filename: x, filename: x,
directory: relativeDir.replace(config.logPath, ''), directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(), time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})), })),
) )
).sort((a, b) => b.time - a.time); ).sort((a, b) => b.time - a.time);
@@ -559,9 +638,11 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`; command = `${TASK_PREFIX}${tab.command}`;
} }
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${ let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
tab.id // Only include log_name if it has a truthy value to avoid passing null/undefined to shell
} `; if (tab.log_name) {
commandVariable += `log_name=${tab.log_name} `;
}
if (tab.task_before) { if (tab.task_before) {
commandVariable += `task_before='${tab.task_before commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''") .replace(/'/g, "'\\''")
@@ -579,15 +660,14 @@ export default class CronService {
return crontab_job_string; return crontab_job_string;
} }
private async set_crontab(data?: { data: Crontab[]; total: number }) { private async setCrontab(data?: { data: Crontab[]; total: number }) {
const tabs = data ?? (await this.crontabs()); const tabs = data ?? (await this.crontabs());
var crontab_string = ''; var crontab_string = '';
tabs.data.forEach((tab) => { tabs.data.forEach((tab) => {
const _schedule = tab.schedule && tab.schedule.split(/ +/);
if ( if (
tab.isDisabled === 1 || tab.isDisabled === 1 ||
_schedule!.length !== 5 || this.isNodeCron(tab) ||
tab.extra_schedules?.length this.isSpecialSchedule(tab.schedule)
) { ) {
crontab_string += '# '; crontab_string += '# ';
crontab_string += tab.schedule; crontab_string += tab.schedule;
@@ -604,12 +684,16 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string); await writeFileWithLock(config.crontabFile, crontab_string);
execSync(`crontab ${config.crontabFile}`);
await CrontabModel.update({ saved: true }, { where: {} }); await CrontabModel.update({ saved: true }, { where: {} });
} }
public import_crontab() { public importCrontab() {
exec('crontab -l', (error, stdout, stderr) => { exec('crontab -l', (error, stdout) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
const lines = stdout.split('\n'); const lines = stdout.split('\n');
const namePrefix = new Date().getTime(); const namePrefix = new Date().getTime();
@@ -623,7 +707,7 @@ export default class CronService {
if ( if (
command && command &&
schedule && schedule &&
cron_parser.parseExpression(schedule).hasNext() CronExpressionParser.parse(schedule).hasNext()
) { ) {
const name = namePrefix + '_' + index; const name = namePrefix + '_' + index;
@@ -644,17 +728,37 @@ export default class CronService {
public async autosave_crontab() { public async autosave_crontab() {
const tabs = await this.crontabs(); const tabs = await this.crontabs();
this.set_crontab(tabs); const regularCrons = tabs.data
.filter((x) => x.isDisabled !== 1 && !this.isSpecialSchedule(x.schedule))
const sixCron = tabs.data
.filter((x) => this.isNodeCron(x) && x.isDisabled !== 1)
.map((doc) => ({ .map((doc) => ({
name: doc.name || '', name: doc.name || '',
id: String(doc.id), id: String(doc.id),
schedule: doc.schedule!, schedule: doc.schedule!,
command: this.makeCommand(doc), command: this.makeCommand(doc),
extraSchedules: doc.extra_schedules || [], extra_schedules: doc.extra_schedules || [],
})); }));
await cronClient.addCron(sixCron);
if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, '');
return;
}
await cronClient.addCron(regularCrons);
this.setCrontab(tabs);
}
public async bootTask() {
const tabs = await this.crontabs();
const bootTasks = tabs.data.filter(
(x) => !x.isDisabled && this.isBootSchedule(x.schedule),
);
if (bootTasks.length > 0) {
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: bootTasks.map((t) => t.id!) } },
);
for (const task of bootTasks) {
await this.runSingle(task.id!);
}
}
} }
} }
+91 -54
View File
@@ -3,12 +3,9 @@ import winston from 'winston';
import config from '../config'; import config from '../config';
import { import {
Dependence, Dependence,
InstallDependenceCommandTypes,
DependenceStatus, DependenceStatus,
DependenceTypes, DependenceTypes,
unInstallDependenceCommandTypes,
DependenceModel, DependenceModel,
GetDependenceCommandTypes,
versionDependenceCommandTypes, versionDependenceCommandTypes,
} from '../data/dependence'; } from '../data/dependence';
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
@@ -19,9 +16,14 @@ import {
getPid, getPid,
killTask, killTask,
promiseExecSuccess, promiseExecSuccess,
getInstallCommand,
getUninstallCommand,
getGetCommand,
} 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 { LINUX_DEPENDENCE_COMMAND } from '../config/const';
@Service() @Service()
export default class DependenceService { export default class DependenceService {
@@ -67,6 +69,9 @@ export default class DependenceService {
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.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
const unInstalledDeps = docs.filter( const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed, (x) => x.status !== DependenceStatus.installed,
); );
@@ -95,34 +100,32 @@ export default class DependenceService {
searchValue, searchValue,
type, type,
status, status,
}: { searchValue: string; type: string; status: string }, }: {
searchValue: string;
type: keyof typeof DependenceTypes;
status: string;
},
sort: any = [], sort: any = [],
query: any = {}, query: any = {},
): Promise<Dependence[]> { ): Promise<Dependence[]> {
let condition = { let condition = query;
...query, if (DependenceTypes[type]) {
type: DependenceTypes[type as any], condition.type = DependenceTypes[type];
}; }
if (status) { if (status) {
condition.status = status.split(',').map(Number); condition.status = status.split(',').map(Number);
} }
if (searchValue) { if (searchValue) {
const encodeText = encodeURI(searchValue); const encodeText = encodeURI(searchValue);
const reg = { condition.name = {
[Op.or]: [ [Op.or]: [
{ [Op.like]: `%${searchValue}%` }, { [Op.like]: `%${searchValue}%` },
{ [Op.like]: `%${encodeText}%` }, { [Op.like]: `%${encodeText}%` },
], ],
}; };
condition = {
...condition,
name: reg,
};
} }
try { try {
const result = await this.find(condition, sort); return await this.find(condition, sort);
return result as any;
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -132,10 +135,12 @@ export default class DependenceService {
docs: Dependence[], docs: Dependence[],
isInstall: boolean = true, isInstall: boolean = true,
force: boolean = false, force: boolean = false,
) { ): Promise<void> {
docs.forEach((dep) => { docs.forEach((dep) => {
this.installOrUninstallDependency(dep, isInstall, force); this.installOrUninstallDependency(dep, isInstall, force);
}); });
return taskLimit.waitDependencyQueueDone();
} }
public async reInstall(ids: number[]): Promise<Dependence[]> { public async reInstall(ids: number[]): Promise<Dependence[]> {
@@ -145,6 +150,9 @@ export default class DependenceService {
); );
const docs = await DependenceModel.findAll({ where: { id: ids } }); const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
this.installDependenceOneByOne(docs, true, true); this.installDependenceOneByOne(docs, true, true);
return docs; return docs;
} }
@@ -153,13 +161,22 @@ export default class DependenceService {
const docs = await DependenceModel.findAll({ where: { 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);
const depInstallCommand = InstallDependenceCommandTypes[doc.type]; let depInstallCommand = getInstallCommand(doc.type, doc.name);
const depUnInstallCommand = unInstallDependenceCommandTypes[doc.type]; let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const installCmd = `${depInstallCommand} ${doc.name.trim()}`; const isLinuxDependence = doc.type === DependenceTypes.linux;
const unInstallCmd = `${depUnInstallCommand} ${doc.name.trim()}`;
if (isLinuxDependence) {
const osType = await detectOS();
if (!osType) {
continue;
}
const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`;
depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`;
}
const pids = await Promise.all([ const pids = await Promise.all([
getPid(installCmd), getPid(depInstallCommand),
getPid(unInstallCmd), getPid(depUnInstallCommand),
]); ]);
for (const pid of pids) { for (const pid of pids) {
pid && (await killTask(pid)); pid && (await killTask(pid));
@@ -213,25 +230,54 @@ export default class DependenceService {
if (taskLimit.firstDependencyId !== dependency.id) { if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null); return resolve(null);
} }
taskLimit.removeQueuedDependency(dependency);
const depIds = [dependency.id!]; const depIds = [dependency.id!];
let depName = dependency.name.trim();
const actionText = isInstall ? '安装' : '删除';
const socketMessageType = isInstall
? 'installDependence'
: 'uninstallDependence';
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence = dependency.type === DependenceTypes.python3;
const osType = await detectOS();
let linuxCommand = {} as typeof LINUX_DEPENDENCE_COMMAND.Alpine;
taskLimit.removeQueuedDependency(dependency);
if (isLinuxDependence) {
if (!osType) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: depIds } },
);
const startTime = dayjs();
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
'YYYY-MM-DD HH:mm:ss',
)}\n\n当前系统不支持\n\n依赖${actionText}失败,结束时间 ${startTime.format(
'YYYY-MM-DD HH:mm:ss',
)},耗时 ${startTime.diff(startTime, 'second')}`;
this.sockService.sendMessage({
type: socketMessageType,
message,
references: depIds,
});
this.updateLog(depIds, message);
return resolve(null);
}
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.update({ status }, { where: { id: depIds } });
const socketMessageType = isInstall let command = isInstall
? 'installDependence' ? getInstallCommand(dependency.type, depName)
: 'uninstallDependence'; : getUninstallCommand(dependency.type, depName);
let depName = dependency.name.trim(); if (isLinuxDependence) {
const depRunCommand = ( command = isInstall
isInstall ? `${linuxCommand.install} ${depName.trim()}`
? InstallDependenceCommandTypes : `${linuxCommand.uninstall} ${depName.trim()}`;
: unInstallDependenceCommandTypes }
)[dependency.type];
const actionText = isInstall ? '安装' : '删除';
const startTime = dayjs(); const startTime = dayjs();
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
@@ -246,8 +292,12 @@ export default class DependenceService {
// 判断是否已经安装过依赖 // 判断是否已经安装过依赖
if (isInstall && !force) { if (isInstall && !force) {
const getCommandPrefix = GetDependenceCommandTypes[dependency.type]; let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type]; const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
getCommand = `${linuxCommand.info} ${depName}`;
}
let depVersion = ''; let depVersion = '';
if (depName.includes(depVersionStr)) { if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp( const symbolRegx = new RegExp(
@@ -259,17 +309,7 @@ export default class DependenceService {
depVersion = _depVersion; depVersion = _depVersion;
} }
} }
const isNodeDependence = dependency.type === DependenceTypes.nodejs; const depInfo = (await promiseExecSuccess(getCommand))
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence =
dependency.type === DependenceTypes.python3;
const depInfo = (
await promiseExecSuccess(
isNodeDependence
? `${getCommandPrefix} | grep "${depName}" | head -1`
: `${getCommandPrefix} ${depName}`,
)
)
.replace(/\s{2,}/, ' ') .replace(/\s{2,}/, ' ')
.replace(/\s+$/, ''); .replace(/\s+$/, '');
@@ -277,7 +317,7 @@ export default class DependenceService {
depInfo && depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) || ((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
(isLinuxDependence && (isLinuxDependence &&
depInfo.toLocaleLowerCase().includes('installed')) || linuxCommand.check(depInfo.toLocaleLowerCase())) ||
isPythonDependence) && isPythonDependence) &&
(!depVersion || depInfo.includes(depVersion)) (!depVersion || depInfo.includes(depVersion))
) { ) {
@@ -304,12 +344,9 @@ export default class DependenceService {
const proxyStr = dependenceProxyFileExist const proxyStr = dependenceProxyFileExist
? `source ${config.dependenceProxyFile} &&` ? `source ${config.dependenceProxyFile} &&`
: ''; : '';
const cp = spawn( const cp = spawn(`${proxyStr} ${command}`, {
`${proxyStr} ${depRunCommand} ${dependency.name.trim()}`,
{
shell: '/bin/bash', shell: '/bin/bash',
}, });
);
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
+12 -4
View File
@@ -1,7 +1,8 @@
import { Service, Inject } from 'typedi'; import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { Inject, Service } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises';
import { import {
Env, Env,
EnvModel, EnvModel,
@@ -11,8 +12,6 @@ import {
minPosition, minPosition,
stepPosition, stepPosition,
} from '../data/env'; } from '../data/env';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
@Service() @Service()
@@ -147,6 +146,7 @@ export default class EnvService {
} }
try { try {
const result = await this.find(condition, [ const result = await this.find(condition, [
['isPinned', 'DESC'],
['position', 'DESC'], ['position', 'DESC'],
['createdAt', 'ASC'], ['createdAt', 'ASC'],
]); ]);
@@ -190,6 +190,14 @@ export default class EnvService {
await this.set_envs(); await this.set_envs();
} }
public async pin(ids: number[]) {
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } });
}
public async unPin(ids: number[]) {
await EnvModel.update({ isPinned: 0 }, { where: { 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: { [Op.not]: null },
+64
View File
@@ -0,0 +1,64 @@
import { Server, ServerCredentials } from '@grpc/grpc-js';
import { CronService } from '../protos/cron';
import { HealthService } from '../protos/health';
import { ApiService } from '../protos/api';
import { addCron } from '../schedule/addCron';
import { delCron } from '../schedule/delCron';
import { check } from '../schedule/health';
import * as Api from '../schedule/api';
import Logger from '../loaders/logger';
import { promisify } from 'util';
import config from '../config';
import { metricsService } from './metrics';
import { Service } from 'typedi';
@Service()
export class GrpcServerService {
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
async initialize() {
try {
this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron });
this.server.addService(ApiService, Api);
const grpcPort = config.grpcPort;
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
await bindAsync(
`0.0.0.0:${grpcPort}`,
ServerCredentials.createInsecure(),
);
Logger.debug(`✌️ gRPC service started successfully`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
});
return grpcPort;
} catch (err) {
Logger.error('Failed to start gRPC service:', err);
throw err;
}
}
async shutdown() {
try {
if (this.server) {
await new Promise((resolve) => {
this.server.tryShutdown(() => {
Logger.debug('gRPC service stopped');
metricsService.record('grpc_service_stop', 1);
resolve(null);
});
});
}
} catch (err) {
Logger.error('Error while shutting down gRPC service:', err);
throw err;
}
}
getServer() {
return this.server;
}
}
+72
View File
@@ -0,0 +1,72 @@
import { Service } from 'typedi';
import Logger from '../loaders/logger';
import { GrpcServerService } from './grpc';
import { HttpServerService } from './http';
interface HealthStatus {
status: 'ok' | 'error';
services: {
http: boolean;
grpc: boolean;
};
metrics: {
uptime: number;
memory: {
used: number;
total: number;
};
};
}
@Service()
export class HealthService {
private startTime = Date.now();
constructor(
private grpcServerService: GrpcServerService,
private httpServerService: HttpServerService,
) {}
async check(): Promise<HealthStatus> {
const status: HealthStatus = {
status: 'ok',
services: {
http: true,
grpc: true,
},
metrics: {
uptime: Math.floor((Date.now() - this.startTime) / 1000),
memory: {
used: process.memoryUsage().heapUsed,
total: process.memoryUsage().heapTotal,
},
},
};
try {
const httpServer = this.httpServerService.getServer();
if (!httpServer) {
status.services.http = false;
status.status = 'error';
}
} catch (err) {
status.services.http = false;
status.status = 'error';
Logger.error('HTTP server check failed:', err);
}
try {
const grpcServer = this.grpcServerService.getServer();
if (!grpcServer) {
status.services.grpc = false;
status.status = 'error';
}
} catch (err) {
status.services.grpc = false;
status.status = 'error';
Logger.error('gRPC server check failed:', err);
}
return status;
}
}
+53
View File
@@ -0,0 +1,53 @@
import express from 'express';
import Logger from '../loaders/logger';
import { metricsService } from './metrics';
import { Service } from 'typedi';
import { Server } from 'http';
@Service()
export class HttpServerService {
private server?: Server = undefined;
async initialize(expressApp: express.Application, port: number) {
try {
return new Promise((resolve, reject) => {
this.server = expressApp.listen(port, '0.0.0.0', () => {
Logger.debug(`✌️ HTTP service started successfully`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
});
resolve(this.server);
});
this.server?.on('error', (err: Error) => {
Logger.error('Failed to start HTTP service:', err);
reject(err);
});
});
} catch (err) {
Logger.error('Failed to start HTTP service:', err);
throw err;
}
}
async shutdown() {
try {
if (this.server) {
await new Promise((resolve) => {
this.server?.close(() => {
Logger.debug('HTTP service stopped');
metricsService.record('http_service_stop', 1);
resolve(null);
});
});
}
} catch (err) {
Logger.error('Error while shutting down HTTP service:', err);
throw err;
}
}
getServer() {
return this.server;
}
}
+14
View File
@@ -0,0 +1,14 @@
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
@Service()
export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.logPath, filePath, fileName);
return finalPath.startsWith(config.logPath) ? finalPath : '';
}
}
+92
View File
@@ -0,0 +1,92 @@
import { performance } from 'perf_hooks';
import Logger from '../loaders/logger';
interface Metric {
name: string;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
class MetricsService {
private metrics: Metric[] = [];
private static instance: MetricsService;
private constructor() {
// 定期清理旧数据
setInterval(() => {
const oneHourAgo = Date.now() - 3600000;
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
}, 60000);
}
static getInstance(): MetricsService {
if (!MetricsService.instance) {
MetricsService.instance = new MetricsService();
}
return MetricsService.instance;
}
record(name: string, value: number, tags?: Record<string, string>) {
this.metrics.push({
name,
value,
timestamp: Date.now(),
tags,
});
}
measure(name: string, fn: () => void, tags?: Record<string, string>) {
const start = performance.now();
try {
fn();
} finally {
const duration = performance.now() - start;
this.record(name, duration, tags);
}
}
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) {
const start = performance.now();
try {
await fn();
} finally {
const duration = performance.now() - start;
this.record(name, duration, tags);
}
}
getMetrics(name?: string, tags?: Record<string, string>) {
let filtered = this.metrics;
if (name) {
filtered = filtered.filter(m => m.name === name);
}
if (tags) {
filtered = filtered.filter(m => {
if (!m.tags) return false;
return Object.entries(tags).every(([key, value]) => m.tags![key] === value);
});
}
return {
count: filtered.length,
average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length,
min: Math.min(...filtered.map(m => m.value)),
max: Math.max(...filtered.map(m => m.value)),
metrics: filtered,
};
}
report() {
const report = {
timestamp: Date.now(),
metrics: this.getMetrics(),
};
Logger.info('性能指标报告:', report);
return report;
}
}
export const metricsService = MetricsService.getInstance();
+119 -107
View File
@@ -1,11 +1,11 @@
import crypto from 'crypto'; import crypto from 'crypto';
import got from 'got';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { parseBody, parseHeaders } from '../config/util'; import { parseBody, parseHeaders } from '../config/util';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import UserService from './user'; import UserService from './user';
import { httpClient } from '../config/http';
import { ProxyAgent } from 'undici';
@Service() @Service()
export default class NotificationService { export default class NotificationService {
@@ -49,17 +49,26 @@ export default class NotificationService {
public async notify( public async notify(
title: string, title: string,
content: string, content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
const { type, ...rest } = await this.userService.getNotificationMode(); let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) { if (type) {
this.title = title; this.title = title;
this.content = content; this.content = content;
this.params = rest; let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type); const notificationModeAction = this.modeMap.get(type);
try { try {
return await notificationModeAction?.call(this); return await notificationModeAction?.call(this);
} catch (error: any) { } catch (error: any) {
throw error; console.error(error);
} }
} }
return false; return false;
@@ -84,8 +93,9 @@ export default class NotificationService {
private async gotify() { private async gotify() {
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params; const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try { try {
const res: any = await got const res = await httpClient.post(
.post(`${gotifyUrl}/message?token=${gotifyToken}`, { `${gotifyUrl}/message?token=${gotifyToken}`,
{
...this.gotOption, ...this.gotOption,
body: `title=${encodeURIComponent( body: `title=${encodeURIComponent(
this.title, this.title,
@@ -95,8 +105,8 @@ export default class NotificationService {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
}) },
.json(); );
if (typeof res.id === 'number') { if (typeof res.id === 'number') {
return true; return true;
} else { } else {
@@ -110,13 +120,11 @@ export default class NotificationService {
private async goCqHttpBot() { private async goCqHttpBot() {
const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params; const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params;
try { try {
const res: any = await got const res = await httpClient.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
...this.gotOption, ...this.gotOption,
json: { message: `${this.title}\n${this.content}` }, json: { message: `${this.title}\n${this.content}` },
headers: { Authorization: 'Bearer ' + goCqHttpBotToken }, headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
}) });
.json();
if (res.retcode === 0) { if (res.retcode === 0) {
return true; return true;
} else { } else {
@@ -136,15 +144,13 @@ export default class NotificationService {
: `https://sctapi.ftqq.com/${serverChanKey}.send`; : `https://sctapi.ftqq.com/${serverChanKey}.send`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `title=${encodeURIComponent( body: `title=${encodeURIComponent(
this.title, this.title,
)}&desp=${encodeURIComponent(this.content)}`, )}&desp=${encodeURIComponent(this.content)}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}) });
.json();
if (res.errno === 0 || res.data.errno === 0) { if (res.errno === 0 || res.data.errno === 0) {
return true; return true;
} else { } else {
@@ -159,15 +165,13 @@ export default class NotificationService {
const { pushDeerKey, pushDeerUrl } = this.params; const { pushDeerKey, pushDeerUrl } = this.params;
const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`; const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent( body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
this.title, this.title,
)}&desp=${encodeURIComponent(this.content)}&type=markdown`, )}&desp=${encodeURIComponent(this.content)}&type=markdown`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}) });
.json();
if ( if (
res.content.result.length !== undefined && res.content.result.length !== undefined &&
res.content.result.length > 0 res.content.result.length > 0
@@ -182,16 +186,13 @@ export default class NotificationService {
} }
private async chat() { private async chat() {
const { chatUrl, chatToken } = this.params; const { synologyChatUrl } = this.params;
const url = `${chatUrl}${chatToken}`;
try { try {
const res: any = await got const res = await httpClient.post(synologyChatUrl, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `payload={"text":"${this.title}\n${this.content}"}`, body: `payload={"text":"${this.title}\n${this.content}"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}) });
.json();
if (res.success) { if (res.success) {
return true; return true;
} else { } else {
@@ -227,13 +228,11 @@ export default class NotificationService {
url: barkUrl, url: barkUrl,
}; };
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: body, json: body,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
.json();
if (res.code === 200) { if (res.code === 200) {
return true; return true;
} else { } else {
@@ -259,29 +258,17 @@ export default class NotificationService {
}/bot${telegramBotToken}/sendMessage`; }/bot${telegramBotToken}/sendMessage`;
let agent; let agent;
if (telegramBotProxyHost && telegramBotProxyPort) { if (telegramBotProxyHost && telegramBotProxyPort) {
const options: any = { agent = new ProxyAgent({
keepAlive: true, uri: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
keepAliveMsecs: 1000, });
maxSockets: 256,
maxFreeSockets: 256,
proxy: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
};
const httpAgent = new HttpProxyAgent(options);
const httpsAgent = new HttpsProxyAgent(options);
agent = {
http: httpAgent,
https: httpsAgent,
};
} }
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`, body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
agent, dispatcher: agent,
}) });
.json();
if (res.ok) { if (res.ok) {
return true; return true;
} else { } else {
@@ -304,8 +291,7 @@ export default class NotificationService {
} }
const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`; const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
msgtype: 'text', msgtype: 'text',
@@ -313,8 +299,7 @@ export default class NotificationService {
content: ` ${this.title}\n\n${this.content}`, content: ` ${this.title}\n\n${this.content}`,
}, },
}, },
}) });
.json();
if (res.errcode === 0) { if (res.errcode === 0) {
return true; return true;
} else { } else {
@@ -330,8 +315,7 @@ export default class NotificationService {
this.params; this.params;
const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`; const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
msgtype: 'text', msgtype: 'text',
@@ -339,8 +323,7 @@ export default class NotificationService {
content: ` ${this.title}\n\n${this.content}`, content: ` ${this.title}\n\n${this.content}`,
}, },
}, },
}) });
.json();
if (res.errcode === 0) { if (res.errcode === 0) {
return true; return true;
} else { } else {
@@ -357,15 +340,13 @@ export default class NotificationService {
const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] = const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] =
weWorkAppKey.split(','); weWorkAppKey.split(',');
const url = `${weWorkOrigin}/cgi-bin/gettoken`; const url = `${weWorkOrigin}/cgi-bin/gettoken`;
const tokenRes: any = await got const tokenRes = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
corpid, corpid,
corpsecret, corpsecret,
}, },
}) });
.json();
let options: any = { let options: any = {
msgtype: 'mpnews', msgtype: 'mpnews',
@@ -406,8 +387,7 @@ export default class NotificationService {
} }
try { try {
const res: any = await got const res = await httpClient.post(
.post(
`${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`, `${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
{ {
...this.gotOption, ...this.gotOption,
@@ -418,8 +398,7 @@ export default class NotificationService {
...options, ...options,
}, },
}, },
) );
.json();
if (res.errcode === 0) { if (res.errcode === 0) {
return true; return true;
@@ -461,14 +440,12 @@ export default class NotificationService {
} }
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
...json, ...json,
}, },
}) });
.json();
if (res.code === 0) { if (res.code === 0) {
return true; return true;
} else { } else {
@@ -483,13 +460,11 @@ export default class NotificationService {
const { iGotPushKey } = this.params; const { iGotPushKey } = this.params;
const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`; const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `title=${this.title}&content=${this.content}`, body: `title=${this.title}&content=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}) });
.json();
if (res.ret === 0) { if (res.ret === 0) {
return true; return true;
@@ -528,7 +503,7 @@ export default class NotificationService {
}, },
}; };
const res: any = await got.post(url, body).json(); const res = await httpClient.post(url, body);
if (res.code === 200) { if (res.code === 200) {
return true; return true;
@@ -552,8 +527,7 @@ export default class NotificationService {
const url = `https://www.weplusbot.com/send`; const url = `https://www.weplusbot.com/send`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
token: `${wePlusBotToken}`, token: `${wePlusBotToken}`,
@@ -563,8 +537,7 @@ export default class NotificationService {
receiver: `${wePlusBotReceiver || ''}`, receiver: `${wePlusBotReceiver || ''}`,
version: `${wePlusBotVersion || 'pro'}`, version: `${wePlusBotVersion || 'pro'}`,
}, },
}) });
.json();
if (res.code === 200) { if (res.code === 200) {
return true; return true;
@@ -577,23 +550,35 @@ export default class NotificationService {
} }
private async lark() { private async lark() {
let { larkKey } = this.params; let { larkKey, larkSecret } = this.params;
if (!larkKey.startsWith('http')) { if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`; larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
} }
try { const body: Record<string, any> = {
const res: any = await got
.post(larkKey, {
...this.gotOption,
json: {
msg_type: 'text', msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` }, content: { text: `${this.title}\n\n${this.content}` },
}, };
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (larkSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${larkSecret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
try {
const res = await httpClient.post(larkKey, {
...this.gotOption,
json: body,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
.json();
if (res.StatusCode === 0 || res.code === 0) { if (res.StatusCode === 0 || res.code === 0) {
return true; return true;
} else { } else {
@@ -605,7 +590,7 @@ export default class NotificationService {
} }
private async email() { private async email() {
const { emailPass, emailService, emailUser } = this.params; const { emailPass, emailService, emailUser, emailTo } = this.params;
try { try {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
@@ -618,7 +603,7 @@ export default class NotificationService {
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`, from: `"青龙快讯" <${emailUser}>`,
to: `${emailUser}`, to: emailTo ? emailTo.split(';') : emailUser,
subject: `${this.title}`, subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`, html: `${this.content.replace(/\n/g, '<br/>')}`,
}); });
@@ -638,7 +623,9 @@ export default class NotificationService {
private async pushMe() { private async pushMe() {
const { pushMeKey, pushMeUrl } = this.params; const { pushMeKey, pushMeUrl } = this.params;
try { try {
const res: any = await got.post(pushMeUrl || 'https://push.i-i.me/', { const res = await httpClient.post<'text'>(
pushMeUrl || 'https://push.i-i.me/',
{
...this.gotOption, ...this.gotOption,
json: { json: {
push_key: pushMeKey, push_key: pushMeKey,
@@ -646,11 +633,12 @@ export default class NotificationService {
content: this.content, content: this.content,
}, },
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); },
if (res.body === 'success') { );
if (res === 'success') {
return true; return true;
} else { } else {
throw new Error(res.body); throw new Error(res);
} }
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
@@ -658,26 +646,49 @@ export default class NotificationService {
} }
private async ntfy() { private async ntfy() {
const { ntfyUrl, ntfyTopic, ntfyPriority } = this.params; const {
ntfyUrl,
ntfyTopic,
ntfyPriority,
ntfyToken,
ntfyUsername,
ntfyPassword,
ntfyActions,
} = this.params;
// 编码函数 // 编码函数
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => { const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
const encodedText = Buffer.from(text).toString('base64'); const encodedText = Buffer.from(text).toString('base64');
return `=?${charset}?B?${encodedText}?=`; return `=?${charset}?B?${encodedText}?=`;
}; };
try { try {
const encodedTitle = encodeRfc2047(this.title); const headers: Record<string, string> = {
const res: any = await got.post( Title: encodeRfc2047(this.title),
Priority: `${ntfyPriority || '3'}`,
Icon: 'https://qn.whyour.cn/logo.png',
};
if (ntfyToken) {
headers['Authorization'] = `Bearer ${ntfyToken}`;
} else if (ntfyUsername && ntfyPassword) {
headers['Authorization'] = `Basic ${Buffer.from(
`${ntfyUsername}:${ntfyPassword}`,
).toString('base64')}`;
}
if (ntfyActions) {
headers['Actions'] = encodeRfc2047(ntfyActions);
}
const res = await httpClient.request(
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`, `${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
{ {
...this.gotOption, ...this.gotOption,
body: `${this.content}`, body: `${this.content}`,
headers: { Title: encodedTitle, Priority: `${ntfyPriority || '3'}` }, headers: headers,
method: 'POST',
}, },
); );
if (res.statusCode === 200) { if (res.statusCode === 200) {
return true; return true;
} else { } else {
throw new Error(JSON.stringify(res)); throw new Error(await res.body.text());
} }
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
@@ -711,8 +722,7 @@ export default class NotificationService {
const url = `https://wxpusher.zjiecode.com/api/send/message`; const url = `https://wxpusher.zjiecode.com/api/send/message`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
appToken: wxPusherBotAppToken, appToken: wxPusherBotAppToken,
@@ -723,8 +733,7 @@ export default class NotificationService {
uids: uids, uids: uids,
verifyPayType: 0, verifyPayType: 0,
}, },
}) });
.json();
if (res.code === 1000) { if (res.code === 1000) {
return true; return true;
@@ -775,15 +784,16 @@ export default class NotificationService {
}, },
], ],
}; };
const res: any = await got.post(url, { const res = await httpClient.request(url, {
...this.gotOption, ...this.gotOption,
json: data, json: data,
headers, headers,
method: 'POST',
}); });
if (res.statusCode === 200) { if (res.statusCode === 200) {
return true; return true;
} else { } else {
throw new Error(res.body); throw new Error(await res.body.text());
} }
} }
} }
@@ -802,7 +812,7 @@ export default class NotificationService {
webhookContentType, webhookContentType,
} = this.params; } = this.params;
if (!webhookUrl.includes('$title') && !webhookBody.includes('$title')) { if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
throw new Error('Url 或者 Body 中必须包含 $title'); throw new Error('Url 或者 Body 中必须包含 $title');
} }
@@ -818,15 +828,17 @@ export default class NotificationService {
allowGetBody: true, allowGetBody: true,
...bodyParam, ...bodyParam,
}; };
try { try {
const formatUrl = webhookUrl const formatUrl = webhookUrl
?.replaceAll('$title', encodeURIComponent(this.title)) ?.replaceAll('$title', encodeURIComponent(this.title))
?.replaceAll('$content', encodeURIComponent(this.content)); ?.replaceAll('$content', encodeURIComponent(this.content));
const res = await got(formatUrl, options); const res = await httpClient.request(formatUrl, options);
const text = await res.body.text();
if (String(res.statusCode).startsWith('20')) { if (String(res.statusCode).startsWith('20')) {
return true; return true;
} else { } else {
throw new Error(JSON.stringify(res)); throw new Error(await res.body.text());
} }
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
+4 -4
View File
@@ -139,7 +139,7 @@ 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', '[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
_id, _id,
schedule, schedule,
name, name,
@@ -172,7 +172,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('[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);
@@ -187,7 +187,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', '[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
_id, _id,
name, name,
command, command,
@@ -232,7 +232,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', '[panel][取消interval任务] 任务ID: %s, 任务名: %s',
_id, _id,
name, name,
); );
+7 -2
View File
@@ -64,10 +64,15 @@ export default class ScriptService {
return { code: 200 }; return { code: 200 };
} }
public async getFile(filePath: string, fileName: string) { public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.scriptPath, filePath, fileName); const finalPath = path.resolve(config.scriptPath, filePath, fileName);
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
}
if (!finalPath.startsWith(config.scriptPath)) { public async getFile(filePath: string, fileName: string) {
const finalPath = this.checkFilePath(filePath, fileName);
if (!finalPath) {
return ''; return '';
} }
+34 -2
View File
@@ -26,12 +26,13 @@ export default class SshKeyService {
if (_exist) { if (_exist) {
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' }); config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
} else { } else {
await writeFileWithLock(this.sshConfigFilePath, ''); await writeFileWithLock(this.sshConfigFilePath, '', { mode: '600' });
} }
if (!config.includes(this.sshConfigHeader)) { if (!config.includes(this.sshConfigHeader)) {
await writeFileWithLock( await writeFileWithLock(
this.sshConfigFilePath, this.sshConfigFilePath,
`${this.sshConfigHeader}\n\n${config}`, `${this.sshConfigHeader}\n\n${config}`,
{ mode: '600' },
); );
} }
} }
@@ -45,7 +46,6 @@ export default class SshKeyService {
path.join(this.sshPath, alias), path.join(this.sshPath, alias),
`${key}${os.EOL}`, `${key}${os.EOL}`,
{ {
encoding: 'utf8',
mode: '400', mode: '400',
}, },
); );
@@ -81,6 +81,10 @@ export default class SshKeyService {
await writeFileWithLock( await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`, `${path.join(this.sshPath, `${alias}.config`)}`,
config, config,
{
encoding: 'utf8',
mode: '600',
},
); );
} }
@@ -127,4 +131,32 @@ export default class SshKeyService {
} }
} }
} }
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`);
await this.removeSshConfig(`~global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
} }
+12 -8
View File
@@ -31,6 +31,7 @@ import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron'; import { CrontabModel } from '../data/cron';
import CrontabService from './cron'; import CrontabService from './cron';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
@@ -136,7 +137,7 @@ export default class SubscriptionService {
let beforeStr = ''; let beforeStr = '';
try { try {
if (doc.sub_before) { if (doc.sub_before) {
await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`); await logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`);
beforeStr = await promiseExec(doc.sub_before); beforeStr = await promiseExec(doc.sub_before);
} }
} catch (error: any) { } catch (error: any) {
@@ -144,7 +145,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (beforeStr) { if (beforeStr) {
await fs.appendFile(absolutePath, `${beforeStr}\n`); await logStreamManager.write(absolutePath, `${beforeStr}\n`);
} }
}, },
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => { onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -163,7 +164,7 @@ export default class SubscriptionService {
let afterStr = ''; let afterStr = '';
try { try {
if (sub.sub_after) { if (sub.sub_after) {
await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`); await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`);
afterStr = await promiseExec(sub.sub_after); afterStr = await promiseExec(sub.sub_after);
} }
} catch (error: any) { } catch (error: any) {
@@ -171,16 +172,19 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (afterStr) { if (afterStr) {
await fs.appendFile(absolutePath, `${afterStr}\n`); await logStreamManager.write(absolutePath, `${afterStr}\n`);
} }
await fs.appendFile( await logStreamManager.write(
absolutePath, absolutePath,
`\n## 执行结束... ${endTime.format( `\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${diff}${LOG_END_SYMBOL}`, )} 耗时 ${diff}${LOG_END_SYMBOL}`,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update( await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined }, { status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } }, { where: { id: sub.id } },
@@ -195,12 +199,12 @@ export default class SubscriptionService {
onError: async (message: string) => { onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
await fs.appendFile(absolutePath, `\n${message}`); await logStreamManager.write(absolutePath, `\n${message}`);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
await fs.appendFile(absolutePath, `\n${message}`); await logStreamManager.write(absolutePath, `\n${message}`);
}, },
}; };
} }
@@ -384,7 +388,7 @@ export default class SubscriptionService {
files.map(async (x) => ({ files.map(async (x) => ({
filename: x, filename: x,
directory: relativeDir.replace(config.logPath, ''), directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(), time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})), })),
) )
).sort((a, b) => b.time - a.time); ).sort((a, b) => b.time - a.time);
+113 -37
View File
@@ -1,13 +1,13 @@
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
import { Response } from 'express'; import { Response } from 'express';
import fs from 'fs'; import fs from 'fs';
import got from 'got'; import { Agent, request } from 'undici';
import sum from 'lodash/sum'; import sum from 'lodash/sum';
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';
import config from '../config'; import config from '../config';
import { TASK_COMMAND } from '../config/const'; import { NotificationModeStringMap, TASK_COMMAND } from '../config/const';
import { import {
getPid, getPid,
killTask, killTask,
@@ -16,6 +16,7 @@ import {
promiseExec, promiseExec,
readDirs, readDirs,
rmPath, rmPath,
setSystemTimezone,
} from '../config/util'; } from '../config/util';
import { import {
DependenceModel, DependenceModel,
@@ -36,6 +37,7 @@ import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock'; import SockService from './sock';
import os from 'os'; import os from 'os';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { updateLinuxMirrorFile } from '../config/util';
@Service() @Service()
export default class SystemService { export default class SystemService {
@@ -50,7 +52,10 @@ export default class SystemService {
public async getSystemConfig() { public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig }); const doc = await this.getDb({ type: AuthDataType.systemConfig });
return doc; return {
...doc,
info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' },
};
} }
private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> { private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> {
@@ -210,33 +215,11 @@ export default class SystemService {
onEnd?: () => void, onEnd?: () => void,
) { ) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let defaultDomain = 'https://dl-cdn.alpinelinux.org';
let targetDomain = 'https://dl-cdn.alpinelinux.org';
if (os.platform() !== 'linux') { if (os.platform() !== 'linux') {
return; return;
} }
const content = await fs.promises.readFile('/etc/apk/repositories', { const command = await updateLinuxMirrorFile(info.linuxMirror || '');
encoding: 'utf-8', let hasError = false;
});
const domainMatch = content.match(/(http.*)\/alpine\/.*/);
if (domainMatch) {
defaultDomain = domainMatch[1];
}
if (info.linuxMirror) {
targetDomain = info.linuxMirror;
}
const command = `sed -i 's/${defaultDomain.replace(
/\//g,
'\\/',
)}/${targetDomain.replace(
/\//g,
'\\/',
)}/g' /etc/apk/repositories && apk update -f`;
this.scheduleService.runTask( this.scheduleService.runTask(
command, command,
{ {
@@ -250,8 +233,15 @@ export default class SystemService {
message: 'update linux mirror end', message: 'update linux mirror end',
}); });
onEnd?.(); onEnd?.();
if (!hasError) {
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
}
}, },
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) => {
@@ -272,13 +262,17 @@ export default class SystemService {
let lastVersionContent; let lastVersionContent;
try { try {
const result = await got.get( const { body } = await request(
`${config.lastVersionFile}?t=${Date.now()}`, `${config.lastVersionFile}?t=${Date.now()}`,
{ {
timeout: 30000, dispatcher: new Agent({
keepAliveTimeout: 30000,
keepAliveMaxTimeout: 30000,
}),
}, },
); );
lastVersionContent = parseContentVersion(result.body); const text = await body.text();
lastVersionContent = parseContentVersion(text);
} catch (error) { } } catch (error) { }
if (!lastVersionContent) { if (!lastVersionContent) {
@@ -353,13 +347,39 @@ export default class SystemService {
public async reloadSystem(target?: 'system' | 'data') { public async reloadSystem(target?: 'system' | 'data') {
const cmd = `real_time=true ql reload ${target || ''}`; const cmd = `real_time=true ql reload ${target || ''}`;
const cp = spawn(cmd, { shell: '/bin/bash' }); const cp = spawn(cmd, {
shell: '/bin/bash',
detached: true,
stdio: 'ignore',
});
cp.unref(); cp.unref();
setTimeout(() => {
process.exit(0);
});
return { code: 200 }; return { code: 200 };
} }
public async notify({ title, content }: { title: string; content: string }) { public async notify({
const isSuccess = await this.notificationService.notify(title, content); title,
content,
notificationInfo,
}: {
title: string;
content: string;
notificationInfo?: NotificationInfo;
}) {
const typeString =
typeof notificationInfo?.type === 'number'
? NotificationModeStringMap[notificationInfo.type]
: undefined;
if (notificationInfo && typeString) {
notificationInfo.type = typeString;
}
const isSuccess = await this.notificationService.notify(
title,
content,
notificationInfo,
);
if (isSuccess) { if (isSuccess) {
return { code: 200, message: '通知发送成功' }; return { code: 200, message: '通知发送成功' };
} else { } else {
@@ -367,11 +387,12 @@ export default class SystemService {
} }
} }
public async run({ command }: { command: string }, callback: TaskCallbacks) { 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}`;
} }
this.scheduleService.runTask(`real_time=true ${command}`, callback, { const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command, command,
id: command.replace(/ /g, '-'), id: command.replace(/ /g, '-'),
runOrigin: 'system', runOrigin: 'system',
@@ -400,10 +421,16 @@ export default class SystemService {
} }
} }
public async exportData(res: Response) { public async exportData(res: Response, type?: string[]) {
try { try {
let dataDirs = ['db', 'upload'];
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
await promiseExec( await promiseExec(
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile} data/`, `cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
} ${dataPaths.join(' ')}`,
); );
res.download(config.dataTgzFile); res.download(config.dataTgzFile);
} catch (error: any) { } catch (error: any) {
@@ -471,4 +498,53 @@ export default class SystemService {
await rmPath(path.join(config.systemLogPath, log.title)); await rmPath(path.join(config.systemLogPath, log.title));
} }
} }
public async updateTimezone(info: SystemModelInfo) {
if (!info.timezone) {
info.timezone = 'Asia/Shanghai';
}
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
const success = await setSystemTimezone(info.timezone);
if (success) {
return { code: 200, data: info };
} else {
return { code: 400, message: '设置时区失败' };
}
}
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' };
}
try {
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true });
} catch (error) { }
return { code: 200 };
}
} }
+173 -16
View File
@@ -1,6 +1,6 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import { createRandomString, getNetIp } from '../config/util'; import { createRandomString } from '../config/util';
import config from '../config'; import config from '../config';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { authenticator } from '@otplib/preset-default'; import { authenticator } from '@otplib/preset-default';
@@ -11,6 +11,7 @@ import {
SystemModelInfo, SystemModelInfo,
LoginStatus, LoginStatus,
AuthInfo, AuthInfo,
TokenInfo,
} from '../data/system'; } from '../data/system';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import NotificationService from './notify'; import NotificationService from './notify';
@@ -21,6 +22,8 @@ import dayjs from 'dayjs';
import IP2Region from 'ip2region'; import IP2Region from 'ip2region';
import requestIp from 'request-ip'; import requestIp from 'request-ip';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
@Service() @Service()
@@ -93,18 +96,29 @@ export default class UserService {
} }
if (username === cUsername && password === cPassword) { if (username === cUsername && password === cPassword) {
const data = createRandomString(50, 100); const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? 60 : 20; const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.secret as any, { let token = jwt.sign({ data }, config.jwt.secret, {
expiresIn: 60 * 60 * 24 * expiration, expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384', algorithm: 'HS384',
}); });
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
token, token,
tokens: { tokens: updatedTokens,
...tokens,
[req.platform]: token,
},
lastlogon: timestamp, lastlogon: timestamp,
retries: 0, retries: 0,
lastip: ip, lastip: ip,
@@ -131,7 +145,14 @@ export default class UserService {
this.getLoginLog(); this.getLoginLog();
return { return {
code: 200, code: 200,
data: { token, lastip, lastaddr, lastlogon, retries, platform }, data: {
token,
lastip,
lastaddr,
lastlogon,
retries,
platform,
},
}; };
} else { } else {
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
@@ -171,11 +192,37 @@ export default class UserService {
} }
} }
public async logout(platform: string): Promise<any> { public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
token: '', token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: { ...authInfo.tokens, [platform]: '' }, tokens: updatedTokens,
}); });
} }
@@ -264,7 +311,16 @@ export default class UserService {
if (isValid) { if (isValid) {
return this.login({ username, password }, req, false); return this.login({ username, password }, req, false);
} else { } else {
const { ip, address } = await getNetIp(req); const ip = requestIp.getClientIp(req) || '';
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp])
.filter(Boolean)
.join(' ');
}
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
lastip: ip, lastip: ip,
lastaddr: address, lastaddr: address,
@@ -346,12 +402,113 @@ export default class UserService {
} }
} }
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) { public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated } = info; const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { const payload = pickBy(
{
retries, retries,
twoFactorActivated, twoFactorActivated,
}); password,
username,
},
(x) => !isNil(x),
);
await this.updateAuthInfo(authInfo, payload);
} }
} }
+46
View File
@@ -0,0 +1,46 @@
import { AuthInfo, TokenInfo } from '../data/system';
/**
* Validates if a token exists in the authentication info.
* Supports both legacy string tokens and new TokenInfo array format.
*
* @param authInfo - The authentication information
* @param headerToken - The token to validate
* @param platform - The platform (desktop, mobile)
* @returns true if the token is valid, false otherwise
*/
export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
if (headerToken === token) {
return true;
}
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
const platformTokens = tokens[platform];
// Handle null/undefined platformTokens
if (platformTokens === null || platformTokens === undefined) {
return false;
}
if (typeof platformTokens === 'string') {
// Legacy format: single string token
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
}
// Unexpected type - log warning and reject
return false;
}
+110
View File
@@ -0,0 +1,110 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
*/
export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
}
}
/**
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);
}
/**
* Get the number of open streams
*/
getOpenStreamCount(): number {
return this.streams.size;
}
}
// Export a singleton instance for shared use
export const logStreamManager = new LogStreamManager();
+14 -1
View File
@@ -37,7 +37,7 @@ class TaskLimit {
concurrency: Math.max(os.cpus().length, 4), concurrency: Math.max(os.cpus().length, 4),
}); });
private client = new ApiClient( private client = new ApiClient(
`0.0.0.0:${config.cronPort}`, `0.0.0.0:${config.grpcPort}`,
credentials.createInsecure(), credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 }, { 'grpc.enable_http_proxy': 0 },
); );
@@ -189,6 +189,19 @@ class TaskLimit {
return this.scriptLimit.add(fn, options); return this.scriptLimit.add(fn, options);
} }
public async waitDependencyQueueDone(): Promise<void> {
if (this.dependenyLimit.size === 0 && this.dependenyLimit.pending === 0) {
return;
}
return new Promise((resolve) => {
const onIdle = () => {
this.dependenyLimit.removeListener('idle', onIdle);
resolve();
};
this.dependenyLimit.on('idle', onIdle);
});
}
public runDependeny<T>( public runDependeny<T>(
dependency: Dependence, dependency: Dependence,
fn: IDependencyFn<T>, fn: IDependencyFn<T>,
+35
View File
@@ -2,10 +2,45 @@ import { spawn } from 'cross-spawn';
import taskLimit from './pLimit'; import taskLimit from './pLimit';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { ICron } from '../protos/cron'; import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
export function runCron(cmd: string, cron: ICron): Promise<number | void> { export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => { return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => { return new Promise(async (resolve: any) => {
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info( Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({ `[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron, ...cron,
+1 -1
View File
@@ -13,7 +13,7 @@ function getUniqueLockPath(filePath: string) {
export async function writeFileWithLock( export async function writeFileWithLock(
filePath: string, filePath: string,
content: string | Buffer, content: string,
options: Parameters<typeof writeFile>[2] = {}, options: Parameters<typeof writeFile>[2] = {},
) { ) {
if (typeof options === 'string') { if (typeof options === 'string') {
-1
View File
@@ -2,7 +2,6 @@ import 'reflect-metadata';
import OpenService from './services/open'; import OpenService from './services/open';
import { Container } from 'typedi'; import { Container } from 'typedi';
import LoggerInstance from './loaders/logger'; import LoggerInstance from './loaders/logger';
import fs from 'fs';
import config from './config'; import config from './config';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
+8 -5
View File
@@ -2,7 +2,11 @@
"compilerOptions": { "compilerOptions": {
"target": "es2017", "target": "es2017",
"lib": ["ESNext"], "lib": ["ESNext"],
"typeRoots": ["./node_modules/celebrate/lib", "./node_modules/@types"], "typeRoots": [
"./types",
"../node_modules/celebrate/lib",
"../node_modules/@types"
],
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
@@ -13,12 +17,11 @@
"module": "commonjs", "module": "commonjs",
"pretty": true, "pretty": true,
"sourceMap": true, "sourceMap": true,
"outDir": "./static/build", "outDir": "../static/build",
"allowJs": true, "allowJs": true,
"noEmit": false, "noEmit": false,
"esModuleInterop": true "esModuleInterop": true
}, },
"include": ["./back/**/*", "./back.d.ts"], "include": ["./**/*"],
"exclude": ["node_modules"], "exclude": ["node_modules"]
"files": ["./back/index.d.ts"]
} }
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="express" />
export {};
declare global {
namespace Express {
interface Request {
platform: 'desktop' | 'mobile';
}
}
}
-27
View File
@@ -1,27 +0,0 @@
import 'reflect-metadata'; // We need this in order to use @Decorators
import config from './config';
import express from 'express';
import depInjectorLoader from './loaders/depInjector';
import Logger from './loaders/logger';
async function startServer() {
const app = express();
depInjectorLoader();
await require('./loaders/update').default({ app });
app
.listen(config.updatePort, '0.0.0.0', () => {
Logger.debug(`✌️ 更新服务启动成功!`);
console.debug(`✌️ 更新服务启动成功!`);
process.send?.('ready');
})
.on('error', (err) => {
Logger.error(err);
console.error(err);
process.exit(1);
});
}
startServer();
+85
View File
@@ -0,0 +1,85 @@
import { Joi } from 'celebrate';
import { CronExpressionParser } from 'cron-parser';
import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => {
if (
value.startsWith(ScheduleType.ONCE) ||
value.startsWith(ScheduleType.BOOT)
) {
return value;
}
try {
if (CronExpressionParser.parse(value).hasNext()) {
return value;
}
} catch (e) {
return helpers.error('any.invalid');
}
return helpers.error('any.invalid');
};
export const scheduleSchema = Joi.string()
.required()
.custom(validateSchedule)
.messages({
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
});
export const commonCronSchema = {
name: Joi.string().optional(),
command: Joi.string().required(),
schedule: scheduleSchema,
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
.optional()
.allow('')
.allow(null)
.custom((value, helpers) => {
if (!value) return value;
// Check if it's an absolute path
if (value.startsWith('/')) {
// Allow /dev/null as special case
if (value === '/dev/null') {
return value;
}
// For other absolute paths, ensure they are within the safe log directory
const normalizedValue = path.normalize(value);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedValue.startsWith(normalizedLogPath)) {
return helpers.error('string.unsafePath');
}
return value;
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
return helpers.error('string.max');
}
return value;
})
.messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
};
+80 -52
View File
@@ -1,83 +1,111 @@
FROM python:3.10-alpine3.18 AS builder FROM node:22-slim AS nodebuilder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \
&& apk update \
&& apk add nodejs npm git \
&& npm i -g pnpm@8.3.1 pm2 ts-node \
&& cd /tmp/build \
&& pnpm install --prod
FROM python:3.10-alpine FROM python:3.10-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.10-slim-bookworm
ARG QL_MAINTAINER="whyour" ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}" LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.10
ENV PNPM_HOME=/root/.local/share/pnpm \ ENV QL_DIR=/ql \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules \ QL_BRANCH=${QL_BRANCH} \
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
SHELL=/bin/bash \ SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \ PS1="\u@\h:\w \$ "
QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH}
VOLUME /ql/data ARG QL_UID=5432
ARG QL_GID=5432
RUN groupadd -g ${QL_GID} qinglong && \
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
chmod 700 /home/qinglong/.ssh && \
chown -R ${QL_UID}:${QL_GID} /home/qinglong
EXPOSE 5700 ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=builder /usr/local/bin/. /usr/local/bin/ COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x \ RUN set -x && \
&& apk update -f \ ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
&& apk upgrade \ apt-get update && \
&& apk --no-cache add -f bash \ apt-get upgrade -y && \
coreutils \ apt-get install --no-install-recommends -y git \
git \
curl \ curl \
wget \ wget \
tzdata \ tzdata \
perl \ perl \
openssl \ openssl \
nginx \ openssh-client \
nodejs \
jq \ jq \
openssh \
procps \ procps \
netcat-openbsd \ netcat-openbsd \
unzip \ unzip \
npm \ libatomic1 && \
&& rm -rf /var/cache/apk/* \ apt-get clean && \
&& apk update \ ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo "Asia/Shanghai" >/etc/timezone && \
&& echo "Asia/Shanghai" > /etc/timezone \ git config --global user.email "qinglong@users.noreply.github.com" && \
&& git config --global user.email "qinglong@@users.noreply.github.com" \ git config --global user.name "qinglong" && \
&& git config --global user.name "qinglong" \ git config --global http.postBuffer 524288000 && \
&& git config --global http.postBuffer 524288000 \ npm install -g pnpm@8.3.1 pm2 ts-node && \
&& rm -rf /root/.pnpm-store \ rm -rf /root/.cache && \
&& rm -rf /root/.local/share/pnpm/store \ rm -rf /root/.npm && \
&& rm -rf /root/.cache \ rm -rf /etc/apt/apt.conf.d/docker-clean && \
&& ulimit -c 0 \ ulimit -c 0
&& pip3 install requests
RUN mkdir -p ${QL_DIR} && \
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
USER qinglong
ARG SOURCE_COMMIT ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \
&& cd ${QL_DIR} \ cd ${QL_DIR} && \
&& cp -f .env.example .env \ cp -f .env.example .env && \
&& chmod 777 ${QL_DIR}/shell/*.sh \ chmod 777 ${QL_DIR}/shell/*.sh && \
&& chmod 777 ${QL_DIR}/docker/*.sh \ chmod 777 ${QL_DIR}/docker/*.sh && \
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \
&& mkdir -p ${QL_DIR}/static \ mkdir -p ${QL_DIR}/static && \
&& cp -rf /static/* ${QL_DIR}/static \ cp -rf /tmp/static/* ${QL_DIR}/static && \
&& rm -rf /static rm -rf /tmp/static
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
USER root
WORKDIR ${QL_DIR} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5400/api/health || exit 1 CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"] ENTRYPOINT ["./docker/docker-entrypoint.sh"]
VOLUME /ql/data
EXPOSE 5700
+80 -52
View File
@@ -1,83 +1,111 @@
FROM python:3.11-alpine3.18 AS builder FROM node:22-slim AS nodebuilder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \
&& apk update \
&& apk add nodejs npm git \
&& npm i -g pnpm@8.3.1 pm2 ts-node \
&& cd /tmp/build \
&& pnpm install --prod
FROM python:3.11-alpine FROM python:3.11-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.11-slim-bookworm
ARG QL_MAINTAINER="whyour" ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}" LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.11
ENV PNPM_HOME=/root/.local/share/pnpm \ ENV QL_DIR=/ql \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules \ QL_BRANCH=${QL_BRANCH} \
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
SHELL=/bin/bash \ SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \ PS1="\u@\h:\w \$ "
QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH}
VOLUME /ql/data ARG QL_UID=5432
ARG QL_GID=5432
RUN groupadd -g ${QL_GID} qinglong && \
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
chmod 700 /home/qinglong/.ssh && \
chown -R ${QL_UID}:${QL_GID} /home/qinglong
EXPOSE 5700 ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=builder /usr/local/bin/. /usr/local/bin/ COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x \ RUN set -x && \
&& apk update -f \ ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
&& apk upgrade \ ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
&& apk --no-cache add -f bash \ apt-get update && \
coreutils \ apt-get upgrade -y && \
git \ apt-get install --no-install-recommends -y git \
curl \ curl \
wget \ wget \
tzdata \ tzdata \
perl \ perl \
openssl \ openssl \
nginx \ openssh-client \
nodejs \
jq \ jq \
openssh \
procps \ procps \
netcat-openbsd \ netcat-openbsd \
unzip \ unzip \
npm \ libatomic1 && \
&& rm -rf /var/cache/apk/* \ apt-get clean && \
&& apk update \ ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo "Asia/Shanghai" >/etc/timezone && \
&& echo "Asia/Shanghai" > /etc/timezone \ git config --global user.email "qinglong@users.noreply.github.com" && \
&& git config --global user.email "qinglong@@users.noreply.github.com" \ git config --global user.name "qinglong" && \
&& git config --global user.name "qinglong" \ git config --global http.postBuffer 524288000 && \
&& git config --global http.postBuffer 524288000 \ npm install -g pnpm@8.3.1 pm2 ts-node && \
&& rm -rf /root/.pnpm-store \ rm -rf /root/.cache && \
&& rm -rf /root/.local/share/pnpm/store \ rm -rf /root/.npm && \
&& rm -rf /root/.cache \ rm -rf /etc/apt/apt.conf.d/docker-clean && \
&& ulimit -c 0 \ ulimit -c 0
&& pip3 install requests
RUN mkdir -p ${QL_DIR} && \
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
USER qinglong
ARG SOURCE_COMMIT ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} && \
&& cd ${QL_DIR} \ cd ${QL_DIR} && \
&& cp -f .env.example .env \ cp -f .env.example .env && \
&& chmod 777 ${QL_DIR}/shell/*.sh \ chmod 777 ${QL_DIR}/shell/*.sh && \
&& chmod 777 ${QL_DIR}/docker/*.sh \ chmod 777 ${QL_DIR}/docker/*.sh && \
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /tmp/static && \
&& mkdir -p ${QL_DIR}/static \ mkdir -p ${QL_DIR}/static && \
&& cp -rf /static/* ${QL_DIR}/static \ cp -rf /tmp/static/* ${QL_DIR}/static && \
&& rm -rf /static rm -rf /tmp/static
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
USER root
WORKDIR ${QL_DIR} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5400/api/health || exit 1 CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"] ENTRYPOINT ["./docker/docker-entrypoint.sh"]
VOLUME /ql/data
EXPOSE 5700
+35 -23
View File
@@ -1,45 +1,57 @@
#!/bin/bash #!/bin/bash
export PATH="$HOME/bin:$PATH"
dir_shell=/ql/shell dir_shell=/ql/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/env.sh
echo -e "======================1. 检测配置文件========================\n" export_ql_envs() {
export BACK_PORT="${ql_port}"
export GRPC_PORT="${ql_grpc_port}"
}
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
# Fix DNS resolution issues in Alpine Linux
# Alpine uses musl libc which has known DNS resolver issues with certain domains
# Adding ndots:0 prevents unnecessary search domain appending
if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf
log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)"
fi
fi
log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs
export_ql_envs
. $dir_shell/env.sh
import_config "$@" import_config "$@"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
init_nginx
fix_config fix_config
pm2 l &>/dev/null # Try to initialize PM2, but don't fail if it doesn't work
pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案"
echo -e "======================2. 安装依赖========================\n" log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
patch_version
echo -e "======================3. 启动nginx========================\n"
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
echo -e "nginx启动成功...\n"
echo -e "======================4. 启动pm2服务========================\n"
reload_update
reload_pm2 reload_pm2
if [[ $AutoStartBot == true ]]; then if [[ $AutoStartBot == true ]]; then
echo -e "======================5. 启动bot========================\n" log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 & nohup ql bot >$dir_log/bot.log 2>&1 &
echo -e "bot后台启动中...\n"
fi fi
if [[ $EnableExtraShell == true ]]; then if [[ $EnableExtraShell == true ]]; then
echo -e "====================6. 执行自定义脚本========================\n" log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 & nohup ql extra >$dir_log/extra.log 2>&1 &
echo -e "自定义脚本后台执行中...\n"
fi fi
echo -e "############################################################\n" log_with_style "SUCCESS" "🎉 容器启动成功!"
echo -e "容器启动成功..."
echo -e "############################################################\n"
crond -f >/dev/null tail -f /dev/null
exec "$@" exec "$@"
-93
View File
@@ -1,93 +0,0 @@
upstream baseApi {
server 0.0.0.0:5600;
}
upstream publicApi {
server 0.0.0.0:5400;
}
upstream updateApi {
server 0.0.0.0:5300;
}
map $http_upgrade $connection_upgrade {
default keep-alive;
'websocket' upgrade;
}
server {
IPV4_CONFIG
IPV6_CONFIG
ssl_session_timeout 5m;
location QL_BASE_URLapi/update/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://updateApi/api/;
proxy_buffering off;
proxy_redirect default;
proxy_connect_timeout 1800;
proxy_send_timeout 1800;
proxy_read_timeout 1800;
}
location QL_BASE_URLapi/public/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://publicApi/api/;
proxy_buffering off;
proxy_redirect default;
proxy_connect_timeout 1800;
proxy_send_timeout 1800;
proxy_read_timeout 1800;
}
location QL_BASE_URLapi/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://baseApi/api/;
proxy_buffering off;
proxy_redirect default;
proxy_connect_timeout 1800;
proxy_send_timeout 1800;
proxy_read_timeout 1800;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location QL_BASE_URLopen/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://baseApi/open/;
proxy_buffering off;
proxy_redirect default;
proxy_connect_timeout 1800;
proxy_send_timeout 1800;
proxy_read_timeout 1800;
}
gzip on;
gzip_static on;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.0;
QL_ROOT_CONFIG
location QL_BASE_URL_LOCATION {
QL_ALIAS_CONFIG
index index.html index.htm;
try_files $uri QL_BASE_URLindex.html;
}
location ~ .*\.(html)$ {
add_header Cache-Control no-cache;
}
}
-45
View File
@@ -1,45 +0,0 @@
user root;
worker_processes auto;
pcre_jit on;
error_log /var/log/nginx/error.log warn;
include /etc/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
client_max_body_size 4096m;
client_body_buffer_size 20m;
keepalive_timeout 65;
sendfile on;
tcp_nodelay on;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:2m;
gzip on;
gzip_static on;
gzip_types text/plain application/json application/javascript application/x-javascript text/css application/xml text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.0;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
include /etc/nginx/conf.d/*.conf;
}
+5 -25
View File
@@ -1,14 +1,14 @@
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: 'schedule', name: 'qinglong',
max_restarts: 10, max_restarts: 5,
kill_timeout: 15000, kill_timeout: 1000,
wait_ready: true, wait_ready: true,
listen_timeout: 10000, listen_timeout: 5000,
source_map_support: true, source_map_support: true,
time: true, time: true,
script: 'static/build/schedule/index.js', script: 'static/build/app.js',
env: { env: {
http_proxy: '', http_proxy: '',
https_proxy: '', https_proxy: '',
@@ -18,25 +18,5 @@ module.exports = {
ALL_PROXY: '', ALL_PROXY: '',
}, },
}, },
{
name: 'public',
max_restarts: 10,
kill_timeout: 15000,
wait_ready: true,
listen_timeout: 10000,
source_map_support: true,
time: true,
script: 'static/build/public.js',
},
{
name: 'panel',
max_restarts: 10,
kill_timeout: 15000,
wait_ready: true,
listen_timeout: 10000,
source_map_support: true,
time: true,
script: 'static/build/app.js',
},
], ],
}; };
+12 -2
View File
@@ -1,5 +1,15 @@
{ {
"watch": ["back", ".env"], "watch": [
"back",
".env"
],
"ext": "js,ts,json", "ext": "js,ts,json",
"exec": "ts-node -P tsconfig.back.json ./back/app.ts" "env": {
"NODE_ENV": "development",
"TS_NODE_PROJECT": "./back/tsconfig.json"
},
"verbose": true,
"execMap": {
"ts": "node --require ts-node/register"
}
} }
-13
View File
@@ -1,13 +0,0 @@
module.exports = {
apps: [
{
name: 'update',
max_restarts: 10,
kill_timeout: 15000,
wait_ready: true,
listen_timeout: 10000,
time: true,
script: 'static/build/update.js',
},
],
};
+34 -22
View File
@@ -1,20 +1,25 @@
{ {
"private": true, "name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.20.0-2",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
"url": "https://github.com/whyour/qinglong.git"
},
"author": "whyour",
"license": "Apache License 2.0",
"bugs": {
"url": "https://github.com/whyour/qinglong/issues"
},
"scripts": { "scripts": {
"start": "concurrently -n w: npm:start:*", "start": "concurrently -n w: npm:start:*",
"start:update": "ts-node -P tsconfig.back.json ./back/update.ts", "start:back": "nodemon ./back/app.ts",
"start:public": "ts-node -P tsconfig.back.json ./back/public.ts",
"start:rpc": "ts-node -P tsconfig.back.json ./back/schedule/index.ts",
"start:back": "nodemon",
"start:front": "max dev", "start:front": "max dev",
"build:front": "max build", "build:front": "max build",
"build:back": "tsc -p tsconfig.back.json", "build:back": "tsc -p back/tsconfig.json",
"panel": "npm run build:back && node static/build/app.js", "panel": "npm run build:back && node static/build/app.js",
"schedule": "npm run build:back && node static/build/schedule/index.js", "gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"public": "npm run build:back && node static/build/public.js",
"update": "npm run build:back && node static/build/update.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true",
"gen:api": "python3 -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. ./back/protos/api.proto",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true", "postinstall": "max setup 2>/dev/null || true",
"test": "umi-test", "test": "umi-test",
@@ -31,6 +36,11 @@
"prettier --parser=typescript --write" "prettier --parser=typescript --write"
] ]
}, },
"bin": {
"ql": "shell/update.sh",
"task": "shell/task.sh",
"qinglong": "shell/start.sh"
},
"pnpm": { "pnpm": {
"peerDependencyRules": { "peerDependencyRules": {
"ignoreMissing": [ "ignoreMissing": [
@@ -61,14 +71,14 @@
} }
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.12.3", "@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.7.13", "@grpc/proto-loader": "^0.8.0",
"@otplib/preset-default": "^12.0.1", "@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"celebrate": "^15.0.3", "celebrate": "^15.0.3",
"chokidar": "^4.0.1", "chokidar": "^4.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.9.0", "cron-parser": "^5.4.0",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.6", "dotenv": "^16.4.6",
@@ -76,8 +86,7 @@
"express-jwt": "^8.4.1", "express-jwt": "^8.4.1",
"express-rate-limit": "^7.4.1", "express-rate-limit": "^7.4.1",
"express-urlrewrite": "^2.0.3", "express-urlrewrite": "^2.0.3",
"form-data": "^4.0.0", "undici": "^7.9.0",
"got": "^11.8.2",
"hpagent": "^1.2.0", "hpagent": "^1.2.0",
"http-proxy-middleware": "^3.0.3", "http-proxy-middleware": "^3.0.3",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
@@ -88,11 +97,10 @@
"node-schedule": "^2.1.0", "node-schedule": "^2.1.0",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"p-queue-cjs": "7.3.4", "p-queue-cjs": "7.3.4",
"protobufjs": "^7.4.0", "@bufbuild/protobuf": "^2.10.0",
"pstree.remy": "^1.1.8", "ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"serve-handler": "^6.1.6",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
"toad-scheduler": "^3.0.1", "toad-scheduler": "^3.0.1",
@@ -104,7 +112,9 @@
"ip2region": "2.3.0", "ip2region": "2.3.0",
"keyv": "^5.2.3", "keyv": "^5.2.3",
"@keyv/sqlite": "^4.0.1", "@keyv/sqlite": "^4.0.1",
"proper-lockfile": "^4.1.2" "proper-lockfile": "^4.1.2",
"compression": "^1.7.4",
"helmet": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"moment": "2.30.1", "moment": "2.30.1",
@@ -138,6 +148,7 @@
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@types/request-ip": "0.0.41", "@types/request-ip": "0.0.41",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@uiw/codemirror-extensions-langs": "^4.21.9", "@uiw/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.21.9", "@uiw/react-codemirror": "^4.21.9",
"@umijs/max": "^4.4.4", "@umijs/max": "^4.4.4",
@@ -171,11 +182,12 @@
"react-split-pane": "^0.1.92", "react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0", "sockjs-client": "^1.6.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"ts-proto": "^1.146.0", "ts-proto": "^2.6.1",
"tslib": "^2.4.0", "tslib": "^2.4.0",
"typescript": "5.2.2", "typescript": "5.2.2",
"vh-check": "^2.0.5", "vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0", "virtualizedtableforantd4": "1.3.0",
"yorkie": "^2.0.0" "@types/compression": "^1.7.2",
"@types/helmet": "^4.0.0"
} }
} }
+152 -349
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -226,9 +226,17 @@ export QMSG_TYPE=""
## ntfy_url 填写ntfy地址,如https://ntfy.sh ## ntfy_url 填写ntfy地址,如https://ntfy.sh
## ntfy_topic 填写ntfy的消息应用topic ## ntfy_topic 填写ntfy的消息应用topic
## ntfy_priority 填写推送消息优先级,默认为3 ## ntfy_priority 填写推送消息优先级,默认为3
## ntfy_token 填写推送token,可选
## ntfy_username 填写推送用户名称,可选
## ntfy_password 填写推送用户密码,可选
## ntfy_actions 填写推送用户动作,可选
export NTFY_URL="" export NTFY_URL=""
export NTFY_TOPIC="" export NTFY_TOPIC=""
export NTFY_PRIORITY="3" export NTFY_PRIORITY="3"
export NTFY_TOKEN=""
export NTFY_USERNAME=""
export NTFY_PASSWORD=""
export NTFY_ACTIONS=""
## 21. wxPusher ## 21. wxPusher
## 官方文档: https://wxpusher.zjiecode.com/docs/ ## 官方文档: https://wxpusher.zjiecode.com/docs/
+92 -34
View File
@@ -1,7 +1,42 @@
const querystring = require('node:querystring'); const querystring = require('node:querystring');
const got = require('got'); const { request: undiciRequest, ProxyAgent, FormData } = require('undici');
const timeout = 15000; const timeout = 15000;
async function request(url, options = {}) {
const { json, form, body, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
let finalBody = body;
if (json) {
finalHeaders['content-type'] = 'application/json';
finalBody = JSON.stringify(json);
} else if (form) {
finalBody = form;
delete finalHeaders['content-type'];
}
return undiciRequest(url, {
headers: finalHeaders,
body: finalBody,
...rest,
});
}
function post(url, options = {}) {
return request(url, { ...options, method: 'POST' });
}
function get(url, options = {}) {
return request(url, { ...options, method: 'GET' });
}
const httpClient = {
request,
post,
get,
};
const push_config = { const push_config = {
HITOKOTO: true, // 启用一言(随机句子) HITOKOTO: true, // 启用一言(随机句子)
@@ -17,6 +52,7 @@ const push_config = {
DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN
FSKEY: '', // 飞书机器人的 FSKEY FSKEY: '', // 飞书机器人的 FSKEY
FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
// 推送到个人QQhttp://127.0.0.1/send_private_msg // 推送到个人QQhttp://127.0.0.1/send_private_msg
// 群:http://127.0.0.1/send_group_msg // 群:http://127.0.0.1/send_group_msg
@@ -84,7 +120,8 @@ const push_config = {
AIBOTK_NAME: '', // 智能微秘书 发送群名 或者好友昵称和type要对应好 AIBOTK_NAME: '', // 智能微秘书 发送群名 或者好友昵称和type要对应好
SMTP_SERVICE: '', // 邮箱服务名称,比如 126、163、Gmail、QQ 等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json SMTP_SERVICE: '', // 邮箱服务名称,比如 126、163、Gmail、QQ 等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json
SMTP_EMAIL: '', // SMTP 发件邮箱,通知将会由自己发给自己 SMTP_EMAIL: '', // SMTP 发件邮箱
SMTP_TO: '', // SMTP 收件邮箱,默认通知将会发给发件邮箱
SMTP_PASSWORD: '', // SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 SMTP_PASSWORD: '', // SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
SMTP_NAME: '', // SMTP 收发件人姓名,可随意填写 SMTP_NAME: '', // SMTP 收发件人姓名,可随意填写
@@ -104,6 +141,10 @@ const push_config = {
NTFY_URL: '', // ntfy地址,如https://ntfy.sh,默认为https://ntfy.sh NTFY_URL: '', // ntfy地址,如https://ntfy.sh,默认为https://ntfy.sh
NTFY_TOPIC: '', // ntfy的消息应用topic NTFY_TOPIC: '', // ntfy的消息应用topic
NTFY_PRIORITY: '3', // 推送消息优先级,默认为3 NTFY_PRIORITY: '3', // 推送消息优先级,默认为3
NTFY_TOKEN: '', // 推送token,可选
NTFY_USERNAME: '', // 推送用户名称,可选
NTFY_PASSWORD: '', // 推送用户密码,可选
NTFY_ACTIONS: '', // 推送用户动作,可选
// 官方文档: https://wxpusher.zjiecode.com/docs/ // 官方文档: https://wxpusher.zjiecode.com/docs/
// 管理后台: https://wxpusher.zjiecode.com/admin/ // 管理后台: https://wxpusher.zjiecode.com/admin/
@@ -122,9 +163,9 @@ for (const key in push_config) {
const $ = { const $ = {
post: (params, callback) => { post: (params, callback) => {
const { url, ...others } = params; const { url, ...others } = params;
got.post(url, others).then( httpClient.post(url, others).then(
(res) => { async (res) => {
let body = res.body; let body = await res.body.text();
try { try {
body = JSON.parse(body); body = JSON.parse(body);
} catch (error) {} } catch (error) {}
@@ -137,9 +178,9 @@ const $ = {
}, },
get: (params, callback) => { get: (params, callback) => {
const { url, ...others } = params; const { url, ...others } = params;
got.get(url, others).then( httpClient.get(url, others).then(
(res) => { async (res) => {
let body = res.body; let body = await res.body.text();
try { try {
body = JSON.parse(body); body = JSON.parse(body);
} catch (error) {} } catch (error) {}
@@ -155,8 +196,8 @@ const $ = {
async function one() { async function one() {
const url = 'https://v1.hitokoto.cn/'; const url = 'https://v1.hitokoto.cn/';
const res = await got.get(url); const res = await httpClient.request(url);
const body = JSON.parse(res.body); const body = await res.body.json();
return `${body.hitokoto} ----${body.from}`; return `${body.hitokoto} ----${body.from}`;
} }
@@ -441,21 +482,11 @@ function tgBotNotify(text, desp) {
timeout, timeout,
}; };
if (TG_PROXY_HOST && TG_PROXY_PORT) { if (TG_PROXY_HOST && TG_PROXY_PORT) {
const { HttpProxyAgent, HttpsProxyAgent } = require('hpagent'); let agent;
const _options = { agent = new ProxyAgent({
keepAlive: true, uri: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`,
keepAliveMsecs: 1000, });
maxSockets: 256, options.dispatcher = agent;
maxFreeSockets: 256,
proxy: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`,
};
const httpAgent = new HttpProxyAgent(_options);
const httpsAgent = new HttpsProxyAgent(_options);
const agent = {
http: httpAgent,
https: httpsAgent,
};
options.agent = agent;
} }
$.post(options, (err, resp, data) => { $.post(options, (err, resp, data) => {
try { try {
@@ -959,11 +990,26 @@ function aibotkNotify(text, desp) {
function fsBotNotify(text, desp) { function fsBotNotify(text, desp) {
return new Promise((resolve) => { return new Promise((resolve) => {
const { FSKEY } = push_config; const { FSKEY, FSSECRET } = push_config;
if (FSKEY) { if (FSKEY) {
const body = { msg_type: 'text', content: { text: `${text}\n\n${desp}` } };
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (FSSECRET) {
const crypto = require('crypto');
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${FSSECRET}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
const options = { const options = {
url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`, url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`,
json: { msg_type: 'text', content: { text: `${text}\n\n${desp}` } }, json: body,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -993,7 +1039,8 @@ function fsBotNotify(text, desp) {
} }
async function smtpNotify(text, desp) { async function smtpNotify(text, desp) {
const { SMTP_EMAIL, SMTP_PASSWORD, SMTP_SERVICE, SMTP_NAME } = push_config; const { SMTP_EMAIL, SMTP_TO, SMTP_PASSWORD, SMTP_SERVICE, SMTP_NAME } =
push_config;
if (![SMTP_EMAIL, SMTP_PASSWORD].every(Boolean) || !SMTP_SERVICE) { if (![SMTP_EMAIL, SMTP_PASSWORD].every(Boolean) || !SMTP_SERVICE) {
return; return;
} }
@@ -1011,7 +1058,7 @@ async function smtpNotify(text, desp) {
const addr = SMTP_NAME ? `"${SMTP_NAME}" <${SMTP_EMAIL}>` : SMTP_EMAIL; const addr = SMTP_NAME ? `"${SMTP_NAME}" <${SMTP_EMAIL}>` : SMTP_EMAIL;
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: addr, from: addr,
to: addr, to: SMTP_TO ? SMTP_TO.split(';') : addr,
subject: text, subject: text,
html: `${desp.replace(/\n/g, '<br/>')}`, html: `${desp.replace(/\n/g, '<br/>')}`,
}); });
@@ -1207,17 +1254,18 @@ function webhookNotify(text, desp) {
'$title', '$title',
encodeURIComponent(text), encodeURIComponent(text),
).replaceAll('$content', encodeURIComponent(desp)); ).replaceAll('$content', encodeURIComponent(desp));
got(formatUrl, options).then((resp) => { httpClient.request(formatUrl, options).then(async (resp) => {
const body = await resp.body.text();
try { try {
if (resp.statusCode !== 200) { if (resp.statusCode !== 200) {
console.log(`自定义发送通知消息失败😞 ${resp.body}\n`); console.log(`自定义发送通知消息失败😞 ${body}\n`);
} else { } else {
console.log(`自定义发送通知消息成功🎉 ${resp.body}\n`); console.log(`自定义发送通知消息成功🎉 ${body}\n`);
} }
} catch (e) { } catch (e) {
$.logErr(e, resp); $.logErr(e, resp);
} finally { } finally {
resolve(resp.body); resolve(body);
} }
}); });
}); });
@@ -1230,7 +1278,7 @@ function ntfyNotify(text, desp) {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY } = push_config; const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config;
if (NTFY_TOPIC) { if (NTFY_TOPIC) {
const options = { const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`, url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1238,9 +1286,19 @@ function ntfyNotify(text, desp) {
headers: { headers: {
Title: `${encodeRFC2047(text)}`, Title: `${encodeRFC2047(text)}`,
Priority: NTFY_PRIORITY || '3', Priority: NTFY_PRIORITY || '3',
Icon: 'https://qn.whyour.cn/logo.png',
}, },
timeout, timeout,
}; };
if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
}
if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
}
$.post(options, (err, resp, data) => { $.post(options, (err, resp, data) => {
try { try {
if (err) { if (err) {
+27 -1
View File
@@ -49,6 +49,7 @@ push_config = {
'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
'FSKEY': '', # 飞书机器人的 FSKEY 'FSKEY': '', # 飞书机器人的 FSKEY
'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
'GOBOT_URL': '', # go-cqhttp 'GOBOT_URL': '', # go-cqhttp
# 推送到个人QQhttp://127.0.0.1/send_private_msg # 推送到个人QQhttp://127.0.0.1/send_private_msg
@@ -126,6 +127,10 @@ push_config = {
'NTFY_URL': '', # ntfy地址,如https://ntfy.sh 'NTFY_URL': '', # ntfy地址,如https://ntfy.sh
'NTFY_TOPIC': '', # ntfy的消息应用topic 'NTFY_TOPIC': '', # ntfy的消息应用topic
'NTFY_PRIORITY':'3', # 推送消息优先级,默认为3 'NTFY_PRIORITY':'3', # 推送消息优先级,默认为3
'NTFY_TOKEN': '', # 推送token,可选
'NTFY_USERNAME': '', # 推送用户名称,可选
'NTFY_PASSWORD': '', # 推送用户密码,可选
'NTFY_ACTIONS': '', # 推送用户动作,可选
'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/ 'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/
'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
@@ -229,6 +234,20 @@ def feishu_bot(title: str, content: str) -> None:
url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}' url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}'
data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}} data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}}
# Add signature if secret is provided
# Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
# and signs an empty message, which differs from typical HMAC usage
if push_config.get("FSSECRET"):
timestamp = str(int(time.time()))
string_to_sign = f'{timestamp}\n{push_config.get("FSSECRET")}'
hmac_code = hmac.new(
string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
).digest()
sign = base64.b64encode(hmac_code).decode("utf-8")
data["timestamp"] = timestamp
data["sign"] = sign
response = requests.post(url, data=json.dumps(data)).json() response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0: if response.get("StatusCode") == 0 or response.get("code") == 0:
@@ -806,7 +825,14 @@ def ntfy(title: str, content: str) -> None:
encoded_title = encode_rfc2047(title) encoded_title = encode_rfc2047(title)
data = content.encode(encoding="utf-8") data = content.encode(encoding="utf-8")
headers = {"Title": encoded_title, "Priority": priority} # 使用编码后的 title headers = {"Title": encoded_title, "Priority": priority, "Icon": "https://qn.whyour.cn/logo.png"} # 使用编码后的 title
if push_config.get("NTFY_TOKEN"):
headers['Authorization'] = "Bearer " + push_config.get("NTFY_TOKEN")
elif push_config.get("NTFY_USERNAME") and push_config.get("NTFY_PASSWORD"):
authStr = push_config.get("NTFY_USERNAME") + ":" + push_config.get("NTFY_PASSWORD")
headers['Authorization'] = "Basic " + base64.b64encode(authStr.encode('utf-8')).decode('utf-8')
if push_config.get("NTFY_ACTIONS"):
headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS"))
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC") url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers) response = requests.post(url, data=data, headers=headers)
+28
View File
@@ -12,4 +12,32 @@ QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => { QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', x); console.log('systemNotify', x);
}); });
// 查询定时任务 (Query cron tasks)
QLAPI.getCrons({ searchValue: 'test' }).then((x) => {
console.log('getCrons', x);
});
// 通过ID查询定时任务 (Get cron by ID)
QLAPI.getCronById({ id: 1 }).then((x) => {
console.log('getCronById', x);
}).catch((err) => {
console.log('getCronById error', err);
});
// 启用定时任务 (Enable cron tasks)
QLAPI.enableCrons({ ids: [1, 2] }).then((x) => {
console.log('enableCrons', x);
});
// 禁用定时任务 (Disable cron tasks)
QLAPI.disableCrons({ ids: [1, 2] }).then((x) => {
console.log('disableCrons', x);
});
// 手动执行定时任务 (Run cron tasks manually)
QLAPI.runCrons({ ids: [1] }).then((x) => {
console.log('runCrons', x);
});
console.log('test desc'); console.log('test desc');
+8 -48
View File
@@ -41,14 +41,9 @@ add_cron_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
--compressed --compressed
) )
@@ -76,15 +71,10 @@ update_cron_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}" \
--compressed --compressed
) )
@@ -108,15 +98,10 @@ update_cron_command_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}" \ --data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}" \
--compressed --compressed
) )
@@ -133,15 +118,10 @@ del_cron_api() {
local ids="$1" local ids="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'DELETE' \ -X 'DELETE' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "[$ids]" \ --data-raw "[$ids]" \
--compressed --compressed
) )
@@ -163,15 +143,10 @@ update_cron() {
local runningTime="${6:-0}" local runningTime="${6:-0}"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons/status?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/status?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime}" \ --data-raw "{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime}" \
--compressed --compressed
) )
@@ -190,15 +165,10 @@ notify_api() {
local content="$2" local content="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/system/notify?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/notify?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}" \ --data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}" \
--compressed --compressed
) )
@@ -215,14 +185,9 @@ find_cron_api() {
local params="$1" local params="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/crons/detail?$params&t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--compressed --compressed
) )
data=$(echo "$api" | jq -r .data) data=$(echo "$api" | jq -r .data)
@@ -239,15 +204,10 @@ update_auth_config() {
local tip="$2" local tip="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/open/system/auth/reset?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Accept: application/json" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
-H "Origin: http://0.0.0.0:5700" \
-H "Referer: http://0.0.0.0:5700/crontab" \
-H "Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" \
--data-raw "{$body}" \ --data-raw "{$body}" \
--compressed --compressed
) )
+8
View File
@@ -9,7 +9,15 @@ else
fi fi
echo -e "\n1、安装bot依赖...\n" echo -e "\n1、安装bot依赖...\n"
os_name=$(source /etc/os-release && echo "$ID")
if [[ $os_name == 'alpine' ]]; then
apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev
elif [[ $os_name == 'debian' ]] || [[ $os_name == 'ubuntu' ]]; then
apt-get install -y gcc python3-dev musl-dev
else
echo -e "暂不支持此系统 $os_name"
exit 1
fi
echo -e "\nbot依赖安装成功...\n" echo -e "\nbot依赖安装成功...\n"
echo -e "2、下载bot所需文件...\n" echo -e "2、下载bot所需文件...\n"
+5 -24
View File
@@ -20,34 +20,18 @@ copy_dep() {
echo -e "---> 复制一份 $file_notify_js_sample$file_notify_js\n" echo -e "---> 复制一份 $file_notify_js_sample$file_notify_js\n"
cp -fv $file_notify_js_sample $file_notify_js cp -fv $file_notify_js_sample $file_notify_js
echo -e "---> 通知文件复制完成\n" echo -e "---> 通知文件复制完成\n"
echo -e "---> 2. 复制nginx配置文件\n"
init_nginx
echo -e "---> 配置文件复制完成\n"
} }
pm2_log() { pm2_log() {
echo -e "---> pm2日志" echo -e "---> pm2日志"
local panelOut="/root/.pm2/logs/panel-out.log" local panelOut="/root/.pm2/logs/qinglong-out.log"
local panelError="/root/.pm2/logs/panel-error.log" local panelError="/root/.pm2/logs/qinglong-error.log"
tail -n 300 "$panelOut" tail -n 300 "$panelOut"
tail -n 300 "$panelError" tail -n 300 "$panelError"
} }
check_nginx() {
local nginxPid=$(ps -eo pid,command | grep nginx | grep -v grep)
echo -e "=====> 检测nginx服务\n$nginxPid"
if [[ $nginxPid ]]; then
echo -e "\n=====> nginx服务正常\n"
nginx -s reload
else
echo -e "\n=====> nginx服务异常,重新启动nginx\n"
nginx -c /etc/nginx/nginx.conf
fi
}
check_ql() { check_ql() {
local api=$(curl -s --noproxy "*" "http://0.0.0.0:5700") local api=$(curl -s --noproxy "*" "http://0.0.0.0:${ql_port}")
echo -e "\n=====> 检测面板\n\n$api\n" echo -e "\n=====> 检测面板\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n" echo -e "=====> 面板服务启动正常\n"
@@ -58,10 +42,10 @@ check_pm2() {
pm2_log pm2_log
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5600/api/system?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \ -H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H 'Referer: http://0.0.0.0:5700/crontab' \ -H "Referer: http://0.0.0.0:${ql_port}/crontab" \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed --compressed
) )
@@ -74,14 +58,11 @@ check_pm2() {
main() { main() {
echo -e "=====> 开始检测" echo -e "=====> 开始检测"
npm i -g pnpm@8.3.1 pm2 ts-node npm i -g pnpm@8.3.1 pm2 ts-node
patch_version
reset_env reset_env
copy_dep copy_dep
check_ql check_ql
check_nginx
check_pm2 check_pm2
reload_update
reload_pm2 reload_pm2
echo -e "\n=====> 检测结束\n" echo -e "\n=====> 检测结束\n"
} }
+125 -25
View File
@@ -1,45 +1,145 @@
const grpc = require('@grpc/grpc-js'); const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader'); const protoLoader = require('@grpc/proto-loader');
const { join } = require('path');
const PROTO_PATH = `${process.env.QL_DIR}/back/protos/api.proto`; class GrpcClient {
const options = { static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'),
serverAddress: `0.0.0.0:${process.env.GRPC_PORT || '5500'}`,
protoOptions: {
keepCase: true, keepCase: true,
longs: String, longs: String,
enums: String, enums: String,
defaults: true, defaults: true,
},
grpcOptions: {
'grpc.enable_http_proxy': 0,
},
defaultTimeout: 30000,
}; };
const packageDefinition = protoLoader.loadSync(PROTO_PATH, options); static #methods = [
'getEnvs',
'createEnv',
'updateEnv',
'deleteEnvs',
'moveEnv',
'disableEnvs',
'enableEnvs',
'updateEnvNames',
'getEnvById',
'systemNotify',
'getCronDetail',
'createCron',
'updateCron',
'deleteCrons',
'getCrons',
'getCronById',
'enableCrons',
'disableCrons',
'runCrons',
];
#client;
#api = {};
constructor() {
this.#initializeClient();
this.#bindMethods();
}
#initializeClient() {
try {
const { protoPath, protoOptions, serverAddress, grpcOptions } =
GrpcClient.#config;
const packageDefinition = protoLoader.loadSync(protoPath, protoOptions);
const apiProto = grpc.loadPackageDefinition(packageDefinition).com.ql.api; const apiProto = grpc.loadPackageDefinition(packageDefinition).com.ql.api;
const client = new apiProto.Api( this.#client = new apiProto.Api(
`0.0.0.0:5500`, serverAddress,
grpc.credentials.createInsecure(), grpc.credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 }, grpcOptions,
);
} catch (error) {
console.error('Failed to initialize gRPC client:', error);
process.exit(1);
}
}
#promisifyMethod(methodName) {
const capitalizedMethod =
methodName.charAt(0).toUpperCase() + methodName.slice(1);
const method = this.#client[capitalizedMethod].bind(this.#client);
return async (params = {}) => {
return new Promise((resolve, reject) => {
const metadata = new grpc.Metadata();
const deadline = new Date(
Date.now() + GrpcClient.#config.defaultTimeout,
); );
const promisify = (fn) => { method(params, metadata, { deadline }, (error, response) => {
return (...args) => { if (error) {
return new Promise((resolve, reject) => { return reject(error);
fn.call(client, ...args, (err, response) => { }
if (err) return reject(err);
resolve(response); resolve(response);
}); });
}); });
}; };
}; }
const api = { #bindMethods() {
getEnvs: promisify(client.GetEnvs), GrpcClient.#methods.forEach((method) => {
createEnv: promisify(client.CreateEnv), this.#api[method] = this.#promisifyMethod(method);
updateEnv: promisify(client.UpdateEnv), });
deleteEnvs: promisify(client.DeleteEnvs), }
moveEnv: promisify(client.MoveEnv),
disableEnvs: promisify(client.DisableEnvs),
enableEnvs: promisify(client.EnableEnvs),
updateEnvNames: promisify(client.UpdateEnvNames),
getEnvById: promisify(client.GetEnvById),
systemNotify: promisify(client.SystemNotify),
};
module.exports = api; getApi() {
return {
...this.#api,
close: this.close.bind(this),
};
}
close() {
if (this.#client) {
this.#client.close();
this.#client = null;
}
}
}
const grpcClient = new GrpcClient();
process.on('SIGTERM', () => {
grpcClient.close();
process.exit(0);
});
process.on('SIGINT', () => {
grpcClient.close();
process.exit(0);
});
process.on('unhandledRejection', (reason, promise) => {
if (reason instanceof Error) {
if (reason.stack) {
const relevantStack = reason.stack
.split('\n')
.filter((line) => {
return (
!line.includes('node:internal') &&
!line.includes('node_modules/@grpc') &&
!line.includes('processTicksAndRejections')
);
})
.join('\n');
console.error(relevantStack);
}
} else {
console.error(reason);
}
});
module.exports = grpcClient.getApi();

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