Compare commits

...
521 Commits
Author SHA1 Message Date
whyour 83e4490b57 fix: pin Python base for multi-arch builds 2026-08-11 00:12:47 +08:00
whyour 2ac4f07fca fix: address dashboard scope and preview interactions 2026-08-10 23:40:53 +08:00
Kobi HikriandGitHub 6618240c61 ci: attach provenance and SBOM attestations to the published images (#3048)
* ci: attach provenance and SBOM attestations to the published images

* ci: restore trailing newline at end of file
2026-08-01 20:11:55 +08:00
whyour 0bb41ebe96 新版本 v2.21.0 2026-07-25 13:28:44 +08:00
whyour d67f33df39 fix: 防止 task_before 污染 Node.js 依赖路径 2026-07-24 22:33:48 +08:00
whyour 24d539fc05 fix: 使用 npm 包安装 sqlite3 2026-07-19 00:40:40 +08:00
whyour 214e23a63d fix: 恢复 linux/s390x 镜像支持 2026-07-18 11:02:07 +08:00
whyour ddb3ab7ac3 Add configurable browser tab title for multi-instance QingLong deployments (#3041) 2026-07-10 02:07:11 +08:00
whyour da6dac8bf1 ts-node 异常,锁定 typescript 版本 2026-07-10 01:36:31 +08:00
whyour 9b17a41987 修复 dashboard 接口地址 2026-07-08 00:28:35 +08:00
whyour cdfa6c3c3c 增加启动错误日志 2026-07-04 21:36:11 +08:00
whyour 1953022b99 修复识别系统默认语言 2026-07-04 01:13:59 +08:00
whyour 38d84f9f31 修复订阅链接正则判断 2026-07-02 00:08:39 +08:00
whyour e92ddb0aed 修复添加任务有可能失败 2026-07-01 00:37:42 +08:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
a0d57c78c5 Fix "Validation failed" when editing env variables with null labels (#3026)
* Initial plan

* Fix env variable edit validation error when labels is null

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-30 23:51:21 +08:00
Martha RamirezandGitHub a942a5686f 修复删除定时任务后 crontab.list 残留导致订阅更新无法重新注册 (#3021)
* 修复删除定时任务后 crontab.list 未同步导致订阅更新无法重新注册脚本

crontab.list 作为数据库的文件镜像,其同步 setCrontab() 此前串行在尽力而为
的 gRPC addCron/delCron 调用之后:当调度 worker 重启等导致 gRPC 短暂不可用、
addCron/delCron reject 时,setCrontab() 会被跳过,crontab.list 与数据库脱节。
而 shell/update.sh 的 gen_list_repo() 将 crontab.list 作为"已有任务"真源,
于是订阅更新误判脚本已存在、跳过新增注册(前端再也看不到这些脚本)。

将 create/update/remove/disabled/enabled/autosave_crontab 中的 gRPC 调用改为
尽力而为(try/catch + 告警日志),保证 setCrontab() 总是执行;autosave_crontab
额外将 setCrontab 提前到 addCron 之前,确保启动/调度器重启时文件总是同步,
并避免 gRPC 失败导致应用启动崩溃。

调度器内存中的任务注册与 crontab.list/系统 crontab 同步是两个相互独立的关注点:
gRPC 失败时内存调度可能在调度器重启后由 autosave_crontab 重新注册,但文件同步
不应因此被阻断。修复后既有的 crontab.list 残留会在下次重启或任意增删改时自愈。

可能与 #2422(订阅更新重复添加任务)同根因。

* 过滤 delCron 中 scheduleStacks 的 null job

nodeSchedule.scheduleJob() 对无效 cron 表达式(如 "0 * /6 * * *",
空格分开了 * /6)会返回 null,原代码原样存入 scheduleStacks 数组。
delCron 取消时对 null 调 cancel() 抛出 UNKNOWN 错误,导致 gRPC
handler 中断、HTTP 端 cronService.remove() 跳过 setCrontab(),
crontab.list 残留已删任务的记录,导致订阅更新时 gen_list_repo()
误判脚本已存在、不再重新注册。

过滤 null 并对每个 cancel 调用加 try/catch 容错。
2026-06-30 23:48:51 +08:00
magicJieandGitHub a1ccd9947d Add Kubernetes deployment manifests (#3025) 2026-06-30 22:24:03 +08:00
327c3a317b Add WxPusher simple push support,Enhance user experience and reduce configuration complexity (#3023)
* 新增对WxPusher极简推送的支持,减小用户使用成本

* 新增脚本对WxPusher极简推送的支持

* 修复通知配置的枚举定义

---------

Co-authored-by: zjiecode <zjiecode@gmail.com>
2026-06-30 14:30:31 +08:00
whyour d3016431ce 定时任务显示完整运行历史 2026-06-28 15:48:58 +08:00
whyour 5fbff0e1c8 修复 openapi 鉴权提示 2026-06-26 15:31:25 +08:00
whyour 3044f63f03 修复国际化文案 2026-06-21 23:53:32 +08:00
whyour 369dd13212 修复 deps 目录依赖逻辑 2026-06-21 12:12:29 +08:00
whyour b5a5fb3be6 shell 增加国际化 2026-06-14 21:19:06 +08:00
whyour 0ee0b83207 延迟增加运行时间提示 2026-06-14 15:21:29 +08:00
whyour 5f2d5bb24a 修复 esm 依赖查询路径 2026-06-13 21:08:25 +08:00
whyour 949d956aef grpc 服务增加证书校验 2026-06-13 20:16:49 +08:00
whyour 96b4c90398 增加 sudo 命令判断 2026-06-13 00:53:41 +08:00
whyour 7d8feadc78 修复获取定时任务参数 2026-06-13 00:09:31 +08:00
whyour d1dfde3ca9 修复配置文件路径可能越权 2026-06-12 23:45:40 +08:00
whyour 6796068523 更新国际化文案 2026-06-11 23:18:45 +08:00
whyour 05f8fd3805 接口提示信息国际化 2026-06-11 02:19:04 +08:00
whyour 946731ac8d 增加运行实例 2026-06-10 01:53:10 +08:00
whyour 617cf7e5b4 更新 stop emoji 2026-06-09 00:48:03 +08:00
whyour 662adc0f9c 修复 dashboard title 2026-06-07 23:45:43 +08:00
whyour 23b41893a3 修复获取任务退出码 2026-06-07 23:28:21 +08:00
whyour 34d4526413 修复 work_dir 目录判断 2026-06-07 22:03:48 +08:00
whyour ad92e0c2c0 修复任务统计日志 2026-06-07 15:15:12 +08:00
whyour c14390cf41 修复 work_dir 验证 2026-06-07 13:56:06 +08:00
whyour fd6b4e4cde 定时任务增加 work_dir 设置 2026-06-07 13:19:01 +08:00
whyour 865e3035b9 开机运行任务同时开始运行 2026-06-01 23:24:10 +08:00
whyour d8d30c3619 修复 initFile 2026-06-01 19:07:15 +08:00
whyour 4e803df51e 修复内部服务 ip 地址 2026-06-01 18:28:54 +08:00
whyour e8ac195c96 增加任务统计 2026-06-01 18:20:18 +08:00
whyour c0b7527148 修复路径穿越 2026-06-01 13:49:32 +08:00
whyour ca347c5854 修复非 root 用户更新时区和 linux 配置 2026-05-31 16:48:13 +08:00
whyour 4bee3fbdf4 修复 getCronById api status 2026-05-31 15:53:24 +08:00
whyour b6537e27ef update mirror action 2026-05-31 15:04:10 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
0e5b8bef4b Fix HITOKOTO parameter boolean/string type handling (#2918)
* Initial plan

* Fix HITOKOTO parameter type mismatch in notify scripts

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

* Refactor JavaScript HITOKOTO check for consistency with Python

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>
2026-05-31 14:26:57 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
e06c3571a8 Fix undefined error in serverNotify when Server酱 returns error responses (#2917)
* Initial plan

* Fix: Replace data.data.errno with data.code to prevent undefined error

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>
2026-05-31 14:25:51 +08:00
whyour c2eac8b8fd 移除未使用配置 2026-05-31 00:36:54 +08:00
jmcluluandGitHub 3aab1233bb fix: correct typos in source code and locales (#3003)
- Fix Countrys -> Countries in comment
- Fix Scrolldown -> ScrollDown in variable name
- Fix completeTowFactor -> completeTwoFactor (3x)
- Fix deactiveTowFactor -> deactivateTwoFactor (3x)
- Fix activeOrDeactiveTwoFactor -> activeOrDeactivateTwoFactor
- Fix API route /two-factor/deactive -> /two-factor/deactivate
- Fix elment -> element in function param
- Fix synolog -> synology in comment
- Fix Chinese comment 制定 -> 指定
- Fix swapped BARK English translations on lines 360-361
2026-05-31 00:32:04 +08:00
whyour 2fe9470ff0 fix: gRPC extra_schedules 为空时序列化报错 not iterable 2026-05-31 00:14:22 +08:00
whyour abad29cbf9 修复非 root debian dockerfile 2026-05-30 23:55:57 +08:00
84d730d510 统一 Alpine/Debian 分支,QL_SCHEDULER 参数化调度
* 修改获取示例文件 api path

* 增加 debian-slim 基础镜像

* 修复 debian apt 命令,支持 qinglong 命令

* 更新 npm 版本 0.7.7

* 更新 npm v0.8.4

* 修复linux依赖检测 (#2082)

* 修复拉取私有仓库

* 修复 shell check_server

* 修复 qinglong 命令

* 更新 npm 版本 v0.13.2

* 增加 debian 开发版本

* 修改切换 linux 镜像源

* 修复 qinglong 命令

* 移除 qinglong 命令 npm 默认镜像源

* 修复 workflow

* 更新 npm 版本 v0.14.5

* 增加 npx 命令

* 更新 workflow action 版本

* 更新 npm 版本 v0.16.0

* 修复 linux 镜像源

* 更新 npm 版本 v0.17.0

* 更新 npm 版本 v0.18.0

* 修改 npm 安装启动命令

* 更新 npm 版本 v0.19.9

* 修复 debian netcat 包名

* 更新 npm 版本 v0.20.4

* 安装 linux 依赖自动识别 alpine 和 debian

* 修改 apt 命令

* 更新 npm 版本 v0.21.2

* 修改 ts 文件执行依赖

* npm 启动增加 reload 逻辑

* 更新 npm 版本 v2.17.8

* 修复 qinglong 命令

* 更新 npm 版本 v2.17.9

* 更新 npm 版本 v2.17.10

* 更新 npm 版本 v2.17.11

* 修改 debian 版本为 12 bookworm

* 更新 npm 版本 v2.17.12

* 修改本地服务启动提示

* 更新 npm 版本 v2.17.13

* 写入文件增加文件锁

* 修复系统安装依赖提示

* 更新 npm 版本 v2.18.2-6

* 更新 nodejs 版本

* 更新 npm 版本 v2.18.3-3

* 修复 command 变量

* 移除自动清除 deb

* 修复 npm 启动脚本

* 修复发布 npm包依赖文件

* 修改 linux 启动文件逻辑

* 更新 npm 版本 v2.19.0-10

* 修复 apt 命令

* 更新 npm 版本 v2.19.1-0

* 更新 npm 版本 v2.19.2-2

* 增加 packageManager

* 增加用户 qinglong

* 更新 pipeline

* 移除 init_nginx

* 更新 npm 版本 v2.20.0

* 更新 npm 版本 2.20.1

* 更新 npm 版本 2.20.2

* fix: 修复非 root 用户启动

* chore: 合并 debian 和 alpine 逻辑

---------

Co-authored-by: dream10201 <xiuxiu10201@gmail.com>
2026-05-30 18:03:51 +08:00
whyour 57d58c871e fix: isolate task node dependencies 2026-05-24 01:40:07 +08:00
whyour bb6d436c19 fix: run scripts from their own directory 2026-05-24 00:15:24 +08:00
whyour 8bc0906949 feat: add environment variable labels 2026-05-23 23:21:38 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
7a8917f8e4 Fix CodeMirror "multiple instances of @codemirror/state" crash on page navigation (#2969)
* Initial plan

* Fix CodeMirror multiple instances error by pinning to single versions

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>
2026-05-23 20:48:10 +08:00
whyour 66f9457be8 ci: replace hub-mirror-action with direct git push for Gitee sync
Replace Yikun/hub-mirror-action with manual git push for Gitee mirrors. The action HTTPS API call to gitee.com timed out (60s). Use set +e with explicit notice/warning status. Also add .deepseek/ to .gitignore.
2026-05-18 01:05:46 +08:00
whyour 0c5e3b5d04 ci: update workflow actions 2026-05-18 00:12:22 +08:00
whyour 400e4770de 修复环境变量 position 数据类型可能异常 2026-05-17 23:53:43 +08:00
whyour 40d4de9017 增加 localhost 检测 2026-05-17 23:43:09 +08:00
whyour 3464c4da61 fix IPv6 connectivity 2026-05-06 01:29:01 +08:00
MaxandGitHub 1315578878 支持自定义接收邮箱地址 (#2973)
* 支持自定义接收邮箱地址

* 新增支持多个接收邮箱,同步到node和系统内置版本
2026-05-06 00:34:26 +08:00
TengDreamandGitHub a1ae08da58 fix: create root .tmp directory on init for data export (#2993)
The data export feature (system backup) writes data.tgz to
`config.tmpPath` which resolves to `<rootPath>/.tmp/`. However,
`initFile.ts` only created `<dataPath>/log/.tmp/` (used for crontab
list temp files), never the root-level `.tmp/` directory.

In Docker deployments, `shell/share.sh`'s `fix_config()` creates
`$dir_root/.tmp` during shell initialization, but local/non-Docker
deployments that start the Node service directly skip the shell init,
causing a 404 ENOENT error when attempting to export/backup data.

Add `rootTmpPath` (`<rootPath>/.tmp/`) to the directories array in
`initFile.ts` so it is created during Node service startup regardless
of deployment method.
2026-05-06 00:32:29 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
66700ebe1a Add OpeniLink notification channel (#2988)
* Initial plan

* Add OpeniLink notification channel support

Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/c80b4882-1bd7-4ffe-9180-cd3220da5986

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

* Add context_token and hub_url to OpeniLink notification channel

Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/a5e66f5a-dab8-4a65-96ca-960d35fa9d50

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>
2026-04-25 16:01:36 +08:00
07bf0c705b fix: respect QlPort env var in Docker health check (#2963)
* Initial plan

* fix: use QlPort env variable in health check with fallback to default 5700

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-03-11 20:43:56 +08:00
whyour fd516977e3 chore: upgrade nodemailer 2026-03-07 22:35:18 +08:00
whyour c39f4ef846 chore: 更新 multer,解决 cve 漏洞 2026-03-07 21:31:24 +08:00
whyour 275d8af4e2 更新版本 v2.20.2 2026-03-01 20:35:25 +08:00
whyour 544c432f49 修复 PATH 环境变量 2026-03-01 20:35:19 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
6bec52dca1 Fix /open/user/init auth bypass allowing credential reset on initialized systems (#2941)
* Initial plan

* fix: add /open/user/init paths to init guard to prevent auth bypass

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-03-01 18:02:21 +08:00
rockymelodyandGitHub ce599d306f 青龙面板鉴权绕过漏洞已修复 (#2935)
已实施的安全加固措施
第一层防御:启用Express严格路由(第17-18行)
app.set('case sensitive routing', true);  // 路由大小写敏感
app.set('strict routing', true);           // 严格路由匹配
第二层防御:路径标准化检查中间件(第23-37行)
app.use((req, res, next) => {
  const originalPath = req.path;
  const normalizedPath = originalPath.toLowerCase();

  // 检测并拦截大小写混淆攻击
  if (originalPath !== normalizedPath &&
      (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
    return res.status(400).json({
      code: 400,
      message: 'Invalid path format'
    });
  }

  next();
});
作用:主动检测并拒绝含有大小写变体的恶意请求
第三层防御:JWT中间件正则表达式修复(第59行)
// 修复前:
path: [...config.apiWhiteList, /^\/(?!api\/).*/],

// 修复后:添加大小写不敏感标志 'i'
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
作用:防御正则匹配层面的绕过
第四层防御:自定义Token中间件路径标准化(第74-87行)
// 修复前:
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) {

// 修复后:统一转小写比较
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
}
作用:确保Token验证逻辑对所有路径变体生效

第五层防御:初始化接口路径检查修复(第122-123行)
// 修复前:
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {

// 修复后:
const pathLower = req.path.toLowerCase();
if (!['/api/user/init', '/api/user/notification/init'].includes(pathLower)) {
2026-03-01 17:44:03 +08:00
whyour d53437d169 更新 2.20.1 2025-12-26 21:17:30 +08:00
whyour d526602d19 修复运行中任务停止操作 2025-12-26 01:07:08 +08:00
whyour 91b44914f6 修复环境变量排序 2025-12-26 00:41:32 +08:00
whyour 4f6c93cc1c 更新 workflow 2025-12-24 01:03:21 +08:00
whyour e326d89571 修复 apiWhiteList 路径 2025-12-23 00:58:09 +08:00
whyour 5f0dafa010 修复 cron-parser import,websocket basepath 2025-12-23 00:28:16 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
dc0b3f2eb2 Fix QlBaseUrl: use URL rewrite for base path support (#2876)
* Initial plan

* Add QlBaseUrl support to backend routes

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

* Fix whitelist check to use base-URL-aware paths

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

* Update websocket and frontend to support base URL

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

* Address code review feedback: fix JWT regex and path construction

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

* Fix path construction: use req.path directly for whitelist check

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

* Add clarifying comments and improve code readability

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

* Apply code review suggestions: improve clarity and simplify logic

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

* Simplify baseUrl implementation using URL rewrite

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-12-22 23:44:29 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
3db716763d Fix cron-parser v5 bundling incompatibility causing validation failures (#2877)
* Initial plan

* Fix: Use default import for cron-parser to ensure browser compatibility

Changed from named export `{ CronExpressionParser }` to default export `cronParser` and access `CronExpressionParser` through it. This ensures compatibility with webpack/UmiJS bundling for browser environments while maintaining backend functionality.

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-12-22 23:43:54 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
fae226745e Add missing larkSecret field to gRPC NotificationInfo proto (#2880)
* Initial plan

* Add larkSecret field to NotificationInfo proto definition

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-12-22 23:38:42 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
9330650163 Fix TG_PROXY_AUTH concatenation in notify.js - add missing @ separator (#2882)
* Initial plan

* Fix TG_PROXY_AUTH handling in notify.js to match notify.py logic

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

* Apply prettier formatting to notify.js

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-12-22 23:05:06 +08:00
CopilotGitHubwhyourwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
073de76a4a Fix validation error when saving scripts in debug window (v2.20.0 regression) (#2862)
* 更新版本 2.20.0

* Initial plan

* Fix validation error when saving scripts by allowing unknown fields in POST /scripts

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

* Revert version.yaml to 2.19.2 - should not include version bump in bug fix PR

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

---------

Co-authored-by: whyour <imwhyour@gmail.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-12-22 22:43:48 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
c61d1aa828 Fix enum value 0 causing type filter to fail for NodeJS dependencies (#2869)
* Initial plan

* Fix: Prevent Python3 dependencies from appearing in NodeJs tab

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-12-15 18:21:14 +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
whyour 924dfd6a6f 更新版本 v2.18.1 2025-01-22 01:17:15 +08:00
whyour 07f43538df 修复写入文件 mode 2025-01-22 01:17:13 +08:00
whyour 4fa5fa2014 修改 lock 文件名称规则 2025-01-16 00:35:47 +08:00
whyour f9b9543a6d 修复 JavaScript telegram 通知 2025-01-15 23:23:54 +08:00
whyour af97543918 修改错误提示 2025-01-14 23:20:53 +08:00
whyour a508522872 增加安全报告提示 2025-01-14 01:05:48 +08:00
whyour f1ca2134b7 移除 nedb 和 sentry 2025-01-14 00:24:25 +08:00
whyour a9755655b2 更新 readme 2025-01-12 18:32:48 +08:00
whyour 6a76e82f26 修改 QLAPI python 示例 2025-01-12 18:11:42 +08:00
whyour 6775f5d123 修复 QLAPI 系统通知响应 2025-01-12 17:02:21 +08:00
whyour ad6e08525c 修改 QLAPI 系统通知 2025-01-12 15:39:28 +08:00
whyour 51ef4e7476 修改任务状态更新失败提示,重复运行提示 2025-01-12 00:19:14 +08:00
whyour e5b35273f9 修复更新环境变量 2025-01-11 17:14:30 +08:00
whyour 647ed3b66c QLAPI 支持操作环境变量和系统通知 2025-01-11 01:59:46 +08:00
whyour 4667af4ebe 更新版本 v2.18.0 2025-01-05 22:03:45 +08:00
whyour 3f775a0e6c 修复创建系统应用 2025-01-05 22:03:41 +08:00
whyour fa79de3f05 修复 status 类型 2025-01-05 12:23:13 +08:00
whyour ffa8b25a66 优化初始化文件操作 2025-01-05 00:28:08 +08:00
whyour 05f8bbd26e 写入文件增加文件锁,避免竞争条件引起文件内容异常 2025-01-04 01:22:29 +08:00
whyour 7d43b14f81 修复重置登录错误次数和 tfa 2025-01-02 23:50:20 +08:00
whyour cecc5aeb15 修复初始化 SystemConfig 数据 2025-01-01 21:33:43 +08:00
whyour 43d6ac2071 增加手动停止标识 2024-12-31 00:17:30 +08:00
whyour 678e3e2dc6 修改认证信息存储方式,避免认证信息异常 2024-12-30 14:23:04 +08:00
whyour 75f91e1473 修改通知文件未设置变量提示 2024-12-22 22:15:09 +08:00
whyour bdc45c538c 更新版本 v2.17.13 2024-12-21 22:48:58 +08:00
FanchangWangandGitHub 955c7377d7 新增 wxPusher 推送通道。wxPusher 官方文档: https://wxpusher.zjiecode.com/docs/ (#2594)
* 增加 wxPusher 推送方式

* 补全 wxPusher 推送方式代码

* fix js 方法多进行了一次 JSON 格式化
2024-12-13 14:13:34 +08:00
c71abd8c86 完善pushplus通知方式的代码逻辑 (#2454)
* 1. pushplus通知方式返回结果优化
2. pushplus通知方式适配更多参数

* 细节bug修改

* 删除没用的空格

---------

Co-authored-by: 陈思远 <chensiyuan@yiban.com.cn>
Co-authored-by: 陈大人 <chensiyuan@pushplus.plus>
2024-12-13 14:12:16 +08:00
whyour ab27a4c908 更新 node 依赖 2024-12-12 00:26:11 +08:00
whyour 026640a757 修复任务列表下次运行时间展示 2024-12-03 22:35:34 +08:00
whyour a1501705c1 更新 readme 2024-11-18 23:19:15 +08:00
whyour b321530dcf 修复本地部署设置 dataPath 后,备份目录可能错误 2024-11-18 22:59:07 +08:00
xiaobingtechandGitHub 02c6ad8004 mobile增加识别鸿蒙系统 (#2571)
由于鸿蒙系统的User-Agent如下
Mozilla/5.0 (Phone; OpenHarmony5.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 ArkWeb/4.1.6.1 Mobile
导致目前登录显示desktop
2024-11-18 22:40:14 +08:00
Hugh GaoandGitHub fff572869e chore: Update docker-compose.yml (#2553) 2024-11-08 22:52:19 +08:00
whyour b733937691 更新版本 v2.17.12 2024-10-26 14:59:36 +08:00
whyour 0d9eba4b6e 定时任务外漏标签改为 10 个 2024-10-26 14:49:02 +08:00
whyour 20c6a1e8bf 移除任务执行前后的脚本参数 2024-10-26 14:12:58 +08:00
qiaoyun680GitHubqiaoyun680 <qiaoyun680>
56bc2f0b1d 改进ntfy通知,将中文标题进行编码后发送 (#2541)
* 1.改进ntfy通知,将中文标题进行编码后发送,可以直接展示中文标题;2.改进notify.py和notify.js中ntfy的推送;
---------

Co-authored-by: qiaoyun680 <qiaoyun680>
2024-10-23 16:38:51 +08:00
EasyandGitHub ecc55883f0 优化Server酱Key相关的逻辑 (#2538)
* 优化sendkey的兼容

* 对title和desp encode
2024-10-22 21:49:07 +08:00
qiaoyun680GitHubqiaoyun680 <qiaoyun680>
185fd2ff91 增加 ntfy 通知 (#2537)
Co-authored-by: qiaoyun680 <qiaoyun680>
2024-10-22 21:47:38 +08:00
whyour 3822c37fa0 修复登录日志保存 2024-10-19 00:18:24 +08:00
whyour 4244502949 增加 log 图标样式 2024-10-13 21:24:17 +08:00
EasyandGitHub 71dd82f74e 适配Server酱APP分支(Server酱³),移除已废弃的旧版api入口 (#2524) 2024-10-10 22:09:00 +08:00
whyour 6ea8d361fd 修复 mac 时间格式化 2024-09-29 22:47:57 +08:00
whyour 3f71f9acdb 修复任务命令带有 -m 参数时,日志目录生成异常 2024-09-29 22:27:52 +08:00
whyour 418695c4aa 定时任务支持复制 2024-09-24 23:33:04 +08:00
whyour 4c5223ba11 更新版本 v2.17.11 2024-09-16 21:35:38 +08:00
whyour 2f70e10be7 修复 shell 检查 nounset 变量 2024-09-16 21:35:36 +08:00
whyour 66706ce150 修复任务视图过多无法选到视图管理 2024-09-10 22:50:33 +08:00
whyour 3d719baa54 修复任务执行初始化环境变量 PYTHONPATH 和 NODE_OPTIONS 被清空 2024-09-10 22:41:39 +08:00
whyour 926ad067c1 修复通知文件换行符解析错误 2024-09-09 23:19:58 +08:00
whyour ff98c3a499 修复任务跳转脚本未显示文件大小,订阅删除未删除 repo 目录 2024-09-08 21:08:15 +08:00
whyour 69fcf45231 修复任务执行前命令错误提示 2024-09-08 17:58:37 +08:00
whyour c3704502bf 更新 readme 2024-09-05 22:35:14 +08:00
whyour a0613d0f39 修复文件越权访问 2024-09-04 23:25:48 +08:00
whyour 8c0f46420e 更新 readme 2024-09-04 22:27:03 +08:00
whyour ea4b474b83 更新版本 v2.17.10 2024-09-01 23:42:38 +08:00
whyour 53ef0fe296 修复订阅任务参数 2024-09-01 23:42:36 +08:00
whyour c573186e34 修复启动无法创建订阅任务 2024-09-01 22:47:29 +08:00
whyour c229eca315 修复依赖管理和脚本管理样式 2024-08-31 23:46:29 +08:00
whyour a41dd74048 修复 task_before/task_after 中换行后 crontab 不识别 2024-08-31 20:46:00 +08:00
whyour 459f465f3b 修复 shell 未定义变量错误 2024-08-31 20:23:37 +08:00
whyour b508e97dc1 修复依赖列表样式 2024-08-31 14:59:54 +08:00
whyour 7414a9d33d 执行任务增加结束日志 2024-08-31 14:43:44 +08:00
whyour a48d100b2d 修复表格样式 2024-08-30 00:30:31 +08:00
whyour a3044f9d29 修复 node 内置脚本运行错误 2024-08-28 00:28:25 +08:00
whyour 19cfc9e351 修复任务重复运行提示 2024-08-26 23:05:53 +08:00
whyour 6c61ac5106 修复 dockerfile 2024-08-26 00:56:55 +08:00
whyour 65f7483688 修复任务频繁运行通知 2024-08-25 16:28:32 +08:00
whyour 8b042d90f3 修复删除日志命令 2024-08-24 22:43:09 +08:00
whyour 4e5ad6d5f3 定时服务区分系统、订阅、脚本任务 2024-08-23 23:06:50 +08:00
whyour 8b8eae211b 增加任务重复运行提醒 2024-08-23 09:37:26 +08:00
whyour f4cb3eacf8 系统日志增加时间筛选和清空 2024-08-22 00:47:24 +08:00
whyour f6021c8157 增加内置 Python requests 模块 2024-08-19 23:06:16 +08:00
whyour 73601ca853 修复任务执行前命令字符转义 2024-08-19 22:27:09 +08:00
whyour 8218d4ba94 修复 Dockerfile 2024-08-18 17:26:33 +08:00
whyour d47f835531 更新 docker action 2024-08-18 17:03:49 +08:00
whyour 230a8f61b8 修复 smtp python 参数说明 2024-08-18 16:31:39 +08:00
whyour af5de8372c 修复 shell 变量初始化检查,更新 sentry 版本 2024-08-18 14:19:45 +08:00
whyour a743aa8115 更新版本 v2.17.9 2024-07-28 18:40:10 +08:00
whyour fe4516df00 修复执行 task_before 环境变量加载顺序 2024-07-28 18:40:02 +08:00
whyour be2da98674 修复任务中 task_before 参数双引号解析 2024-07-26 11:30:03 +08:00
whyour 0da8155bac 修复 desi 和 conc 命令参数解析 2024-07-25 20:53:11 +08:00
whyour eddd258614 修复 node 前置逻辑 2024-07-24 23:30:18 +08:00
whyour 2ad6437672 更新 config.sample 2024-07-22 23:37:51 +08:00
whyour 162bd9d8cc 修复 config 引入顺序 2024-07-22 23:29:08 +08:00
whyour e694570f1a 增加 task_before.js 和 task_before.py 文件,在执行任务前执行,避免环境变量过大报错 2024-07-22 01:07:00 +08:00
whyour eb5cc3943d Javascript 和 Python 增加内置函数 QLAPI.notify 2024-07-21 01:15:16 +08:00
whyour 1b39d3ab48 JavaScript 和 Python 执行脚本前执行 task_before 2024-07-20 17:28:48 +08:00
whyour f47a788184 更新版本 v2.17.8 2024-07-13 23:54:06 +08:00
whyour 738fe04ce4 修复 js py 变量分割 2024-07-13 23:53:59 +08:00
whyour a2812d9c03 移除 ts-node dependence 2024-07-13 00:49:23 +08:00
whyour 5c53ae4993 修改仓库默认拉取文件后缀,支持运行 cjs pyc 2024-07-12 01:40:37 +08:00
whyour e191aca41f 修改环境变量加载逻辑 2024-07-10 23:29:44 +08:00
whyour 5afac3a3ac 更新版本 v2.17.7 2024-07-02 00:44:56 +08:00
whyour 71ba1534f2 增加自定写入 js 和 py 类型环境变量 2024-07-02 00:44:48 +08:00
whyour 791cf657b5 修复更新 linux 镜像源错误 2024-06-30 15:09:17 +08:00
whyour c1ef021009 更新 readme 2024-06-30 00:55:10 +08:00
whyour 90fe63211d 修复修改任务状态可能报错 2024-06-28 20:41:05 +08:00
whyour b60cda66bb 更新版本 v2.17.6 2024-06-25 22:25:43 +08:00
whyour 7efe81df9e 修复 data 目录判断逻辑 2024-06-25 22:25:36 +08:00
whyour 0d492e94f4 修复未设置通知时通知报错 2024-06-23 22:22:36 +08:00
whyour a45efbd69b 修复定时任务查询不存在的订阅报错 2024-06-17 23:04:47 +08:00
whyour 035f0eb9e3 修复通知文件一言设置 2024-06-16 00:28:45 +08:00
whyour 7d0cae7839 修复单文件订阅代理无效 2024-06-13 22:53:49 +08:00
whyour 46e71d8213 修复系统设置数据未初始化 2024-06-12 13:53:45 +08:00
whyour 372afb92c6 更新 readme 2024-06-09 21:33:30 +08:00
whyour 69d9307be9 bark 推送改为 post 请求 2024-06-07 10:54:58 +08:00
whyour c3908e956f 修复下载单文件订阅 2024-06-06 22:40:51 +08:00
whyour 1e749f3731 更新版本 v2.17.5 2024-06-02 17:53:40 +08:00
whyour a68f4b0cde 后端服务指定 ipv4 端口启动 2024-06-02 17:53:37 +08:00
whyour 07541569c5 修复定时任务 real_time 参数,升级 protobufjs 2024-06-02 11:08:35 +08:00
whyour 70f2bef5d2 修改 ql 和 task 命令生成逻辑 2024-05-29 22:09:01 +08:00
whyour 805a716033 修复任务详情查看脚本错误 2024-05-25 18:22:01 +08:00
e77d4c4337 增加微加机器人消息通道 (#2373)
Co-authored-by: chensiyuan <chensiyuan@pushplus.plus>
2024-05-25 00:33:31 +08:00
whyour b309ac7613 修复有可能手动运行任务无日志 2024-05-25 00:28:55 +08:00
雨思andGitHub 8dd379b6b9 PushMe 通知支持自建服务 (#2358) 2024-05-15 22:38:25 +08:00
whyour 0b52e05af8 bark 推送增加 archive 参数 2024-05-05 20:10:57 +08:00
whyour e9852a0002 修改示例脚本 2024-05-05 19:47:54 +08:00
whyour 20be84b352 重构 JavaScript 脚本通知文件 2024-05-05 18:58:15 +08:00
whyour 4ab24ea1b9 更新版本 v2.17.4 2024-04-26 21:04:10 +08:00
whyour 417f91207f 更新 tsx 版本 2024-04-26 20:58:48 +08:00
whyour f723631647 修复脚本管理上传文件文件名乱码 2024-04-22 22:39:27 +08:00
whyour 6657ff0560 修改删除日志脚本提示 2024-04-21 22:35:20 +08:00
whyour 0593dae41d 增加示例脚本 2024-04-21 22:27:51 +08:00
whyour 445dee00f7 修复脚本和日志列表获取软链接循环失败 2024-04-21 21:59:02 +08:00
whyour 6ca28190b0 修复 text/plain body 格式化 2024-04-16 14:06:32 +08:00
whyour 510534ee0c 修复 text/plain 通知消息替换 2024-04-16 10:48:33 +08:00
whyour 2e7f3a1578 修复删除日志提示 2024-04-11 21:11:21 +08:00
Cp0204andGitHub 591a17e7ee 改进自定义 send() 方法,修正自定义通知 body 解析笔误 (#2335) 2024-04-11 21:07:49 +08:00
星野SKYandGitHub bd7c361300 更新飞书 bot 验证过程,兼容新状态码 (#2302) 2024-04-11 19:13:39 +08:00
409b3281cc 更新飞书 bot 验证过程,兼容新状态码 (#2300)
Co-authored-by: Yu Zhao <0+cmal@users.noreply.github.com>
2024-04-11 19:13:23 +08:00
whyour ac6de4911a 更新版本 v2.17.3 2024-03-29 22:45:02 +08:00
whyour 1628e05ece 复制仓库任务时,保留任务原始目录层级 2024-03-28 23:42:05 +08:00
whyour 7b7e03b503 修改 git clone 参数 2024-03-28 23:00:49 +08:00
whyour 8d14c9dae1 修复配置文件默认参数 2024-03-25 23:41:39 +08:00
whyour 4d6d0a55e7 修改基础镜像 node 版本 2024-03-25 22:42:28 +08:00
whyour 8ab2dc3280 修复重启命令 2024-03-22 23:01:20 +08:00
whyour 3f54048127 修改压缩解压文件命令 2024-03-22 22:23:49 +08:00
whyour 47c2c61f33 增加自定义通知 header 和 body 环境变量填写提示 2024-03-16 14:50:38 +08:00
whyour 47d2fc24bc 修改系统 reload 分支 2024-03-16 13:25:42 +08:00
whyour 7a8a8ab9b3 修改系统重启逻辑 2024-03-14 17:37:52 +08:00
whyour 2ac4581d54 修改自定义通知 body 解析逻辑 2024-03-12 22:22:10 +08:00
whyour 68ad01e0e8 增加 update 服务 2024-03-10 22:07:06 +08:00
秋澪AkimioandGitHub cdeca4b808 notify.py 支持自定义参数 (#2285)
* Bark自定义参数

* 钉钉机器人自定义参数

* 飞书机器人自定义参数

* go_cqhttp自定义参数

* gotify自定义参数

* iGot自定义参数

* serverJ自定义参数

* PushDeer自定义参数

* Chat自定义参数

* push+自定义参数

* qmsg自定义参数

* 企业微信自定义参数

* 企业微信机器人自定义参数

* telegram自定义参数

* 智能微秘书自定义参数

* SMTP 邮件自定义设置

* PUSHME自定义参数

* CHRONOCAT自定义参数

* 智能微秘书条件判断错误修正
2024-03-05 22:13:38 +08:00
whyour 55945e4cd1 更新版本 v2.17.2 2024-03-02 16:24:30 +08:00
whyour b39036f8f8 取消生成 core 文件 2024-03-02 16:22:18 +08:00
pharaoh2012andGitHub f07093d29f 企业微信有长度限制,超长的进行分段提交 (#2255) 2024-03-02 16:15:58 +08:00
whyour 11c789c71c 修复 webhook 通知 body 拆分逻辑 2024-02-25 15:27:48 +08:00
whyour 81898f9dd7 修复依赖安装失败状态变更 2024-02-14 22:28:32 +08:00
whyour 6dba8ae72d 依赖增加已取消状态 2024-02-14 21:54:53 +08:00
whyour c47896e787 修改依赖操作状态判断 2024-02-14 16:41:14 +08:00
whyour 14cb1f7788 依赖管理支持取消安装和状态筛选 2024-02-13 22:42:22 +08:00
whyour 892d91edc4 更新版本 v2.17.1 2024-02-13 15:19:52 +08:00
whyour 74916670eb 修复设置 nodejs 镜像源 2024-02-13 15:19:44 +08:00
whyour b31eab02d7 修复重启系统 2024-02-11 22:04:41 +08:00
whyour 9c258ab113 修复初始化更新镜像源配置 2024-02-11 21:46:27 +08:00
whyour 3bcba11b33 修改 workflow 编译 2024-02-08 22:10:11 +08:00
whyour 15140fd87d 静态资源编译增加 git gc 2024-02-08 21:28:46 +08:00
whyour e83058c3bc 兼容脚本、日志、配置文件详情接口 2024-02-08 20:47:03 +08:00
whyour 29f0c2c9ac 修复对比工具保存文件 2024-02-07 20:08:10 +08:00
whyour f25fe27473 脚本管理重命名增加默认值,增加新建、删除快捷键 2024-02-06 22:17:07 +08:00
whyour 0ae1f284ec 修改脚本详情和日志详情接口 2024-01-29 21:47:03 +08:00
whyour 49c441117a 修改 sqlite 版本 2024-01-28 20:22:55 +08:00
whyour 7bc5ff01a6 更新 readme 2024-01-25 23:54:03 +08:00
whyour 8128fb8a9f 修改依赖设置提示 2024-01-25 22:42:33 +08:00
秋澪andGitHub 315ae49360 添加发布端口和数据卷 (#2231) 2024-01-25 20:48:03 +08:00
Cp0204andGitHub 35c6475534 修复未开启一言时多余空行, 通知渠道改发送前检查 (#2222) 2024-01-25 13:14:26 +08:00
whyour 7f3e60423b 修复启动时依赖配置未初始化 2024-01-24 22:55:21 +08:00
whyour 0a723318a1 修复 workflow 缓存 2024-01-22 23:50:47 +08:00
whyour 2c1e7535b8 修复 debian 系统内更新源代码分支错误 2024-01-22 23:38:58 +08:00
whyour 48d1fba699 系统设置增加重启 2024-01-22 23:36:11 +08:00
whyour 06f861baaf 更新版本 v2.17.0 2024-01-22 22:51:33 +08:00
whyour bbbd353567 增加 python3.10 镜像 2024-01-22 22:51:22 +08:00
whyour 8283f62db2 修改获取配置文件接口,初始化系统配置 2024-01-18 13:51:06 +08:00
whyour 1a94a660a5 修改对比工具数据源,修复编辑器语言 2024-01-14 16:28:11 +08:00
whyour 5c393fef36 更新 readme 2024-01-13 21:34:13 +08:00
whyour 53346a3962 修复 notify.js smtp 通知 2024-01-13 20:52:04 +08:00
whyour 4e66a3aff5 修改 monaco-editor 静态资源地址 2024-01-10 22:12:41 +08:00
whyour b79d10b452 修改获取登录 ip 和 ip 地址方式 2024-01-08 19:05:12 +08:00
whyour 3dc3ece993 修改日志脚本文件大小展示 2024-01-07 16:54:05 +08:00
whyour 792cf9ad20 修复依赖设置样式 2024-01-02 22:07:12 +08:00
whyour 201f41e15b 修改镜像源设置样式 2024-01-01 22:33:20 +08:00
whyour 71e7ba9cb6 修改 nginx api 代理超时时间 2024-01-01 17:14:57 +08:00
whyour a7562259d1 更新 sqlite3 版本 2024-01-01 15:07:56 +08:00
whyour 18671264bf 移除 config.sh 中 PipMirror 和 NpmMirror配置 2023-12-25 22:05:33 +08:00
whyour 37bcb77be0 修复健康检查命令 2023-12-24 15:48:34 +08:00
whyour 563457cf1b JavaScript 通知文件 tunnel 替换为 hpagent,移除 scripts 目录下 package.json 2023-12-24 14:30:56 +08:00
whyour d2cf482d11 修复保存文件 2023-12-23 23:56:11 +08:00
whyour 2713942a68 脚本管理忽略符号文件 2023-12-23 00:28:31 +08:00
whyour 62168979e3 修复更新用户信息 2023-12-22 22:13:09 +08:00
whyour f8aba4b1fb 日志和脚本增加文件大小展示,修改脚本管理列表排序 2023-12-21 09:57:06 +08:00
whyour 52a89a21f6 降级 Alpine 为 3.18 2023-12-18 23:39:16 +08:00
whyour ca8790bbec 修复任务详情脚本行数展示 2023-12-17 17:53:15 +08:00
whyour be1d68c0d1 修改 schedule 服务代理环境变量 2023-12-10 21:53:23 +08:00
whyour 95afcbc9dd 更新 workflow 2023-12-09 21:29:04 +08:00
whyour 20249119a0 修复 sentry 配置 2023-12-09 12:02:58 +08:00
whyour 6e0ee6f627 移除 console 2023-12-05 23:23:13 +08:00
whyour 13e7dac333 修复自定义通知 body 中 key 被自动转为小写 2023-12-04 17:44:13 +08:00
whyour b89dc6dfc6 修改镜像源按钮增加 loading 2023-12-01 09:28:11 +08:00
whyour 897b710527 通知 TG_API_HOST 参数移除默认 https 协议,参数包含协议 2023-12-01 09:21:27 +08:00
whyour 5afec03548 修改获取切换 node 和 linux 源日志 2023-11-30 23:39:14 +08:00
whyour c6a25e5359 修改代理地址提示 2023-11-25 23:03:16 +08:00
whyour 3777a4e7b4 系统设置增加依赖代理和镜像设置 2023-11-25 21:49:33 +08:00
luckrnx09andGitHub 9b7d4cb8fe Enhance the script editor experience (#2186) 2023-11-25 14:24:08 +08:00
whyour 59f7b72bb1 移除 ql -l 参数 2023-11-23 19:40:52 +08:00
whyour 4d97f0f44d 重新安装依赖时不检查是否已安装 2023-11-22 22:54:36 +08:00
whyour de9e9ce627 webhook 通知 body 增加 text/plain 类型,修复多定时任务服务重启未初始化 2023-11-22 22:43:48 +08:00
whyour f056b611a4 修复删除依赖逻辑 2023-11-11 11:23:04 +08:00
whyour 659484511f 修复 js 通知文件 2023-11-10 22:57:17 +08:00
whyour 9ed107980c 系统设置增加依赖代理和镜像源设置 2023-11-06 23:55:16 +08:00
whyour 4a3ac7dc4b 移除随机延迟相关配置及 ProxyUrl 默认值,修复拉库通知可能失败 2023-11-05 01:02:31 +08:00
whyour c3a0f58003 更新 python 版本为 3.11 2023-11-04 21:21:21 +08:00
whyour 23781d8b34 修复获取日志列表 2023-11-03 21:31:48 +08:00
6dylan6andwhyour c1d6a0a40b 修复调用就结束执行的问题 2023-11-03 18:07:07 +08:00
Bad ChildandGitHub 82c9978514 修复 chronocat 通知成功判断 (#2166) 2023-11-03 17:57:10 +08:00
TungliesandGitHub 9f6d4943ea 修复: 邮件服务支持列表链接失效(#2167) 2023-11-03 17:55:59 +08:00
whyour c4a44a48c5 使用 npm 安装 pm2 tsx 2023-11-02 19:37:24 +08:00
whyour d2fef75e01 修改 trust proxy 2023-11-02 00:47:33 +08:00
whyour 085cb789b5 修复初始化设置通知,shell 映射,邮箱通知错误提示 2023-11-02 00:41:04 +08:00
whyour 20f615eadf fs 文件操作替换为 fs.promise 2023-11-01 16:44:34 +08:00
whyour 66a2769e7c 更新版本 v2.16.5 2023-10-30 15:04:16 +08:00
whyour 899a30eacd 修改重置环境变量逻辑 2023-10-30 15:04:14 +08:00
whyour 8760ac2964 修复定时任务详情日志列表样式 2023-10-26 23:03:58 +08:00
whyour 62a29867a3 更新 readme 2023-10-21 16:57:11 +08:00
whyour 4938635ef4 修复服务启动时环境变量污染 2023-10-20 17:54:32 +08:00
whyour 046239404f 更新版本 v2.16.4 2023-10-19 21:37:00 +08:00
whyour b90243f55c 修改 task 日志展示逻辑 2023-10-19 21:36:39 +08:00
whyour 43c0cd8132 修复 mac 任务无法正常退出 2023-10-16 23:44:39 +08:00
whyour 533e12a796 脚本推送增加自定义 webhook 方式 2023-10-15 20:42:05 +08:00
whyour bd004a0489 修复设置代理时,schedule 服务通信异常 2023-10-14 23:05:25 +08:00
whyour 41befcc0a8 更新 readme 2023-10-14 21:50:12 +08:00
whyour 9fb9b3d121 task 命令支持给脚本传参,使用 -- 分割,后面的参数都会传给脚本
例如 task x.js -n whyour,脚本就能接收到参数 -n whyour
2023-10-14 21:47:46 +08:00
5055045d22 增加chronocat无头模式的QQNT推送 用于无法使go-cqhttp的接替 (#2141)
* 增加chronocat无头模式的QQNT推送

* Chronocat发送时没有配置群号或个人消息号发送出错

* 增加系统通知、去除注释

* 增加系统通知

---------

Co-authored-by: child <wulincheng@javalc.com>
2023-10-14 20:56:32 +08:00
whyour 9f7beb934d 系统日志增加置顶置底按钮 2023-10-13 23:44:28 +08:00
whyour 26b06c17c5 修复清空文件夹报错 2023-10-13 15:35:50 +08:00
whyour b8a9b26ca3 修复定时任务任务前后命令校验 2023-10-12 00:55:22 +08:00
whyour b6376ed2e8 修复定时任务 task_after 命令无法停止 2023-10-12 00:45:49 +08:00
whyour 99f6073c8e 定时任务增加前后规则校验,不能包含 task 命令 2023-10-10 23:06:01 +08:00
whyour 5e73f0390f 修复定时任务详情页查看历史日志异常 2023-10-09 23:56:35 +08:00
whyour c35cfba8b0 修改任务队列重新设置并发 2023-10-08 22:18:54 +08:00
whyour aac109621a 修改任务队列日志打印 2023-10-08 22:11:46 +08:00
whyour a340964c82 修改获取登录 ip 判断 2023-10-08 00:10:46 +08:00
whyour 00818b694a 使用 spawn exit 方法监听进程退出 2023-10-07 22:51:44 +08:00
whyour ec5b885476 修改任务队列执行日志 2023-10-06 02:34:40 +08:00
whyour 9d55cb108c 定时任务详情增加额外定时展示 2023-09-29 23:16:07 +08:00
whyour 4c19054b30 修复 api 前缀 2023-09-29 16:26:03 +08:00
whyour 2a41f64d1b 修复 QlBaseUrl 末尾不加斜杠无法访问 2023-09-29 16:07:41 +08:00
whyour d3023d31e3 修复定时任务日志滚动 2023-09-27 23:04:16 +08:00
whyour eddc03e295 修复定时任务间隔较小,任务状态不准确 2023-09-26 22:51:33 +08:00
whyour 77a8e00b17 更新版本 v2.16.3 2023-09-24 13:39:53 +08:00
whyour 99281a061e 修复更新历史定时任务规则未生效 2023-09-24 13:39:52 +08:00
whyour 22eedebf14 修复任务管理创建视图 2023-09-24 00:47:56 +08:00
whyour d7e0531935 修复系统设置展示版本失败 2023-09-23 11:14:24 +08:00
whyour 16734326cf 修复 ws 变量错误 2023-09-22 20:44:47 +08:00
whyour 956cfe18be 环境变量名称增加复制功能 2023-09-22 00:47:36 +08:00
whyour a864a56917 修复调试脚本日志丢失 2023-09-22 00:46:16 +08:00
whyour ab3fc9b5f1 定时任务增加运行任务前和运行任务后参数 2023-09-19 22:44:32 +08:00
whyour 8d899f1a53 更新 workflow 2023-09-19 00:30:45 +08:00
whyour a1eef2c644 修复使用 ubuntu-latest linux/s390x 无法使用 npm 2023-09-18 13:09:14 +08:00
zui-langandGitHub aab096843c 消息通知 Bark 增加额外参数:时效性通知、跳转Url (#2112) 2023-09-17 01:01:34 +08:00
whyour acc7443004 定时任务支持多个定时规则 2023-09-16 00:15:50 +08:00
whyour 042d7d3b8e 修复任务管理创建视图 placeholder,更新 npm 包 2023-09-15 21:27:53 +08:00
whyour 0511a4af0d 增加 QlPort 环境变量,支持设置服务启动的端口,修复定时任务视图操作符筛选 2023-09-15 17:57:28 +08:00
whyour 748a099087 修复日志滚动逻辑 2023-09-12 22:28:26 +08:00
whyour 742d0b2135 更新版本 v2.16.2 2023-09-02 00:41:16 +08:00
whyour 2081364325 修改定时任务置顶样式 2023-09-02 00:41:13 +08:00
whyour 39dc31916e 修复日志弹框样式 2023-09-01 23:16:12 +08:00
whyour 5395cebdb4 修复环境变量有空格时并发数量错误 2023-09-01 22:35:15 +08:00
whyour fe934fa4af 修复环境变量中单引号转义 2023-09-01 15:15:25 +08:00
whyour 461fa8e131 修改系统任务日志 2023-08-29 23:29:02 +08:00
whyour 4618a19c04 修改本地监听 ip 2023-08-29 21:50:23 +08:00
whyour e2bd15683e 修复 JSON.parse 错误,修复删除环境变量名称过长 2023-08-27 12:41:06 +08:00
whyour 648b9c4520 修改系统日志时间打印 2023-08-26 20:51:17 +08:00
whyour b4e4e84bbd 修复多语言翻译 2023-08-26 15:29:44 +08:00
whyour aa5d6f3cb6 系统设置增加系统语言切换 2023-08-26 00:16:15 +08:00
whyour e9416c23df 增加任务运行数日志 2023-08-25 19:25:12 +08:00
NANSIYVandGitHub ef4999be55 修复 python 企业微信推送错误 (#2087)
close #2086
2023-08-25 10:37:03 +08:00
whyour e7be4999b0 修复定时任务日志滚动 2023-08-25 09:05:32 +08:00
whyour 5907553670 修改定时任务置顶样式 2023-08-25 00:46:28 +08:00
whyour 0b9066525a 修复任务运行错误时没有日志,修改 tooltip 样式 2023-08-24 23:14:22 +08:00
whyour ac904cae61 修复停止订阅执行日志 2023-08-23 22:57:18 +08:00
whyour 4d5fa320ea 修改私有仓库 ssh 配置 2023-08-23 18:06:28 +08:00
whyour 003defedcf 修复环境变量中$变量被计算 2023-08-23 14:55:57 +08:00
whyour 40e8041401 修复退出调试自动删除调试日志目录 2023-08-22 22:18:18 +08:00
whyour a8174e89be 更新版本 v2.16.1 2023-08-21 22:51:00 +08:00
whyour cce83f7a4c 修复日志中多语言文本判断 2023-08-21 22:50:57 +08:00
whyour 4f7649f157 系统设置增加系统运行日志 2023-08-21 00:10:43 +08:00
whyour b002cbef3a 修复手机端日志弹框样式 2023-08-19 09:29:35 +08:00
whyour 1d93fe0de0 修复添加订阅粘贴 ql 链接 2023-08-18 09:17:18 +08:00
whyour 2d936f1341 修复定时任务页面样式 2023-08-17 08:26:48 +08:00
whyour b40673e9ac 修改日志弹框样式 2023-08-16 22:14:45 +08:00
whyour 637874e426 初始化数据增加错误捕获 2023-08-11 22:50:53 +08:00
whyour de71d1a258 修复任务筛选条件切换 2023-08-11 22:25:14 +08:00
whyour e174e190ee 修复 python 企业微信推送,debian 版本关于页面报错 2023-08-11 21:40:01 +08:00
whyour 89164a3bd2 更新版本 v2.16.0 2023-08-06 20:57:10 +08:00
whyour a9b9c4d7ad 修复错误信息展示 2023-08-06 20:56:59 +08:00
whyour f947866c32 修复全部任务多语言 2023-08-06 14:55:51 +08:00
whyour a2e33d1ed4 修复 editor 判断语言 2023-08-05 13:18:28 +08:00
whyour b1a242c8ee 更新多语言 2023-08-05 12:33:43 +08:00
whyour 0bf28e9ab3 task 支持运行 mjs 文件 2023-08-05 11:02:29 +08:00
whyour 624c9374e5 修复定时任务列表虚拟滚动 2023-08-04 22:24:54 +08:00
雨思andGitHub a9da8fffc1 PushMe消息通道增加params参数,以支持markdown消息 (#2044) 2023-08-01 21:33:19 +08:00
whyour b4e5db9da9 删除订阅支持自动删除任务和脚本 2023-07-30 21:15:46 +08:00
whyour 2e27d4057e 定时任务增加关联订阅 2023-07-30 13:38:38 +08:00
whyour e7d023a7e0 支持多语言英文 2023-07-29 18:26:30 +08:00
whyour 39bfd39559 修复任务详情日志列表卡顿 2023-07-27 23:15:48 +08:00
whyour 6832cb4e22 修复 6 位 cron 不以 task 开头运行失败 2023-07-25 22:18:35 +08:00
雨思andGitHub 590dbba19b PushMe 推送通道修复 (#2034) 2023-07-25 17:44:27 +08:00
whyour 3987ef8d97 更新版本 v2.15.20 2023-07-24 22:02:33 +08:00
whyour 8b0dedaf8c 修复 linux 依赖是否存在判断 2023-07-24 22:02:26 +08:00
whyour 04edd97967 修复 node 依赖匹配判断 2023-07-21 21:22:48 +08:00
whyour 6131fe0110 更新版本 v2.15.19 2023-07-20 23:14:16 +08:00
whyour 665c344ae4 修复依赖是否已经安装判断 2023-07-20 22:54:11 +08:00
雨思andGitHub 373b8c97d7 增加PushMe消息通道 (#2018),修复系统设置保存通知 2023-07-20 13:19:39 +08:00
whyour 56eb0c5408 更新版本 v2.15.18 2023-07-19 21:17:22 +08:00
whyour fe55929959 更新 readme 2023-07-19 21:02:52 +08:00
whyour 01e2bd007d 修复重启提示持续时间 2023-07-18 21:39:03 +08:00
whyour 4e091b0c3e 修改 nginx body 限制,服务重启延迟时间 2023-07-18 15:30:43 +08:00
whyour db94cd3799 修复备份数据超时时间 2023-07-18 13:43:02 +08:00
whyour a15192b9b2 升级 @umijs/max 版本 2023-07-17 23:18:48 +08:00
whyour efd4f1d5ab umi-request 替换为 axios 2023-07-17 23:13:06 +08:00
whyour bd166ee794 修复 pm2 重启逻辑 2023-07-17 13:25:56 +08:00
whyour 93e94ea94c 系统设置增加数据恢复功能 2023-07-16 22:02:30 +08:00
whyour 88b87de391 增加数据备份功能 2023-07-16 00:23:29 +08:00
whyour 8affff96f3 修改系统内更新系统逻辑 2023-07-13 22:09:45 +08:00
whyour 936b565fb1 修改系统更新时间和版本获取 2023-07-12 00:05:46 +08:00
whyour b69ff2895e 增加依赖是否已经安装判断 2023-07-10 23:48:05 +08:00
whyour f3791cbb62 修复编译错误 2023-07-09 17:46:02 +08:00
whyour b0f3b51736 修复服务启动时定时删除日志失效 2023-07-08 20:58:36 +08:00
whyour 3aa112e373 修改 node 进程错误监控 2023-07-08 00:27:05 +08:00
whyour 683482e067 修改 ql update 逻辑 2023-07-08 00:18:09 +08:00
228 changed files with 33613 additions and 13717 deletions
+13 -10
View File
@@ -1,13 +1,16 @@
PUBLIC_PORT=5400
CRON_PORT=5500
BACK_PORT=5600
PORT=5700
GRPC_PORT=5500
BACK_PORT=5700
LOG_LEVEL='debug'
# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6)
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
# BIND_HOST=0.0.0.0
# BIND_HOST_GRPC=0.0.0.0
SECRET='whyour'
LOG_LEVEL='info'
QINIU_AK=''
QINIU_SK=''
QINIU_SCOPE=''
TEMP=''
JWT_SECRET=
JWT_EXPIRES_IN=
QINIU_AK=
QINIU_SK=
QINIU_SCOPE=
+4
View File
@@ -0,0 +1,4 @@
---
name: Bug Fixer
description: Fix this issue following our error handling pattern.
---
+465
View File
@@ -0,0 +1,465 @@
name: Build And Push Docker Image
on:
push:
paths-ignore:
- "*.md"
branches:
- "master"
- "develop"
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
jobs:
code_gitlab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Push to GitLab
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitlab git@gitlab.com:whyour/qinglong.git 2>/dev/null
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
code_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Push to Gitee
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee push --all succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
run: |
mkdir -p tmp
cd ./tmp
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add .
git config --local user.name 'github-actions[bot]'
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
static_gitlab:
needs: build-static
runs-on: ubuntu-latest
steps:
- name: Push qinglong-static to GitLab
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
static_gitee:
needs: build-static
runs-on: ubuntu-latest
steps:
- name: Push qinglong-static to Gitee
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee static push succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong-static","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-alpine
cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile.310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
publish:
if: ${{ github.ref_name == 'master' }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: publish npm package
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
npm publish
-169
View File
@@ -1,169 +0,0 @@
name: Build And Push Docker Image
on:
push:
branches:
- 'master'
- 'develop'
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
schedule:
# 参考 https://jasonet.co/posts/scheduled-actions/
- cron: '00 14 * * *' # GMT 15:00 => 北京时间 23:00
workflow_dispatch:
jobs:
to_gitlab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@gitlab.com:whyour/qinglong.git
ssh_private_key:
${{ secrets.GITLAB_SSH_PK }}
to_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@gitee.com:whyour/qinglong.git
ssh_private_key:
${{ secrets.GITLAB_SSH_PK }}
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: '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: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
REPO_GITEE: git@gitee.com:whyour/qinglong-static.git
REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git
PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: |
mkdir -p tmp
cd ./tmp
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add .
git config --local user.name 'github-actions[bot]'
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
mkdir -p ~/.ssh
echo "${PRIVATE_KEY}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
export GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -l git"
git remote add gitee "${REPO_GITEE}"
git remote add gitlab "${REPO_GITLAB}"
git push --force --quiet gitee ${GITHUB_BRANCH}:${GITHUB_BRANCH}
git push --force --quiet gitlab ${GITHUB_BRANCH}:${GITHUB_BRANCH}
build:
needs: build-static
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: '8.3.1'
- uses: actions/setup-node@v3
with:
cache: 'pnpm'
- name: Setup timezone
uses: zcong1993/setup-timezone@master
with:
timezone: Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: |
latest=false
tags: |
type=schedule,pattern=nightly
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=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
id: docker_build
uses: docker/build-push-action@v3
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
+12 -6
View File
@@ -22,10 +22,16 @@
.env
.history
.version.ts
/.tmp
__pycache__
/shell/preload/env.*
/shell/preload/notify.*
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
/config
/log
/db
/manual_log
/scripts
/bak
.deepseek/
.claude/
# local Kubernetes overlays
/deploy/kubernetes/overlays/local/
+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
View File
@@ -1,2 +1 @@
sentrycli_cdnurl=https://npmmirror.com/mirrors/sentry-cli/
strict-peer-dependencies=false
+20 -4
View File
@@ -2,7 +2,23 @@
**/*.svg
**/*.ejs
**/*.html
package.json
.umi
.umi-production
.umi-test
/.umi
/.umi-production
/.umi-test
/.history
/.tmp
/node_modules
npm-debug.log*
yarn-error.log
yarn.lock
package-lock.json
/static
/data
DS_Store
/src/.umi
/src/.umi-production
/src/.umi-test
.env.local
.env
version.ts
/.tmp
+14 -17
View File
@@ -4,22 +4,20 @@ const CompressionPlugin = require('compression-webpack-plugin');
const baseUrl = process.env.QlBaseUrl || '/';
export default defineConfig({
hash: true,
jsMinifier: 'terser',
antd: {},
locale: {
antd: true,
title: true,
baseNavigator: true,
},
outputPath: 'static/dist',
fastRefresh: true,
favicons: [`https://qn.whyour.cn/favicon.svg`],
mfsu: {
strategy: 'eager',
},
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
proxy: {
[`${baseUrl}api/public`]: {
target: 'http://127.0.0.1:5400/',
changeOrigin: true,
pathRewrite: { [`^${baseUrl}api/public`]: '/api' },
},
[`${baseUrl}api`]: {
target: 'http://127.0.0.1:5600/',
target: 'http://127.0.0.1:5700/',
changeOrigin: true,
ws: true,
pathRewrite: { [`^${baseUrl}api`]: '/api' },
@@ -35,13 +33,12 @@ export default defineConfig({
}),
);
}) as any,
externals: {
react: 'window.React',
'react-dom': 'window.ReactDOM',
},
headScripts: [
`./api/env.js`,
'https://gw.alipayobjects.com/os/lib/react/18.2.0/umd/react.production.min.js',
'https://gw.alipayobjects.com/os/lib/react-dom/18.2.0/umd/react-dom.production.min.js',
headScripts: [`./api/env.js`],
copy: [
{
from: 'node_modules/monaco-editor/min/vs',
to: 'static/dist/monaco-editor/min/vs',
},
],
npmClient: 'pnpm',
});
+43
View File
@@ -0,0 +1,43 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **qinglong** (2778 symbols, 6698 relationships, 233 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "develop"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/qinglong/context` | Codebase overview, check index freshness |
| `gitnexus://repo/qinglong/clusters` | All functional areas |
| `gitnexus://repo/qinglong/processes` | All execution flows |
| `gitnexus://repo/qinglong/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+46 -111
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-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.dlww.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.dlww.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>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
@@ -35,128 +35,63 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- Support dark mode
- Support cell phone operation
## Version
### docker
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
When running the `debian` image as a non-root user, specify `--user qinglong`:
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
```bash
npm i @whyour/qinglong
```
## Deployment
### Docker (Recommended)
[View Documentation](https://qinglong.online/guide/getting-started/installation-guide)
```bash
# curl -sSL get.docker.com | sh
docker run -dit \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
## Built-in API
### Docker-compose (Recommended)
[View Documentation](https://qinglong.online/guide/user-guide/built-in-api)
```bash
# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
## Built-in commands
# start
docker-compose up -d
# stop
docker-compose down
```
### Podman (Recommended)
```bash
# https://podman.io/getting-started/installation
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### Local
It is recommended to use a pure system installation to avoid losing the original system data, you need to install node/npm/python3/pip3 yourself
```bash
npm install -g @whyour/qinglong
qinglong
# Add the environment variables QL_DIR and QL_DATA_DIR when prompted
export QL_DIR=""
export QL_DATA_DIR=""
# Run again
qinglong
```
## Use
1. built-in commands
```bash
# Update and restart Green Dragon
ql update
# Run custom scripts extra.sh
ql extra
# Adding a single script file
ql raw <file_url>
# Add a specific script for a single repository
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch>
# Delete old logs
ql rmlog <days>
# Start bot
ql bot
# Detecting the Green Dragon environment and repairing it
ql check
# Reset the number of login errors
ql resetlet
# Disable two-step login
ql resettfa
# Execute in sequence, if a random delay is set, it will be randomly delayed by a certain number of seconds
task <file_path>
# Execute in sequence, regardless of whether a random delay is set, all run immediately,
# and the foreground will output the day, while recorded in the log file
task <file_path> now
# Concurrent execution, regardless of whether a random delay is set, are run immediately,
# the foreground does not generate the day, directly recorded in the log file, and can be specified account execution
task <file_path> conc <env_name> <account_number>(Optional)
# Specify the account to execute and run immediately regardless of whether a random delay is set
task <file_path> desi <env_name> <account_number>
# Set task timeout
task -m <max_time> <file_path>
# Print task log in real time, no need to carry this parameter when creating timed tasks
task -l <file_path>
```
2. parameter description
* file_url: Script address
* repo_url: Repository address
* whitelist: The whitelist when pulling the repository, i.e., the string contained in the path of the script to be pulled
* blacklist: Blacklisting when pulling repositories, i.e. strings that are not included in the path of the script to be pulled
* dependence: Pulling the dependencies needed for the repository will be copied directly from the repository to the repository directory under scripts, regardless of the blacklist
* branch: Pull the branch of the repository
* days: Number of days of logs to be kept
* file_path: File path for task execution
* env_name: The name of the environment variable that needs to be concurrent or specified at the time of task execution
* account_number: Specify the account number of an environment variable to be executed when the task is executed
* max_time: Timeout, suffix "s" for seconds (default), "m" for minutes, "h" for hours, "d" for days
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
## Development
```bash
$ git clone git@github.com:whyour/qinglong.git
$ cd qinglong
$ cp .env.example .env
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
$ npm install -g pnpm@8.3.1
$ pnpm install
$ pnpm start
npm install -g pnpm@8.3.1
pnpm install
pnpm start
```
Open your browser and visit http://127.0.0.1:5700
Open your browser and visit <http://127.0.0.1:5700>
## Links
+46 -114
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-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.dlww.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.dlww.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>
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
@@ -37,131 +37,63 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- 支持暗黑模式
- 支持手机端操作
## 版本
### docker
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
```bash
npm i @whyour/qinglong
```
## 部署
### docker (推荐)
[查看文档](https://qinglong.online/guide/getting-started/installation-guide)
```bash
# curl -sSL get.docker.com | sh
docker run -dit \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
# 部署路径非必须,以斜杠开头和结尾,比如 /test/
-e QlBaseUrl="/" \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
## 内置 API
### docker-compose (推荐)
[查看文档](https://qinglong.online/guide/user-guide/built-in-api)
```bash
# curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
## 内置命令
# 启动
docker-compose up -d
# 停止
docker-compose down
```
### podman (推荐)
```bash
# https://podman.io/getting-started/installation
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
# 部署路径非必须,以斜杠开头和结尾,比如 /test/
-e QlBaseUrl="/" \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### 本机
建议使用纯净系统安装,避免系统原有数据丢失,需要自己安装 node/npm/python3/pip3
```bash
npm install -g @whyour/qinglong
qinglong
# 根据提示增加环境变量 QL_DIR 和 QL_DATA_DIR
export QL_DIR=""
export QL_DATA_DIR=""
# 再次执行
qinglong
```
## 使用
1. 内置命令
```bash
# 更新并重启青龙
ql update
# 运行自定义脚本extra.sh
ql extra
# 添加单个脚本文件
ql raw <file_url>
# 添加单个仓库的指定脚本
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> <extensions>
# 删除旧日志
ql rmlog <days>
# 启动tg-bot
ql bot
# 检测青龙环境并修复
ql check
# 重置登录错误次数
ql resetlet
# 禁用两步登录
ql resettfa
# 依次执行,如果设置了随机延迟,将随机延迟一定秒数
task <file_path>
# 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日,同时记录在日志文件中
task <file_path> now
# 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日,直接记录在日志文件中,且可指定账号执行
task <file_path> conc <env_name> <account_number>(可选的)
# 指定账号执行,无论是否设置了随机延迟,均立即运行
task <file_path> desi <env_name> <account_number>
# 设置任务超时时间
task -m <max_time> <file_path>
# 实时打印任务日志,创建定时任务时,不用携带此参数
task -l <file_path>
```
2. 参数说明
* file_url: 脚本地址
* repo_url: 仓库地址
* whitelist: 拉取仓库时的白名单,即就是需要拉取的脚本的路径包含的字符串,多个竖线分割
* blacklist: 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串,多个竖线分割
* dependence: 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响,多个竖线分割
* extensions: 拉取仓库的文件后缀,多个竖线分割
* branch: 拉取仓库的分支
* days: 需要保留的日志的天数
* file_path: 任务执行时的文件路径
* env_name: 任务执行时需要并发或者指定时的环境变量名称
* account_number: 任务执行时指定某个环境变量需要执行的账号序号
* max_time: 超时时间,后缀"s"代表秒(默认值), "m"代表分, "h"代表小时, "d"代表天
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
## 开发
```bash
$ git clone git@github.com:whyour/qinglong.git
$ cd qinglong
$ cp .env.example .env
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# 推荐使用 pnpm https://pnpm.io/zh/installation
$ npm install -g pnpm@8.3.1
$ pnpm install
$ pnpm start
npm install -g pnpm@8.3.1
pnpm install
pnpm start
```
打开你的浏览器,访问 http://127.0.0.1:5700
打开你的浏览器,访问 <http://127.0.0.1:5700>
## 链接
+5
View File
@@ -0,0 +1,5 @@
## Reporting a Vulnerability
To report a vulnerability, please open a private vulnerability report at <https://github.com/whyour/qinglong/security>.
While the discovery of new vulnerabilities is rare, we also recommend always using the latest versions of Qinglong to ensure your application remains as secure as possible.
+53 -25
View File
@@ -1,22 +1,39 @@
import { getFileContentByName, getLastModifyFilePath } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import * as fs from 'fs';
import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate';
import { join } from 'path';
import { join, basename } from 'path';
import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils';
const route = Router();
export default (app: Router) => {
app.use('/configs', route);
route.get(
'/samples',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({
code: 200,
data: SAMPLE_FILES,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/files',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const fileList = fs.readdirSync(config.configPath, 'utf-8');
const fileList = await fs.readdir(config.configPath, 'utf-8');
res.send({
code: 200,
data: fileList
@@ -32,24 +49,11 @@ export default (app: Router) => {
);
route.get(
'/:file',
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let content = '';
if (config.blackFileList.includes(req.params.file)) {
res.send({ code: 403, message: '文件无法访问' });
}
if (req.params.file.includes('sample')) {
content = getFileContentByName(
join(config.samplePath, req.params.file),
);
} else {
content = getFileContentByName(
join(config.configPath, req.params.file),
);
}
res.send({ code: 200, data: content });
const configService = Container.get(ConfigService);
await configService.getFile(req.query.path as string, res);
} catch (e) {
return next(e);
}
@@ -68,12 +72,36 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const { name, content } = req.body;
if (config.blackFileList.includes(name)) {
res.send({ code: 403, message: '文件无法访问' });
// Resolve path first to prevent traversal attacks
let basePath = config.configPath;
if (name.startsWith('data/scripts/')) {
basePath = join(config.rootPath, 'data/scripts');
}
const path = join(config.configPath, name);
fs.writeFileSync(path, content);
res.send({ code: 200, message: '保存成功' });
const cleanName = name.replace(/^data\/scripts\//, '');
const resolvedPath = join(basePath, cleanName);
const normalized = join(resolvedPath);
// Verify the resolved path stays within allowed directory
if (!normalized.startsWith(basePath)) {
return res.send({ code: 403, message: t('文件路径无效') });
}
// Check blacklist on actual filename (not user input)
if (config.blackFileList.includes(basename(normalized))) {
return res.send({ code: 403, message: t('文件无法访问') });
}
await writeFileWithLock(normalized, content);
res.send({ code: 200, message: t('保存成功') });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
try {
const configService = Container.get(ConfigService);
await configService.getFile(req.params.file, res);
} catch (e) {
return next(e);
}
+64 -37
View File
@@ -4,7 +4,13 @@ import { Logger } from 'winston';
import CronService from '../services/cron';
import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate';
import cron_parser from 'cron-parser';
import { commonCronSchema } from '../validation/schedule';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { t } from '../shared/i18n';
const route = Router();
export default (app: Router) => {
@@ -59,7 +65,7 @@ export default (app: Router) => {
try {
const cronViewService = Container.get(CronViewService);
if (req.body.type === 1) {
return res.send({ code: 400, message: '参数错误' });
return res.send({ code: 400, message: t('参数错误') });
} else {
const data = await cronViewService.update(req.body);
return res.send({ code: 200, data });
@@ -170,24 +176,14 @@ export default (app: Router) => {
route.post(
'/',
celebrate({
body: Joi.object({
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional(),
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
}),
body: Joi.object(commonCronSchema),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (cron_parser.parseExpression(req.body.schedule).hasNext()) {
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
const cronService = Container.get(CronService);
const data = await cronService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
@@ -316,8 +312,8 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.log(req.params.id);
return res.send({ code: 200, data });
const result = await cronService.log(req.params.id);
return res.send({ code: 200, data: result.content, logStatus: result.status });
} catch (e) {
return next(e);
}
@@ -328,27 +324,16 @@ export default (app: Router) => {
'/',
celebrate({
body: Joi.object({
labels: Joi.array().optional().allow(null),
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional().allow(null),
sub_id: Joi.number().optional().allow(null),
...commonCronSchema,
id: Joi.number().required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const cronService = Container.get(CronService);
const data = await cronService.update(req.body);
return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
const cronService = Container.get(CronService);
const data = await cronService.update(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
@@ -412,7 +397,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.import_crontab();
const data = await cronService.importCrontab();
return res.send({ code: 200, data });
} catch (e) {
return next(e);
@@ -449,16 +434,16 @@ export default (app: Router) => {
log_path: Joi.string().optional().allow(null),
last_running_time: Joi.number().optional().allow(null),
last_execution_time: Joi.number().optional().allow(null),
exit_code: Joi.number().optional().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const cronService = Container.get(CronService);
const data = await cronService.status({
...req.body,
status: parseInt(req.body.status),
pid: parseInt(req.body.pid) || '',
status: req.body.status ? parseInt(req.body.status) : undefined,
pid: req.body.pid ? parseInt(req.body.pid) : undefined,
});
return res.send({ code: 200, data });
} catch (e) {
@@ -467,6 +452,48 @@ export default (app: Router) => {
},
);
route.get(
'/:id/instances',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const instances = await RunningInstanceModel.findAll({
where: {
cron_id: req.params.id,
},
order: [['started_at', 'DESC']],
raw: true,
});
return res.send({ code: 200, data: instances });
} catch (e) {
return next(e);
}
},
);
route.post(
'/:id/instances/:instanceId/stop',
celebrate({
params: Joi.object({
id: Joi.number().required(),
instanceId: Joi.number().required(),
}),
}),
async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => {
try {
const cronService = Container.get(CronService);
const data = await cronService.stopInstance(req.params.instanceId);
return res.send(data);
} catch (e) {
return next(e);
}
},
);
route.get(
'/:id/logs',
celebrate({
+397
View File
@@ -0,0 +1,397 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { fn, col, where, Op } from 'sequelize';
import { CrontabModel } from '../data/cron';
import { CrontabStatModel } from '../data/cronStats';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs';
import os from 'os';
import { isEmpty } from 'lodash';
import { t, tf } from '../shared/i18n';
const route = Router();
export default (app: Router) => {
app.use('/dashboard', route);
route.post('/record', async (req: Request, res: Response) => {
try {
const { ref_id, code, elapsed } = req.body;
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
const today = dayjs().format('YYYY-MM-DD');
const isSuccess = code === 0 ? 1 : 0;
const isFail = code !== 0 ? 1 : 0;
const elapsedMs = (Number(elapsed) || 0) * 1000;
const existing = await CrontabStatModel.findOne({
where: { ref_id: Number(ref_id), date: today },
});
if (existing) {
await CrontabStatModel.update(
{
run_count: (existing.run_count || 0) + 1,
success_count: (existing.success_count || 0) + isSuccess,
fail_count: (existing.fail_count || 0) + isFail,
total_time: (existing.total_time || 0) + elapsedMs,
max_time: Math.max(existing.max_time || 0, elapsedMs),
},
{ where: { id: existing.id } },
);
} else {
await CrontabStatModel.create({
ref_id: Number(ref_id),
date: today,
run_count: 1,
success_count: isSuccess,
fail_count: isFail,
total_time: elapsedMs,
max_time: elapsedMs,
});
}
res.send({ code: 200 });
} catch (e) {
res.send({ code: 500 });
}
});
route.get(
'/overview',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [total, enabled, disabled, stats] = await Promise.all([
CrontabModel.count(),
CrontabModel.count({ where: { isDisabled: 0 } }),
CrontabModel.count({ where: { isDisabled: 1 } }),
CrontabStatModel.findOne({
attributes: [
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
[fn('SUM', col('total_time')), 'total_time'],
],
where: { date: today },
raw: true,
}),
]);
const row = stats as any;
const totalRuns = Number(row?.total_runs) || 0;
const totalSuccess = Number(row?.total_success) || 0;
const totalFail = Number(row?.total_fail) || 0;
const totalTime = Number(row?.total_time) || 0;
res.send({
code: 200,
data: {
total,
enabled,
disabled,
todayRuns: totalRuns,
todaySuccess: totalSuccess,
todayFail: totalFail,
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
},
});
} catch (e) {
next(e);
}
},
);
route.get(
'/trend',
async (req: Request, res: Response, next: NextFunction) => {
try {
const days = parseInt(req.query.days as string) || 7;
const dates: string[] = [];
for (let i = days - 1; i >= 0; i--) {
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
}
const rows = (await CrontabStatModel.findAll({
attributes: [
'date',
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
],
where: {
date: { [Op.in]: dates },
},
group: ['date'],
order: [['date', 'ASC']],
raw: true,
})) as any[];
const dataMap: Record<string, any> = {};
rows.forEach((r: any) => {
dataMap[r.date] = {
total: Number(r.total_runs) || 0,
success: Number(r.total_success) || 0,
fail: Number(r.total_fail) || 0,
};
});
const data = dates.map((d) => ({
date: dayjs(d).format('MM-DD'),
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-time',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('run_count')), 'run_count'],
[fn('MAX', col('max_time')), 'max_time'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('total_time')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
maxTime: Number(r.max_time),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-count',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('run_count')), 'run_count'],
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('success_count')), 'success_count'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('run_count')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
runCount: Number(r.run_count),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
successRate:
Number(r.run_count) > 0
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
: '0',
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/runtime',
async (req: Request, res: Response, next: NextFunction) => {
try {
const runningInstances = await RunningInstanceModel.findAll({
where: {
status: InstanceStatus.running,
},
raw: true,
});
const queuedCrons = await CrontabModel.findAll({
where: {
status: 3, // queued
},
raw: true,
});
// Fetch cron names for running instances
const cronIds = [
...new Set(runningInstances.map((i: any) => i.cron_id)),
];
const crons =
cronIds.length > 0
? await CrontabModel.findAll({
where: { id: cronIds },
raw: true,
})
: [];
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
const now = dayjs().unix();
const running = runningInstances.map((inst: any) => {
const cron = cronMap.get(inst.cron_id);
return {
instanceId: inst.id,
id: inst.cron_id,
name: cron?.name || cron?.command || tf('任务#%s', inst.cron_id),
pid: inst.pid,
elapsed: inst.started_at ? now - inst.started_at : 0,
logPath: inst.log_path,
};
});
const dayAgo = dayjs().subtract(24, 'hour').unix();
const idleTasks = await CrontabModel.findAll({
where: {
isDisabled: 0,
status: 1,
last_execution_time: { [Op.lt]: dayAgo },
},
order: [['last_execution_time', 'ASC']],
limit: 5,
raw: true,
});
res.send({
code: 200,
data: {
runningCount: running.length,
queuedCount: queuedCrons.length,
running,
idleTasks: idleTasks.map((c: any) => ({
id: c.id,
name: c.name || c.command || tf('任务#%s', c.id),
lastRun: c.last_execution_time
? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm')
: '-',
})),
},
});
} catch (e) {
next(e);
}
},
);
route.get(
'/labels',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [crons, stats] = (await Promise.all([
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
]));
const statMap: Record<number, any> = {};
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
crons.forEach((c) => {
let rawLabels = c.labels;
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
const labels: string[] = Array.isArray(rawLabels)
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
: [];
if (labels.length === 0) {
labels.push(t('未分类'));
}
const st = statMap[c.id!];
labels.forEach((label: string) => {
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
labelMap[label].count += 1;
if (st) {
labelMap[label].runs += Number(st.run_count) || 0;
labelMap[label].success += Number(st.success_count) || 0;
labelMap[label].totalTime += Number(st.total_time) || 0;
}
});
});
const data = Object.entries(labelMap)
.map(([label, v]) => ({
label,
count: v.count,
todayRuns: v.runs,
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
}))
.sort((a, b) => b.todayRuns - a.todayRuns);
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/system',
async (req: Request, res: Response, next: NextFunction) => {
try {
const memUsage = process.memoryUsage();
res.send({
code: 200,
data: {
platform: os.platform(),
uptime: Math.floor(process.uptime()),
memTotal: os.totalmem(),
memFree: os.freemem(),
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
cpus: os.cpus().length,
},
});
} catch (e) {
next(e);
}
},
);
};
+38 -17
View File
@@ -8,17 +8,28 @@ const route = Router();
export default (app: Router) => {
app.use('/dependencies', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.dependencies(req.query as any);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.dependencies(req.query as any);
return res.send({ code: 200, data });
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
},
);
route.post(
'/',
@@ -32,7 +43,6 @@ export default (app: Router) => {
),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.create(req.body);
@@ -54,7 +64,6 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.update(req.body);
@@ -71,7 +80,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body);
@@ -88,7 +96,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
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) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.getDb({ id: req.params.id });
@@ -124,7 +130,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const dependenceService = Container.get(DependenceService);
const data = await dependenceService.reInstall(req.body);
@@ -134,4 +139,20 @@ export default (app: Router) => {
}
},
);
route.put(
'/cancel',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
await dependenceService.cancel(req.body);
return res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
};
+93 -9
View File
@@ -1,11 +1,13 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import EnvService from '../services/env';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import multer from 'multer';
import config from '../config';
import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util';
import { t } from '../shared/i18n';
import EnvService from '../services/env';
const route = Router();
const storage = multer.diskStorage({
@@ -17,6 +19,10 @@ const storage = multer.diskStorage({
},
});
const upload = multer({ storage: storage });
const labelSchema = Joi.array()
.items(Joi.string().trim().required())
.min(1)
.required();
export default (app: Router) => {
app.use('/envs', route);
@@ -43,6 +49,7 @@ export default (app: Router) => {
.required()
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
remarks: Joi.string().optional().allow(''),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}),
),
}),
@@ -50,6 +57,9 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
if (!req.body?.length) {
return res.send({ code: 400, message: t('参数不正确') });
}
const data = await envService.create(req.body);
return res.send({ code: 200, data });
} catch (e) {
@@ -66,6 +76,7 @@ export default (app: Router) => {
name: Joi.string().required(),
remarks: Joi.string().optional().allow('').allow(null),
id: Joi.number().required(),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -192,6 +203,78 @@ 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(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.addLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.removeLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/upload',
upload.single('env'),
@@ -200,7 +283,7 @@ export default (app: Router) => {
try {
const envService = Container.get(EnvService);
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
const parseContent = JSON.parse(fileContent);
const parseContent = safeJSONParse(fileContent);
const data = Array.isArray(parseContent)
? parseContent
: [parseContent];
@@ -210,13 +293,14 @@ export default (app: Router) => {
name: x.name,
value: x.value,
remarks: x.remarks,
labels: x.labels,
})),
);
return res.send({ code: 200, data: result });
} else {
return res.send({
code: 400,
message: '文件缺少name或者value字段,参考导出文件格式',
message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'),
});
}
} catch (e) {
+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,
});
}
});
};
+6
View File
@@ -9,6 +9,9 @@ import open from './open';
import dependence from './dependence';
import system from './system';
import subscription from './subscription';
import update from './update';
import dashboard from './dashboard';
import health from './health';
export default () => {
const app = Router();
@@ -22,6 +25,9 @@ export default () => {
dependence(app);
system(app);
subscription(app);
update(app);
dashboard(app);
health(app);
return app;
};
+97 -23
View File
@@ -1,11 +1,17 @@
import { Router, Request, Response, NextFunction } from 'express';
import { celebrate, Joi } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import * as fs from 'fs';
import config from '../config';
import { emptyDir, getFileContentByName, readDirs } from '../config/util';
import { join } from 'path';
import { celebrate, Joi } from 'celebrate';
import { t } from '../shared/i18n';
import {
getFileContentByName,
readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
const route = Router();
const blacklist = ['.tmp'];
@@ -15,7 +21,7 @@ export default (app: Router) => {
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const result = readDirs(config.logPath, config.logPath, blacklist);
const result = await readDirs(config.logPath, config.logPath, blacklist);
res.send({
code: 200,
data: result,
@@ -26,20 +32,54 @@ export default (app: Router) => {
}
});
route.get(
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
try {
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.query.file as string) || '',
);
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
const logPath = `${req.query.path as string}/${req.query.file as string}`;
const runningInstance = await RunningInstanceModel.findOne({
where: { log_path: logPath, status: InstanceStatus.running },
});
const content = await getFileContentByName(finalPath);
res.send({
code: 200,
data: removeAnsi(content),
logStatus: runningInstance ? 'running' : undefined,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (blacklist.includes(req.path)) {
return res.send({ code: 403, message: '暂无权限' });
}
const filePath = join(
config.logPath,
(req.query.path || '') as string,
req.params.file,
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.params.file as string) || '',
);
const content = getFileContentByName(filePath);
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
@@ -53,22 +93,24 @@ export default (app: Router) => {
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
type: Joi.string().optional()
type: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, type } = req.body as {
let { filename, path } = req.body as {
filename: string;
path: string;
type: string;
};
const filePath = join(config.logPath, path, filename);
if (type === 'directory') {
emptyDir(filePath);
} else {
fs.unlinkSync(filePath);
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(path, filename);
if (!finalPath || blacklist.includes(path)) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await rmPath(finalPath);
res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -76,4 +118,36 @@ export default (app: Router) => {
},
);
route.post(
'/download',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const logService = Container.get(LogService);
const filePath = logService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
};
+188 -80
View File
@@ -1,22 +1,22 @@
import {
fileExist,
getFileContentByName,
readDirs,
getLastModifyFilePath,
readDir,
emptyDir,
} from '../config/util';
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import * as fs from 'fs';
import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate';
import path, { join, parse } from 'path';
import ScriptService from '../services/script';
import { t } from '../shared/i18n';
import multer from 'multer';
import { writeFileWithLock } from '../shared/utils';
const route = Router();
function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return config.writePathList.some((x) => resolved.startsWith(x));
}
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.scriptPath);
@@ -30,41 +30,94 @@ const upload = multer({ storage: storage });
export default (app: Router) => {
app.use('/scripts', route);
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let result = [];
const blacklist = ['node_modules', '.git'];
if (req.query.path) {
const targetPath = path.join(
config.scriptPath,
req.query.path as string,
);
result = readDir(targetPath, config.scriptPath, blacklist);
} else {
result = readDirs(config.scriptPath, config.scriptPath, blacklist);
}
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/:file',
'/',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const filePath = join(
config.scriptPath,
req.query.path as string,
let result: IFile[] = [];
const blacklist = [
'node_modules',
'.git',
'.pnpm',
'pnpm-lock.yaml',
'yarn.lock',
'package-lock.json',
];
if (req.query.path) {
result = await readDir(
req.query.path as string,
config.scriptPath,
blacklist,
);
} else {
result = await readDirs(
config.scriptPath,
config.scriptPath,
blacklist,
(a, b) => {
if (a.type === b.type) {
return a.title.localeCompare(b.title);
} else {
return a.type === 'directory' ? -1 : 1;
}
},
);
}
res.send({
code: 200,
data: result,
});
} catch (e) {
logger.error('🔥 error: %o', e);
return next(e);
}
});
route.get(
'/detail',
celebrate({
query: Joi.object({
path: Joi.string().optional().allow(''),
file: Joi.string().required(),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
req.query.file as string,
);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
}
},
);
route.get(
'/:file',
celebrate({
params: Joi.object({
file: Joi.string().required(),
}),
query: Joi.object({
path: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const scriptService = Container.get(ScriptService);
const content = await scriptService.getFile(
req.query?.path as string || '',
req.params.file,
);
const content = getFileContentByName(filePath);
res.send({ code: 200, data: content });
} catch (e) {
return next(e);
@@ -75,8 +128,17 @@ export default (app: Router) => {
route.post(
'/',
upload.single('file'),
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path, content, originFilename, directory } =
req.body as {
@@ -98,39 +160,49 @@ export default (app: Router) => {
}
if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({
code: 430,
message: '文件路径禁止访问',
code: 403,
message: t('暂无权限'),
});
}
if (req.file) {
fs.renameSync(req.file.path, join(path, req.file.filename));
const uploadPath = join(path, filename);
if (!isPathAllowed(uploadPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(req.file.path, uploadPath);
return res.send({ code: 200 });
}
if (directory) {
fs.mkdirSync(join(path, directory), { recursive: true });
const dirPath = join(path, directory);
if (!isPathAllowed(dirPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(dirPath, { recursive: true });
return res.send({ code: 200 });
}
if (!originFilename) {
originFilename = filename;
}
const originFilePath = join(
path,
`${originFilename.replace(/\//g, '')}`,
);
const filePath = join(path, `${filename.replace(/\//g, '')}`);
if (fs.existsSync(originFilePath)) {
fs.copyFileSync(
const originFilePath = join(path, originFilename);
const filePath = join(path, filename);
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(path, { recursive: true });
const fileExists = await fileExist(filePath);
if (fileExists) {
await fs.copyFile(
originFilePath,
join(config.bakPath, originFilename.replace(/\//g, '')),
);
if (filename !== originFilename) {
fs.unlinkSync(originFilePath);
await rmPath(originFilePath);
}
}
fs.writeFileSync(filePath, content);
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -148,15 +220,21 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, content, path } = req.body as {
filename: string;
content: string;
path: string;
};
const filePath = join(config.scriptPath, path, filename);
fs.writeFileSync(filePath, content);
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -169,24 +247,28 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
path: Joi.string().optional().allow(''),
type: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path, type } = req.body as {
let { filename, path } = req.body as {
filename: string;
path: string;
type: string;
};
const filePath = join(config.scriptPath, path, filename);
if (type === 'directory') {
emptyDir(filePath);
} else {
fs.unlinkSync(filePath);
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
await rmPath(filePath);
res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -199,24 +281,30 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename } = req.body as {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const filePath = join(config.scriptPath, filename);
// const stats = fs.statSync(filePath);
// res.set({
// 'Content-Type': 'application/octet-stream', //告诉浏览器这是一个二进制文件
// 'Content-Disposition': 'attachment; filename=' + filename, //告诉浏览器这是一个需要下载的文件
// 'Content-Length': stats.size //文件大小
// });
// fs.createReadStream(filePath).pipe(res);
if (!path) {
path = '';
}
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: t('暂无权限'),
});
}
return res.download(filePath, filename, (err) => {
return next(err);
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
@@ -237,9 +325,15 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
let { filename, content, path } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
fs.writeFileSync(filePath, content || '', { encoding: 'utf8' });
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await writeFileWithLock(filePath, content || '');
const scriptService = Container.get(ScriptService);
const result = await scriptService.runScript(filePath);
@@ -260,14 +354,23 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path, pid } = req.body;
if (!path) {
path = '';
}
const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
const logPath = join(config.logPath, path, `${name}.swap`);
const scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid);
setTimeout(() => {
rmPath(logPath);
}, 3000);
res.send(result);
} catch (e) {
return next(e);
@@ -286,15 +389,20 @@ export default (app: Router) => {
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, type, newFilename } = req.body as {
let { filename, path, newFilename } = req.body as {
filename: string;
path: string;
type: string;
newFilename: string;
};
if (!path) {
path = '';
}
const filePath = join(config.scriptPath, path, filename);
const newPath = join(config.scriptPath, path, newFilename);
fs.renameSync(filePath, newPath);
if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(filePath, newPath);
res.send({ code: 200 });
} catch (e) {
return next(e);
+9 -4
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import cron_parser from 'cron-parser';
import CronExpressionParser from 'cron-parser';
const route = Router();
export default (app: Router) => {
@@ -15,6 +15,7 @@ export default (app: Router) => {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.list(
req.query.searchValue as string,
req.query.ids as string,
);
return res.send({ code: 200, data });
} catch (e) {
@@ -59,7 +60,7 @@ export default (app: Router) => {
try {
if (
!req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext()
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
@@ -192,7 +193,7 @@ export default (app: Router) => {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
cron_parser.parseExpression(req.body.schedule).hasNext()
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
@@ -210,12 +211,16 @@ export default (app: Router) => {
'/',
celebrate({
body: Joi.array().items(Joi.number().required()),
query: Joi.object({
force: Joi.boolean().optional(),
t: Joi.number(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.remove(req.body);
const data = await subscriptionService.remove(req.body, req.query);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
+326 -43
View File
@@ -1,12 +1,13 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import * as fs from 'fs';
import * as fs from 'fs/promises';
import config from '../config';
import SystemService from '../services/system';
import { celebrate, Joi } from 'celebrate';
import UserService from '../services/user';
import { EnvModel } from '../data/env';
import { t } from '../shared/i18n';
import { isDefaultAuthInfo } from '../shared/auth';
import {
getUniqPath,
handleLogPath,
@@ -14,8 +15,19 @@ import {
promiseExec,
} from '../config/util';
import dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router();
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.tmpPath);
},
filename: function (req, file, cb) {
cb(null, 'data.tgz');
},
});
const upload = multer({ storage: storage });
export default (app: Router) => {
app.use('/system', route);
@@ -24,41 +36,18 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const authInfo = await userService.getUserInfo();
const envCount = await EnvModel.count();
const { version, changeLog, changeLogLink } = await parseVersion(
config.versionFile,
);
const lastCommitTime = (
await promiseExec(
`cd ${config.rootPath} && git show -s --format=%ai | head -1`,
)
).replace('\n', '');
const lastCommitId = (
await promiseExec(`cd ${config.rootPath} && git rev-parse --short HEAD`)
).replace('\n', '');
const branch = (
await promiseExec(
`cd ${config.rootPath} && git symbolic-ref --short HEAD`,
)
).replace('\n', '');
const authInfo = await userService.getAuthInfo();
const { version, changeLog, changeLogLink, publishTime } =
await parseVersion(config.versionFile);
let isInitialized = true;
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
const isInitialized = !isDefaultAuthInfo(authInfo);
res.send({
code: 200,
data: {
isInitialized,
version,
lastCommitTime: dayjs(lastCommitTime).unix(),
lastCommitId,
branch,
publishTime: dayjs(publishTime).unix(),
branch: process.env.QL_BRANCH || 'master',
changeLog,
changeLogLink,
},
@@ -84,20 +73,16 @@ export default (app: Router) => {
);
route.put(
'/config',
'/config/log-remove-frequency',
celebrate({
body: Joi.object({
logRemoveFrequency: Joi.number().optional().allow(null),
cronConcurrency: Joi.number().optional().allow(null),
logRemoveFrequency: Joi.number().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateSystemConfig(
req.body,
);
const result = await systemService.updateLogRemoveFrequency(req.body);
res.send(result);
} catch (e) {
return next(e);
@@ -105,6 +90,96 @@ export default (app: Router) => {
},
);
route.put(
'/config/cron-concurrency',
celebrate({
body: Joi.object({
cronConcurrency: Joi.number().allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateCronConcurrency(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-proxy',
celebrate({
body: Joi.object({
dependenceProxy: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateDependenceProxy(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/node-mirror',
celebrate({
body: Joi.object({
nodeMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
res.setHeader('Content-type', 'application/octet-stream');
await systemService.updateNodeMirror(req.body, res);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/python-mirror',
celebrate({
body: Joi.object({
pythonMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updatePythonMirror(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/linux-mirror',
celebrate({
body: Joi.object({
linuxMirror: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
res.setHeader('Content-type', 'application/octet-stream');
await systemService.updateLinuxMirror(req.body, res);
} catch (e) {
return next(e);
}
},
);
route.put(
'/update-check',
async (req: Request, res: Response, next: NextFunction) => {
@@ -133,6 +208,25 @@ export default (app: Router) => {
},
);
route.put(
'/reload',
celebrate({
body: Joi.object({
type: Joi.string().optional().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const systemService = Container.get(SystemService);
const result = await systemService.reloadSystem(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/notify',
celebrate({
@@ -163,7 +257,10 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const uniqPath = await getUniqPath(req.body.command);
const command = req.body.command;
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
let id = await promiseExec(idStr);
const uniqPath = await getUniqPath(command, id);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`;
res.setHeader('Content-type', 'application/octet-stream');
@@ -172,19 +269,22 @@ export default (app: Router) => {
{
onStart: async (cp, startTime) => {
res.setHeader('QL-Task-Pid', `${cp.pid}`);
res.setHeader('QL-Task-Log', `${logPath}`);
},
onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end();
},
onError: async (message: string) => {
res.write(`\n${message}`);
res.write(message);
const absolutePath = await handleLogPath(logPath);
fs.appendFileSync(absolutePath, `\n${message}`);
await logStreamManager.write(absolutePath, message);
},
onLog: async (message: string) => {
res.write(`\n${message}`);
res.write(message);
const absolutePath = await handleLogPath(logPath);
fs.appendFileSync(absolutePath, `\n${message}`);
await logStreamManager.write(absolutePath, message);
},
},
);
@@ -212,4 +312,187 @@ export default (app: Router) => {
}
},
);
route.put(
'/data/export',
celebrate({
body: Joi.object({
type: Joi.array().items(Joi.string()).optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.exportData(res, req.body.type);
} catch (e) {
return next(e);
}
},
);
route.put(
'/data/import',
upload.single('data'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.importData();
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.get(
'/log',
celebrate({
query: {
startTime: Joi.string().allow('').optional(),
endTime: Joi.string().allow('').optional(),
t: Joi.string().optional(),
},
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.getSystemLog(
res,
req.query as {
startTime?: string;
endTime?: string;
},
);
} catch (e) {
return next(e);
}
},
);
route.delete(
'/log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
await systemService.deleteSystemLog();
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.put(
'/auth/reset',
celebrate({
body: Joi.object({
retries: Joi.number().optional(),
twoFactorActivated: Joi.boolean().optional(),
password: Joi.string().optional(),
username: Joi.string().optional(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userService = Container.get(UserService);
const result = await userService.resetAuthInfo(req.body);
if (result) {
return res.send(result);
}
res.send({ code: 200, message: t('更新成功') });
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/timezone',
celebrate({
body: Joi.object({
timezone: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateTimezone(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/lang',
celebrate({
body: Joi.object({
lang: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateLanguage(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/panel-title',
celebrate({
body: Joi.object({
panelTitle: Joi.string().max(100).allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updatePanelTitle(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/dependence-clean',
celebrate({
body: Joi.object({
type: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.cleanDependence(req.body.type);
res.send(result);
} catch (e) {
return next(e);
}
},
);
};
+51
View File
@@ -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);
}
},
);
};
+12 -7
View File
@@ -8,6 +8,8 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit';
import config from '../config';
import { t } from '../shared/i18n';
import { isDemoEnv, getToken } from '../config/util';
const route = Router();
const storage = multer.diskStorage({
@@ -55,7 +57,8 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
await userService.logout(req.platform);
const token = getToken(req);
await userService.logout(req.platform, token);
res.send({ code: 200 });
} catch (e) {
return next(e);
@@ -72,11 +75,13 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (isDemoEnv()) {
return res.send({ code: 450, message: t('未知错误') });
}
const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: '更新成功' });
res.send({ code: 200, message: t('更新成功') });
} catch (e) {
return next(e);
}
@@ -87,7 +92,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const authInfo = await userService.getUserInfo();
const authInfo = await userService.getAuthInfo();
res.send({
code: 200,
data: {
@@ -136,12 +141,12 @@ export default (app: Router) => {
);
route.put(
'/two-factor/deactive',
'/two-factor/deactivate',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const data = await userService.deactiveTwoFactor();
const data = await userService.deactivateTwoFactor();
res.send({ code: 200, data });
} catch (e) {
return next(e);
@@ -225,7 +230,7 @@ export default (app: Router) => {
try {
const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: '更新成功' });
res.send({ code: 200, message: t('更新成功') });
} catch (e) {
return next(e);
}
+303 -26
View File
@@ -1,31 +1,308 @@
import 'reflect-metadata'; // We need this in order to use @Decorators
import config from './config';
import 'reflect-metadata';
import cluster, { type Worker } from 'cluster';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger';
import path from 'path';
import { monitoringMiddleware } from './middlewares/monitoring';
import { errStack } from './config/util';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
async function startServer() {
const app = express();
await require('./loaders/db').default();
await require('./loaders/initFile').default();
await require('./loaders/sentry').default({ expressApp: app });
await require('./loaders/app').default({ expressApp: app });
const server = app
.listen(config.port, () => {
Logger.debug(`✌️ 后端服务启动成功!`);
process.send?.('ready');
})
.on('error', (err) => {
Logger.error(err);
process.exit(1);
});
await require('./loaders/server').default({ server });
interface WorkerMetadata {
id: number;
pid: number;
serviceType: string;
startTime: Date;
}
startServer();
class Application {
private app: express.Application;
private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() {
this.app = express();
// 创建一个全局中间件,删除查询参数中的t
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.t) {
delete req.query.t;
}
next();
});
}
async start() {
try {
if (cluster.isPrimary) {
await this.initializeDatabase();
}
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
await this.startWorkerProcess();
}
} catch (error) {
Logger.error(`Failed to start application:\n${errStack(error)}`);
process.exit(1);
}
}
private startMasterProcess() {
// Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
.then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
})
.catch((error) => {
Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id);
if (metadata) {
if (!this.isShuttingDown) {
Logger.error(
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`,
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
}
}
})
.catch((error) => {
Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
}
this.workerMetadataMap.delete(worker.id);
}
});
this.setupMasterShutdown();
}
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
this.workerMetadataMap.set(worker.id, {
id: worker.id,
pid: worker.process.pid!,
serviceType,
startTime: new Date(),
});
return worker;
}
private async initializeDatabase() {
const dbLoader = await import('./loaders/db');
await dbLoader.default();
}
private setupMiddlewares() {
this.app.use(helmet({
contentSecurityPolicy: false,
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
}
private setupMasterShutdown() {
const shutdown = async () => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
const workers = Object.values(cluster.workers || {});
const workerPromises: Promise<void>[] = [];
workers.forEach((worker) => {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`Worker ${worker.process.pid} exited`);
resolve();
});
try {
worker.send('shutdown');
} catch (error) {
Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
}
});
workerPromises.push(exitPromise);
}
});
try {
await Promise.race([
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
process.exit(1);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
try {
if (serviceType === 'http') {
await this.startHttpService();
} else {
await this.startGrpcService();
}
process.send?.('ready');
} catch (error) {
Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
process.exit(1);
}
}
private async startHttpService() {
// 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
const { initGrpcCerts } = await import('./config/grpcCerts');
await initGrpcCerts();
this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app');
await appLoader.default({ app: this.app });
const server = await this.httpServerService.initialize(
this.app,
config.port,
);
const serverLoader = await import('./loaders/server');
await (serverLoader.default as any)({ server });
this.setupWorkerShutdown('http');
}
private async startGrpcService() {
const { GrpcServerService } = await import('./services/grpc');
this.grpcServerService = Container.get(GrpcServerService);
await this.grpcServerService.initialize();
this.setupWorkerShutdown('grpc');
}
private setupWorkerShutdown(serviceType: string) {
process.on('message', async (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('[boot] Cron jobs re-registered successfully');
} catch (error) {
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
}
}
});
const shutdown = () => this.gracefulShutdown(serviceType);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async gracefulShutdown(serviceType: string) {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
try {
if (serviceType === 'http') {
await this.httpServerService?.shutdown();
} else {
await this.grpcServerService?.shutdown();
}
process.exit(0);
} catch (error) {
Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
process.exit(1);
}
}
}
const app = new Application();
app.start().catch((error) => {
Logger.error(`🙅‍♀️ Application failed to start:\n${errStack(error)}`);
process.exit(1);
});
+82
View File
@@ -1,3 +1,5 @@
import { maybeSudo } from './container';
export const LOG_END_SYMBOL = '     ';
export const TASK_COMMAND = 'task';
@@ -5,3 +7,83 @@ export const QL_COMMAND = 'ql';
export const TASK_PREFIX = `${TASK_COMMAND} `;
export const QL_PREFIX = `${QL_COMMAND} `;
export const SAMPLE_FILES = [
{
title: 'config.sample.sh',
value: 'sample/config.sample.sh',
target: 'config.sh',
},
{
title: 'notify.js',
value: 'sample/notify.js',
target: 'data/scripts/sendNotify.js',
},
{
title: 'notify.py',
value: 'sample/notify.py',
target: 'data/scripts/notify.py',
},
];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const NotificationModeStringMap = {
0: 'gotify',
1: 'goCqHttpBot',
2: 'serverChan',
3: 'pushDeer',
4: 'bark',
5: 'chat',
6: 'telegramBot',
7: 'dingtalkBot',
8: 'weWorkBot',
9: 'weWorkApp',
10: 'aibotk',
11: 'iGot',
12: 'pushPlus',
13: 'wePlusBot',
14: 'email',
15: 'pushMe',
16: 'feishu',
17: 'webhook',
18: 'chronocat',
19: 'ntfy',
20: 'wxPusherBot',
21: 'wxPusherSpt',
} as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
'Debian' | 'Ubuntu' | 'Alpine',
{
install: string;
uninstall: string;
info: string;
check(info: string): boolean;
}
> = {
Debian: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Ubuntu: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Alpine: {
install: 'apk add --no-check-certificate',
uninstall: 'apk del',
info: 'apk info -es',
check(info: string) {
return info.includes('installed');
},
},
};
+7
View File
@@ -0,0 +1,7 @@
export function isInContainer(): boolean {
return process.env.QL_CONTAINER === 'true';
}
export function maybeSudo(cmd: string): string {
return isInContainer() ? `sudo ${cmd}` : cmd;
}
+150
View File
@@ -0,0 +1,150 @@
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as os from 'os';
import path from 'path';
import config from './index';
import { fileExist } from './util';
import Logger from '../loaders/logger';
export interface GrpcTlsConfig {
caCert: string;
serverCert: string;
serverKey: string;
clientCert: string;
clientKey: string;
}
const certDir = path.join(config.configPath, 'grpc');
const caKeyPath = path.join(certDir, 'ca.key');
const caCertPath = path.join(certDir, 'ca.crt');
const serverKeyPath = path.join(certDir, 'server.key');
const serverCertPath = path.join(certDir, 'server.crt');
const clientKeyPath = path.join(certDir, 'client.key');
const clientCertPath = path.join(certDir, 'client.crt');
let cachedConfig: GrpcTlsConfig | null = null;
function run(cmd: string, execOpts?: Record<string, unknown>): string {
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
return (execSync(cmd, opts) as string).trim();
}
async function tmpFile(prefix: string): Promise<string> {
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
await fs.mkdir(dir, { recursive: true });
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
}
async function generateAllCerts(): Promise<GrpcTlsConfig> {
Logger.info('[boot] Generating gRPC mTLS certificates...');
const caKeyTmp = await tmpFile('ca_key');
const caCertTmp = await tmpFile('ca_cert');
const serverKeyTmp = await tmpFile('server_key');
const serverCsrTmp = await tmpFile('server_csr');
const serverExtTmp = await tmpFile('server_ext');
const clientKeyTmp = await tmpFile('client_key');
const clientCsrTmp = await tmpFile('client_csr');
const clientExtTmp = await tmpFile('client_ext');
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
const cleanup = async () => {
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
try { await fs.unlink(f); } catch {}
}
};
try {
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
const caCert = await fs.readFile(caCertTmp, 'utf-8');
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
// 2. 服务端
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
const serverCert = run(
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
);
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
// 3. 客户端
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
const clientCert = run(
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
);
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
await cleanup();
Logger.info('[boot] gRPC mTLS certificates generated successfully');
return { caCert, serverCert, serverKey, clientCert, clientKey };
} catch (e) {
await cleanup();
throw e;
}
}
async function saveCerts(tlsConfig: GrpcTlsConfig): Promise<void> {
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caCertPath, tlsConfig.caCert, { mode: 0o644 });
await fs.writeFile(serverCertPath, tlsConfig.serverCert, { mode: 0o644 });
await fs.writeFile(serverKeyPath, tlsConfig.serverKey, { mode: 0o600 });
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
Logger.info(`[boot] gRPC mTLS certificates saved to ${certDir}`);
}
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
const exists = await Promise.all([
fileExist(caCertPath),
fileExist(serverCertPath),
fileExist(serverKeyPath),
fileExist(clientCertPath),
fileExist(clientKeyPath),
]);
if (exists.some((e) => !e)) {
return null;
}
const [caCert, serverCert, serverKey, clientCert, clientKey] = await Promise.all([
fs.readFile(caCertPath, 'utf-8'),
fs.readFile(serverCertPath, 'utf-8'),
fs.readFile(serverKeyPath, 'utf-8'),
fs.readFile(clientCertPath, 'utf-8'),
fs.readFile(clientKeyPath, 'utf-8'),
]);
Logger.info('[boot] Loaded existing gRPC mTLS certificates from disk');
return { caCert, serverCert, serverKey, clientCert, clientKey };
}
export async function initGrpcCerts(): Promise<GrpcTlsConfig> {
if (cachedConfig) {
return cachedConfig;
}
let tlsConfig = await loadExistingCerts();
if (!tlsConfig) {
tlsConfig = await generateAllCerts();
await saveCerts(tlsConfig);
}
cachedConfig = tlsConfig;
return tlsConfig;
}
export function getGrpcCerts(): GrpcTlsConfig | null {
return cachedConfig;
}
+68
View File
@@ -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,
};
+111 -19
View File
@@ -1,13 +1,64 @@
import dotenv from 'dotenv';
import path from 'path';
import { createRandomString } from './util';
dotenv.config({
path: path.join(__dirname, '../../.env'),
});
interface Config {
port: number;
grpcPort: number;
bindHost: string;
bindHostGrpc: string;
nodeEnv: string;
isDevelopment: boolean;
isProduction: boolean;
jwt: {
secret: string;
expiresIn?: string;
};
cors: {
origin: string[];
methods: string[];
};
logs: {
level: string;
};
api: {
prefix: string;
};
}
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
bindHost: process.env.BIND_HOST || '::',
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
jwt: {
secret: process.env.JWT_SECRET || 'whyour-secret',
expiresIn: process.env.JWT_EXPIRES_IN,
},
cors: {
origin: process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',')
: ['*'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
},
};
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
if (!process.env.QL_DIR) {
// 声明QL_DIR环境变量
let qlHomePath = path.join(__dirname, '../../');
// 生产环境
if (qlHomePath.endsWith('/static/')) {
qlHomePath = path.join(qlHomePath, '../');
}
@@ -16,20 +67,49 @@ if (!process.env.QL_DIR) {
const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') });
const dataPath = path.join(rootPath, 'data/');
let dataPath = path.join(rootPath, 'data/');
if (process.env.QL_DATA_DIR) {
dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, '');
}
const shellPath = path.join(rootPath, 'shell/');
const preloadPath = path.join(shellPath, 'preload/');
const tmpPath = path.join(rootPath, '.tmp/');
const samplePath = path.join(rootPath, 'sample/');
const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/');
const repoPath = path.join(dataPath, 'repo/');
const bakPath = path.join(dataPath, 'bak/');
const logPath = path.join(dataPath, 'log/');
const dbPath = path.join(dataPath, 'db/');
const uploadPath = path.join(dataPath, 'upload/');
const sshdPath = path.join(dataPath, 'ssh.d/');
const systemLogPath = path.join(dataPath, 'syslog/');
const dependenceCachePath = path.join(dataPath, 'dep_cache/');
const envFile = path.join(configPath, 'env.sh');
const envFile = path.join(preloadPath, 'env.sh');
const jsEnvFile = path.join(preloadPath, 'env.js');
const pyEnvFile = path.join(preloadPath, 'env.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const langEnvFile = path.join(preloadPath, 'lang_env.sh');
const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json');
@@ -38,30 +118,27 @@ const confBakDir = path.join(dataPath, 'config/bak/');
const sampleFile = path.join(samplePath, 'config.sample.sh');
const sqliteFile = path.join(samplePath, 'database.sqlite');
const authError = '错误的用户名密码,请重试';
const loginFaild = '请先登录!';
const configString = 'config sample crontab shareCode diy';
const versionFile = path.join(rootPath, 'version.yaml');
const dataTgzFile = path.join(tmpPath, 'data.tgz');
const shareShellFile = path.join(shellPath, 'share.sh');
const dependenceProxyFile = path.join(configPath, 'dependence-proxy.sh');
if (envFound.error) {
throw new Error("⚠️ Couldn't find .env file ⚠️");
}
export default {
port: parseInt(process.env.BACK_PORT as string, 10),
cronPort: parseInt(process.env.CRON_PORT as string, 10),
publicPort: parseInt(process.env.PUBLIC_PORT as string, 10),
secret: process.env.SECRET || createRandomString(16, 32),
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
...config,
jwt: config.jwt,
baseUrl,
rootPath,
tmpPath,
dataPath,
dataTgzFile,
shareShellFile,
dependenceProxyFile,
configString,
loginFaild,
authError,
logPath,
extraFile,
authConfigFile,
@@ -70,23 +147,35 @@ export default {
sampleFile,
confFile,
envFile,
jsEnvFile,
pyEnvFile,
jsNotifyFile,
pyNotifyFile,
langEnvFile,
dbPath,
uploadPath,
configPath,
scriptPath,
repoPath,
samplePath,
blackFileList: [
'auth.json',
'config.sh.sample',
'cookie.sh',
'crontab.list',
'dependence-proxy.sh',
'env.sh',
'env.js',
'env.py',
'token.json',
'grpc',
'__pycache__',
],
writePathList: [configPath, scriptPath],
bakPath,
apiWhiteList: [
'/api/user/login',
'/api/health',
'/open/auth/token',
'/api/user/two-factor/login',
'/api/system',
@@ -102,4 +191,7 @@ export default {
lastVersionFile,
sqliteFile,
sshdPath,
systemLogPath,
dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
};
+10 -2
View File
@@ -1,11 +1,19 @@
import { Request, Response } from 'express';
import { pick } from 'lodash';
import pick from 'lodash/pick';
let pickedEnv: Record<string, string>;
function getPickedEnv() {
if (pickedEnv) return pickedEnv;
const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv']);
const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv', 'QL_DIR']);
if (picked.QlBaseUrl) {
if (!picked.QlBaseUrl.startsWith('/')) {
picked.QlBaseUrl = `/${picked.QlBaseUrl}`;
}
if (!picked.QlBaseUrl.endsWith('/')) {
picked.QlBaseUrl = `${picked.QlBaseUrl}/`;
}
}
pickedEnv = picked as Record<string, string>;
return picked;
}
+84
View File
@@ -0,0 +1,84 @@
export function createRandomString(min: number, max: number): string {
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const english = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
];
const ENGLISH = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
const special = ['-', '_'];
const config = num.concat(english).concat(ENGLISH).concat(special);
const arr = [];
arr.push(getOne(num));
arr.push(getOne(english));
arr.push(getOne(ENGLISH));
arr.push(getOne(special));
const len = min + Math.floor(Math.random() * (max - min + 1));
for (let i = 4; i < len; i++) {
arr.push(config[Math.floor(Math.random() * config.length)]);
}
const newArr = [];
for (let j = 0; j < len; j++) {
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
}
function getOne(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
return newArr.join('');
}
+8 -4
View File
@@ -32,11 +32,15 @@ export function formatCommand(doc: Subscription, url?: string) {
autoDelCron,
} = doc;
if (type === 'file') {
command += `raw "${_url}"`;
command += `raw "${_url}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
} else {
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${dependences || ''
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
dependences || ''
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
}
return command;
}
+519 -292
View File
@@ -1,31 +1,70 @@
import * as fs from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import got from 'got';
import iconv from 'iconv-lite';
import { exec } from 'child_process';
import FormData from 'form-data';
import psTreeFun from 'pstree.remy';
import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree';
import { promisify } from 'util';
import { load } from 'js-yaml';
import config from './index';
import { TASK_COMMAND } from './const';
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
export function getFileContentByName(fileName: string) {
if (fs.existsSync(fileName)) {
return fs.readFileSync(fileName, 'utf8');
export * from './share';
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
function getOsTypeSync(): 'Debian' | 'Ubuntu' | 'Alpine' | undefined {
// 1. 环境变量覆盖
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') return 'Alpine';
if (envOs === 'debian') return 'Debian';
if (envOs === 'ubuntu') return 'Ubuntu';
// 2. 模块缓存(由 detectOS 设置)
if (osType) return osType;
// 3. 能力检测:检查包管理器二进制
try {
execSync('which apt-get', { stdio: 'ignore' });
return 'Debian';
} catch {
try {
execSync('which apk', { stdio: 'ignore' });
return 'Alpine';
} catch {
// macOS / 未知系统
}
}
return undefined;
}
export async function getFileContentByName(fileName: string) {
const _exsit = await fileExist(fileName);
if (_exsit) {
return await fs.readFile(fileName, 'utf8');
}
return '';
}
export function getLastModifyFilePath(dir: string) {
export function removeAnsi(text: string) {
return text.replace(/\x1b\[\d+m/g, '');
}
export async function getLastModifyFilePath(dir: string) {
let filePath = '';
if (fs.existsSync(dir)) {
const arr = fs.readdirSync(dir);
const _exsit = await fileExist(dir);
if (_exsit) {
const arr = await fs.readdir(dir);
arr.forEach((item) => {
arr.forEach(async (item) => {
const fullpath = path.join(dir, item);
const stats = fs.statSync(fullpath);
const stats = await fs.lstat(fullpath);
if (stats.isFile()) {
if (stats.mtimeMs >= 0) {
filePath = fullpath;
@@ -36,91 +75,6 @@ export function getLastModifyFilePath(dir: string) {
return filePath;
}
export function createRandomString(min: number, max: number): string {
const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const english = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
];
const ENGLISH = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
const special = ['-', '_'];
const config = num.concat(english).concat(ENGLISH).concat(special);
const arr = [];
arr.push(getOne(num));
arr.push(getOne(english));
arr.push(getOne(ENGLISH));
arr.push(getOne(special));
const len = min + Math.floor(Math.random() * (max - min + 1));
for (let i = 4; i < len; i++) {
arr.push(config[Math.floor(Math.random() * config.length)]);
}
const newArr = [];
for (let j = 0; j < len; j++) {
newArr.push(arr.splice(Math.random() * arr.length, 1)[0]);
}
function getOne(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
return newArr.join('');
}
export function getToken(req: any) {
const { authorization = '' } = req.headers;
if (authorization && authorization.split(' ')[0] === 'Bearer') {
@@ -132,69 +86,6 @@ export function getToken(req: any) {
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,
]),
];
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 = '';
}
try {
const baiduApi = got
.get(`https://www.cip.cc/${ip}`, { timeout: 10000, retry: 0 })
.text();
const ipApi = got
.get(`https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`, {
timeout: 10000,
retry: 0,
})
.buffer();
const [data, ipApiBody] = await await Promise.all<any>([baiduApi, ipApi]);
const ipRegx = /.*IP :(.*)\n/;
const addrRegx = /.*数据二 :(.*)\n/;
if (data && ipRegx.test(data) && addrRegx.test(data)) {
const ip = data.match(ipRegx)[1];
const addr = data.match(addrRegx)[1];
return { address: addr, ip };
} else if (ipApiBody) {
const { addr, ip } = JSON.parse(iconv.decode(ipApiBody, 'GBK'));
return { address: `${addr}`, ip };
} else {
return { address: `获取失败`, ip };
}
} catch (error) {
return { address: `获取失败`, ip };
}
}
export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
const ua = userAgent.toLowerCase();
const testUa = (regexp: RegExp) => regexp.test(ua);
@@ -216,12 +107,19 @@ export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
system = 'android'; // android系统
} else if (testUa(/ios|iphone|ipad|ipod|iwatch/g)) {
system = 'ios'; // ios系统
} else if (testUa(/openharmony/g)) {
system = 'openharmony'; // openharmony系统
}
let platform = 'desktop';
if (system === 'windows' || system === 'macos' || system === 'linux') {
platform = 'desktop';
} else if (system === 'android' || system === 'ios' || testUa(/mobile/g)) {
} else if (
system === 'android' ||
system === 'ios' ||
system === 'openharmony' ||
testUa(/mobile/g)
) {
platform = 'mobile';
}
@@ -229,22 +127,17 @@ export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
}
export async function fileExist(file: any) {
return new Promise((resolve) => {
try {
fs.accessSync(file);
resolve(true);
} catch (error) {
resolve(false);
}
});
try {
await fs.access(file);
return true;
} catch (error) {
return false;
}
}
export async function createFile(file: string, data: string = '') {
return new Promise((resolve) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, data);
resolve(true);
});
await fs.mkdir(path.dirname(file), { recursive: true });
await writeFileWithLock(file, data);
}
export async function handleLogPath(
@@ -301,111 +194,155 @@ enum FileType {
'file',
}
interface IFile {
export interface IFile {
title: string;
key: string;
type: 'directory' | 'file';
parent: string;
mtime: number;
createTime: number;
size?: number;
children?: IFile[];
}
export function dirSort(a: IFile, b: IFile) {
if (a.type !== b.type) return FileType[a.type] < FileType[b.type] ? -1 : 1;
else if (a.mtime !== b.mtime) return a.mtime > b.mtime ? -1 : 1;
export function dirSort(a: IFile, b: IFile): number {
if (a.type === 'file' && b.type === 'file') {
return b.createTime - a.createTime;
} else if (a.type === 'directory' && b.type === 'directory') {
return a.title.localeCompare(b.title);
} else {
return a.type === 'directory' ? -1 : 1;
}
}
export function readDirs(
export async function readDirs(
dir: string,
baseDir: string = '',
blacklist: string[] = [],
) {
sort: (a: IFile, b: IFile) => number = dirSort,
): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir);
const files = fs.readdirSync(dir);
const result: any = files
.filter((x) => !blacklist.includes(x))
.map((file: string) => {
const subPath = path.join(dir, file);
const stats = fs.statSync(subPath);
const key = path.join(relativePath, file);
if (stats.isDirectory()) {
return {
title: file,
key,
type: 'directory',
parent: relativePath,
mtime: stats.mtime.getTime(),
children: readDirs(subPath, baseDir).sort(dirSort),
};
}
return {
const files = await fs.readdir(dir);
const result: IFile[] = [];
for (const file of files) {
const subPath = path.join(dir, file);
const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file);
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) {
const children = await readDirs(subPath, baseDir, blacklist, sort);
result.push({
title: file,
key,
type: 'directory',
parent: relativePath,
createTime: stats.birthtime.getTime(),
children: children.sort(sort),
});
} else {
result.push({
title: file,
type: 'file',
isLeaf: true,
key,
parent: relativePath,
mtime: stats.mtime.getTime(),
};
});
return result.sort(dirSort);
size: stats.size,
createTime: stats.birthtime.getTime(),
});
}
}
return result.sort(sort);
}
export function readDir(
export async function readDir(
dir: string,
baseDir: string = '',
blacklist: string[] = [],
) {
const relativePath = path.relative(baseDir, dir);
const files = fs.readdirSync(dir);
const result: any = files
.filter((x) => !blacklist.includes(x))
.map((file: string) => {
const subPath = path.join(dir, file);
const stats = fs.statSync(subPath);
): Promise<IFile[]> {
const absoluteDir = path.resolve(baseDir, dir);
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
return [];
}
const relativePath = path.relative(baseDir, absoluteDir);
try {
const files = await fs.readdir(absoluteDir);
const result: IFile[] = [];
for (const file of files) {
const subPath = path.join(absoluteDir, file);
const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file);
return {
title: file,
type: stats.isDirectory() ? 'directory' : 'file',
key,
parent: relativePath,
};
});
return result;
}
export function emptyDir(path: string) {
const files = fs.readdirSync(path);
files.forEach((file) => {
const filePath = `${path}/${file}`;
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
emptyDir(filePath);
} else {
fs.unlinkSync(filePath);
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) {
result.push({
title: file,
type: 'directory',
key,
parent: relativePath,
createTime: stats.birthtime.getTime(),
children: [],
});
} else {
result.push({
title: file,
type: 'file',
key,
parent: relativePath,
size: stats.size,
createTime: stats.birthtime.getTime(),
});
}
}
});
fs.rmdirSync(path);
return result;
} catch (error: any) {
if (error.code === 'ENOENT') {
return [];
}
throw error;
}
}
export function promiseExec(command: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(
command,
{ maxBuffer: 200 * 1024 * 1024, encoding: 'utf8' },
(err, stdout, stderr) => {
resolve(stdout || stderr || JSON.stringify(err));
},
);
});
export async function promiseExec(command: string): Promise<string> {
try {
const { stderr, stdout } = await promisify(exec)(command, {
maxBuffer: 200 * 1024 * 1024,
encoding: 'utf8',
});
return stdout || stderr;
} catch (error) {
return JSON.stringify(error);
}
}
export async function promiseExecSuccess(command: string): Promise<string> {
try {
const { stdout } = await promisify(exec)(command, {
maxBuffer: 200 * 1024 * 1024,
encoding: 'utf8',
});
return stdout || '';
} catch (error) {
return '';
}
}
export function parseHeaders(headers: string) {
if (!headers) return {};
const parsed: any = {};
let key;
let val;
let i;
let key: string;
let val: string;
let i: number;
headers &&
headers.split('\n').forEach(function parser(line) {
@@ -423,37 +360,49 @@ export function parseHeaders(headers: string) {
return parsed;
}
function parseString(
input: string,
valueFormatFn?: (v: string) => string,
): Record<string, string> {
const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g;
const matches: Record<string, string> = {};
let match;
while ((match = regex.exec(input)) !== null) {
const [, key, value] = match;
const _key = key.trim();
if (!_key || matches[_key]) {
continue;
}
let _value = value.trim();
try {
_value = valueFormatFn ? valueFormatFn(_value) : _value;
const jsonValue = JSON.parse(_value);
matches[_key] = jsonValue;
} catch (error) {
matches[_key] = _value;
}
}
return matches;
}
export function parseBody(
body: string,
contentType:
| 'application/json'
| 'multipart/form-data'
| 'application/x-www-form-urlencoded',
| 'application/x-www-form-urlencoded'
| 'text/plain',
valueFormatFn?: (v: string) => string,
) {
if (!body) return '';
if (contentType === 'text/plain' || !body) {
return valueFormatFn && body ? valueFormatFn(body) : body;
}
const parsed: any = {};
let key;
let val;
let i;
body &&
body.split('\n').forEach(function parser(line) {
i = line.indexOf(':');
key = line.substring(0, i).trim().toLowerCase();
val = line.substring(i + 1).trim();
if (!key || parsed[key]) {
return;
}
try {
const jsonValue = JSON.parse(val);
parsed[key] = jsonValue;
} catch (error) {
parsed[key] = val;
}
});
const parsed = parseString(body, valueFormatFn);
switch (contentType) {
case 'multipart/form-data':
@@ -472,60 +421,87 @@ export function parseBody(
export function psTree(pid: number): Promise<number[]> {
return new Promise((resolve, reject) => {
psTreeFun(pid, (err: any, pids: number[]) => {
psTreeFun(pid, (err: any, children) => {
if (err) {
reject(err);
}
resolve(pids.filter((x) => !isNaN(x)));
resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x)));
});
});
}
export async function killTask(pid: number) {
const pids = await psTree(pid);
// SIGINT 2 程序终止(interrupt)信号,不会打印额外信息
if (pids.length) {
try {
[pid, ...pids].forEach((x) => {
process.kill(x, 2);
[pid, ...pids].reverse().forEach((x) => {
process.kill(x, 15);
});
} catch (error) {}
} catch (error) { }
} else {
process.kill(pid, 2);
}
}
export async function getPid(name: string) {
const taskCommand = `ps -eo pid,command | grep "${name}" | grep -v grep | awk '{print $1}' | head -1 | xargs echo -n`;
export async function getPid(cmd: string) {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}' | head -1 | xargs echo -n`;
const pid = await promiseExec(taskCommand);
return pid ? Number(pid) : undefined;
}
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion {
version: string;
changeLogLink: string;
changeLog: string;
publishTime: string;
}
export async function parseVersion(path: string): Promise<IVersion> {
return load(await promisify(fs.readFile)(path, 'utf8')) as IVersion;
return load(await fs.readFile(path, 'utf8')) as IVersion;
}
export async function parseContentVersion(content: string): Promise<IVersion> {
export function parseContentVersion(content: string): IVersion {
return load(content) as IVersion;
}
export async function getUniqPath(command: string): Promise<string> {
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
let id = await promiseExec(idStr);
export async function getUniqPath(
command: string,
id: string,
): Promise<string> {
let suffix = '';
if (/^\d+$/.test(id)) {
suffix = `_${id}`;
}
if (/^\d\d*\d$/.test(id)) {
id = `_${id}`;
} else {
id = '';
let items = command.split(/ +/);
const maxTimeCommandIndex = items.findIndex((x) => x === '-m');
if (maxTimeCommandIndex !== -1) {
items = items.slice(maxTimeCommandIndex + 2);
}
const items = command.split(/ +/);
let str = items[0];
if (items[0] === TASK_COMMAND) {
str = items[1];
@@ -549,5 +525,256 @@ export async function getUniqPath(command: string): Promise<string> {
str = `${tempStr}_${str.slice(slashIndex + 1)}`;
}
return `${str}${id}`;
return `${str}${suffix}`;
}
export function safeJSONParse(value?: string) {
if (!value) {
return {};
}
try {
return JSON.parse(value);
} catch (error) {
Logger.error('[safeJSONParse error]', error);
return {};
}
}
export function errStack(error: unknown): string {
return error instanceof Error && error.stack
? error.stack
: String(error);
}
export async function rmPath(path: string) {
try {
const _exsit = await fileExist(path);
if (_exsit) {
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
}
} catch (error) {
Logger.error('[rmPath error]', error);
}
}
export async function setSystemTimezone(timezone: string): Promise<boolean> {
try {
if (!(await fileExist(`/usr/share/zoneinfo/${timezone}`))) {
throw new Error('Invalid timezone');
}
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
return true;
} catch (error) {
Logger.error('[setSystemTimezone error]', error);
return false;
}
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
try:
from importlib.metadata import version
print(version(name))
except:
import importlib.util as u
import importlib.metadata as m
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? `apk info -es ${name}`
: maybeSudo(`dpkg-query -s ${name}`),
};
return baseCommands[type];
}
export function getInstallCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.python3]:
'pip3 install --disable-pip-version-check --root-user-action=ignore',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
};
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
}
export function getUninstallCommand(
type: DependenceTypes,
name: string,
): string {
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: maybeSudo('apt-get remove -y'),
};
return `${baseCommands[type]} ${name.trim()}`;
}
export function isDemoEnv() {
return process.env.DeployEnv === 'demo';
}
async function getOSReleaseInfo(): Promise<string> {
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
return osRelease;
}
function isDebian(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Debian');
}
function isUbuntu(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Ubuntu');
}
function isCentOS(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
}
function isAlpine(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Alpine');
}
export async function detectOS(): Promise<
'Debian' | 'Ubuntu' | 'Alpine' | undefined
> {
if (osType) return osType;
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') {
osType = 'Alpine';
return osType;
}
if (envOs === 'debian') {
osType = 'Debian';
return osType;
}
if (envOs === 'ubuntu') {
osType = 'Ubuntu';
return osType;
}
const platform = os.platform();
if (platform === 'linux') {
const osReleaseInfo = await getOSReleaseInfo();
if (isDebian(osReleaseInfo)) {
osType = 'Debian';
} else if (isUbuntu(osReleaseInfo)) {
osType = 'Ubuntu';
} else if (isAlpine(osReleaseInfo)) {
osType = 'Alpine';
} else {
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
}
} else if (platform === 'darwin') {
osType = undefined;
} else {
Logger.error(`Unsupported platform: ${platform}`);
console.error(`Unsupported platform: ${platform}`);
}
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
): Promise<string> {
const S = isInContainer() ? 'sudo ' : '';
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
const detectedOS = await detectOS();
if (!detectedOS) {
throw Error(`Unknown Linux Distribution`);
}
return await _updateLinuxMirror(detectedOS, mirror);
}
-55
View File
@@ -1,55 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { NotificationInfo } from './notify';
export class AuthInfo {
ip?: string;
type: AuthDataType;
info?: AuthModelInfo;
id?: number;
constructor(options: AuthInfo) {
this.ip = options.ip;
this.info = options.info;
this.type = options.type;
this.id = options.id;
}
}
export enum LoginStatus {
'success',
'fail',
}
export enum AuthDataType {
'loginLog' = 'loginLog',
'authToken' = 'authToken',
'notification' = 'notification',
'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig',
}
export interface SystemConfigInfo {
logRemoveFrequency?: number;
cronConcurrency?: number;
}
export interface LoginLogInfo {
timestamp?: number;
address?: string;
ip?: string;
platform?: string;
status?: LoginStatus,
}
export type AuthModelInfo = SystemConfigInfo & Partial<NotificationInfo> & LoginLogInfo;
interface AuthInstance extends Model<AuthInfo, AuthInfo>, AuthInfo { }
export const AuthModel = sequelize.define<AuthInstance>('Auth', {
ip: DataTypes.STRING,
type: DataTypes.STRING,
info: {
type: DataTypes.JSON,
allowNull: true,
},
});
+22 -4
View File
@@ -18,10 +18,16 @@ export class Crontab {
last_running_time?: number;
last_execution_time?: number;
sub_id?: number;
extra_schedules?: Array<{ schedule: string }>;
task_before?: string;
task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
work_dir?: string;
constructor(options: Crontab) {
this.name = options.name;
this.command = options.command;
this.command = options.command.trim();
this.schedule = options.schedule;
this.saved = options.saved;
this.id = options.id;
@@ -39,17 +45,23 @@ export class Crontab {
this.last_running_time = options.last_running_time || 0;
this.last_execution_time = options.last_execution_time || 0;
this.sub_id = options.sub_id;
this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before;
this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
this.work_dir = options.work_dir;
}
}
export enum CrontabStatus {
'running' = 0,
'queued' = 0.5,
'idle' = 1,
'disabled',
'disabled' = 2,
'queued' = 3,
}
interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
export interface CronInstance extends Model<Crontab, Crontab>, Crontab {}
export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: {
unique: 'compositeIndex',
@@ -75,4 +87,10 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
last_running_time: DataTypes.NUMBER,
last_execution_time: DataTypes.NUMBER,
sub_id: { type: DataTypes.NUMBER, allowNull: true },
extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
work_dir: DataTypes.STRING,
});
+71
View File
@@ -0,0 +1,71 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
export class CrontabStat {
id?: number;
ref_id!: number;
date!: string;
run_count?: number;
success_count?: number;
fail_count?: number;
total_time?: number;
max_time?: number;
constructor(options: CrontabStat) {
this.id = options.id;
this.ref_id = options.ref_id;
this.date = options.date;
this.run_count = options.run_count || 0;
this.success_count = options.success_count || 0;
this.fail_count = options.fail_count || 0;
this.total_time = options.total_time || 0;
this.max_time = options.max_time || 0;
}
}
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {}
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
'CrontabStat',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
ref_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
date: {
type: DataTypes.STRING,
allowNull: false,
},
run_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
success_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
fail_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
total_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
max_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
},
{
indexes: [
{ unique: true, fields: ['ref_id', 'date'] },
{ fields: ['date'] },
],
},
);
+1 -1
View File
@@ -39,7 +39,7 @@ export class CrontabView {
}
}
interface CronViewInstance
export interface CronViewInstance
extends Model<CrontabView, CrontabView>,
CrontabView {}
export const CrontabViewModel = sequelize.define<CronViewInstance>(
+10 -15
View File
@@ -4,9 +4,9 @@ import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Dependence {
timestamp?: string;
id?: number;
status?: DependenceStatus;
type?: DependenceTypes;
name?: number;
status: DependenceStatus;
type: DependenceTypes;
name: string;
log?: string[];
remark?: string;
@@ -18,7 +18,7 @@ export class Dependence {
: DependenceStatus.queued;
this.type = options.type || DependenceTypes.nodejs;
this.timestamp = new Date().toString();
this.name = options.name;
this.name = options.name.trim();
this.log = options.log || [];
this.remark = options.remark || '';
}
@@ -32,6 +32,7 @@ export enum DependenceStatus {
'removed',
'removeFailed',
'queued',
'cancelled',
}
export enum DependenceTypes {
@@ -40,19 +41,13 @@ export enum DependenceTypes {
'linux',
}
export enum InstallDependenceCommandTypes {
'pnpm add -g',
'pip3 install',
'apk add',
export enum versionDependenceCommandTypes {
'@',
'==',
'=',
}
export enum unInstallDependenceCommandTypes {
'pnpm remove -g',
'pip3 uninstall -y',
'apk del',
}
interface DependenceInstance
export interface DependenceInstance
extends Model<Dependence, Dependence>,
Dependence {}
export const DependenceModel = sequelize.define<DependenceInstance>(
+9 -3
View File
@@ -1,5 +1,5 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Env {
value?: string;
@@ -9,6 +9,8 @@ export class Env {
position?: number;
name?: string;
remarks?: string;
isPinned?: 1 | 0;
labels?: string[];
constructor(options: Env) {
this.value = options.value;
@@ -20,7 +22,9 @@ export class Env {
this.timestamp = new Date().toString();
this.position = options.position;
this.name = options.name;
this.remarks = options.remarks;
this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
this.labels = options.labels || [];
}
}
@@ -34,7 +38,7 @@ export const initPosition = 4500000000000000;
export const stepPosition = 10000000000;
export const minPosition = 100;
interface EnvInstance extends Model<Env, Env>, Env {}
export interface EnvInstance extends Model<Env, Env>, Env {}
export const EnvModel = sequelize.define<EnvInstance>('Env', {
value: { type: DataTypes.STRING, unique: 'compositeIndex' },
timestamp: DataTypes.STRING,
@@ -42,4 +46,6 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
labels: DataTypes.JSON,
});
+71 -8
View File
@@ -1,5 +1,3 @@
import { IncomingHttpHeaders } from 'http';
export enum NotificationMode {
'gotify' = 'gotify',
'goCqHttpBot' = 'goCqHttpBot',
@@ -14,9 +12,15 @@ export enum NotificationMode {
'aibotk' = 'aibotk',
'iGot' = 'iGot',
'pushPlus' = 'pushPlus',
'wePlusBot' = 'wePlusBot',
'email' = 'email',
'pushMe' = 'pushMe',
'feishu' = 'feishu',
'webhook' = 'webhook',
'chronocat' = 'Chronocat',
'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot',
'openiLink' = 'openiLink',
}
abstract class NotificationBaseInfo {
@@ -44,9 +48,8 @@ export class PushDeerNotification extends NotificationBaseInfo {
public pushDeerUrl = '';
}
export class ChatNotification extends NotificationBaseInfo {
public chatUrl = '';
public chatToken = '';
export class synologyChatNotification extends NotificationBaseInfo {
public synologyChatUrl = '';
}
export class BarkNotification extends NotificationBaseInfo {
@@ -54,6 +57,9 @@ export class BarkNotification extends NotificationBaseInfo {
public barkIcon = 'https://qn.whyour.cn/logo.png';
public barkSound = '';
public barkGroup = 'qinglong';
public barkLevel = 'active';
public barkUrl = '';
public barkArchive = '';
}
export class TelegramBotNotification extends NotificationBaseInfo {
@@ -62,7 +68,7 @@ export class TelegramBotNotification extends NotificationBaseInfo {
public telegramBotProxyHost = '';
public telegramBotProxyPort = '';
public telegramBotProxyAuth = '';
public telegramBotApiHost = 'api.telegram.org';
public telegramBotApiHost = 'https://api.telegram.org';
}
export class DingtalkBotNotification extends NotificationBaseInfo {
@@ -93,12 +99,35 @@ export class IGotNotification extends NotificationBaseInfo {
export class PushPlusNotification extends NotificationBaseInfo {
public pushPlusToken = '';
public pushPlusUser = '';
public pushPlusTemplate = '';
public pushplusChannel = '';
public pushplusWebhook = '';
public pushplusCallbackUrl = '';
public pushplusTo = '';
}
export class WePlusBotNotification extends NotificationBaseInfo {
public wePlusBotToken = '';
public wePlusBotReceiver = '';
public wePlusBotVersion = '';
}
export class EmailNotification extends NotificationBaseInfo {
public emailService: string = '';
public emailUser: string = '';
public emailPass: string = '';
public emailTo: string = '';
}
export class PushMeNotification extends NotificationBaseInfo {
public pushMeKey: string = '';
public pushMeUrl: string = '';
}
export class ChronocatNotification extends NotificationBaseInfo {
public chronocatURL: string = '';
public chronocatQQ: string = '';
public chronocatToken: string = '';
}
export class WebhookNotification extends NotificationBaseInfo {
@@ -114,6 +143,33 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo {
public larkKey = '';
public larkSecret = '';
}
export class NtfyNotification extends NotificationBaseInfo {
public ntfyUrl = '';
public ntfyTopic = '';
public ntfyPriority = '';
public ntfyToken = '';
public ntfyUsername = '';
public ntfyPassword = '';
public ntfyActions = '';
}
export class WxPusherBotNotification extends NotificationBaseInfo {
public wxPusherBotAppToken = '';
public wxPusherBotTopicIds = '';
public wxPusherBotUids = '';
}
export class WxPusherSptNotification extends NotificationBaseInfo {
public wxPusherSptList = '';
}
export class OpeniLinkNotification extends NotificationBaseInfo {
public openiLinkAppToken = '';
public openiLinkHubUrl = '';
public openiLinkContextToken = '';
}
export interface NotificationInfo
@@ -121,7 +177,7 @@ export interface NotificationInfo
GotifyNotification,
ServerChanNotification,
PushDeerNotification,
ChatNotification,
synologyChatNotification,
BarkNotification,
TelegramBotNotification,
DingtalkBotNotification,
@@ -130,6 +186,13 @@ export interface NotificationInfo
AibotkNotification,
IGotNotification,
PushPlusNotification,
WePlusBotNotification,
EmailNotification,
PushMeNotification,
WebhookNotification,
LarkNotification {}
ChronocatNotification,
LarkNotification,
NtfyNotification,
WxPusherBotNotification,
WxPusherSptNotification,
OpeniLinkNotification {}
+2 -2
View File
@@ -24,9 +24,9 @@ export interface AppToken {
expiration: number;
}
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs';
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
interface AppInstance extends Model<App, App>, App {}
export interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', {
name: { type: DataTypes.STRING, unique: 'name' },
scopes: DataTypes.JSON,
+81
View File
@@ -0,0 +1,81 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export enum InstanceStatus {
'running' = 0,
'finished' = 1,
'stopped' = 2,
'error' = 3,
}
export interface RunningInstanceAttributes {
id?: number;
cron_id: number;
pid?: number;
log_path?: string;
started_at: number;
finished_at?: number;
status: InstanceStatus;
exit_code?: number;
}
export class RunningInstance {
id?: number;
cron_id!: number;
pid?: number;
log_path?: string;
started_at!: number;
finished_at?: number;
status!: InstanceStatus;
exit_code?: number;
constructor(options: RunningInstanceAttributes) {
this.id = options.id;
this.cron_id = options.cron_id;
this.pid = options.pid;
this.log_path = options.log_path;
this.started_at = options.started_at;
this.finished_at = options.finished_at;
this.status = options.status;
this.exit_code = options.exit_code;
}
}
export interface RunningInstanceModel
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
RunningInstanceAttributes {}
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
'RunningInstance',
{
cron_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
pid: {
type: DataTypes.NUMBER,
allowNull: true,
},
log_path: {
type: DataTypes.STRING,
allowNull: true,
},
started_at: {
type: DataTypes.NUMBER,
allowNull: false,
},
finished_at: {
type: DataTypes.NUMBER,
allowNull: true,
},
status: {
type: DataTypes.NUMBER,
allowNull: false,
defaultValue: InstanceStatus.running,
},
exit_code: {
type: DataTypes.NUMBER,
allowNull: true,
},
},
);
+6 -1
View File
@@ -2,11 +2,13 @@ export class SockMessage {
message?: string;
type?: SockMessageType;
references?: number[];
status?: number | string;
constructor(options: SockMessage) {
this.type = options.type;
this.message = options.message;
this.references = options.references;
this.status = options.status;
}
}
@@ -16,4 +18,7 @@ export type SockMessageType =
| 'uninstallDependence'
| 'updateSystemVersion'
| 'manuallyRunScript'
| 'runSubscriptionEnd';
| 'runSubscriptionEnd'
| 'reloadSystem'
| 'updateNodeMirror'
| 'updateLinuxMirror';
+1 -1
View File
@@ -70,7 +70,7 @@ export enum SubscriptionStatus {
'queued',
}
interface SubscriptionInstance
export interface SubscriptionInstance
extends Model<Subscription, Subscription>,
Subscription {}
export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
+98
View File
@@ -0,0 +1,98 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
import { NotificationInfo } from './notify';
export class SystemInfo {
ip?: string;
type: AuthDataType;
info?: SystemModelInfo;
id?: number;
constructor(options: SystemInfo) {
this.ip = options.ip;
this.info = options.info;
this.type = options.type;
this.id = options.id;
}
}
export enum LoginStatus {
'success',
'fail',
}
export enum AuthDataType {
'loginLog' = 'loginLog',
'authToken' = 'authToken',
'notification' = 'notification',
'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig',
}
export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
logRemoveFrequency?: number;
cronConcurrency?: number;
dependenceProxy?: string;
nodeMirror?: string;
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
globalSshKey?: string;
}
export interface LoginLogInfo {
timestamp?: number;
address?: string;
ip?: string;
platform?: string;
status?: LoginStatus;
}
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo {
username: string;
password: string;
retries: number;
lastlogon: number;
lastip: string;
lastaddr: string;
platform: string;
isTwoFactorChecking: boolean;
token: string;
tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean;
twoFactorSecret: string;
avatar: string;
}
export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> &
LoginLogInfo &
Partial<AuthInfo>;
export interface SystemInstance
extends Model<SystemInfo, SystemInfo>,
SystemInfo {}
export const SystemModel = sequelize.define<SystemInstance>('Auth', {
ip: DataTypes.STRING,
type: DataTypes.STRING,
info: {
type: DataTypes.JSON,
allowNull: true,
},
});
-5
View File
@@ -1,5 +0,0 @@
declare namespace Express {
interface Request {
platform: 'desktop' | 'mobile';
}
}
+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 }>;
};
+15 -11
View File
@@ -5,20 +5,24 @@ import initData from './initData';
import { Application } from 'express';
import linkDeps from './deps';
import initTask from './initTask';
import initFile from './initFile';
export default async ({ expressApp }: { expressApp: Application }) => {
await depInjectorLoader();
Logger.info('✌️ Dependency Injector loaded');
await expressLoader({ app: expressApp });
Logger.info('✌️ Express loaded');
await initData();
Logger.info('✌️ init data loaded');
export default async ({ app }: { app: Application }) => {
depInjectorLoader();
Logger.info('[boot] Dependency loaded');
await linkDeps();
Logger.info('✌️ link deps loaded');
Logger.info('[boot] Link deps loaded');
await initFile();
Logger.info('[boot] Init file loaded');
await initData();
Logger.info('[boot] Init data loaded');
initTask();
Logger.info('✌️ init task loaded');
Logger.info('[boot] Init task loaded');
expressLoader({ app });
Logger.info('[boot] 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();
};
+40 -105
View File
@@ -1,15 +1,13 @@
import Logger from './logger';
import path from 'path';
import DataStore from 'nedb';
import { EnvModel } from '../data/env';
import { CrontabModel } from '../data/cron';
import { DependenceModel } from '../data/dependence';
import { AppModel } from '../data/open';
import { AuthModel } from '../data/auth';
import { fileExist } from '../config/util';
import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView';
import config from '../config';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data';
export default async () => {
@@ -17,114 +15,51 @@ export default async () => {
await CrontabModel.sync();
await DependenceModel.sync();
await AppModel.sync();
await AuthModel.sync();
await SystemModel.sync();
await EnvModel.sync();
await SubscriptionModel.sync();
await CrontabViewModel.sync();
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
// 初始化新增字段
try {
await sequelize.query(
'alter table CrontabViews add column filterRelation VARCHAR(255)',
);
} catch (error) {}
try {
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) {}
const migrations = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
{ table: 'Envs', column: 'labels', type: 'JSON' },
];
// 2.10-2.11 升级
const cronDbFile = path.join(config.rootPath, 'db/crontab.db');
const envDbFile = path.join(config.rootPath, 'db/env.db');
const appDbFile = path.join(config.rootPath, 'db/app.db');
const authDbFile = path.join(config.rootPath, 'db/auth.db');
const dependenceDbFile = path.join(config.rootPath, 'db/dependence.db');
const crondbExist = await fileExist(cronDbFile);
const dependenceDbExist = await fileExist(dependenceDbFile);
const envDbExist = await fileExist(envDbFile);
const appDbExist = await fileExist(appDbFile);
const authDbExist = await fileExist(authDbFile);
const cronCount = await CrontabModel.count();
const dependenceCount = await DependenceModel.count();
const envCount = await EnvModel.count();
const appCount = await AppModel.count();
const authCount = await AuthModel.count();
if (crondbExist && cronCount === 0) {
const cronDb = new DataStore({
filename: cronDbFile,
autoload: true,
});
cronDb.persistence.compactDatafile();
cronDb.find({}).exec(async (err, docs) => {
await CrontabModel.bulkCreate(docs, { ignoreDuplicates: true });
});
for (const migration of migrations) {
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
if (dependenceDbExist && dependenceCount === 0) {
const dependenceDb = new DataStore({
filename: dependenceDbFile,
autoload: true,
});
dependenceDb.persistence.compactDatafile();
dependenceDb.find({}).exec(async (err, docs) => {
await DependenceModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (envDbExist && envCount === 0) {
const envDb = new DataStore({
filename: envDbFile,
autoload: true,
});
envDb.persistence.compactDatafile();
envDb.find({}).exec(async (err, docs) => {
await EnvModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (appDbExist && appCount === 0) {
const appDb = new DataStore({
filename: appDbFile,
autoload: true,
});
appDb.persistence.compactDatafile();
appDb.find({}).exec(async (err, docs) => {
await AppModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
if (authDbExist && authCount === 0) {
const authDb = new DataStore({
filename: authDbFile,
autoload: true,
});
authDb.persistence.compactDatafile();
authDb.find({}).exec(async (err, docs) => {
await AuthModel.bulkCreate(docs, { ignoreDuplicates: true });
});
}
Logger.info('✌️ DB loaded');
Logger.info('[boot] DB loaded');
} catch (error) {
Logger.info('✌️ DB load failed');
Logger.info(error);
Logger.error('[boot] DB load failed', error);
}
};
+28 -34
View File
@@ -1,57 +1,51 @@
import path from 'path';
import fs from 'fs';
import chokidar from 'chokidar';
import fs from 'fs/promises';
import os from 'os';
import config from '../config/index';
import { promiseExec } from '../config/util';
function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src);
const source = path.join(config.rootPath, src);
fs.lstat(target, (err, stat) => {
if (!stat) {
fs.symlink(source, target, 'dir', (err) => {
if (err) throw err;
});
}
});
}
import Logger from './logger';
async function linkCommand() {
const commandPath = await promiseExec('which node');
const commandDir = path.dirname(commandPath);
const homeDir = os.homedir();
let userBinDir = path.join(homeDir, 'bin');
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const linkShell = [
{
src: 'update.sh',
dest: 'ql',
tmp: 'ql_tmp',
},
{
src: 'task.sh',
dest: 'task',
tmp: 'task_tmp',
},
];
for (const link of linkShell) {
const source = path.join(config.rootPath, 'shell', link.src);
const target = path.join(commandDir, link.dest);
if (fs.existsSync(target)) {
fs.unlinkSync(target);
}
fs.symlink(source, target, (err) => {});
const tmpTarget = path.join(commandDir, link.tmp);
try {
const stats = await fs.lstat(tmpTarget);
if (stats) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
}
}
export default async (src: string = 'deps') => {
export default async () => {
await linkCommand();
linkToNodeModule(src);
const source = path.join(config.rootPath, src);
const watcher = chokidar.watch(source, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
});
watcher
.on('add', (path) => linkToNodeModule(src))
.on('change', (path) => linkToNodeModule(src));
};
+98 -62
View File
@@ -3,43 +3,63 @@ import bodyParser from 'body-parser';
import cors from 'cors';
import routes from '../api';
import config from '../config';
import jwt, { UnauthorizedError } from 'express-jwt';
import fs from 'fs';
import { UnauthorizedError, expressjwt } from 'express-jwt';
import { getPlatform, getToken } from '../config/util';
import Container from 'typedi';
import OpenService from '../services/open';
import rewrite from 'express-urlrewrite';
import UserService from '../services/user';
import * as Sentry from '@sentry/node';
import { EnvModel } from '../data/env';
import { errors } from 'celebrate';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { serveEnv } from '../config/serverEnv';
import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
import { AuthInfo } from '../data/system';
import path from 'path';
import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
export default ({ app }: { app: Application }) => {
app.enable('trust proxy');
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback');
app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
app.use(
'/api/public',
createProxyMiddleware({
target: `http://localhost:${config.publicPort}/api`,
changeOrigin: true,
pathRewrite: { '/api/public': '' },
}),
);
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
const frontendPath = path.join(config.rootPath, 'static/dist');
app.use(express.static(frontendPath));
app.use(
jwt({
secret: config.secret as string,
expressjwt({
secret: config.jwt.secret,
algorithms: ['HS384'],
}).unless({
path: [...config.apiWhiteList, /^\/open\//],
path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}),
);
@@ -53,26 +73,42 @@ export default ({ app }: { app: Application }) => {
return next();
});
app.use(async (req, res, next) => {
app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (req.path.startsWith('/open/')) {
const openService = Container.get(OpenService);
const doc = await openService.findTokenByValue(headerToken);
if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) &&
currentToken &&
currentToken.expiration >= Math.round(Date.now() / 1000)
) {
return next();
if (!doc.scopes.includes(key as AppScope)) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
return next(err);
}
return next();
}
}
const originPath = `${req.baseUrl}${req.path === '/' ? '' : req.path}`;
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
if (
!headerToken &&
originPath &&
@@ -81,41 +117,38 @@ export default ({ app }: { app: Application }) => {
return next();
}
const data = fs.readFileSync(config.authConfigFile, 'utf8');
if (data && headerToken) {
const { token = '', tokens = {} } = JSON.parse(data);
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken
? 'jwt malformed'
: 'No authorization token was found';
? t('Token 已失效')
: t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
});
app.use(async (req, res, next) => {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next();
}
const userService = Container.get(UserService);
const authInfo = await userService.getUserInfo();
const envCount = await EnvModel.count();
const authInfo =
(await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = true;
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
let isInitialized = !isDefaultAuthInfo(authInfo);
if (isInitialized) {
return res.send({ code: 450, message: '未知错误' });
return res.send({ code: 450, message: t('未知错误') });
} else {
return next();
}
@@ -124,10 +157,15 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.use((req, res, next) => {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
app.get('*', (_, res, next) => {
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
const err: any = new Error('Not Found');
err['status'] = 404;
next(err);
}
});
});
app.use(errors());
@@ -161,8 +199,8 @@ export default ({ app }: { app: Application }) => {
.status(500)
.send({
code: 400,
message: `${err.name} ${err.message}`,
validation: err.errors,
message: `${err.message}`,
errors: err.errors,
})
.end();
}
@@ -177,8 +215,6 @@ export default ({ app }: { app: Application }) => {
res: Response,
next: NextFunction,
) => {
Sentry.captureException(err);
res.status(err.status || 500);
res.json({
code: err.status || 500,
+138 -19
View File
@@ -9,11 +9,120 @@ import { Op } from 'sequelize';
import config from '../config';
import { CrontabViewModel, CronViewType } from '../data/cronView';
import { initPosition } from '../data/env';
import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system';
import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
import { AppModel } from '../data/open';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { setLang, systemLang } from '../shared/i18n';
export default async () => {
const cronService = Container.get(CronService);
const envService = Container.get(EnvService);
const dependenceService = Container.get(DependenceService);
const systemService = Container.get(SystemService);
const userService = Container.get(UserService);
const openService = Container.get(OpenService);
// 初始化增加系统配置
let systemApp = (
await AppModel.findOne({
where: { name: 'system' },
})
)?.get({ plain: true });
if (!systemApp) {
systemApp = await AppModel.create({
name: 'system',
scopes: ['crons', 'system', 'dashboard'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
} else if (!systemApp.scopes.includes('dashboard')) {
await AppModel.update(
{ scopes: [...systemApp.scopes, 'dashboard'] },
{ where: { name: 'system' } },
);
}
const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig },
});
await SystemModel.findOrCreate({
where: { type: AuthDataType.notification },
});
const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig },
});
if (!authConfig?.info || isDemoEnv()) {
let authInfo = {
username: 'admin',
password: 'admin',
};
try {
const authFileExist = await fileExist(config.authConfigFile);
if (authFileExist) {
const content = await readFile(config.authConfigFile, 'utf8');
authInfo = safeJSONParse(content);
}
} catch (error) {
Logger.warn('Failed to read auth config file, using default credentials');
}
await SystemModel.upsert({
id: authConfig?.id,
info: authInfo,
type: AuthDataType.authConfig,
});
}
const installDependencies = async () => {
const docs = await DependenceModel.findAll({
where: {},
order: [
['type', 'DESC'],
['createdAt', 'DESC'],
],
raw: true,
});
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: docs.map((x) => x.id!) } },
);
setTimeout(async () => {
await dependenceService.installDependenceOneByOne(docs);
const bootAfterLoader = await import('./bootAfter');
bootAfterLoader.default();
}, 5000);
};
// 初始化更新 linux/python/nodejs 镜像源配置
if (systemConfig.info?.pythonMirror) {
systemService.updatePythonMirror({
pythonMirror: systemConfig.info?.pythonMirror,
});
}
if (systemConfig.info?.linuxMirror) {
systemService.updateLinuxMirror(
{
linuxMirror: systemConfig.info?.linuxMirror,
},
undefined,
() => installDependencies(),
);
} else {
installDependencies();
}
if (systemConfig.info?.nodeMirror) {
systemService.updateNodeMirror({
nodeMirror: systemConfig.info?.nodeMirror,
});
}
// 初始化新增默认全部任务视图
CrontabViewModel.findAll({
@@ -30,23 +139,13 @@ export default async () => {
});
// 初始化更新所有任务状态为空闲
await CrontabModel.update(
{ status: CrontabStatus.idle },
{ where: {} },
);
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖
DependenceModel.findAll({
where: {},
order: [['type', 'DESC']],
raw: true,
}).then(async (docs) => {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: docs.map((x) => x.id!) } },
);
dependenceService.installDependenceOneByOne(docs);
});
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped },
{ where: { status: InstanceStatus.running } },
);
// 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({
@@ -90,7 +189,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/log/`)) {
await CrontabModel.update(
{
command: `${config.rootPath}/data/log/${doc.command.replace(
command: `${config.dataPath}/log/${doc.command.replace(
`${config.rootPath}/log/`,
'',
)}`,
@@ -101,7 +200,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/config/`)) {
await CrontabModel.update(
{
command: `${config.rootPath}/data/config/${doc.command.replace(
command: `${config.dataPath}/config/${doc.command.replace(
`${config.rootPath}/config/`,
'',
)}`,
@@ -112,7 +211,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/db/`)) {
await CrontabModel.update(
{
command: `${config.rootPath}/data/db/${doc.command.replace(
command: `${config.dataPath}/db/${doc.command.replace(
`${config.rootPath}/db/`,
'',
)}`,
@@ -124,7 +223,27 @@ export default async () => {
}
});
// 初始化语言(必须在 autosave_crontab 之前)
const lang = systemConfig.info?.lang || systemLang();
setLang(lang);
// 确保 lang_env.sh 存在
try {
const langEnvExist = await fileExist(config.langEnvFile);
if (!langEnvExist) {
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
}
} catch { }
// 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
const apps = await openService.findApps();
await shareStore.updateAuthInfo(authInfo);
if (apps?.length) {
await shareStore.updateApps(apps);
}
};
+98 -77
View File
@@ -1,12 +1,18 @@
import fs from 'fs';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import dotenv from 'dotenv';
import Logger from './logger';
import { fileExist } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
const rootPath = process.env.QL_DIR as string;;
const dataPath = path.join(rootPath, 'data/');
const rootPath = process.env.QL_DIR as string;
let dataPath = path.join(rootPath, 'data/');
if (process.env.QL_DATA_DIR) {
dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, '');
}
const preloadPath = path.join(rootPath, 'shell/preload/');
const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/');
const logPath = path.join(dataPath, 'log/');
@@ -14,95 +20,110 @@ const uploadPath = path.join(dataPath, 'upload/');
const bakPath = path.join(dataPath, 'bak/');
const samplePath = path.join(rootPath, 'sample/');
const tmpPath = path.join(logPath, '.tmp/');
const rootTmpPath = path.join(rootPath, '.tmp/');
const confFile = path.join(configPath, 'config.sh');
const authConfigFile = path.join(configPath, 'auth.json');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
const sampleAuthFile = path.join(samplePath, 'auth.sample.json');
const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh');
const sampleNotifyJsFile = path.join(samplePath, 'notify.js');
const sampleNotifyPyFile = path.join(samplePath, 'notify.py');
const scriptNotifyJsFile = path.join(scriptPath, 'sendNotify.js');
const scriptNotifyPyFile = path.join(scriptPath, 'notify.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const TaskBeforeFile = path.join(configPath, 'task_before.sh');
const TaskBeforeJsFile = path.join(configPath, 'task_before.js');
const TaskBeforePyFile = path.join(configPath, 'task_before.py');
const TaskAfterFile = path.join(configPath, 'task_after.sh');
const homedir = os.homedir();
const sshPath = path.resolve(homedir, '.ssh');
const sshdPath = path.join(dataPath, 'ssh.d');
const systemLogPath = path.join(dataPath, 'syslog');
const directories = [
configPath,
scriptPath,
preloadPath,
logPath,
tmpPath,
rootTmpPath,
uploadPath,
sshPath,
bakPath,
sshdPath,
systemLogPath,
];
const files = [
{
target: confFile,
source: sampleConfigFile,
checkExistence: true,
},
{
target: jsNotifyFile,
source: sampleNotifyJsFile,
checkExistence: false,
},
{
target: pyNotifyFile,
source: sampleNotifyPyFile,
checkExistence: false,
},
{
target: scriptNotifyJsFile,
source: sampleNotifyJsFile,
checkExistence: true,
},
{
target: scriptNotifyPyFile,
source: sampleNotifyPyFile,
checkExistence: true,
},
{
target: TaskBeforeFile,
source: sampleTaskShellFile,
checkExistence: true,
},
{
target: TaskBeforeJsFile,
content:
'// The JavaScript code that executes before the JavaScript task execution will execute.',
checkExistence: true,
},
{
target: TaskBeforePyFile,
content:
'# The Python code that executes before the Python task execution will execute.',
checkExistence: true,
},
{
target: TaskAfterFile,
source: sampleTaskShellFile,
checkExistence: true,
},
];
export default async () => {
const authFileExist = await fileExist(authConfigFile);
const confFileExist = await fileExist(confFile);
const scriptDirExist = await fileExist(scriptPath);
const logDirExist = await fileExist(logPath);
const configDirExist = await fileExist(configPath);
const uploadDirExist = await fileExist(uploadPath);
const sshDirExist = await fileExist(sshPath);
const bakDirExist = await fileExist(bakPath);
const sshdDirExist = await fileExist(sshdPath);
const tmpDirExist = await fileExist(tmpPath);
const scriptNotifyJsFileExist = await fileExist(scriptNotifyJsFile);
const scriptNotifyPyFileExist = await fileExist(scriptNotifyPyFile);
const TaskBeforeFileExist = await fileExist(TaskBeforeFile);
const TaskAfterFileExist = await fileExist(TaskAfterFile);
if (!configDirExist) {
fs.mkdirSync(configPath);
for (const dirPath of directories) {
if (!(await fileExist(dirPath))) {
await fs.mkdir(dirPath);
}
}
if (!scriptDirExist) {
fs.mkdirSync(scriptPath);
for (const item of files) {
const exists = await fileExist(item.target);
if (!item.checkExistence || !exists) {
if (!item.content && !item.source) {
throw new Error(
`Neither content nor source specified for ${item.target}`,
);
}
const content =
item.content ||
(await fs.readFile(item.source!, { encoding: 'utf-8' }));
await writeFileWithLock(item.target, content);
}
}
if (!logDirExist) {
fs.mkdirSync(logPath);
}
if (!tmpDirExist) {
fs.mkdirSync(tmpPath);
}
if (!uploadDirExist) {
fs.mkdirSync(uploadPath);
}
if (!sshDirExist) {
fs.mkdirSync(sshPath);
}
if (!bakDirExist) {
fs.mkdirSync(bakPath);
}
if (!sshdDirExist) {
fs.mkdirSync(sshdPath);
}
// 初始化文件
if (!authFileExist) {
fs.writeFileSync(authConfigFile, fs.readFileSync(sampleAuthFile));
}
if (!confFileExist) {
fs.writeFileSync(confFile, fs.readFileSync(sampleConfigFile));
}
if (!scriptNotifyJsFileExist) {
fs.writeFileSync(scriptNotifyJsFile, fs.readFileSync(sampleNotifyJsFile));
}
if (!scriptNotifyPyFileExist) {
fs.writeFileSync(scriptNotifyPyFile, fs.readFileSync(sampleNotifyPyFile));
}
if (!TaskBeforeFileExist) {
fs.writeFileSync(TaskBeforeFile, fs.readFileSync(sampleTaskShellFile));
}
if (!TaskAfterFileExist) {
fs.writeFileSync(TaskAfterFile, fs.readFileSync(sampleTaskShellFile));
}
dotenv.config({ path: confFile });
Logger.info('✌️ Init file down');
Logger.info('[boot] Init file down');
};
+43 -19
View File
@@ -1,18 +1,24 @@
import { Container } from 'typedi';
import SystemService from '../services/system';
import ScheduleService from '../services/schedule';
import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
import { t } from '../shared/i18n';
export default async () => {
const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token
let tokenCommand = `tsx ${join(config.rootPath, 'back/token.ts')}`;
let tokenCommand = `ts-node-transpile-only ${join(
config.rootPath,
'back/token.ts',
)}`;
const tokenFile = join(config.rootPath, 'static/build/token.js');
if (await fileExist(tokenFile)) {
@@ -20,32 +26,50 @@ export default async () => {
}
const cron = {
id: NaN,
name: '生成token',
name: t('生成token'),
command: tokenCommand,
};
runOrigin: 'system',
} as ScheduleTaskType;
await scheduleService.cancelIntervalTask(cron);
scheduleService.createIntervalTask(cron, {
days: 28,
});
scheduleService.createIntervalTask(
cron,
{
days: 28,
},
true,
);
// 运行删除日志任务
const data = await systemService.getSystemConfig();
if (data && data.info && data.info.frequency) {
const rmlogCron = {
id: data.id,
name: '删除日志',
command: `ql rmlog ${data.info.frequency}`,
};
await scheduleService.cancelIntervalTask(rmlogCron);
scheduleService.createIntervalTask(rmlogCron, {
days: data.info.frequency,
});
if (data && data.info) {
if (data.info.logRemoveFrequency) {
const rmlogCron = {
id: data.id as number,
name: t('删除日志'),
command: `ql rmlog ${data.info.logRemoveFrequency}`,
runOrigin: 'system' as const,
};
await scheduleService.cancelIntervalTask(rmlogCron);
scheduleService.createIntervalTask(
rmlogCron,
{
days: data.info.logRemoveFrequency,
},
true,
);
}
systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
}
// 运行所有订阅
await subscriptionService.setSshConfig();
const subs = await subscriptionService.list();
for (const sub of subs) {
subscriptionService.handleTask(sub, !sub.is_disabled, !sub.is_disabled);
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
}
};
+54 -23
View File
@@ -1,32 +1,63 @@
import winston from 'winston';
import 'winston-daily-rotate-file';
import config from '../config';
import path from 'path';
const transports = [];
if (process.env.NODE_ENV !== 'development') {
transports.push(new winston.transports.Console());
} else {
transports.push(
new winston.transports.Console({
format: winston.format.combine(
winston.format.cli(),
winston.format.splat(),
),
}),
);
}
const levelMap: Record<string, string> = {
info: '', // info图标
warn: '⚠️', // 警告图标
error: '❌', // 错误图标
debug: '🐛', // debug调试图标
};
const baseFormat = [
winston.format.splat(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.align(),
];
const consoleFormat = winston.format.combine(
winston.format.colorize({ level: true }),
...baseFormat,
winston.format.printf((info) => {
return `[${info.level} ${info.timestamp}]:${info.message}`;
}),
);
const plainFormat = winston.format.combine(
winston.format.uncolorize(),
...baseFormat,
winston.format.printf((info) => {
return `[${levelMap[info.level] || ''}${info.level} ${info.timestamp}]:${
info.message
}`;
}),
);
const consoleTransport = new winston.transports.Console({
format: consoleFormat,
level: 'debug',
});
const fileTransport = new winston.transports.DailyRotateFile({
filename: path.join(config.systemLogPath, '%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '7d',
format: plainFormat,
level: config.logs.level || 'info',
});
const LoggerInstance = winston.createLogger({
level: config.logs.level,
level: 'debug',
levels: winston.config.npm.levels,
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json(),
),
transports,
transports: [consoleTransport, fileTransport],
exceptionHandlers: [consoleTransport, fileTransport],
rejectionHandlers: [consoleTransport, fileTransport],
});
LoggerInstance.on('error', (error) => {
console.error('Logger error:', error);
});
export default LoggerInstance;
-32
View File
@@ -1,32 +0,0 @@
import { Application } from 'express';
import * as Sentry from '@sentry/node';
import * as Tracing from '@sentry/tracing';
import Logger from './logger';
import config from '../config';
import fs from 'fs';
import { parseVersion } from '../config/util';
export default async ({ expressApp }: { expressApp: Application }) => {
const { version } = await parseVersion(config.versionFile);
Sentry.init({
ignoreErrors: [
/SequelizeUniqueConstraintError/i,
/Validation error/i,
/UnauthorizedError/i,
/celebrate request validation failed/i,
],
dsn: 'https://f4b5b55fb3c645b29a5dc2d70a1a4ef4@o1098464.ingest.sentry.io/6122819',
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Tracing.Integrations.Express({ app: expressApp }),
],
tracesSampleRate: 0.8,
release: version,
});
expressApp.use(Sentry.Handlers.requestHandler());
expressApp.use(Sentry.Handlers.tracingHandler());
Logger.info('✌️ Sentry loaded');
};
+6 -12
View File
@@ -4,19 +4,13 @@ import Sock from './sock';
export default async ({ server }: { server: Server }) => {
await Sock({ server });
Logger.info('✌️ Sock loaded');
Logger.info('[boot] Sock loaded');
process.on('SIGINT', () => {
Logger.info('✌️ Server need close');
server.close(() => {
setTimeout(() => {
process.exit();
}, 10000);
});
process.on('uncaughtException', (error) => {
Logger.error('Uncaught exception:', error);
});
setTimeout(() => {
console.log('Forcing server close !!!');
process.exit(1);
}, 15000);
process.on('unhandledRejection', (reason, promise) => {
Logger.error('Unhandled rejection:', reason, promise);
});
};
+16 -21
View File
@@ -1,42 +1,37 @@
import sockJs from 'sockjs';
import { Server } from 'http';
import Logger from './logger';
import { Container } from 'typedi';
import SockService from '../services/sock';
import config from '../config/index';
import fs from 'fs';
import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import config from '../config';
export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } });
const sockService = Container.get(SockService);
echo.on('connection', (conn) => {
echo.on('connection', async (conn) => {
if (!conn.headers || !conn.url || !conn.pathname) {
conn.close('404');
}
const data = fs.readFileSync(config.authConfigFile, 'utf8');
const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (data) {
const { token = '', tokens = {} } = JSON.parse(data);
if (headerToken === token || tokens[platform] === headerToken) {
conn.write(JSON.stringify({ type: 'ping', message: 'hanhh' }));
sockService.addClient(conn);
conn.on('data', (message) => {
conn.write(message);
});
if (isValidToken(authInfo, headerToken, platform)) {
sockService.addClient(conn);
conn.on('close', function () {
sockService.removeClient(conn);
});
conn.on('data', (message) => {
conn.write(message);
});
return;
} else {
conn.write(JSON.stringify({ type: 'ping', message: 'whyour' }));
}
conn.on('close', function () {
sockService.removeClient(conn);
});
return;
}
conn.close('404');
+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),
};
};
+278
View File
@@ -0,0 +1,278 @@
syntax = "proto3";
package com.ql.api;
message EnvItem {
optional int32 id = 1;
optional string name = 2;
optional string value = 3;
optional string remarks = 4;
optional int32 status = 5;
optional int64 position = 6;
}
message GetEnvsRequest { string searchValue = 1; }
message CreateEnvRequest { repeated EnvItem envs = 1; }
message UpdateEnvRequest { EnvItem env = 1; }
message DeleteEnvsRequest { repeated int32 ids = 1; }
message MoveEnvRequest {
int32 id = 1;
int32 fromIndex = 2;
int32 toIndex = 3;
}
message DisableEnvsRequest { repeated int32 ids = 1; }
message EnableEnvsRequest { repeated int32 ids = 1; }
message UpdateEnvNamesRequest {
repeated int32 ids = 1;
string name = 2;
}
message GetEnvByIdRequest { int32 id = 1; }
message EnvsResponse {
int32 code = 1;
repeated EnvItem data = 2;
optional string message = 3;
}
message EnvResponse {
int32 code = 1;
EnvItem data = 2;
optional string message = 3;
}
message Response {
int32 code = 1;
optional string message = 2;
}
message ExtraScheduleItem { string schedule = 1; }
message CronItem {
optional int32 id = 1;
optional string command = 2;
optional string schedule = 3;
optional string name = 4;
repeated string labels = 5;
optional int32 sub_id = 6;
repeated ExtraScheduleItem extra_schedules = 7;
optional string task_before = 8;
optional string task_after = 9;
optional int32 status = 10;
optional string log_path = 11;
optional int32 pid = 12;
optional int64 last_running_time = 13;
optional int64 last_execution_time = 14;
}
message CreateCronRequest {
string command = 1;
string schedule = 2;
optional string name = 3;
repeated string labels = 4;
optional int32 sub_id = 5;
repeated ExtraScheduleItem extra_schedules = 6;
optional string task_before = 7;
optional string task_after = 8;
}
message UpdateCronRequest {
int32 id = 1;
optional string command = 2;
optional string schedule = 3;
optional string name = 4;
repeated string labels = 5;
optional int32 sub_id = 6;
repeated ExtraScheduleItem extra_schedules = 7;
optional string task_before = 8;
optional string task_after = 9;
}
message DeleteCronsRequest { repeated int32 ids = 1; }
message GetCronsRequest {
optional string searchValue = 1;
}
message GetCronByIdRequest { int32 id = 1; }
message EnableCronsRequest { repeated int32 ids = 1; }
message DisableCronsRequest { repeated int32 ids = 1; }
message RunCronsRequest { repeated int32 ids = 1; }
message CronsResponse {
int32 code = 1;
repeated CronItem data = 2;
optional string message = 3;
}
message CronResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
message CronDetailRequest { string log_path = 1; }
message CronDetailResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
enum NotificationMode {
gotify = 0;
goCqHttpBot = 1;
serverChan = 2;
pushDeer = 3;
bark = 4;
chat = 5;
telegramBot = 6;
dingtalkBot = 7;
weWorkBot = 8;
weWorkApp = 9;
aibotk = 10;
iGot = 11;
pushPlus = 12;
wePlusBot = 13;
email = 14;
pushMe = 15;
feishu = 16;
webhook = 17;
chronocat = 18;
ntfy = 19;
wxPusherBot = 20;
wxPusherSpt = 21;
}
message NotificationInfo {
NotificationMode type = 1;
optional string gotifyUrl = 2;
optional string gotifyToken = 3;
optional int32 gotifyPriority = 4;
optional string goCqHttpBotUrl = 5;
optional string goCqHttpBotToken = 6;
optional string goCqHttpBotQq = 7;
optional string serverChanKey = 8;
optional string pushDeerKey = 9;
optional string pushDeerUrl = 10;
optional string synologyChatUrl = 11;
optional string barkPush = 12;
optional string barkIcon = 13;
optional string barkSound = 14;
optional string barkGroup = 15;
optional string barkLevel = 16;
optional string barkUrl = 17;
optional string barkArchive = 18;
optional string telegramBotToken = 19;
optional string telegramBotUserId = 20;
optional string telegramBotProxyHost = 21;
optional string telegramBotProxyPort = 22;
optional string telegramBotProxyAuth = 23;
optional string telegramBotApiHost = 24;
optional string dingtalkBotToken = 25;
optional string dingtalkBotSecret = 26;
optional string weWorkBotKey = 27;
optional string weWorkOrigin = 28;
optional string weWorkAppKey = 29;
optional string aibotkKey = 30;
optional string aibotkType = 31;
optional string aibotkName = 32;
optional string iGotPushKey = 33;
optional string pushPlusToken = 34;
optional string pushPlusUser = 35;
optional string pushPlusTemplate = 36;
optional string pushplusChannel = 37;
optional string pushplusWebhook = 38;
optional string pushplusCallbackUrl = 39;
optional string pushplusTo = 40;
optional string wePlusBotToken = 41;
optional string wePlusBotReceiver = 42;
optional string wePlusBotVersion = 43;
optional string emailService = 44;
optional string emailUser = 45;
optional string emailPass = 46;
optional string emailTo = 47;
optional string pushMeKey = 48;
optional string pushMeUrl = 49;
optional string chronocatURL = 50;
optional string chronocatQQ = 51;
optional string chronocatToken = 52;
optional string webhookHeaders = 53;
optional string webhookBody = 54;
optional string webhookUrl = 55;
optional string webhookMethod = 56;
optional string webhookContentType = 57;
optional string larkKey = 58;
optional string larkSecret = 69;
optional string ntfyUrl = 59;
optional string ntfyTopic = 60;
optional string ntfyPriority = 61;
optional string ntfyToken = 62;
optional string ntfyUsername = 63;
optional string ntfyPassword = 64;
optional string ntfyActions = 65;
optional string wxPusherBotAppToken = 66;
optional string wxPusherBotTopicIds = 67;
optional string wxPusherBotUids = 68;
optional string wxPusherSptList = 70;
}
message SystemNotifyRequest {
string title = 1;
string content = 2;
optional NotificationInfo notificationInfo = 3;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
rpc UpdateEnv(UpdateEnvRequest) returns (EnvResponse) {}
rpc DeleteEnvs(DeleteEnvsRequest) returns (Response) {}
rpc MoveEnv(MoveEnvRequest) returns (EnvResponse) {}
rpc DisableEnvs(DisableEnvsRequest) returns (Response) {}
rpc EnableEnvs(EnableEnvsRequest) returns (Response) {}
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
}
+4764
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -7,10 +7,14 @@ service Cron {
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
}
message ISchedule { string schedule = 1; }
message ICron {
string id = 1;
string schedule = 2;
string command = 3;
repeated ISchedule extra_schedules = 4;
string name = 5;
}
message AddCronRequest { repeated ICron crons = 1; }
+242 -199
View File
@@ -1,119 +1,232 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// source: back/protos/cron.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
CallOptions,
type CallOptions,
ChannelCredentials,
Client,
ClientOptions,
ClientUnaryCall,
handleUnaryCall,
type ClientOptions,
type ClientUnaryCall,
type handleUnaryCall,
makeGenericClientConstructor,
Metadata,
ServiceError,
UntypedServiceImplementation,
} from '@grpc/grpc-js';
import _m0 from 'protobufjs/minimal';
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
export const protobufPackage = 'com.ql.cron';
export const protobufPackage = "com.ql.cron";
export interface ISchedule {
schedule: string;
}
export interface ICron {
id: string;
schedule: string;
command: string;
extra_schedules: ISchedule[];
name: string;
}
export interface AddCronRequest {
crons: ICron[];
}
export interface AddCronResponse {}
export interface AddCronResponse {
}
export interface DeleteCronRequest {
ids: string[];
}
export interface DeleteCronResponse {}
function createBaseICron(): ICron {
return { id: '', schedule: '', command: '' };
export interface DeleteCronResponse {
}
export const ICron = {
encode(message: ICron, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
if (message.id !== '') {
writer.uint32(10).string(message.id);
}
if (message.schedule !== '') {
writer.uint32(18).string(message.schedule);
}
if (message.command !== '') {
writer.uint32(26).string(message.command);
function createBaseISchedule(): ISchedule {
return { schedule: "" };
}
export const ISchedule: MessageFns<ISchedule> = {
encode(message: ISchedule, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.schedule !== "") {
writer.uint32(10).string(message.schedule);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): ICron {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): ISchedule {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseICron();
const message = createBaseISchedule();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag != 10) {
break;
}
message.id = reader.string();
continue;
case 2:
if (tag != 18) {
case 1: {
if (tag !== 10) {
break;
}
message.schedule = reader.string();
continue;
case 3:
if (tag != 26) {
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ISchedule {
return { schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "" };
},
toJSON(message: ISchedule): unknown {
const obj: any = {};
if (message.schedule !== "") {
obj.schedule = message.schedule;
}
return obj;
},
create<I extends Exact<DeepPartial<ISchedule>, I>>(base?: I): ISchedule {
return ISchedule.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ISchedule>, I>>(object: I): ISchedule {
const message = createBaseISchedule();
message.schedule = object.schedule ?? "";
return message;
},
};
function createBaseICron(): ICron {
return { id: "", schedule: "", command: "", extra_schedules: [], name: "" };
}
export const ICron: MessageFns<ICron> = {
encode(message: ICron, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== "") {
writer.uint32(10).string(message.id);
}
if (message.schedule !== "") {
writer.uint32(18).string(message.schedule);
}
if (message.command !== "") {
writer.uint32(26).string(message.command);
}
for (const v of message.extra_schedules) {
ISchedule.encode(v!, writer.uint32(34).fork()).join();
}
if (message.name !== "") {
writer.uint32(42).string(message.name);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): ICron {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseICron();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.schedule = reader.string();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.command = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.extra_schedules.push(ISchedule.decode(reader, reader.uint32()));
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.name = reader.string();
continue;
}
}
if ((tag & 7) == 4 || tag == 0) {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): ICron {
return {
id: isSet(object.id) ? String(object.id) : '',
schedule: isSet(object.schedule) ? String(object.schedule) : '',
command: isSet(object.command) ? String(object.command) : '',
id: isSet(object.id) ? globalThis.String(object.id) : "",
schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "",
command: isSet(object.command) ? globalThis.String(object.command) : "",
extra_schedules: globalThis.Array.isArray(object?.extra_schedules)
? object.extra_schedules.map((e: any) => ISchedule.fromJSON(e))
: [],
name: isSet(object.name) ? globalThis.String(object.name) : "",
};
},
toJSON(message: ICron): unknown {
const obj: any = {};
message.id !== undefined && (obj.id = message.id);
message.schedule !== undefined && (obj.schedule = message.schedule);
message.command !== undefined && (obj.command = message.command);
if (message.id !== "") {
obj.id = message.id;
}
if (message.schedule !== "") {
obj.schedule = message.schedule;
}
if (message.command !== "") {
obj.command = message.command;
}
if (message.extra_schedules?.length) {
obj.extra_schedules = message.extra_schedules.map((e) => ISchedule.toJSON(e));
}
if (message.name !== "") {
obj.name = message.name;
}
return obj;
},
create<I extends Exact<DeepPartial<ICron>, I>>(base?: I): ICron {
return ICron.fromPartial(base ?? {});
return ICron.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ICron>, I>>(object: I): ICron {
const message = createBaseICron();
message.id = object.id ?? '';
message.schedule = object.schedule ?? '';
message.command = object.command ?? '';
message.id = object.id ?? "";
message.schedule = object.schedule ?? "";
message.command = object.command ?? "";
message.extra_schedules = object.extra_schedules?.map((e) => ISchedule.fromPartial(e)) || [];
message.name = object.name ?? "";
return message;
},
};
@@ -122,68 +235,54 @@ function createBaseAddCronRequest(): AddCronRequest {
return { crons: [] };
}
export const AddCronRequest = {
encode(
message: AddCronRequest,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
export const AddCronRequest: MessageFns<AddCronRequest> = {
encode(message: AddCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.crons) {
ICron.encode(v!, writer.uint32(10).fork()).ldelim();
ICron.encode(v!, writer.uint32(10).fork()).join();
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): AddCronRequest {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): AddCronRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag != 10) {
case 1: {
if (tag !== 10) {
break;
}
message.crons.push(ICron.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) == 4 || tag == 0) {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): AddCronRequest {
return {
crons: Array.isArray(object?.crons)
? object.crons.map((e: any) => ICron.fromJSON(e))
: [],
};
return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
},
toJSON(message: AddCronRequest): unknown {
const obj: any = {};
if (message.crons) {
obj.crons = message.crons.map((e) => (e ? ICron.toJSON(e) : undefined));
} else {
obj.crons = [];
if (message.crons?.length) {
obj.crons = message.crons.map((e) => ICron.toJSON(e));
}
return obj;
},
create<I extends Exact<DeepPartial<AddCronRequest>, I>>(
base?: I,
): AddCronRequest {
return AddCronRequest.fromPartial(base ?? {});
create<I extends Exact<DeepPartial<AddCronRequest>, I>>(base?: I): AddCronRequest {
return AddCronRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(
object: I,
): AddCronRequest {
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
const message = createBaseAddCronRequest();
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
return message;
@@ -194,27 +293,23 @@ function createBaseAddCronResponse(): AddCronResponse {
return {};
}
export const AddCronResponse = {
encode(
_: AddCronResponse,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
export const AddCronResponse: MessageFns<AddCronResponse> = {
encode(_: AddCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): AddCronResponse {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): AddCronResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
}
if ((tag & 7) == 4 || tag == 0) {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
@@ -228,15 +323,10 @@ export const AddCronResponse = {
return obj;
},
create<I extends Exact<DeepPartial<AddCronResponse>, I>>(
base?: I,
): AddCronResponse {
return AddCronResponse.fromPartial(base ?? {});
create<I extends Exact<DeepPartial<AddCronResponse>, I>>(base?: I): AddCronResponse {
return AddCronResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AddCronResponse>, I>>(
_: I,
): AddCronResponse {
fromPartial<I extends Exact<DeepPartial<AddCronResponse>, I>>(_: I): AddCronResponse {
const message = createBaseAddCronResponse();
return message;
},
@@ -246,68 +336,54 @@ function createBaseDeleteCronRequest(): DeleteCronRequest {
return { ids: [] };
}
export const DeleteCronRequest = {
encode(
message: DeleteCronRequest,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
export const DeleteCronRequest: MessageFns<DeleteCronRequest> = {
encode(message: DeleteCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.ids) {
writer.uint32(10).string(v!);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): DeleteCronRequest {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag != 10) {
case 1: {
if (tag !== 10) {
break;
}
message.ids.push(reader.string());
continue;
}
}
if ((tag & 7) == 4 || tag == 0) {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DeleteCronRequest {
return {
ids: Array.isArray(object?.ids)
? object.ids.map((e: any) => String(e))
: [],
};
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.String(e)) : [] };
},
toJSON(message: DeleteCronRequest): unknown {
const obj: any = {};
if (message.ids) {
obj.ids = message.ids.map((e) => e);
} else {
obj.ids = [];
if (message.ids?.length) {
obj.ids = message.ids;
}
return obj;
},
create<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(
base?: I,
): DeleteCronRequest {
return DeleteCronRequest.fromPartial(base ?? {});
create<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(base?: I): DeleteCronRequest {
return DeleteCronRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(
object: I,
): DeleteCronRequest {
fromPartial<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(object: I): DeleteCronRequest {
const message = createBaseDeleteCronRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
@@ -318,27 +394,23 @@ function createBaseDeleteCronResponse(): DeleteCronResponse {
return {};
}
export const DeleteCronResponse = {
encode(
_: DeleteCronResponse,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
encode(_: DeleteCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): DeleteCronResponse {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
}
if ((tag & 7) == 4 || tag == 0) {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
@@ -352,15 +424,10 @@ export const DeleteCronResponse = {
return obj;
},
create<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(
base?: I,
): DeleteCronResponse {
return DeleteCronResponse.fromPartial(base ?? {});
create<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(base?: I): DeleteCronResponse {
return DeleteCronResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(
_: I,
): DeleteCronResponse {
fromPartial<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(_: I): DeleteCronResponse {
const message = createBaseDeleteCronResponse();
return message;
},
@@ -369,25 +436,21 @@ export const DeleteCronResponse = {
export type CronService = typeof CronService;
export const CronService = {
addCron: {
path: '/com.ql.cron.Cron/addCron',
path: "/com.ql.cron.Cron/addCron",
requestStream: false,
responseStream: false,
requestSerialize: (value: AddCronRequest) =>
Buffer.from(AddCronRequest.encode(value).finish()),
requestSerialize: (value: AddCronRequest) => Buffer.from(AddCronRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => AddCronRequest.decode(value),
responseSerialize: (value: AddCronResponse) =>
Buffer.from(AddCronResponse.encode(value).finish()),
responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
},
delCron: {
path: '/com.ql.cron.Cron/delCron',
path: "/com.ql.cron.Cron/delCron",
requestStream: false,
responseStream: false,
requestSerialize: (value: DeleteCronRequest) =>
Buffer.from(DeleteCronRequest.encode(value).finish()),
requestSerialize: (value: DeleteCronRequest) => Buffer.from(DeleteCronRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => DeleteCronRequest.decode(value),
responseSerialize: (value: DeleteCronResponse) =>
Buffer.from(DeleteCronResponse.encode(value).finish()),
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
},
} as const;
@@ -415,68 +478,48 @@ export interface CronClient extends Client {
): ClientUnaryCall;
delCron(
request: DeleteCronRequest,
callback: (
error: ServiceError | null,
response: DeleteCronResponse,
) => void,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall;
delCron(
request: DeleteCronRequest,
metadata: Metadata,
callback: (
error: ServiceError | null,
response: DeleteCronResponse,
) => void,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall;
delCron(
request: DeleteCronRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (
error: ServiceError | null,
response: DeleteCronResponse,
) => void,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall;
}
export const CronClient = makeGenericClientConstructor(
CronService,
'com.ql.cron.Cron',
) as unknown as {
new (
address: string,
credentials: ChannelCredentials,
options?: Partial<ClientOptions>,
): CronClient;
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): CronClient;
service: typeof CronService;
serviceName: string;
};
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin
? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & {
[K in Exclude<keyof I, KeysOfUnion<P>>]: never;
};
export type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
+93 -135
View File
@@ -1,21 +1,27 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// source: back/protos/health.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import {
CallOptions,
type CallOptions,
ChannelCredentials,
Client,
ClientOptions,
ClientReadableStream,
ClientUnaryCall,
handleServerStreamingCall,
handleUnaryCall,
type ClientOptions,
type ClientReadableStream,
type ClientUnaryCall,
type handleServerStreamingCall,
type handleUnaryCall,
makeGenericClientConstructor,
Metadata,
ServiceError,
UntypedServiceImplementation,
} from '@grpc/grpc-js';
import _m0 from 'protobufjs/minimal';
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
export const protobufPackage = 'com.ql.health';
export const protobufPackage = "com.ql.health";
export interface HealthCheckRequest {
service: string;
@@ -33,107 +39,97 @@ export enum HealthCheckResponse_ServingStatus {
UNRECOGNIZED = -1,
}
export function healthCheckResponse_ServingStatusFromJSON(
object: any,
): HealthCheckResponse_ServingStatus {
export function healthCheckResponse_ServingStatusFromJSON(object: any): HealthCheckResponse_ServingStatus {
switch (object) {
case 0:
case 'UNKNOWN':
case "UNKNOWN":
return HealthCheckResponse_ServingStatus.UNKNOWN;
case 1:
case 'SERVING':
case "SERVING":
return HealthCheckResponse_ServingStatus.SERVING;
case 2:
case 'NOT_SERVING':
case "NOT_SERVING":
return HealthCheckResponse_ServingStatus.NOT_SERVING;
case 3:
case 'SERVICE_UNKNOWN':
case "SERVICE_UNKNOWN":
return HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN;
case -1:
case 'UNRECOGNIZED':
case "UNRECOGNIZED":
default:
return HealthCheckResponse_ServingStatus.UNRECOGNIZED;
}
}
export function healthCheckResponse_ServingStatusToJSON(
object: HealthCheckResponse_ServingStatus,
): string {
export function healthCheckResponse_ServingStatusToJSON(object: HealthCheckResponse_ServingStatus): string {
switch (object) {
case HealthCheckResponse_ServingStatus.UNKNOWN:
return 'UNKNOWN';
return "UNKNOWN";
case HealthCheckResponse_ServingStatus.SERVING:
return 'SERVING';
return "SERVING";
case HealthCheckResponse_ServingStatus.NOT_SERVING:
return 'NOT_SERVING';
return "NOT_SERVING";
case HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN:
return 'SERVICE_UNKNOWN';
return "SERVICE_UNKNOWN";
case HealthCheckResponse_ServingStatus.UNRECOGNIZED:
default:
return 'UNRECOGNIZED';
return "UNRECOGNIZED";
}
}
function createBaseHealthCheckRequest(): HealthCheckRequest {
return { service: '' };
return { service: "" };
}
export const HealthCheckRequest = {
encode(
message: HealthCheckRequest,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
if (message.service !== '') {
export const HealthCheckRequest: MessageFns<HealthCheckRequest> = {
encode(message: HealthCheckRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.service !== "") {
writer.uint32(10).string(message.service);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckRequest {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag != 10) {
case 1: {
if (tag !== 10) {
break;
}
message.service = reader.string();
continue;
}
}
if ((tag & 7) == 4 || tag == 0) {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): HealthCheckRequest {
return { service: isSet(object.service) ? String(object.service) : '' };
return { service: isSet(object.service) ? globalThis.String(object.service) : "" };
},
toJSON(message: HealthCheckRequest): unknown {
const obj: any = {};
message.service !== undefined && (obj.service = message.service);
if (message.service !== "") {
obj.service = message.service;
}
return obj;
},
create<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(
base?: I,
): HealthCheckRequest {
return HealthCheckRequest.fromPartial(base ?? {});
create<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(base?: I): HealthCheckRequest {
return HealthCheckRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(
object: I,
): HealthCheckRequest {
fromPartial<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(object: I): HealthCheckRequest {
const message = createBaseHealthCheckRequest();
message.service = object.service ?? '';
message.service = object.service ?? "";
return message;
},
};
@@ -142,65 +138,54 @@ function createBaseHealthCheckResponse(): HealthCheckResponse {
return { status: 0 };
}
export const HealthCheckResponse = {
encode(
message: HealthCheckResponse,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
export const HealthCheckResponse: MessageFns<HealthCheckResponse> = {
encode(message: HealthCheckResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.status !== 0) {
writer.uint32(8).int32(message.status);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckResponse {
const reader =
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag != 8) {
case 1: {
if (tag !== 8) {
break;
}
message.status = reader.int32() as any;
continue;
}
}
if ((tag & 7) == 4 || tag == 0) {
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): HealthCheckResponse {
return {
status: isSet(object.status)
? healthCheckResponse_ServingStatusFromJSON(object.status)
: 0,
};
return { status: isSet(object.status) ? healthCheckResponse_ServingStatusFromJSON(object.status) : 0 };
},
toJSON(message: HealthCheckResponse): unknown {
const obj: any = {};
message.status !== undefined &&
(obj.status = healthCheckResponse_ServingStatusToJSON(message.status));
if (message.status !== 0) {
obj.status = healthCheckResponse_ServingStatusToJSON(message.status);
}
return obj;
},
create<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(
base?: I,
): HealthCheckResponse {
return HealthCheckResponse.fromPartial(base ?? {});
create<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(base?: I): HealthCheckResponse {
return HealthCheckResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(
object: I,
): HealthCheckResponse {
fromPartial<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(object: I): HealthCheckResponse {
const message = createBaseHealthCheckResponse();
message.status = object.status ?? 0;
return message;
@@ -210,25 +195,21 @@ export const HealthCheckResponse = {
export type HealthService = typeof HealthService;
export const HealthService = {
check: {
path: '/com.ql.health.Health/Check',
path: "/com.ql.health.Health/Check",
requestStream: false,
responseStream: false,
requestSerialize: (value: HealthCheckRequest) =>
Buffer.from(HealthCheckRequest.encode(value).finish()),
requestSerialize: (value: HealthCheckRequest) => Buffer.from(HealthCheckRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value),
responseSerialize: (value: HealthCheckResponse) =>
Buffer.from(HealthCheckResponse.encode(value).finish()),
responseSerialize: (value: HealthCheckResponse) => Buffer.from(HealthCheckResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value),
},
watch: {
path: '/com.ql.health.Health/Watch',
path: "/com.ql.health.Health/Watch",
requestStream: false,
responseStream: true,
requestSerialize: (value: HealthCheckRequest) =>
Buffer.from(HealthCheckRequest.encode(value).finish()),
requestSerialize: (value: HealthCheckRequest) => Buffer.from(HealthCheckRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value),
responseSerialize: (value: HealthCheckResponse) =>
Buffer.from(HealthCheckResponse.encode(value).finish()),
responseSerialize: (value: HealthCheckResponse) => Buffer.from(HealthCheckResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value),
},
} as const;
@@ -241,32 +222,20 @@ export interface HealthServer extends UntypedServiceImplementation {
export interface HealthClient extends Client {
check(
request: HealthCheckRequest,
callback: (
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
): ClientUnaryCall;
check(
request: HealthCheckRequest,
metadata: Metadata,
callback: (
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
): ClientUnaryCall;
check(
request: HealthCheckRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
): ClientUnaryCall;
watch(
request: HealthCheckRequest,
options?: Partial<CallOptions>,
): ClientReadableStream<HealthCheckResponse>;
watch(request: HealthCheckRequest, options?: Partial<CallOptions>): ClientReadableStream<HealthCheckResponse>;
watch(
request: HealthCheckRequest,
metadata?: Metadata,
@@ -274,44 +243,33 @@ export interface HealthClient extends Client {
): ClientReadableStream<HealthCheckResponse>;
}
export const HealthClient = makeGenericClientConstructor(
HealthService,
'com.ql.health.Health',
) as unknown as {
new (
address: string,
credentials: ChannelCredentials,
options?: Partial<ClientOptions>,
): HealthClient;
export const HealthClient = makeGenericClientConstructor(HealthService, "com.ql.health.Health") as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): HealthClient;
service: typeof HealthService;
serviceName: string;
};
type Builtin =
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin
? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & {
[K in Exclude<keyof I, KeysOfUnion<P>>]: never;
};
export type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
-33
View File
@@ -1,33 +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(
`localhost:${config.cronPort}`,
credentials.createInsecure(),
);
app.get('/api/health', (req, res) => {
client.check({ service: 'cron' }, (err, response) => {
if (err) {
return res.status(500).send({ code: 500, error: err });
}
return res.status(200).send({ code: 200, data: response });
});
});
app
.listen(config.publicPort, async () => {
await require('./loaders/sentry').default({ expressApp: app });
await require('./loaders/db').default();
Logger.debug(`✌️ 公共服务启动成功!`);
process.send?.('ready');
})
.on('error', (err) => {
Logger.error(err);
process.exit(1);
});
+122 -8
View File
@@ -1,24 +1,138 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger';
import { tf } from '../shared/i18n';
/**
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
* 提前拦截避免走 scheduleJob 后才发现无效。
*/
const isValidCronField = (cron: string): boolean => {
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
// node-schedule 会对这种字段返回 null
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
return false;
}
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
// cron-parser 接受但 node-schedule 拒绝,提前拦截
if (/\?/.test(cron)) {
return false;
}
return true;
};
const addCron = (
call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
callback: sendUnaryData<AddCronResponse>,
) => {
// ===== 第一遍:预校验所有 cron 表达式 =====
const validationErrors: string[] = [];
for (const item of call.request.crons) {
const { id, schedule, command } = item;
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.cancel();
const { id, schedule, extra_schedules } = item;
if (!isValidCronField(schedule)) {
validationErrors.push(
tf(
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
String(id),
schedule,
),
);
}
scheduleStacks.set(
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
if (!isValidCronField(x.schedule)) {
validationErrors.push(
tf(
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
String(id),
x.schedule,
),
);
}
});
}
}
if (validationErrors.length > 0) {
const details = validationErrors.join('\n');
const err: any = new Error(details);
err.code = status.INVALID_ARGUMENT;
err.details = details;
callback(err, null);
return;
}
// ===== 第二遍:注册所有任务 =====
for (const item of call.request.crons) {
const { id, schedule, command, extra_schedules, name } = item;
// 取消该 id 已有的旧任务
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel());
}
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
nodeSchedule.scheduleJob(id, schedule, async () => {
runCron(`ID=${id} ${command}`)
}),
name,
schedule,
command,
);
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
command,
);
});
}
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!mainJob) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
schedule,
);
}
const extraJobs = extra_schedules?.length
? extra_schedules.map((x) => {
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!job) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
x.schedule,
);
}
return job;
})
: [];
// 过滤 null(兜底保护,正常情况下预校验已拦截)
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
if (jobs.length > 0) {
scheduleStacks.set(id, jobs);
}
}
callback(null, null);
+444
View File
@@ -0,0 +1,444 @@
import 'reflect-metadata';
import { Container } from 'typedi';
import EnvService from '../services/env';
import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js';
import {
CreateEnvRequest,
CronItem,
DeleteEnvsRequest,
DisableEnvsRequest,
EnableEnvsRequest,
EnvItem,
EnvResponse,
EnvsResponse,
GetEnvByIdRequest,
GetEnvsRequest,
MoveEnvRequest,
Response,
SystemNotifyRequest,
UpdateEnvNamesRequest,
UpdateEnvRequest,
} from '../protos/api';
import LoggerInstance from '../loaders/logger';
import pick from 'lodash/pick';
import SystemService from '../services/system';
import CronService from '../services/cron';
import {
CronDetailRequest,
CronDetailResponse,
CreateCronRequest,
UpdateCronRequest,
DeleteCronsRequest,
CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
Container.set('logger', LoggerInstance);
export const getEnvs = async (
call: ServerUnaryCall<GetEnvsRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>,
) => {
try {
const envService = Container.get(EnvService);
const data = await envService.envs(call.request.searchValue);
callback(null, {
code: 200,
data: data.map((x) => ({ ...x, remarks: x.remarks || '' })),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const createEnv = async (
call: ServerUnaryCall<CreateEnvRequest, EnvsResponse>,
callback: sendUnaryData<EnvsResponse>,
) => {
try {
const envService = Container.get(EnvService);
const data = await envService.create(call.request.envs);
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const updateEnv = async (
call: ServerUnaryCall<UpdateEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
if (!call.request.env?.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.update(
pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem,
);
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const deleteEnvs = async (
call: ServerUnaryCall<DeleteEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.remove(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const moveEnv = async (
call: ServerUnaryCall<MoveEnvRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.move(call.request.id, {
fromIndex: call.request.fromIndex,
toIndex: call.request.toIndex,
});
callback(null, { code: 200, data });
} catch (e: any) {
callback(e);
}
};
export const disableEnvs = async (
call: ServerUnaryCall<DisableEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const enableEnvs = async (
call: ServerUnaryCall<EnableEnvsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const updateEnvNames = async (
call: ServerUnaryCall<UpdateEnvNamesRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const envService = Container.get(EnvService);
await envService.updateNames({
ids: call.request.ids,
name: call.request.name,
});
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const getEnvById = async (
call: ServerUnaryCall<GetEnvByIdRequest, EnvResponse>,
callback: sendUnaryData<EnvResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const envService = Container.get(EnvService);
const data = await envService.getDb({ id: call.request.id });
callback(null, {
code: 200,
data: { ...data, remarks: data.remarks || '' },
});
} catch (e: any) {
callback(e);
}
};
export const systemNotify = async (
call: ServerUnaryCall<SystemNotifyRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
callback(e);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
return {
...data,
sub_id: data.sub_id ?? undefined,
extra_schedules: data.extra_schedules ?? [],
pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined,
};
};
export const getCronDetail = async (
call: ServerUnaryCall<CronDetailRequest, CronDetailResponse>,
callback: sendUnaryData<CronDetailResponse>,
) => {
try {
if (!call.request.log_path) {
return callback(null, {
code: 400,
data: undefined,
message: 'log_path is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.find({
log_path: call.request.log_path,
})) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const createCron = async (
call: ServerUnaryCall<CreateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const data = (await cronService.create(call.request)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const updateCron = async (
call: ServerUnaryCall<UpdateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const { id, ...fields } = call.request;
const updateRequest = {
id,
...Object.entries(fields).reduce((acc: any, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {}),
} as UpdateCronRequest;
const data = (await cronService.update(updateRequest)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const deleteCrons = async (
call: ServerUnaryCall<DeleteCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const cronService = Container.get(CronService);
await cronService.remove(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
) => {
try {
const cronService = Container.get(CronService);
const result = await cronService.crontabs({
searchValue: call.request.searchValue || '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
});
const data = result.data.map((x) => normalizeCronData(x as CronItem));
callback(null, {
code: 200,
data: data.filter((x): x is CronItem => x !== undefined),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(null, {
code: 404,
data: undefined,
message: e.message,
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+18 -4
View File
@@ -7,12 +7,26 @@ import {
DeleteCronResponse,
} from '../protos/cron';
import config from '../config';
import { getGrpcCerts } from '../config/grpcCerts';
class Client {
private client = new CronClient(
`localhost:${config.cronPort}`,
credentials.createInsecure(),
);
private _client: CronClient | null = null;
private get client(): CronClient {
if (!this._client) {
const tlsConfig = getGrpcCerts()!;
this._client = new CronClient(
`localhost:${config.grpcPort}`,
credentials.createSsl(
Buffer.from(tlsConfig.caCert),
Buffer.from(tlsConfig.clientKey),
Buffer.from(tlsConfig.clientCert),
),
{ 'grpc.enable_http_proxy': 0 },
);
}
return this._client;
}
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
return new Promise((resolve, reject) => {
+1 -1
View File
@@ -1,6 +1,6 @@
import nodeSchedule from 'node-schedule';
import { ToadScheduler } from 'toad-scheduler';
export const scheduleStacks = new Map<string, nodeSchedule.Job>();
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>();
export const intervalSchedule = new ToadScheduler();
+19 -1
View File
@@ -1,6 +1,7 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data';
import Logger from '../loaders/logger';
const delCron = (
call: ServerUnaryCall<DeleteCronRequest, DeleteCronResponse>,
@@ -8,7 +9,24 @@ const delCron = (
) => {
for (const id of call.request.ids) {
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.cancel();
Logger.info(
'[schedule][取消定时任务] 任务ID: %s',
id,
);
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
id,
error?.message || error,
);
}
});
scheduleStacks.delete(id);
}
}
+4 -7
View File
@@ -10,21 +10,18 @@ const check = async (
switch (call.request.service) {
case 'cron':
const res = await promiseExec(
`curl -s http://localhost:${config.port}/api/system`,
`curl -s --noproxy '*' http://localhost:${config.port}/api/system`,
);
if (res.includes('200')) {
return callback(null, { status: 1 });
}
const panelErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/qinglong-error.log`,
);
return callback(
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`),
new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
);
default:
-24
View File
@@ -1,24 +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';
const server = new Server();
server.addService(HealthService, { check });
server.addService(CronService, { addCron, delCron });
server.bindAsync(
`localhost:${config.cronPort}`,
ServerCredentials.createInsecure(),
(err, port) => {
if (err) {
throw err;
}
server.start();
Logger.debug(`✌️ 定时服务启动成功!`);
process.send?.('ready');
},
);
+49
View File
@@ -0,0 +1,49 @@
import { Service, Inject } from 'typedi';
import path, { join } from 'path';
import config from '../config';
import { getFileContentByName } from '../config/util';
import { t } from '../shared/i18n';
import { Response } from 'express';
import { request } from 'undici';
@Service()
export default class ConfigService {
constructor() {}
public async getFile(filePath: string, res: Response) {
let content = '';
if (!filePath) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const normalized = path.normalize(filePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const resolvedRoot = path.resolve(config.rootPath, normalized);
const resolvedConfig = path.resolve(config.configPath, normalized);
const isValidPath =
resolvedRoot.startsWith(config.scriptPath) ||
resolvedRoot.startsWith(config.configPath) ||
resolvedConfig.startsWith(config.scriptPath) ||
resolvedConfig.startsWith(config.configPath);
if (!isValidPath) {
return res.send({ code: 403, message: t('文件无法访问') });
}
if (config.blackFileList.includes(path.basename(normalized))) {
return res.send({ code: 403, message: t('文件无法访问') });
}
if (filePath.startsWith('sample/')) {
const res = await request(
`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`,
);
content = await res.body.text();
} else if (filePath.startsWith('data/scripts/')) {
content = await getFileContentByName(join(config.rootPath, filePath));
} else {
content = await getFileContentByName(join(config.configPath, filePath));
}
res.send({ code: 200, data: content });
}
}
+524 -144
View File
@@ -2,44 +2,135 @@ import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { exec, execSync } from 'child_process';
import fs from 'fs';
import cron_parser from 'cron-parser';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
import {
getFileContentByName,
fileExist,
killTask,
killAllTasks,
getUniqPath,
safeJSONParse,
isDemoEnv,
} from '../config/util';
import { promises, existsSync } from 'fs';
import { Op, where, col as colFn, FindOptions, fn } from 'sequelize';
import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize';
import path from 'path';
import { TASK_PREFIX, QL_PREFIX } from '../config/const';
import cronClient from '../schedule/client';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
import dayjs from 'dayjs';
import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils';
import { t } from '../shared/i18n';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
import { isEmpty } from 'lodash';
@Service()
export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) { }
private isSixCron(cron: Crontab) {
const { schedule } = cron;
if (schedule?.split(/ +/).length === 6) {
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
return true;
}
return false;
}
private get schedulerMode(): 'system' | 'node' {
const env = process.env.QL_SCHEDULER;
if (env === 'system') return 'system';
if (env === 'node') return 'node';
try {
execSync('which crond', { stdio: 'ignore' });
return 'system';
} catch {
return 'node';
}
}
private shouldUseCronClient(cron: Crontab): boolean {
if (this.schedulerMode === 'node') {
return !this.isSpecialSchedule(cron.schedule);
}
return this.isNodeCron(cron) && !this.isSpecialSchedule(cron.schedule);
}
private isOnceSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.ONCE);
}
private isBootSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.BOOT);
}
private isSpecialSchedule(schedule?: string) {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
}
private async getLogName(cron: Crontab) {
const { log_name, command, id } = cron;
if (log_name === '/dev/null') {
return log_name;
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
}
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
return uniqPath;
}
public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload);
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
if (this.isSixCron(doc)) {
await cronClient.addCron([
{ id: String(doc.id), schedule: doc.schedule!, command: doc.command },
]);
if (isDemoEnv()) {
return doc;
}
await this.set_crontab();
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
// DB 和 crontab.list 有记录但调度器永远不会执行)
await CrontabModel.destroy({ where: { id: doc.id } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
}
await this.setCrontab();
return doc;
}
@@ -47,27 +138,69 @@ export default class CronService {
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 tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1) {
if (doc.isDisabled === 1 || isDemoEnv()) {
return newDoc;
}
if (this.isSixCron(doc)) {
try {
await cronClient.delCron([String(newDoc.id)]);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
if (this.isSixCron(newDoc)) {
await cronClient.addCron([
{
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: newDoc.command,
},
]);
if (this.shouldUseCronClient(newDoc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: this.makeCommand(newDoc),
extra_schedules: newDoc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
await CrontabModel.update(doc, { where: { id: doc.id } });
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (_recoveryError: any) {
this.logger.warn(
'[crontab] Failed to restore old cron job in scheduler after rollback:',
_recoveryError?.message || _recoveryError,
);
}
}
this.logger.error(
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
}
await this.set_crontab();
await this.setCrontab();
return newDoc;
}
@@ -83,6 +216,7 @@ export default class CronService {
log_path,
last_running_time = 0,
last_execution_time = 0,
exit_code,
}: {
ids: number[];
status: CrontabStatus;
@@ -90,8 +224,9 @@ export default class CronService {
log_path: string;
last_running_time: number;
last_execution_time: number;
exit_code?: number;
}) {
const options: any = {
let options: any = {
status,
pid,
log_path,
@@ -101,13 +236,69 @@ export default class CronService {
options.last_running_time = last_running_time;
}
return await CrontabModel.update({ ...options }, { where: { id: ids } });
for (const id of ids) {
let cron;
try {
cron = await this.getDb({ id });
} catch (err) { }
if (!cron) {
continue;
}
if (status === CrontabStatus.idle && log_path !== cron.log_path) {
options = omit(options, ['status', 'log_path', 'pid']);
}
// Manage RunningInstance records for status transitions from shell scripts
if (status === CrontabStatus.running) {
// Create a new running instance record
await RunningInstanceModel.create({
cron_id: id,
pid: pid || undefined,
log_path: log_path || undefined,
started_at: last_execution_time || dayjs().unix(),
status: InstanceStatus.running,
});
} else if (status === CrontabStatus.idle) {
// Mark the matching running instance as finished
const finishedAt = dayjs().unix();
const instanceStatus =
exit_code !== undefined && exit_code !== null && exit_code !== 0
? InstanceStatus.error
: InstanceStatus.finished;
await RunningInstanceModel.update(
{
finished_at: finishedAt,
status: instanceStatus,
exit_code: exit_code ?? undefined,
},
{
where: {
cron_id: id,
pid: pid || undefined,
status: InstanceStatus.running,
},
},
);
}
await CrontabModel.update(
{ ...pickBy(options, (v) => v === 0 || !!v) },
{ where: { id } },
);
}
}
public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } });
await cronClient.delCron(ids.map(String));
await this.set_crontab();
try {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab();
}
public async pin(ids: number[]) {
@@ -151,8 +342,8 @@ export default class CronService {
for (const col of viewQuery.filters) {
const { property, value, operation } = col;
let q: any = {};
let operate2 = null;
let operate = null;
let operate2: any = null;
let operate: any = null;
switch (operation) {
case 'Reg':
operate = Op.like;
@@ -163,21 +354,38 @@ export default class CronService {
operate2 = Op.and;
break;
case 'In':
q[Op.or] = [
{
[property]: value,
},
property === 'status' && value.includes(2)
? { isDisabled: 1 }
: {},
];
if (
property === 'status' &&
!value.includes(CrontabStatus.disabled)
) {
q[Op.and] = [
{ [property]: Array.isArray(value) ? value : [value] },
{ isDisabled: 0 },
];
} else {
q[Op.or] = [
{
[property]: Array.isArray(value) ? value : [value],
},
property === 'status' && value.includes(CrontabStatus.disabled)
? { isDisabled: 1 }
: {},
];
}
break;
case 'Nin':
q[Op.and] = [
{
[property]: {
[Op.notIn]: value,
},
[Op.or]: [
{
[property]: {
[Op.notIn]: Array.isArray(value) ? value : [value],
},
},
{
[property]: { [Op.is]: null },
},
],
},
property === 'status' && value.includes(2)
? { isDisabled: { [Op.ne]: 1 } }
@@ -258,7 +466,7 @@ export default class CronService {
}
private formatFilterQuery(query: any, filterQuery: any) {
if (filterQuery) {
if (!isEmpty(filterQuery)) {
if (!query[Op.and]) {
query[Op.and] = [];
}
@@ -292,10 +500,10 @@ export default class CronService {
log_path,
}: {
log_path: string;
}): Promise<Crontab | null> {
}): Promise<Crontab | undefined> {
try {
const result = await CrontabModel.findOne({ where: { log_path } });
return result;
return result?.get({ plain: true });
} catch (error) {
throw error;
}
@@ -312,9 +520,9 @@ export default class CronService {
const searchText = params?.searchValue;
const page = Number(params?.page || '0');
const size = Number(params?.size || '0');
const viewQuery = JSON.parse(params?.queryString || '{}');
const filterQuery = JSON.parse(params?.filters || '{}');
const sorterQuery = JSON.parse(params?.sorter || '{}');
const viewQuery = safeJSONParse(params?.queryString);
const filterQuery = safeJSONParse(params?.filters);
const sorterQuery = safeJSONParse(params?.sorter);
let query: any = {};
let order = [
@@ -335,9 +543,9 @@ export default class CronService {
order.unshift([field, type]);
}
}
let condition: any = {
let condition: FindOptions<Crontab> = {
where: query,
order: order,
order: order as Order,
};
if (page && size) {
condition.offset = (page - 1) * size;
@@ -346,7 +554,7 @@ export default class CronService {
try {
const result = await CrontabModel.findAll(condition);
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) {
throw error;
}
@@ -354,7 +562,10 @@ export default class CronService {
public async getDb(query: FindOptions<Crontab>['where']): Promise<Crontab> {
const doc: any = await CrontabModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as Crontab);
if (!doc) {
throw new Error(`Cron ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async run(ids: number[]) {
@@ -370,80 +581,140 @@ export default class CronService {
public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) {
if (doc.pid) {
try {
// Kill all running instances of this task
try {
if (doc.pid) {
await killTask(doc.pid);
} catch (error) {
this.logger.silly(error);
}
const command = doc.command.replace(/\s+/g, ' ').trim();
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
}
}
// Mark all running instances as stopped
const finishedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: finishedAt },
{ where: { cron_id: ids, status: InstanceStatus.running } },
);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: ids } },
);
}
private async runSingle(cronId: number): Promise<number> {
return taskLimit.runWithCpuLimit(() => {
public async stopInstance(instanceId: number) {
const instance = await RunningInstanceModel.findOne({
where: { id: instanceId, status: InstanceStatus.running },
});
if (!instance) {
return { code: 400, message: t('实例不存在或已停止') };
}
if (instance.pid) {
try {
await killTask(instance.pid);
} catch (error) {
this.logger.error(
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
);
}
}
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix(), exit_code: 143 },
{ where: { id: instanceId } },
);
// Check if there are still other running instances for this cron
const otherRunning = await RunningInstanceModel.count({
where: { cron_id: instance.cron_id, status: InstanceStatus.running },
});
if (otherRunning === 0) {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: instance.cron_id } },
);
}
return { code: 200, message: t('实例已停止') };
}
private async runSingle(cronId: number): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(() => {
return new Promise(async (resolve: any) => {
const cron = await this.getDb({ id: cronId });
const params = {
name: cron.name,
command: cron.command,
schedule: cron.schedule,
extra_schedules: cron.extra_schedules,
};
if (cron.status !== CrontabStatus.queued) {
resolve();
resolve(params);
return;
}
let { id, command, log_path } = cron;
const absolutePath = path.resolve(config.logPath, `${log_path}`);
const logFileExist = log_path && (await fileExist(absolutePath));
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
);
this.logger.silly('Running job');
this.logger.silly('ID: ' + id);
this.logger.silly('Original command: ' + command);
let { id, command, log_name } = cron;
let cmdStr = command;
if (!cmdStr.startsWith(TASK_PREFIX) && !cmdStr.startsWith(QL_PREFIX)) {
cmdStr = `${TASK_PREFIX}${cmdStr}`;
}
if (
cmdStr.endsWith('.js') ||
cmdStr.endsWith('.py') ||
cmdStr.endsWith('.pyc') ||
cmdStr.endsWith('.sh') ||
cmdStr.endsWith('.ts')
) {
cmdStr = `${cmdStr} now`;
}
const cp = spawn(`ID=${id} ${cmdStr}`, { shell: '/bin/bash' });
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`);
const cp = spawn(
`real_log_path=${logPath} no_delay=true ${this.makeCommand(
cron,
true,
)}`,
{ shell: '/bin/bash' },
);
await CrontabModel.update(
{ status: CrontabStatus.running, pid: cp.pid },
{ status: CrontabStatus.running, pid: cp.pid, log_path: logPath },
{ where: { id } },
);
cp.stderr.on('data', (data) => {
if (logFileExist) {
fs.appendFileSync(`${absolutePath}`, `${data.toString()}`);
}
cp.stdout.on('data', async (data) => {
await logStreamManager.write(absolutePath, data.toString());
});
cp.on('error', (err) => {
if (logFileExist) {
fs.appendFileSync(`${absolutePath}`, `${JSON.stringify(err)}`);
}
cp.stderr.on('data', async (data) => {
this.logger.info(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
data.toString(),
);
await logStreamManager.write(absolutePath, data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
await logStreamManager.write(absolutePath, JSON.stringify(err));
});
cp.on('exit', async (code, signal) => {
cp.on('exit', async (code) => {
this.logger.info(
`任务 ${command} 进程id: ${cp.pid} 退出,退出码 ${code}`,
'[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify(params),
code,
);
});
cp.on('close', async (code) => {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id } },
);
resolve();
await logStreamManager.closeStream(absolutePath);
resolve({ ...params, pid: cp.pid, code });
});
});
});
@@ -451,36 +722,71 @@ export default class CronService {
public async disabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await cronClient.delCron(ids.map(String));
await this.set_crontab();
try {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab();
}
public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const sixCron = docs
.filter((x) => this.isSixCron(x))
const crons = docs
.filter((x) => this.shouldUseCronClient(x))
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: doc.command,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
await cronClient.addCron(sixCron);
await this.set_crontab();
}
public async log(id: number) {
const doc = await this.getDb({ id });
if (!doc) {
return '';
if (isDemoEnv()) {
return;
}
try {
await cronClient.addCron(crons);
} catch (error: any) {
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
await this.setCrontab();
}
public async log(id: number): Promise<{ content: string; status: string }> {
const doc = await this.getDb({ id });
if (!doc) {
return { content: '', status: 'empty' };
}
if (doc.log_name === '/dev/null') {
return { content: t('日志设置为忽略'), status: 'ignored' };
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) {
return getFileContentByName(`${absolutePath}`);
const content = await getFileContentByName(`${absolutePath}`);
const isRunning =
typeof doc.status === 'number' &&
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
return { content, status: isRunning ? 'running' : 'completed' };
} else {
return '任务未运行';
return typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? { content: t('运行中...'), status: 'running' }
: { content: t('日志不存在...'), status: 'notFound' };
}
}
@@ -492,59 +798,96 @@ export default class CronService {
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
if (existsSync(dir)) {
let files = await promises.readdir(dir);
return files
.map((x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: fs.statSync(`${dir}/${x}`).mtime.getTime(),
}))
.sort((a, b) => b.time - a.time);
const dirExist = await fileExist(dir);
if (dirExist) {
let files = await fs.readdir(dir);
return (
await Promise.all(
files.map(async (x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})),
)
).sort((a, b) => b.time - a.time);
} else {
return [];
}
}
private make_command(tab: Crontab) {
if (
!tab.command.startsWith(TASK_PREFIX) &&
!tab.command.startsWith(QL_PREFIX)
) {
tab.command = `${TASK_PREFIX}${tab.command}`;
private makeCommand(tab: Crontab, realTime?: boolean) {
let command = tab.command.trim();
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`;
}
const crontab_job_string = `ID=${tab.id} ${tab.command}`;
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
// Only include log_name if it has a truthy value to avoid passing null/undefined to shell
if (tab.log_name) {
commandVariable += `log_name=${tab.log_name} `;
}
if (tab.task_before) {
commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''")
.replace(/;? *\n/g, ';')
.trim()}' `;
}
if (tab.task_after) {
commandVariable += `task_after='${tab.task_after
.replace(/'/g, "'\\''")
.replace(/;? *\n/g, ';')
.trim()}' `;
}
if (tab.work_dir) {
commandVariable += `work_dir='${tab.work_dir.replace(/'/g, "'\\''")}' `;
}
const crontab_job_string = `${commandVariable}${command}`;
return crontab_job_string;
}
private async set_crontab(data?: { data: Crontab[]; total: number }) {
private async setCrontab(data?: { data: Crontab[]; total: number }) {
const tabs = data ?? (await this.crontabs());
var crontab_string = '';
tabs.data.forEach((tab) => {
const _schedule = tab.schedule && tab.schedule.split(/ +/);
if (tab.isDisabled === 1 || _schedule!.length !== 5) {
if (
tab.isDisabled === 1 ||
this.isNodeCron(tab) ||
this.isSpecialSchedule(tab.schedule)
) {
crontab_string += '# ';
crontab_string += tab.schedule;
crontab_string += ' ';
crontab_string += this.make_command(tab);
crontab_string += this.makeCommand(tab);
crontab_string += '\n';
} else {
crontab_string += tab.schedule;
crontab_string += ' ';
crontab_string += this.make_command(tab);
crontab_string += this.makeCommand(tab);
crontab_string += '\n';
}
});
this.logger.silly(crontab_string);
fs.writeFileSync(config.crontabFile, crontab_string);
await writeFileWithLock(config.crontabFile, crontab_string);
if (this.schedulerMode === 'system') {
try {
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
}
execSync(`crontab ${config.crontabFile}`);
await CrontabModel.update({ saved: true }, { where: {} });
}
public import_crontab() {
exec('crontab -l', (error, stdout, stderr) => {
public importCrontab() {
exec('crontab -l', (error, stdout) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
const lines = stdout.split('\n');
const namePrefix = new Date().getTime();
@@ -558,7 +901,7 @@ export default class CronService {
if (
command &&
schedule &&
cron_parser.parseExpression(schedule).hasNext()
CronExpressionParser.parse(schedule).hasNext()
) {
const name = namePrefix + '_' + index;
@@ -579,15 +922,52 @@ export default class CronService {
public async autosave_crontab() {
const tabs = await this.crontabs();
this.set_crontab(tabs);
const sixCron = tabs.data
.filter((x) => this.isSixCron(x) && x.isDisabled !== 1)
const regularCrons = tabs.data
.filter(
(x) =>
x.isDisabled !== 1 &&
this.shouldUseCronClient(x),
)
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: doc.command,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
await cronClient.addCron(sixCron);
if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, '');
return;
}
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
await this.setCrontab(tabs);
try {
await cronClient.addCron(regularCrons);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to register cron job in scheduler:',
error?.message || error,
);
}
}
public async bootTask() {
const tabs = await this.crontabs();
const bootTasks = tabs.data.filter(
(x) => !x.isDisabled && this.isBootSchedule(x.schedule),
);
if (bootTasks.length > 0) {
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: bootTasks.map((t) => t.id!) } },
);
for (const task of bootTasks) {
this.runSingle(task.id!);
}
}
}
}
+8 -3
View File
@@ -32,7 +32,7 @@ export default class CronViewService {
}
public async update(payload: CrontabView): Promise<CrontabView> {
const doc = await this.getDb({ id: payload.id })
const doc = await this.getDb({ id: payload.id });
const tab = new CrontabView({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
return newDoc;
@@ -59,9 +59,14 @@ export default class CronViewService {
}
}
public async getDb(query: FindOptions<CrontabView>['where']): Promise<CrontabView> {
public async getDb(
query: FindOptions<CrontabView>['where'],
): Promise<CrontabView> {
const doc: any = await CrontabViewModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as CrontabView);
if (!doc) {
throw new Error(`CronView ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async disabled(ids: number[]) {
+255 -60
View File
@@ -3,18 +3,28 @@ import winston from 'winston';
import config from '../config';
import {
Dependence,
InstallDependenceCommandTypes,
DependenceStatus,
DependenceTypes,
unInstallDependenceCommandTypes,
DependenceModel,
versionDependenceCommandTypes,
} from '../data/dependence';
import { spawn } from 'cross-spawn';
import SockService from './sock';
import { FindOptions, Op } from 'sequelize';
import { concurrentRun } from '../config/util';
import {
fileExist,
getPid,
killTask,
promiseExecSuccess,
getInstallCommand,
getUninstallCommand,
getGetCommand,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
@Service()
export default class DependenceService {
@@ -59,12 +69,26 @@ export default class DependenceService {
}
public async remove(ids: number[], force = false): Promise<Dependence[]> {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
);
const docs = await DependenceModel.findAll({ where: { id: ids } });
this.installDependenceOneByOne(docs, false, force);
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter(
(x) => x.status === DependenceStatus.installed,
);
await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
);
this.installDependenceOneByOne(docs, false, force);
}
return docs;
}
@@ -73,28 +97,36 @@ export default class DependenceService {
}
public async dependencies(
{ searchValue, type }: { searchValue: string; type: string },
sort: any = { position: -1 },
{
searchValue,
type,
status,
}: {
searchValue: string;
type: keyof typeof DependenceTypes;
status: string;
},
sort: any = [],
query: any = {},
): Promise<Dependence[]> {
let condition = { ...query, type: DependenceTypes[type as any] };
let condition = query;
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
if (status) {
condition.status = status.split(',').map(Number);
}
if (searchValue) {
const encodeText = encodeURI(searchValue);
const reg = {
condition.name = {
[Op.or]: [
{ [Op.like]: `%${searchValue}%` },
{ [Op.like]: `%${encodeText}%` },
],
};
condition = {
...condition,
name: reg,
};
}
try {
const result = await this.find(condition);
return result as any;
return await this.find(condition, sort);
} catch (error) {
throw error;
}
@@ -104,10 +136,12 @@ export default class DependenceService {
docs: Dependence[],
isInstall: boolean = true,
force: boolean = false,
) {
): Promise<void> {
docs.forEach((dep) => {
this.installOrUninstallDependency(dep, isInstall, force);
});
return taskLimit.waitDependencyQueueDone();
}
public async reInstall(ids: number[]): Promise<Dependence[]> {
@@ -117,10 +151,44 @@ export default class DependenceService {
);
const docs = await DependenceModel.findAll({ where: { id: ids } });
this.installDependenceOneByOne(docs);
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
this.installDependenceOneByOne(docs, true, true);
return docs;
}
public async cancel(ids: number[]) {
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
let depInstallCommand = getInstallCommand(doc.type, doc.name);
let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const isLinuxDependence = doc.type === DependenceTypes.linux;
if (isLinuxDependence) {
const osType = await detectOS();
if (!osType) {
continue;
}
const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`;
depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`;
}
const pids = await Promise.all([
getPid(depInstallCommand),
getPid(depUnInstallCommand),
]);
for (const pid of pids) {
pid && (await killTask(pid));
}
}
await DependenceModel.update(
{ status: DependenceStatus.cancelled },
{ where: { id: ids } },
);
}
private async find(query: any, sort: any = []): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({
where: { ...query },
@@ -133,13 +201,24 @@ export default class DependenceService {
query: FindOptions<Dependence>['where'],
): Promise<Dependence> {
const doc: any = await DependenceModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as Dependence);
if (!doc) {
throw new Error(`Dependency ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
private async updateLog(ids: number[], log: string): Promise<void> {
const doc = await DependenceModel.findOne({ where: { id: ids } });
const newLog = doc?.log ? [...doc.log, log] : [log];
await DependenceModel.update({ log: newLog }, { where: { id: ids } });
taskLimit.updateDepLog(async () => {
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
const newLog = doc?.log ? [...doc.log, log] : [log];
await DependenceModel.update(
{ log: newLog },
{ where: { id: doc.id } },
);
}
return null;
});
}
public installOrUninstallDependency(
@@ -147,37 +226,141 @@ export default class DependenceService {
isInstall: boolean = true,
force: boolean = false,
) {
return taskLimit.runOneByOne(() => {
return taskLimit.runDependeny(dependency, () => {
return new Promise(async (resolve) => {
if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null);
}
const depIds = [dependency.id!];
let depName = dependency.name.trim();
const actionText = isInstall ? t('安装') : t('删除');
const socketMessageType = isInstall
? 'installDependence'
: 'uninstallDependence';
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence = dependency.type === DependenceTypes.python3;
const osType = await detectOS();
let linuxCommand = {} as typeof LINUX_DEPENDENCE_COMMAND.Alpine;
taskLimit.removeQueuedDependency(dependency);
if (isLinuxDependence) {
if (!osType) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: depIds } },
);
const startTime = dayjs();
const message = tf(
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒',
actionText,
depName,
startTime.format('YYYY-MM-DD HH:mm:ss'),
actionText,
startTime.format('YYYY-MM-DD HH:mm:ss'),
String(startTime.diff(startTime, 'second')),
);
this.sockService.sendMessage({
type: socketMessageType,
message,
references: depIds,
status: DependenceStatus.installFailed,
});
this.updateLog(depIds, message);
return resolve(null);
}
linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
}
const status = isInstall
? DependenceStatus.installing
: DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } });
const socketMessageType = !force
? 'installDependence'
: 'uninstallDependence';
const depName = dependency.name;
const depRunCommand = (
isInstall
? InstallDependenceCommandTypes
: unInstallDependenceCommandTypes
)[dependency.type as any];
const actionText = isInstall ? '安装' : '删除';
let command = isInstall
? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName);
if (isLinuxDependence) {
command = isInstall
? `${linuxCommand.install} ${depName.trim()}`
: `${linuxCommand.uninstall} ${depName.trim()}`;
}
const startTime = dayjs();
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
'YYYY-MM-DD HH:mm:ss',
)}\n\n`;
const message = tf(
'开始%s依赖 %s,开始时间 %s\n\n',
actionText,
depName,
startTime.format('YYYY-MM-DD HH:mm:ss'),
);
this.sockService.sendMessage({
type: socketMessageType,
message,
references: depIds,
status,
});
await this.updateLog(depIds, message);
this.updateLog(depIds, message);
const cp = spawn(`${depRunCommand} ${depName}`, {
// 判断是否已经安装过依赖
if (isInstall && !force) {
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
getCommand = `${linuxCommand.info} ${depName}`;
}
let depVersion = '';
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
const [, _depName, _depVersion] = depName.match(symbolRegx) || [];
if (_depVersion && _depName) {
depName = _depName;
depVersion = _depVersion;
}
}
const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
if (
depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
(isLinuxDependence &&
linuxCommand.check(depInfo.toLocaleLowerCase())) ||
isPythonDependence) &&
(!depVersion || depInfo.includes(depVersion))
) {
const endTime = dayjs();
const _message = tf(
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒',
depName,
depInfo,
actionText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
this.sockService.sendMessage({
type: socketMessageType,
message: _message,
references: depIds,
status: DependenceStatus.installed,
});
this.updateLog(depIds, _message);
await DependenceModel.update(
{ status: DependenceStatus.installed },
{ where: { id: depIds } },
);
return resolve(null);
}
}
const dependenceProxyFileExist = await fileExist(
config.dependenceProxyFile,
);
const proxyStr = dependenceProxyFileExist
? `source ${config.dependenceProxyFile} &&`
: '';
const cp = spawn(`${proxyStr} ${command}`, {
shell: '/bin/bash',
});
@@ -186,8 +369,9 @@ export default class DependenceService {
type: socketMessageType,
message: data.toString(),
references: depIds,
status,
});
await this.updateLog(depIds, data.toString());
this.updateLog(depIds, data.toString());
});
cp.stderr.on('data', async (data) => {
@@ -195,8 +379,9 @@ export default class DependenceService {
type: socketMessageType,
message: data.toString(),
references: depIds,
status,
});
await this.updateLog(depIds, data.toString());
this.updateLog(depIds, data.toString());
});
cp.on('error', async (err) => {
@@ -204,36 +389,46 @@ export default class DependenceService {
type: socketMessageType,
message: JSON.stringify(err),
references: depIds,
status,
});
await this.updateLog(depIds, JSON.stringify(err));
this.updateLog(depIds, JSON.stringify(err));
});
cp.on('close', async (code) => {
cp.on('exit', async (code) => {
const endTime = dayjs();
const isSucceed = code === 0;
const resultText = isSucceed ? '成功' : '失败';
const resultText = isSucceed ? t('成功') : t('失败');
const message = `\n依赖${actionText}${resultText},结束时间 ${endTime.format(
'YYYY-MM-DD HH:mm:ss',
)},耗时 ${endTime.diff(startTime, 'second')}`;
const message =
'\n' +
tf('依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
const exitStatus = isSucceed
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed)
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed);
this.sockService.sendMessage({
type: socketMessageType,
message,
references: depIds,
status: exitStatus,
});
await this.updateLog(depIds, message);
this.updateLog(depIds, message);
let status = null;
if (isSucceed) {
status = isInstall
? DependenceStatus.installed
: DependenceStatus.removed;
} else {
status = isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed;
const docs = await DependenceModel.findAll({ where: { id: depIds } });
const _docIds = docs
.filter((x) => x.status !== DependenceStatus.cancelled)
.map((x) => x.id!);
if (_docIds.length > 0) {
await DependenceModel.update(
{ status: exitStatus },
{ where: { id: _docIds } },
);
}
await DependenceModel.update({ status }, { where: { id: depIds } });
// 如果删除依赖成功或者强制删除
if ((isSucceed || force) && !isInstall) {
+86 -21
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 config from '../config';
import * as fs from 'fs';
import {
Env,
EnvModel,
@@ -11,12 +12,12 @@ import {
minPosition,
stepPosition,
} from '../data/env';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) {}
constructor(@Inject('logger') private logger: winston.Logger) { }
public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs();
@@ -26,7 +27,7 @@ export default class EnvService {
envs.length > 0 &&
typeof envs[envs.length - 1].position === 'number'
) {
position = envs[envs.length - 1].position!;
position = this.getPrecisionPosition(envs[envs.length - 1].position!);
}
const tabs = payloads.map((x) => {
position = position - stepPosition;
@@ -40,7 +41,7 @@ export default class EnvService {
}
public async insert(payloads: Env[]): Promise<Env[]> {
const result = [];
const result: Env[] = [];
for (const env of payloads) {
const doc = await EnvModel.create(env, { returning: true });
result.push(doc);
@@ -61,7 +62,7 @@ export default class EnvService {
return await this.getDb({ id: payload.id });
}
public async remove(ids: string[]) {
public async remove(ids: number[]) {
await EnvModel.destroy({ where: { id: ids } });
await this.set_envs();
}
@@ -99,7 +100,7 @@ export default class EnvService {
}
private async checkPosition(position: number, edge: number = 0) {
const precisionPosition = parseFloat(position.toPrecision(16));
const precisionPosition = this.getPrecisionPosition(position);
if (
precisionPosition < minPosition ||
precisionPosition > maxPosition ||
@@ -115,7 +116,7 @@ export default class EnvService {
}
private getPrecisionPosition(position: number): number {
return parseFloat(position.toPrecision(16));
return Math.trunc(parseFloat(position.toPrecision(16)));
}
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
@@ -146,10 +147,11 @@ export default class EnvService {
}
try {
const result = await this.find(condition, [
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'],
['position', 'DESC'],
['createdAt', 'ASC'],
]);
return result as any;
return result;
} catch (error) {
throw error;
}
@@ -160,15 +162,18 @@ export default class EnvService {
where: { ...query },
order: [...sort],
});
return docs;
return docs.map((x) => x.get({ plain: true }));
}
public async getDb(query: FindOptions<Env>['where']): Promise<Env> {
const doc: any = await EnvModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as Env);
if (!doc) {
throw new Error(`Env ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async disabled(ids: string[]) {
public async disabled(ids: number[]) {
await EnvModel.update(
{ status: EnvStatus.disabled },
{ where: { id: ids } },
@@ -176,16 +181,62 @@ export default class EnvService {
await this.set_envs();
}
public async enabled(ids: string[]) {
public async enabled(ids: number[]) {
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs();
}
public async updateNames({ ids, name }: { ids: string[]; name: string }) {
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
await EnvModel.update({ name }, { where: { id: ids } });
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 addLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{ labels: Array.from(new Set([...(env.labels || []), ...labels])) },
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async removeLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{
labels: (env.labels || []).filter(
(label: string) => !labels.includes(label),
),
},
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async set_envs() {
const envs = await this.envs('', {
name: { [Op.not]: null },
@@ -193,6 +244,8 @@ export default class EnvService {
});
const groups = groupBy(envs, 'name');
let env_string = '';
let js_env_string = '';
let py_env_string = 'import os\n';
for (const key in groups) {
if (Object.prototype.hasOwnProperty.call(groups, key)) {
const group = groups[key];
@@ -202,14 +255,26 @@ export default class EnvService {
let value = group
.map((x) => x.value)
.join('&')
.replace(/(\\)[^n]/g, '\\\\')
.replace(/(\\$)/, '\\\\')
.replace(/"/g, '\\"')
.replace(/'/g, "'\\''")
.trim();
env_string += `export ${key}="${value}"\n`;
env_string += `export ${key}='${value}'\n`;
const _env_value = `${group
.map((x) => x.value)
.join('&')
.replace(/\\/g, '\\\\')}`;
js_env_string += `process.env.${key}=\`${_env_value.replace(
/\`/g,
'\\`',
)}\`;\n`;
py_env_string += `os.environ['${key}']='''${_env_value.replace(
/\'/g,
"\\'",
)}'''\n`;
}
}
}
fs.writeFileSync(config.envFile, env_string);
await writeFileWithLock(config.envFile, env_string);
await writeFileWithLock(config.jsEnvFile, js_env_string);
await writeFileWithLock(config.pyEnvFile, py_env_string);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { Server, ServerCredentials } from '@grpc/grpc-js';
import { CronService } from '../protos/cron';
import { HealthService } from '../protos/health';
import { ApiService } from '../protos/api';
import { addCron } from '../schedule/addCron';
import { delCron } from '../schedule/delCron';
import { check } from '../schedule/health';
import * as Api from '../schedule/api';
import Logger from '../loaders/logger';
import { promisify } from 'util';
import config from '../config';
import { metricsService } from './metrics';
import { Service } from 'typedi';
import { initGrpcCerts } from '../config/grpcCerts';
@Service()
export class GrpcServerService {
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
private formatGrpcAddress(host: string, port: number): string {
if (host === '::') {
return `[::]:${port}`;
}
return `${host}:${port}`;
}
async initialize() {
try {
this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron });
this.server.addService(ApiService, Api);
const tlsConfig = await initGrpcCerts();
const credentials = ServerCredentials.createSsl(
Buffer.from(tlsConfig.caCert),
[{ cert_chain: Buffer.from(tlsConfig.serverCert), private_key: Buffer.from(tlsConfig.serverKey) }],
true,
);
const grpcPort = config.grpcPort;
const hostsToTry = [
config.bindHostGrpc,
...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
let lastError: Error | null = null;
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return grpcPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
}
}
Logger.error('Failed to start gRPC service on all hosts');
throw lastError || new Error('Failed to start gRPC service');
} catch (err) {
Logger.error('Failed to start gRPC service:', err);
throw err;
}
}
async shutdown() {
try {
if (this.server) {
await new Promise((resolve) => {
this.server.tryShutdown(() => {
Logger.debug('gRPC service stopped');
metricsService.record('grpc_service_stop', 1);
resolve(null);
});
});
}
} catch (err) {
Logger.error('Error while shutting down gRPC service:', err);
throw err;
}
}
getServer() {
return this.server;
}
}
+72
View File
@@ -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;
}
}
+73
View File
@@ -0,0 +1,73 @@
import express from 'express';
import Logger from '../loaders/logger';
import { metricsService } from './metrics';
import { Service } from 'typedi';
import { Server } from 'http';
import config from '../config';
@Service()
export class HttpServerService {
private server?: Server = undefined;
async initialize(expressApp: express.Application, port: number) {
const hostsToTry = [
config.bindHost,
...(config.bindHost !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
let lastError: Error | null = null;
for (const host of hostsToTry) {
try {
const server = await this.tryListen(expressApp, port, host);
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
host
});
this.server = server;
return server;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
}
}
Logger.error('Failed to start HTTP service on all hosts');
throw lastError || new Error('Failed to start HTTP service');
}
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
return new Promise((resolve, reject) => {
const server = expressApp.listen(port, host, () => {
resolve(server);
});
server.on('error', (err: Error) => {
server.close();
reject(err);
});
});
}
async shutdown() {
try {
if (this.server) {
await new Promise((resolve) => {
this.server?.close(() => {
Logger.debug('HTTP service stopped');
metricsService.record('http_service_stop', 1);
resolve(null);
});
});
}
} catch (err) {
Logger.error('Error while shutting down HTTP service:', err);
throw err;
}
}
getServer() {
return this.server;
}
}
+14
View File
@@ -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();
+518 -197
View File
@@ -1,12 +1,12 @@
import { NotificationInfo } from '../data/notify';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import UserService from './user';
import got from 'got';
import nodemailer from 'nodemailer';
import crypto from 'crypto';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
import { parseBody, parseHeaders } from '../config/util';
import { NotificationInfo } from '../data/notify';
import { t } from '../shared/i18n';
import UserService from './user';
import { httpClient } from '../config/http';
import { ProxyAgent } from 'undici';
@Service()
export default class NotificationService {
@@ -27,9 +27,16 @@ export default class NotificationService {
['aibotk', this.aibotk],
['iGot', this.iGot],
['pushPlus', this.pushPlus],
['wePlusBot', this.wePlusBot],
['email', this.email],
['pushMe', this.pushMe],
['webhook', this.webhook],
['lark', this.lark],
['chronocat', this.chronocat],
['ntfy', this.ntfy],
['wxPusherBot', this.wxPusherBot],
['wxPusherSpt', this.wxPusherSpt],
['openiLink', this.openiLink],
]);
private title = '';
@@ -40,22 +47,31 @@ export default class NotificationService {
retry: 1,
};
constructor(@Inject('logger') private logger: winston.Logger) {}
constructor() {}
public async notify(
title: string,
content: string,
notificationInfo?: NotificationInfo,
): 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) {
this.title = title;
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);
try {
return await notificationModeAction?.call(this);
} catch (error: any) {
return false;
console.error(error);
}
}
return false;
@@ -77,11 +93,20 @@ export default class NotificationService {
return true;
}
private parseMailRecipients(value?: string) {
const recipients = (value || '')
.split(/[;]/)
.map((item) => item.trim())
.filter(Boolean);
return recipients.length > 0 ? recipients : undefined;
}
private async gotify() {
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try {
const res: any = await got
.post(`${gotifyUrl}/message?token=${gotifyToken}`, {
const res = await httpClient.post(
`${gotifyUrl}/message?token=${gotifyToken}`,
{
...this.gotOption,
body: `title=${encodeURIComponent(
this.title,
@@ -91,8 +116,8 @@ export default class NotificationService {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.json();
},
);
if (typeof res.id === 'number') {
return true;
} else {
@@ -106,13 +131,11 @@ export default class NotificationService {
private async goCqHttpBot() {
const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params;
try {
const res: any = await got
.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
...this.gotOption,
json: { message: `${this.title}\n${this.content}` },
headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
})
.json();
const res = await httpClient.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
...this.gotOption,
json: { message: `${this.title}\n${this.content}` },
headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
});
if (res.retcode === 0) {
return true;
} else {
@@ -125,17 +148,20 @@ export default class NotificationService {
private async serverChan() {
const { serverChanKey } = this.params;
const url = serverChanKey.startsWith('SCT')
? `https://sctapi.ftqq.com/${serverChanKey}.send`
: `https://sc.ftqq.com/${serverChanKey}.send`;
const matchResult = serverChanKey.match(/^sctp(\d+)t/i);
const url =
matchResult && matchResult[1]
? `https://${matchResult[1]}.push.ft07.com/send/${serverChanKey}.send`
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `title=${this.title}&desp=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
const res = await httpClient.post(url, {
...this.gotOption,
body: `title=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (res.errno === 0 || res.data.errno === 0) {
return true;
} else {
@@ -150,15 +176,13 @@ export default class NotificationService {
const { pushDeerKey, pushDeerUrl } = this.params;
const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`;
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}&type=markdown`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
const res = await httpClient.post(url, {
...this.gotOption,
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}&type=markdown`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (
res.content.result.length !== undefined &&
res.content.result.length > 0
@@ -173,16 +197,13 @@ export default class NotificationService {
}
private async chat() {
const { chatUrl, chatToken } = this.params;
const url = `${chatUrl}${chatToken}`;
const { synologyChatUrl } = this.params;
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `payload={"text":"${this.title}\n${this.content}"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
const res = await httpClient.post(synologyChatUrl, {
...this.gotOption,
body: `payload={"text":"${this.title}\n${this.content}"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (res.success) {
return true;
} else {
@@ -194,23 +215,35 @@ export default class NotificationService {
}
private async bark() {
let { barkPush, barkIcon, barkSound, barkGroup } = this.params;
let {
barkPush,
barkIcon = '',
barkSound = '',
barkGroup = '',
barkLevel = '',
barkUrl = '',
barkArchive = '',
} = this.params;
if (!barkPush.startsWith('http')) {
barkPush = `https://api.day.app/${barkPush}`;
}
const url = `${barkPush}/${encodeURIComponent(
this.title,
)}/${encodeURIComponent(
this.content,
)}?icon=${barkIcon}&sound=${barkSound}&group=${barkGroup}`;
const url = `${barkPush}`;
const body = {
title: this.title,
body: this.content,
icon: barkIcon,
sound: barkSound,
group: barkGroup,
isArchive: barkArchive,
level: barkLevel,
url: barkUrl,
};
try {
const res: any = await got
.get(url, {
...this.gotOption,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
const res = await httpClient.post(url, {
...this.gotOption,
json: body,
headers: { 'Content-Type': 'application/json' },
});
if (res.code === 200) {
return true;
} else {
@@ -231,34 +264,22 @@ export default class NotificationService {
telegramBotUserId,
} = this.params;
const authStr = telegramBotProxyAuth ? `${telegramBotProxyAuth}@` : '';
const url = `https://${
telegramBotApiHost ? telegramBotApiHost : 'api.telegram.org'
const url = `${
telegramBotApiHost ? telegramBotApiHost : 'https://api.telegram.org'
}/bot${telegramBotToken}/sendMessage`;
let agent;
if (telegramBotProxyHost && telegramBotProxyPort) {
const options: any = {
keepAlive: true,
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,
};
agent = new ProxyAgent({
uri: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
});
}
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
agent,
})
.json();
const res = await httpClient.post(url, {
...this.gotOption,
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
dispatcher: agent,
});
if (res.ok) {
return true;
} else {
@@ -281,17 +302,15 @@ export default class NotificationService {
}
const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`;
try {
const res: any = await got
.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
},
const res = await httpClient.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
},
})
.json();
},
});
if (res.errcode === 0) {
return true;
} else {
@@ -303,20 +322,19 @@ export default class NotificationService {
}
private async weWorkBot() {
const { weWorkBotKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } = this.params;
const { weWorkBotKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } =
this.params;
const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`;
try {
const res: any = await got
.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
},
const res = await httpClient.post(url, {
...this.gotOption,
json: {
msgtype: 'text',
text: {
content: ` ${this.title}\n\n${this.content}`,
},
})
.json();
},
});
if (res.errcode === 0) {
return true;
} else {
@@ -328,19 +346,18 @@ export default class NotificationService {
}
private async weWorkApp() {
const { weWorkAppKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } = this.params;
const { weWorkAppKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } =
this.params;
const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] =
weWorkAppKey.split(',');
const url = `${weWorkOrigin}/cgi-bin/gettoken`;
const tokenRes: any = await got
.post(url, {
...this.gotOption,
json: {
corpid,
corpsecret,
},
})
.json();
const tokenRes = await httpClient.post(url, {
...this.gotOption,
json: {
corpid,
corpsecret,
},
});
let options: any = {
msgtype: 'mpnews',
@@ -349,7 +366,7 @@ export default class NotificationService {
{
title: `${this.title}`,
thumb_media_id,
author: `智能助手`,
author: t('智能助手'),
content_source_url: ``,
content: `${this.content.replace(/\n/g, '<br/>')}`,
digest: `${this.content}`,
@@ -365,7 +382,7 @@ export default class NotificationService {
title: `${this.title}`,
description: `${this.content}`,
url: 'https://github.com/whyour/qinglong',
btntxt: '更多',
btntxt: t('更多'),
},
};
break;
@@ -381,20 +398,18 @@ export default class NotificationService {
}
try {
const res: any = await got
.post(
`${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
{
...this.gotOption,
json: {
touser,
agentid,
safe: '0',
...options,
},
const res = await httpClient.post(
`${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
{
...this.gotOption,
json: {
touser,
agentid,
safe: '0',
...options,
},
)
.json();
},
);
if (res.errcode === 0) {
return true;
@@ -418,7 +433,7 @@ export default class NotificationService {
roomName: `${aibotkName}`,
message: {
type: 1,
content: `青龙快讯\n\n${this.title}\n${this.content}`,
content: `${t('青龙快讯')}\n\n${this.title}\n${this.content}`,
},
};
break;
@@ -429,21 +444,19 @@ export default class NotificationService {
name: `${aibotkName}`,
message: {
type: 1,
content: `青龙快讯\n\n${this.title}\n${this.content}`,
content: `${t('青龙快讯')}\n\n${this.title}\n${this.content}`,
},
};
break;
}
try {
const res: any = await got
.post(url, {
...this.gotOption,
json: {
...json,
},
})
.json();
const res = await httpClient.post(url, {
...this.gotOption,
json: {
...json,
},
});
if (res.code === 0) {
return true;
} else {
@@ -458,13 +471,11 @@ export default class NotificationService {
const { iGotPushKey } = this.params;
const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`;
try {
const res: any = await got
.post(url, {
...this.gotOption,
body: `title=${this.title}&content=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.json();
const res = await httpClient.post(url, {
...this.gotOption,
body: `title=${this.title}&content=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (res.ret === 0) {
return true;
@@ -477,20 +488,67 @@ export default class NotificationService {
}
private async pushPlus() {
const { pushPlusToken, pushPlusUser } = this.params;
const {
pushPlusToken,
pushPlusUser,
pushplusWebhook,
pushPlusTemplate,
pushplusChannel,
pushplusCallbackUrl,
pushplusTo,
} = this.params;
const url = `https://www.pushplus.plus/send`;
try {
const res: any = await got
.post(url, {
...this.gotOption,
json: {
token: `${pushPlusToken}`,
title: `${this.title}`,
content: `${this.content.replace(/[\n\r]/g, '<br>')}`,
topic: `${pushPlusUser || ''}`,
},
})
.json();
let body = {
...this.gotOption,
json: {
token: `${pushPlusToken}`,
title: `${this.title}`,
content: `${this.content.replace(/[\n\r]/g, '<br>')}`,
topic: `${pushPlusUser || ''}`,
template: `${pushPlusTemplate || 'html'}`,
channel: `${pushplusChannel || 'wechat'}`,
webhook: `${pushplusWebhook || ''}`,
callbackUrl: `${pushplusCallbackUrl || ''}`,
to: `${pushplusTo || ''}`,
},
};
const res = await httpClient.post(url, body);
if (res.code === 200) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async wePlusBot() {
const { wePlusBotToken, wePlusBotReceiver, wePlusBotVersion } = this.params;
let content = this.content;
let template = 'txt';
if (this.content.length > 800) {
template = 'html';
content = content.replace(/[\n\r]/g, '<br>');
}
const url = `https://www.weplusbot.com/send`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: {
token: `${wePlusBotToken}`,
title: `${this.title}`,
template: `${template}`,
content: `${content}`,
receiver: `${wePlusBotReceiver || ''}`,
version: `${wePlusBotVersion || 'pro'}`,
},
});
if (res.code === 200) {
return true;
@@ -503,24 +561,36 @@ export default class NotificationService {
}
private async lark() {
let { larkKey } = this.params;
let { larkKey, larkSecret } = this.params;
if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
}
const body: Record<string, any> = {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (larkSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${larkSecret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
try {
const res: any = await got
.post(larkKey, {
...this.gotOption,
json: {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
headers: { 'Content-Type': 'application/json' },
})
.json();
if (res.StatusCode === 0) {
const res = await httpClient.post(larkKey, {
...this.gotOption,
json: body,
headers: { 'Content-Type': 'application/json' },
});
if (res.StatusCode === 0 || res.code === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
@@ -531,7 +601,8 @@ export default class NotificationService {
}
private async email() {
const { emailPass, emailService, emailUser } = this.params;
const { emailPass, emailService, emailUser, emailTo } = this.params;
const recipients = this.parseMailRecipients(emailTo) || emailUser;
try {
const transporter = nodemailer.createTransport({
@@ -543,8 +614,8 @@ export default class NotificationService {
});
const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`,
to: `${emailUser}`,
from: `"${t('青龙快讯')}" <${emailUser}>`,
to: recipients,
subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`,
});
@@ -556,6 +627,235 @@ export default class NotificationService {
} else {
throw new Error(JSON.stringify(info));
}
} catch (error: any) {
throw error;
}
}
private async pushMe() {
const { pushMeKey, pushMeUrl } = this.params;
try {
const res = await httpClient.post<'text'>(
pushMeUrl || 'https://push.i-i.me/',
{
...this.gotOption,
json: {
push_key: pushMeKey,
title: this.title,
content: this.content,
},
headers: { 'Content-Type': 'application/json' },
},
);
if (res === 'success') {
return true;
} else {
throw new Error(res);
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async ntfy() {
const {
ntfyUrl,
ntfyTopic,
ntfyPriority,
ntfyToken,
ntfyUsername,
ntfyPassword,
ntfyActions,
} = this.params;
// 编码函数
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
const encodedText = Buffer.from(text).toString('base64');
return `=?${charset}?B?${encodedText}?=`;
};
try {
const headers: Record<string, string> = {
Title: encodeRfc2047(this.title),
Priority: `${ntfyPriority || '3'}`,
Icon: 'https://qn.whyour.cn/logo.png',
};
if (ntfyToken) {
headers['Authorization'] = `Bearer ${ntfyToken}`;
} else if (ntfyUsername && ntfyPassword) {
headers['Authorization'] = `Basic ${Buffer.from(
`${ntfyUsername}:${ntfyPassword}`,
).toString('base64')}`;
}
if (ntfyActions) {
headers['Actions'] = encodeRfc2047(ntfyActions);
}
const res = await httpClient.request(
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
{
...this.gotOption,
body: `${this.content}`,
headers: headers,
method: 'POST',
},
);
if (res.statusCode === 200) {
return true;
} else {
throw new Error(await res.body.text());
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async wxPusherBot() {
const { wxPusherBotAppToken, wxPusherBotTopicIds, wxPusherBotUids } =
this.params;
// 处理 topicIds,将分号分隔的字符串转为数组
const topicIds = wxPusherBotTopicIds
? wxPusherBotTopicIds
.split(';')
.map((id) => id.trim())
.filter((id) => id)
.map((id) => parseInt(id))
: [];
// 处理 uids,将分号分隔的字符串转为数组
const uids = wxPusherBotUids
? wxPusherBotUids
.split(';')
.map((uid) => uid.trim())
.filter((uid) => uid)
: [];
// topic_ids 和 uids 至少要有一个
if (!topicIds.length && !uids.length) {
throw new Error(t('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'));
}
const url = `https://wxpusher.zjiecode.com/api/send/message`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: {
appToken: wxPusherBotAppToken,
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
summary: this.title,
contentType: 2,
topicIds: topicIds,
uids: uids,
verifyPayType: 0,
},
});
if (res.code === 1000) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async wxPusherSpt() {
const { wxPusherSptList } = this.params;
// 处理 SPT,将逗号分隔的字符串转为数组
const spts = wxPusherSptList
? wxPusherSptList
.split(',')
.map((spt) => spt.trim())
.filter((spt) => spt)
: [];
if (!spts.length) {
throw new Error(t('wxPusher SPT 不能为空'));
}
if (spts.length > 10) {
throw new Error(t('wxPusher SPT 最多支持 10 个'));
}
const url = `https://wxpusher.zjiecode.com/api/send/message/simple-push`;
const json: any = {
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
summary: this.title,
contentType: 2,
};
// 单个 SPT 用 spt,多个用 sptList
if (spts.length === 1) {
json.spt = spts[0];
} else {
json.sptList = spts;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json,
});
if (res.code === 1000) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async chronocat() {
const { chronocatURL, chronocatQQ, chronocatToken } = this.params;
try {
const user_ids = chronocatQQ
.match(/user_id=(\d+)/g)
?.map((match: any) => match.split('=')[1]);
const group_ids = chronocatQQ
.match(/group_id=(\d+)/g)
?.map((match: any) => match.split('=')[1]);
const url = `${chronocatURL}/api/message/send`;
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${chronocatToken}`,
};
for (const [chat_type, ids] of [
[1, user_ids],
[2, group_ids],
]) {
if (!ids) {
continue;
}
let _ids: any = ids;
for (const chat_id of _ids) {
const data = {
peer: {
chatType: chat_type,
peerUin: chat_id,
},
elements: [
{
elementType: 1,
textElement: {
content: `${this.title}\n\n${this.content}`,
},
},
],
};
const res = await httpClient.request(url, {
...this.gotOption,
json: data,
headers,
method: 'POST',
});
if (res.statusCode === 200) {
return true;
} else {
throw new Error(await res.body.text());
}
}
}
return false;
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
@@ -570,15 +870,14 @@ export default class NotificationService {
webhookContentType,
} = this.params;
const { formatBody, formatUrl } = this.formatNotifyContent(
webhookUrl,
webhookBody,
);
if (!formatUrl && !formatBody) {
return false;
if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
throw new Error(t('Url 或者 Body 中必须包含 $title'));
}
const headers = parseHeaders(webhookHeaders);
const body = parseBody(formatBody, webhookContentType);
const body = parseBody(webhookBody, webhookContentType, (v) =>
v?.replaceAll('$title', this.title)?.replaceAll('$content', this.content),
);
const bodyParam = this.formatBody(webhookContentType, body);
const options = {
method: webhookMethod,
@@ -587,12 +886,17 @@ export default class NotificationService {
allowGetBody: true,
...bodyParam,
};
try {
const res = await got(formatUrl, options);
const formatUrl = webhookUrl
?.replaceAll('$title', encodeURIComponent(this.title))
?.replaceAll('$content', encodeURIComponent(this.content));
const res = await httpClient.request(formatUrl, options);
const text = await res.body.text();
if (String(res.statusCode).startsWith('20')) {
return true;
} else {
throw new Error(JSON.stringify(res));
throw new Error(await res.body.text());
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
@@ -607,23 +911,40 @@ export default class NotificationService {
case 'multipart/form-data':
return { form: body };
case 'application/x-www-form-urlencoded':
case 'text/plain':
return { body };
}
return {};
}
private formatNotifyContent(url: string, body: string) {
if (!url.includes('$title') && !body.includes('$title')) {
return {};
}
return {
formatUrl: url
?.replaceAll('$title', encodeURIComponent(this.title))
?.replaceAll('$content', encodeURIComponent(this.content)),
formatBody: body
?.replaceAll('$title', this.title)
?.replaceAll('$content', this.content),
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
content: `${this.title}\n\n${this.content}`,
};
if (openiLinkContextToken) {
body.context_token = openiLinkContextToken;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: body,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${openiLinkAppToken}`,
},
});
if (res.ok) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
}
+34 -22
View File
@@ -1,19 +1,19 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import { createRandomString } from '../config/util';
import config from '../config';
import { App, AppModel } from '../data/open';
import { v4 as uuidV4 } from 'uuid';
import sequelize, { Op } from 'sequelize';
import { shareStore } from '../shared/store';
import { t } from '../shared/i18n';
@Service()
export default class OpenService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public async findTokenByValue(token: string): Promise<App | null> {
public async findApps(): Promise<App[] | null> {
const docs = await this.find({});
const doc = docs.filter((x) => x.tokens?.find((y) => y.value === token));
return doc[0];
return docs;
}
public async create(payload: App): Promise<App> {
@@ -21,12 +21,14 @@ export default class OpenService {
tab.client_id = createRandomString(12, 12);
tab.client_secret = createRandomString(24, 24);
const doc = await this.insert(tab);
const apps = await this.find({});
await shareStore.updateApps(apps);
return { ...doc, tokens: [] };
}
public async insert(payload: App): Promise<App> {
const doc = await AppModel.create(payload, { returning: true });
return doc.get({ plain: true }) as App;
return doc.get({ plain: true });
}
public async update(payload: App): Promise<App> {
@@ -34,26 +36,33 @@ export default class OpenService {
name: payload.name,
scopes: payload.scopes,
id: payload.id,
} as any);
} as App);
return { ...newDoc, tokens: [] };
}
private async updateDb(payload: App): Promise<App> {
private async updateDb(payload: Partial<App>): Promise<App> {
await AppModel.update(payload, { where: { id: payload.id } });
return await this.getDb({ id: payload.id });
const apps = await this.find({});
await shareStore.updateApps(apps);
return apps?.find((x) => x.id === payload.id) as App;
}
public async getDb(query: any): Promise<App> {
const doc: any = await AppModel.findOne({ where: query });
return doc && (doc.get({ plain: true }) as App);
public async getDb(query: Record<string, any>): Promise<App> {
const doc = await AppModel.findOne({ where: query });
if (!doc) {
throw new Error(`App ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async remove(ids: number[]) {
await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
}
public async resetSecret(id: number): Promise<App> {
const tab: any = {
const tab: Partial<App> = {
client_secret: createRandomString(24, 24),
tokens: [],
id,
@@ -71,7 +80,7 @@ export default class OpenService {
public async list(
searchText: string = '',
sort: any = {},
query: any = {},
query: Record<string, any> = {},
): Promise<App[]> {
let condition = { ...query };
if (searchText) {
@@ -98,7 +107,7 @@ export default class OpenService {
}
}
private async find(query: any, sort?: any): Promise<App[]> {
private async find(query: Record<string, any>, sort?: any): Promise<App[]> {
const docs = await AppModel.findAll({ where: { ...query } });
return docs.map((x) => x.get({ plain: true }));
}
@@ -132,6 +141,8 @@ export default class OpenService {
{ tokens },
{ where: { client_id, client_secret } },
);
const apps = await this.find({});
await shareStore.updateApps(apps);
return {
code: 200,
data: {
@@ -141,7 +152,7 @@ export default class OpenService {
},
};
} else {
return { code: 400, message: 'client_idclient_seret有误' };
return { code: 400, message: t('client_idclient_seret 有误') };
}
}
@@ -149,14 +160,15 @@ export default class OpenService {
value: string;
expiration: number;
}> {
let systemApp = (await AppModel.findOne({
where: { name: 'system' },
})) as App;
const apps = await shareStore.getApps();
const systemApp = apps?.find((x) => x.name === 'system');
if (!systemApp) {
systemApp = await this.create({
name: 'system',
scopes: ['crons', 'system'],
} as App);
throw new Error('system app not found');
}
const now = Math.round(Date.now() / 1000);
const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
if (currentToken) {
return currentToken;
}
const { data } = await this.authToken({
client_id: systemApp.client_id,
+77 -32
View File
@@ -12,11 +12,12 @@ import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
interface ScheduleTaskType {
id: number;
export interface ScheduleTaskType {
id?: number;
command: string;
name?: string;
schedule?: string;
runOrigin: 'subscription' | 'system' | 'script';
}
export interface TaskCallbacks {
@@ -40,17 +41,37 @@ export default class ScheduleService {
private intervalSchedule = new ToadScheduler();
private maxBuffer = 200 * 1024 * 1024;
private taskLimitMap = {
system: 'runWithSystemLimit' as const,
script: 'runWithScriptLimit' as const,
subscription: 'runWithSubscriptionLimit' as const,
};
constructor(@Inject('logger') private logger: winston.Logger) { }
constructor(@Inject('logger') private logger: winston.Logger) {}
async runTask(
command: string,
callbacks: TaskCallbacks = {},
params: {
schedule?: string;
name?: string;
command?: string;
id: string;
runOrigin: 'subscription' | 'system' | 'script';
},
completionTime: 'start' | 'end' = 'end',
) {
return taskLimit.runWithCpuLimit(() => {
const { runOrigin, ...others } = params;
return taskLimit[this.taskLimitMap[runOrigin]](others, () => {
return new Promise(async (resolve, reject) => {
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify({
...others,
command,
})}`,
);
try {
const startTime = dayjs();
await callbacks.onBefore?.(startTime);
@@ -66,9 +87,8 @@ export default class ScheduleService {
cp.stderr.on('data', async (data) => {
this.logger.info(
'[执行任务失败] %s,时间:%s, 错误信息%j',
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
new Date().toLocaleString(),
data.toString(),
);
await callbacks.onError?.(data.toString());
@@ -76,50 +96,50 @@ export default class ScheduleService {
cp.on('error', async (err) => {
this.logger.error(
'[创建任务失败] %s,时间:%s, 错误信息%j',
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
new Date().toLocaleString(),
err,
);
await callbacks.onError?.(JSON.stringify(err));
});
cp.on('exit', async (code, signal) => {
cp.on('exit', async (code) => {
this.logger.info(
`[任务退出] ${command} 进程id: ${cp.pid},退出码 ${code}`,
'[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...others,
command,
}),
code,
);
});
cp.on('close', async (code) => {
const endTime = dayjs();
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
resolve(null);
resolve({ ...others, pid: cp.pid, code });
});
} catch (error) {
await this.logger.error(
'执行任务%s失败,时间:%s, 错误信息%j',
this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
new Date().toLocaleString(),
error,
);
await callbacks.onError?.(JSON.stringify(error));
}
});
})
});
}
async createCronTask(
{ id = 0, command, name, schedule = '' }: ScheduleTaskType,
{ id = 0, command, name, schedule = '', runOrigin }: ScheduleTaskType,
callbacks?: TaskCallbacks,
runImmediately = false,
) {
const _id = this.formatId(id);
this.logger.info(
'[创建cron任务]任务ID: %scron: %s任务名: %s执行命令: %s',
'[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
_id,
schedule,
name,
@@ -129,18 +149,30 @@ export default class ScheduleService {
this.scheduleStacks.set(
_id,
nodeSchedule.scheduleJob(_id, schedule, async () => {
this.runTask(command, callbacks);
this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
});
}),
);
if (runImmediately) {
this.runTask(command, callbacks);
this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
});
}
}
async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info('[取消定时任务]任务名%s', name);
this.logger.info('[panel][取消定时任务] 任务名: %s', name);
if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id);
@@ -148,14 +180,14 @@ export default class ScheduleService {
}
async createIntervalTask(
{ id = 0, command, name = '' }: ScheduleTaskType,
{ id = 0, command, name = '', runOrigin }: ScheduleTaskType,
schedule: SimpleIntervalSchedule,
runImmediately = true,
callbacks?: TaskCallbacks,
) {
const _id = this.formatId(id);
this.logger.info(
'[创建interval任务]任务ID: %s任务名: %s执行命令: %s',
'[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s',
_id,
name,
command,
@@ -163,13 +195,17 @@ export default class ScheduleService {
const task = new Task(
name,
() => {
this.runTask(command, callbacks);
this.runTask(command, callbacks, {
name,
command,
id: _id,
runOrigin,
});
},
(err) => {
this.logger.error(
'执行任务%s失败,时间:%s, 错误信息%j',
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
new Date().toLocaleString(),
err,
);
},
@@ -178,19 +214,28 @@ export default class ScheduleService {
const job = new LongIntervalJob(
{ runImmediately: false, ...schedule },
task,
_id,
{ id: _id },
);
this.intervalSchedule.addIntervalJob(job);
if (runImmediately) {
this.runTask(command, callbacks);
this.runTask(command, callbacks, {
name,
command,
id: _id,
runOrigin,
});
}
}
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id);
this.logger.info('[取消interval任务],任务ID: %s,任务名:%s', _id, name);
this.logger.info(
'[panel][取消interval任务] 任务ID: %s, 任务名: %s',
_id,
name,
);
this.intervalSchedule.removeById(_id);
}
+25 -10
View File
@@ -1,13 +1,13 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import fs from 'fs';
import path from 'path';
import path, { join } from 'path';
import SockService from './sock';
import CronService from './cron';
import ScheduleService, { TaskCallbacks } from './schedule';
import config from '../config';
import { TASK_COMMAND } from '../config/const';
import { getPid, killTask } from '../config/util';
import { getFileContentByName, getPid, killTask, rmPath } from '../config/util';
import taskLimit from '../shared/pLimit';
@Service()
export default class ScriptService {
@@ -21,9 +21,7 @@ export default class ScriptService {
private taskCallbacks(filePath: string): TaskCallbacks {
return {
onEnd: async (cp, endTime, diff) => {
try {
fs.unlinkSync(filePath);
} catch (error) {}
await rmPath(filePath);
},
onError: async (message: string) => {
this.sockService.sendMessage({
@@ -42,10 +40,11 @@ export default class ScriptService {
public async runScript(filePath: string) {
const relativePath = path.relative(config.scriptPath, filePath);
const command = `${TASK_COMMAND} -l ${relativePath} now`;
const command = `${TASK_COMMAND} ${relativePath} now`;
const pid = await this.scheduleService.runTask(
command,
`real_time=true ${command}`,
this.taskCallbacks(filePath),
{ command, id: relativePath.replace(/ /g, '-'), runOrigin: 'script' },
'start',
);
@@ -53,10 +52,10 @@ export default class ScriptService {
}
public async stopScript(filePath: string, pid: number) {
let str = '';
if (!pid) {
const relativePath = path.relative(config.scriptPath, filePath);
pid = await getPid(`${TASK_COMMAND} -l ${relativePath} now`);
taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-'));
pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number;
}
try {
await killTask(pid);
@@ -64,4 +63,20 @@ export default class ScriptService {
return { code: 200 };
}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
}
public async getFile(filePath: string, fileName: string) {
const finalPath = this.checkFilePath(filePath, fileName);
if (!finalPath) {
return '';
}
const content = await getFileContentByName(finalPath);
return content;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { SockMessage } from '../data/sock';
export default class SockService {
private clients: Connection[] = [];
constructor(@Inject('logger') private logger: winston.Logger) {}
constructor(@Inject('logger') private logger: winston.Logger) { }
public getClients() {
return this.clients;
+81 -37
View File
@@ -1,11 +1,13 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import fs, { existsSync } from 'fs';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { Subscription } from '../data/subscription';
import { formatUrl } from '../config/subscription';
import config from '../config';
import { fileExist, rmPath } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
@Service()
export default class SshKeyService {
@@ -18,47 +20,54 @@ export default class SshKeyService {
this.initSshConfigFile();
}
private initSshConfigFile() {
private async initSshConfigFile() {
let config = '';
if (existsSync(this.sshConfigFilePath)) {
config = fs.readFileSync(this.sshConfigFilePath, { encoding: 'utf-8' });
const _exist = await fileExist(this.sshConfigFilePath);
if (_exist) {
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
} else {
fs.writeFileSync(this.sshConfigFilePath, '');
await writeFileWithLock(this.sshConfigFilePath, '', { mode: '600' });
}
if (!config.includes(this.sshConfigHeader)) {
fs.writeFileSync(
await writeFileWithLock(
this.sshConfigFilePath,
`${this.sshConfigHeader}\n\n${config}`,
{ encoding: 'utf-8' },
{ mode: '600' },
);
}
}
private generatePrivateKeyFile(alias: string, key: string): void {
private async generatePrivateKeyFile(
alias: string,
key: string,
): Promise<void> {
try {
fs.writeFileSync(path.join(this.sshPath, alias), `${key}${os.EOL}`, {
encoding: 'utf8',
mode: '400',
});
const filePath = path.join(this.sshPath, alias);
try {
await rmPath(filePath);
} catch { }
await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
} catch (error) {
this.logger.error('生成私钥文件失败', error);
}
}
private removePrivateKeyFile(alias: string): void {
private async removePrivateKeyFile(alias: string): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
if (existsSync(filePath)) {
fs.unlinkSync(filePath);
}
await rmPath(filePath);
} catch (error) {
this.logger.error('删除私钥文件失败', error);
}
}
private generateSingleSshConfig(alias: string, host: string, proxy?: string) {
private async generateSingleSshConfig(
alias: string,
host: string,
proxy?: string,
) {
if (host === 'github.com') {
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa\n PubkeyAcceptedAlgorithms +ssh-rsa`;
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
}
const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
@@ -67,50 +76,85 @@ export default class SshKeyService {
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`;
fs.writeFileSync(`${path.join(this.sshPath, `${alias}.config`)}`, config, {
encoding: 'utf8',
});
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
private removeSshConfig(alias: string) {
private async removeSshConfig(alias: string) {
try {
const filePath = path.join(this.sshPath, `${alias}.config`);
if (existsSync(filePath)) {
fs.unlinkSync(filePath);
}
await rmPath(filePath);
} catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error);
}
}
public addSSHKey(
public async addSSHKey(
key: string,
alias: string,
host: string,
proxy?: string,
): void {
this.generatePrivateKeyFile(alias, key);
this.generateSingleSshConfig(alias, host, proxy);
): Promise<void> {
await this.generatePrivateKeyFile(alias, key);
await this.generateSingleSshConfig(alias, host, proxy);
}
public removeSSHKey(alias: string, host: string, proxy?: string): void {
this.removePrivateKeyFile(alias);
this.removeSshConfig(alias);
public async removeSSHKey(
alias: string,
host: string,
proxy?: string,
): Promise<void> {
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
}
public setSshConfig(docs: Subscription[]) {
public async setSshConfig(docs: Subscription[]) {
for (const doc of docs) {
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
const { alias, proxy } = doc;
const { host } = formatUrl(doc);
this.removePrivateKeyFile(alias);
this.removeSshConfig(alias);
this.generatePrivateKeyFile(
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
await this.generatePrivateKeyFile(
alias,
(doc.pull_option as any).private_key,
);
this.generateSingleSshConfig(alias, host, proxy);
await this.generateSingleSshConfig(alias, host, proxy);
}
}
}
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`);
await this.removeSshConfig(`~global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
}
+85 -50
View File
@@ -3,11 +3,11 @@ import winston from 'winston';
import config from '../config';
import {
Subscription,
SubscriptionInstance,
SubscriptionModel,
SubscriptionStatus,
} from '../data/subscription';
import { ChildProcessWithoutNullStreams } from 'child_process';
import fs from 'fs';
import {
getFileContentByName,
concurrentRun,
@@ -16,17 +16,23 @@ import {
killTask,
handleLogPath,
promiseExec,
rmPath,
} from '../config/util';
import { promises, existsSync } from 'fs';
import fs from 'fs/promises';
import { FindOptions, Op } from 'sequelize';
import path from 'path';
import path, { join } from 'path';
import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler';
import SockService from './sock';
import { t, tf } from '../shared/i18n';
import SshKeyService from './sshKey';
import dayjs from 'dayjs';
import { LOG_END_SYMBOL } from '../config/const';
import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service()
export default class SubscriptionService {
@@ -35,10 +41,15 @@ export default class SubscriptionService {
private scheduleService: ScheduleService,
private sockService: SockService,
private sshKeyService: SshKeyService,
private crontabService: CrontabService,
) {}
public async list(searchText?: string): Promise<Subscription[]> {
public async list(
searchText?: string,
ids?: string,
): Promise<SubscriptionInstance[]> {
let query = {};
const subIds = JSON.parse(ids || '[]');
if (searchText) {
const reg = {
[Op.or]: [
@@ -59,7 +70,7 @@ export default class SubscriptionService {
}
try {
const result = await SubscriptionModel.findAll({
where: query,
where: { ...query, ...(ids ? { id: subIds } : undefined) },
order: [
['is_disabled', 'ASC'],
['createdAt', 'DESC'],
@@ -84,16 +95,16 @@ export default class SubscriptionService {
this.scheduleService.cancelCronTask(doc as any);
needCreate &&
(await this.scheduleService.createCronTask(
doc as any,
{ ...doc, runOrigin: 'subscription' } as any,
this.taskCallbacks(doc),
runImmediately,
));
} else {
} else if (doc.interval_schedule) {
this.scheduleService.cancelIntervalTask(doc as any);
const { type, value } = doc.interval_schedule as any;
const { type, value } = doc.interval_schedule;
needCreate &&
(await this.scheduleService.createIntervalTask(
doc as any,
{ ...doc, runOrigin: 'subscription' } as any,
{ [type]: value } as SimpleIntervalSchedule,
runImmediately,
this.taskCallbacks(doc),
@@ -103,7 +114,7 @@ export default class SubscriptionService {
public async setSshConfig() {
const docs = await SubscriptionModel.findAll();
this.sshKeyService.setSshConfig(docs);
await this.sshKeyService.setSshConfig(docs);
}
private taskCallbacks(doc: Subscription): TaskCallbacks {
@@ -120,14 +131,14 @@ export default class SubscriptionService {
);
const absolutePath = await handleLogPath(
logPath as string,
`## 开始执行... ${startTime.format('YYYY-MM-DD HH:mm:ss')}\n`,
tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')),
);
// 执行sub_before
let beforeStr = '';
try {
if (doc.sub_before) {
fs.appendFileSync(absolutePath, `\n## 执行before命令...\n\n`);
await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`);
beforeStr = await promiseExec(doc.sub_before);
}
} catch (error: any) {
@@ -135,7 +146,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (beforeStr) {
fs.appendFileSync(absolutePath, `${beforeStr}\n`);
await logStreamManager.write(absolutePath, `${beforeStr}\n`);
}
},
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -154,7 +165,7 @@ export default class SubscriptionService {
let afterStr = '';
try {
if (sub.sub_after) {
fs.appendFileSync(absolutePath, `\n\n## 执行after命令...\n\n`);
await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`);
afterStr = await promiseExec(sub.sub_after);
}
} catch (error: any) {
@@ -162,16 +173,23 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (afterStr) {
fs.appendFileSync(absolutePath, `${afterStr}\n`);
await logStreamManager.write(absolutePath, `${afterStr}\n`);
}
fs.appendFileSync(
await logStreamManager.write(
absolutePath,
`\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss',
)} 耗时 ${diff}${LOG_END_SYMBOL}`,
'\n' +
tf(
'## 执行结束... %s 耗时 %s 秒',
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(diff),
) +
LOG_END_SYMBOL,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } },
@@ -179,19 +197,19 @@ export default class SubscriptionService {
this.sockService.sendMessage({
type: 'runSubscriptionEnd',
message: '订阅执行完成',
message: t('订阅执行完成'),
references: [doc.id as number],
});
},
onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, `\n${message}`);
await logStreamManager.write(absolutePath, `\n${message}`);
},
onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, `\n${message}`);
await logStreamManager.write(absolutePath, `\n${message}`);
},
};
}
@@ -199,12 +217,12 @@ export default class SubscriptionService {
public async create(payload: Subscription): Promise<Subscription> {
const tab = new Subscription(payload);
const doc = await this.insert(tab);
await this.handleTask(doc);
await this.handleTask(doc.get({ plain: true }));
await this.setSshConfig();
return doc;
}
public async insert(payload: Subscription): Promise<Subscription> {
public async insert(payload: Subscription): Promise<SubscriptionInstance> {
return await SubscriptionModel.create(payload, { returning: true });
}
@@ -253,20 +271,36 @@ export default class SubscriptionService {
);
}
public async remove(ids: number[]) {
public async remove(ids: number[], query: { force?: boolean }) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await this.handleTask(doc, false);
await this.handleTask(doc.get({ plain: true }), false);
}
await SubscriptionModel.destroy({ where: { id: ids } });
await this.setSshConfig();
if (query?.force === true) {
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) {
await this.crontabService.remove(crons.map((x) => x.id!));
}
for (const doc of docs) {
const filePath = join(config.scriptPath, doc.alias);
const repoPath = join(config.repoPath, doc.alias);
await rmPath(filePath);
await rmPath(repoPath);
}
}
}
public async getDb(
query: FindOptions<Subscription>['where'],
): Promise<Subscription> {
const doc: any = await SubscriptionModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as Subscription);
const doc = await SubscriptionModel.findOne({ where: { ...query } });
if (!doc) {
throw new Error(`Subscription ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async run(ids: number[]) {
@@ -286,17 +320,9 @@ export default class SubscriptionService {
try {
await killTask(doc.pid);
} catch (error) {
this.logger.silly(error);
this.logger.error(error);
}
}
const absolutePath = await handleLogPath(doc.log_path as string);
fs.appendFileSync(
`${absolutePath}`,
`\n## 执行结束... ${dayjs().format(
'YYYY-MM-DD HH:mm:ss',
)}${LOG_END_SYMBOL}`,
);
}
await SubscriptionModel.update(
@@ -313,7 +339,13 @@ export default class SubscriptionService {
const command = formatCommand(subscription);
this.scheduleService.runTask(command, this.taskCallbacks(subscription));
this.scheduleService.runTask(command, this.taskCallbacks(subscription), {
name: subscription.name,
schedule: subscription.schedule,
command,
id: String(subscription.id),
runOrigin: 'subscription',
});
}
public async disabled(ids: number[]) {
@@ -321,7 +353,7 @@ export default class SubscriptionService {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.setSshConfig();
for (const doc of docs) {
await this.handleTask(doc, false);
await this.handleTask(doc.get({ plain: true }), false);
}
}
@@ -330,7 +362,7 @@ export default class SubscriptionService {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.setSshConfig();
for (const doc of docs) {
await this.handleTask(doc);
await this.handleTask(doc.get({ plain: true }));
}
}
@@ -341,7 +373,7 @@ export default class SubscriptionService {
}
const absolutePath = await handleLogPath(doc.log_path as string);
return getFileContentByName(absolutePath);
return await getFileContentByName(absolutePath);
}
public async logs(id: number) {
@@ -353,15 +385,18 @@ export default class SubscriptionService {
if (doc.log_path) {
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
if (existsSync(dir)) {
let files = await promises.readdir(dir);
return files
.map((x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: fs.statSync(`${dir}/${x}`).mtime.getTime(),
}))
.sort((a, b) => b.time - a.time);
const _exist = await fileExist(dir);
if (_exist) {
let files = await fs.readdir(dir);
return (
await Promise.all(
files.map(async (x) => ({
filename: x,
directory: relativeDir.replace(config.logPath, ''),
time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs,
})),
)
).sort((a, b) => b.time - a.time);
}
}
}
+427 -55
View File
@@ -1,22 +1,44 @@
import { Service, Inject } from 'typedi';
import { spawn } from 'cross-spawn';
import { Response } from 'express';
import fs from 'fs';
import { Agent, request } from 'undici';
import sum from 'lodash/sum';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
import * as fs from 'fs';
import { AuthDataType, AuthInfo, AuthModel, AuthModelInfo } from '../data/auth';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
import ScheduleService, { TaskCallbacks } from './schedule';
import { spawn } from 'cross-spawn';
import SockService from './sock';
import got from 'got';
import { NotificationModeStringMap, TASK_COMMAND } from '../config/const';
import {
getPid,
killTask,
parseContentVersion,
parseVersion,
promiseExec,
readDirs,
rmPath,
setSystemTimezone,
} from '../config/util';
import { TASK_COMMAND } from '../config/const';
import taskLimit from '../shared/pLimit'
import {
DependenceModel,
DependenceStatus,
DependenceTypes,
} from '../data/dependence';
import { NotificationInfo } from '../data/notify';
import {
AuthDataType,
SystemInfo,
SystemInstance,
SystemModel,
SystemModelInfo,
} from '../data/system';
import taskLimit from '../shared/pLimit';
import NotificationService from './notify';
import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock';
import os from 'os';
import dayjs from 'dayjs';
import { t, setLang } from '../shared/i18n';
import { updateLinuxMirrorFile } from '../config/util';
@Service()
export default class SystemService {
@@ -27,30 +49,37 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) {}
) { }
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
return doc || {};
return {
...doc,
info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' },
};
}
private async updateAuthDb(payload: AuthInfo): Promise<any> {
await AuthModel.upsert({ ...payload });
const doc = await this.getDb({ type: payload.type });
private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> {
const { id, ...others } = payload;
await SystemModel.update(others, { where: { id } });
const doc = await this.getDb({ id });
return doc;
}
public async getDb(query: any): Promise<any> {
const doc: any = await AuthModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as any);
public async getDb(query: any): Promise<SystemInfo> {
const doc = await SystemModel.findOne({ where: query });
if (!doc) {
throw new Error(`System ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async updateNotificationMode(notificationInfo: NotificationInfo) {
const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify(
notificationInfo,
'青龙',
`【蛟龙】测试通知 https://t.me/jiao_long`,
t('青龙'),
t('【蛟龙】测试通知 https://t.me/jiao_long'),
);
if (isSuccess) {
const result = await this.updateAuthDb({
@@ -59,50 +88,195 @@ export default class SystemService {
});
return { code: 200, data: { ...result, code } };
} else {
return { code: 400, message: '通知发送失败,请检查参数' };
return { code: 400, message: t('通知发送失败,请检查参数') };
}
}
public async updateSystemConfig(info: AuthModelInfo) {
public async updateLogRemoveFrequency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
type: AuthDataType.systemConfig,
info,
info: { ...oDoc.info, ...info },
});
if (info.logRemoveFrequency) {
const cron = {
id: result.id,
name: '删除日志',
command: `ql rmlog ${info.logRemoveFrequency}`,
};
const cron = {
id: result.id as number,
name: t('删除日志'),
command: `ql rmlog ${info.logRemoveFrequency}`,
runOrigin: 'system' as const,
};
if (oDoc.info?.logRemoveFrequency) {
await this.scheduleService.cancelIntervalTask(cron);
if (info.logRemoveFrequency > 0) {
this.scheduleService.createIntervalTask(cron, {
days: info.logRemoveFrequency,
});
}
}
if (info.logRemoveFrequency && info.logRemoveFrequency > 0) {
this.scheduleService.createIntervalTask(
cron,
{
days: info.logRemoveFrequency,
},
true,
);
}
return { code: 200, data: info };
}
public async updateCronConcurrency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
if (info.cronConcurrency) {
await taskLimit.setCustomLimit(info.cronConcurrency);
}
return { code: 200, data: info };
}
public async updateDependenceProxy(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
}
return { code: 200, data: info };
}
public async updateNodeMirror(info: SystemModelInfo, res?: Response) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let cmd = 'pnpm config delete registry';
if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`;
}
let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({
where: {
type: DependenceTypes.nodejs,
status: DependenceStatus.installed,
},
});
if (docs.length > 0) {
command += ` && pnpm i -g`;
}
this.scheduleService.runTask(
command,
{
onStart: async (cp) => {
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end();
},
onEnd: async () => {
this.sockService.sendMessage({
type: 'updateNodeMirror',
message: 'update node mirror end',
status: 'completed',
});
},
onError: async (message: string) => {
this.sockService.sendMessage({ type: 'updateNodeMirror', message });
},
onLog: async (message: string) => {
this.sockService.sendMessage({ type: 'updateNodeMirror', message });
},
},
{
command,
id: 'update-node-mirror',
runOrigin: 'system',
},
);
}
public async updatePythonMirror(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let cmd = 'pip config unset global.index-url';
if (info.pythonMirror) {
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
}
await promiseExec(cmd);
return { code: 200, data: info };
}
public async updateLinuxMirror(
info: SystemModelInfo,
res?: Response,
onEnd?: () => void,
) {
const oDoc = await this.getSystemConfig();
if (os.platform() !== 'linux') {
return;
}
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
let hasError = false;
this.scheduleService.runTask(
command,
{
onStart: async (cp) => {
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end();
},
onEnd: async () => {
this.sockService.sendMessage({
type: 'updateLinuxMirror',
message: 'update linux mirror end',
status: 'completed',
});
onEnd?.();
if (!hasError) {
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
}
},
onError: async (message: string) => {
hasError = true;
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
},
onLog: async (message: string) => {
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
},
},
{
command,
id: 'update-linux-mirror',
runOrigin: 'system',
},
);
}
public async checkUpdate() {
try {
const currentVersionContent = await parseVersion(config.versionFile);
let lastVersionContent;
try {
const result = await got.get(
const { body } = await request(
`${config.lastVersionFile}?t=${Date.now()}`,
{
timeout: 30000,
dispatcher: new Agent({
keepAliveTimeout: 30000,
keepAliveMaxTimeout: 30000,
}),
},
);
lastVersionContent = await parseContentVersion(result.body);
} catch (error) {}
const text = await body.text();
lastVersionContent = parseContentVersion(text);
} catch (error) { }
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
@@ -148,7 +322,7 @@ export default class SystemService {
}
public async updateSystem() {
const cp = spawn('ql -l update', { shell: '/bin/bash' });
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
cp.stdout.on('data', (data) => {
this.sockService.sendMessage({
@@ -168,37 +342,78 @@ export default class SystemService {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: JSON.stringify(err),
status: 'failed',
});
});
cp.on('exit', (code) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: '',
status: code === 0 ? 'success' : 'failed',
});
});
return { code: 200 };
}
public async notify({ title, content }: { title: string; content: string }) {
const isSuccess = await this.notificationService.notify(title, content);
public async reloadSystem(target?: 'system' | 'data') {
const cmd = `real_time=true ql reload ${target || ''}`;
const cp = spawn(cmd, {
shell: '/bin/bash',
detached: true,
stdio: 'ignore',
});
cp.unref();
setTimeout(() => {
process.exit(0);
});
return { code: 200 };
}
public async notify({
title,
content,
notificationInfo,
}: {
title: string;
content: string;
notificationInfo?: NotificationInfo;
}) {
const typeString =
typeof notificationInfo?.type === 'number'
? NotificationModeStringMap[notificationInfo.type]
: undefined;
if (notificationInfo && typeString) {
notificationInfo.type = typeString;
}
const isSuccess = await this.notificationService.notify(
title,
content,
notificationInfo,
);
if (isSuccess) {
return { code: 200, message: '通知发送成功' };
return { code: 200, message: t('通知发送成功') };
} else {
return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' };
return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
}
}
public async run(
{ command, logPath }: { command: string; logPath: string },
callback: TaskCallbacks,
) {
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
this.scheduleService.runTask(
`real_log_path=${logPath} real_time=true ${command}`,
callback,
);
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
});
}
public async stop({ command, pid }: { command: string; pid: number }) {
if (!pid && !command) {
return { code: 400, message: '参数错误' };
return { code: 400, message: t('参数错误') };
}
if (pid) {
@@ -214,7 +429,164 @@ export default class SystemService {
await killTask(_pid);
return { code: 200 };
} else {
return { code: 400, message: '任务未找到' };
return { code: 400, message: t('任务未找到') };
}
}
public async exportData(res: Response, type?: string[]) {
try {
let dataDirs = ['db', 'upload'];
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
await promiseExec(
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
} ${dataPaths.join(' ')}`,
);
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
}
}
public async importData() {
try {
await promiseExec(`rm -rf ${path.join(config.tmpPath, 'data')}`);
const res = await promiseExec(
`cd ${config.tmpPath} && tar -zxvf ${config.dataTgzFile}`,
);
return { code: 200, data: res };
} catch (error: any) {
return { code: 400, message: error.message };
}
}
public async getSystemLog(
res: Response,
query: {
startTime?: string;
endTime?: string;
},
) {
const startTime = dayjs(query.startTime || undefined)
.startOf('d')
.valueOf();
const endTime = dayjs(query.endTime || undefined)
.endOf('d')
.valueOf();
const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result
.reverse()
.filter((x) => x.title.endsWith('.log'))
.filter((x) => x.createTime >= startTime && x.createTime <= endTime);
res.set({
'Content-Length': sum(logs.map((x) => x.size)),
});
(function sendFiles(res, fileNames) {
if (fileNames.length === 0) {
res.end();
return;
}
const currentLog = fileNames.shift();
if (currentLog) {
const currentFileStream = fs.createReadStream(
path.join(config.systemLogPath, currentLog.title),
);
currentFileStream.on('end', () => {
sendFiles(res, fileNames);
});
currentFileStream.pipe(res, { end: false });
}
})(res, logs);
}
public async deleteSystemLog() {
const result = await readDirs(config.systemLogPath, config.systemLogPath);
const logs = result.reverse().filter((x) => x.title.endsWith('.log'));
for (const log of logs) {
await rmPath(path.join(config.systemLogPath, log.title));
}
}
public async updateTimezone(info: SystemModelInfo) {
if (!info.timezone) {
info.timezone = 'Asia/Shanghai';
}
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
const success = await setSystemTimezone(info.timezone);
if (success) {
return { code: 200, data: info };
} else {
return { code: 400, message: t('设置时区失败') };
}
}
public async updateLanguage(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const lang = info.lang || 'zh';
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, lang },
});
// Write to standalone lang_env.sh, sourced by shell scripts
try {
await fs.promises.writeFile(
config.langEnvFile,
`export QL_LANG='${lang}'\n`,
);
} catch (error) {
this.logger.error(`Failed to write lang_env.sh: ${error}`);
}
setLang(lang);
return { code: 200, data: { lang } };
}
public async updatePanelTitle(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const panelTitle = info.panelTitle?.trim() || '';
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, panelTitle },
});
return { code: 200, data: { panelTitle } };
}
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: t('参数错误') };
}
try {
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true });
} catch (error) { }
return { code: 200 };
}
}
+382 -217
View File
@@ -1,22 +1,31 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import {
createRandomString,
fileExist,
getNetIp,
getPlatform,
} from '../config/util';
import { createRandomString } from '../config/util';
import config from '../config';
import * as fs from 'fs';
import jwt from 'jsonwebtoken';
import { authenticator } from '@otplib/preset-default';
import { AuthDataType, AuthInfo, AuthModel, AuthModelInfo, LoginStatus } from '../data/auth';
import {
AuthDataType,
SystemInfo,
SystemModel,
SystemModelInfo,
LoginStatus,
AuthInfo,
TokenInfo,
} from '../data/system';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
import { Request } from 'express';
import ScheduleService from './schedule';
import SockService from './sock';
import dayjs from 'dayjs';
import IP2Region from 'ip2region';
import requestIp from 'request-ip';
import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store';
import { t, tf } from '../shared/i18n';
@Service()
export default class UserService {
@@ -27,7 +36,7 @@ export default class UserService {
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) { }
) {}
public async login(
payloads: {
@@ -37,163 +46,217 @@ export default class UserService {
req: Request,
needTwoFactor = true,
): Promise<any> {
if (!fs.existsSync(config.authConfigFile)) {
return this.initAuthInfo();
let { username, password } = payloads;
const content = await this.getAuthInfo();
const timestamp = Date.now();
let {
username: cUsername,
password: cPassword,
retries = 0,
lastlogon,
lastip,
lastaddr,
twoFactorActivated,
tokens = {},
platform,
} = content;
const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil(
(retriesTime - (timestamp - lastlogon)) / 1000,
);
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime,
};
}
let { username, password } = payloads;
const content = this.getAuthInfo();
const timestamp = Date.now();
if (content) {
let {
username: cUsername,
password: cPassword,
retries = 0,
lastlogon,
lastip,
lastaddr,
twoFactorActivated,
twoFactorActived,
tokens = {},
platform,
} = content;
// patch old field
twoFactorActivated = twoFactorActivated || twoFactorActived;
if (
username === cUsername &&
password === cPassword &&
twoFactorActivated &&
needTwoFactor
) {
await this.updateAuthInfo(content, {
isTwoFactorChecking: true,
});
return {
code: 420,
message: '',
};
}
if (
(cUsername === 'admin' && cPassword === 'admin') ||
!cUsername ||
!cPassword
) {
return this.initAuthInfo();
}
const ip = requestIp.getClientIp(req) || '';
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
if (username === cUsername && password === cPassword) {
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384',
});
const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil(
(retriesTime - (timestamp - lastlogon)) / 1000,
);
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, {
token,
tokens: updatedTokens,
lastlogon: timestamp,
retries: 0,
lastip: ip,
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
});
this.notificationService.notify(
t('登录通知'),
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
address +
' ' +
req.platform +
t('端') +
' ' +
t('登录成功') +
t('ip地址') +
' ' +
ip,
);
await this.insertDb({
type: AuthDataType.loginLog,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.success,
},
});
this.getLoginLog();
return {
code: 200,
data: {
token,
lastip,
lastaddr,
lastlogon,
retries,
platform,
},
};
} else {
await this.updateAuthInfo(content, {
retries: retries + 1,
lastlogon: timestamp,
lastip: ip,
lastaddr: address,
platform: req.platform,
});
this.notificationService.notify(
t('登录通知'),
t('你于') +
dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
t('在') +
address +
' ' +
req.platform +
t('端') +
' ' +
t('登录失败') +
t('ip地址') +
' ' +
ip,
);
await this.insertDb({
type: AuthDataType.loginLog,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.fail,
},
});
this.getLoginLog();
if (retries > 2) {
const waitTime = Math.round(Math.pow(3, retries + 1));
return {
code: 410,
message: `失败次数过多,请${waitTime}秒后重试`,
message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime,
};
}
if (
username === cUsername &&
password === cPassword &&
twoFactorActivated &&
needTwoFactor
) {
this.updateAuthInfo(content, {
isTwoFactorChecking: true,
});
return {
code: 420,
message: '',
};
}
const { ip, address } = await getNetIp(req);
if (username === cUsername && password === cPassword) {
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? 60 : 20;
let token = jwt.sign({ data }, config.secret as any, {
expiresIn: 60 * 60 * 24 * expiration,
algorithm: 'HS384',
});
this.updateAuthInfo(content, {
token,
tokens: {
...tokens,
[req.platform]: token,
},
lastlogon: timestamp,
retries: 0,
lastip: ip,
lastaddr: address,
platform: req.platform,
isTwoFactorChecking: false,
});
await this.notificationService.notify(
'登录通知',
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${req.platform
}端 登录成功,ip地址 ${ip}`,
);
await this.getLoginLog();
await this.insertDb({
type: AuthDataType.loginLog,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.success,
},
});
return {
code: 200,
data: { token, lastip, lastaddr, lastlogon, retries, platform },
};
} else {
this.updateAuthInfo(content, {
retries: retries + 1,
lastlogon: timestamp,
lastip: ip,
lastaddr: address,
platform: req.platform,
});
await this.notificationService.notify(
'登录通知',
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${req.platform
}端 登录失败,ip地址 ${ip}`,
);
await this.getLoginLog();
await this.insertDb({
type: AuthDataType.loginLog,
info: {
timestamp,
address,
ip,
platform: req.platform,
status: LoginStatus.fail,
},
});
if (retries > 2) {
const waitTime = Math.round(Math.pow(3, retries + 1));
return {
code: 410,
message: `失败次数过多,请${waitTime}秒后重试`,
data: waitTime,
};
} else {
return { code: 400, message: config.authError };
}
return { code: 400, message: t('错误的用户名密码,请重试') };
}
} else {
return this.initAuthInfo();
}
}
public async logout(platform: string): Promise<any> {
const authInfo = this.getAuthInfo();
this.updateAuthInfo(authInfo, {
token: '',
tokens: { ...authInfo.tokens, [platform]: '' },
public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, {
token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: updatedTokens,
});
}
public async getLoginLog(): Promise<Array<AuthModelInfo | undefined>> {
const docs = await AuthModel.findAll({
public async getLoginLog(): Promise<Array<SystemModelInfo | undefined>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.loginLog },
});
if (docs && docs.length > 0) {
const result = docs.sort((a, b) => b.info!.timestamp! - a.info!.timestamp!);
const result = docs.sort(
(a, b) => b.info!.timestamp! - a.info!.timestamp!,
);
if (result.length > 100) {
await AuthModel.destroy({
where: { id: result[result.length - 1].id },
const ids = result.slice(100).map((x) => x.id!);
await SystemModel.destroy({
where: { id: ids },
});
}
return result.map((x) => x.info);
@@ -201,25 +264,11 @@ export default class UserService {
return [];
}
private async insertDb(payload: AuthInfo): Promise<AuthInfo> {
const doc = await AuthModel.create({ ...payload }, { returning: true });
private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await SystemModel.create({ ...payload }, { returning: true });
return doc;
}
private initAuthInfo() {
fs.writeFileSync(
config.authConfigFile,
JSON.stringify({
username: 'admin',
password: 'admin',
}),
);
return {
code: 100,
message: '未找到认证文件,重新初始化',
};
}
public async updateUsernameAndPassword({
username,
password,
@@ -228,49 +277,35 @@ export default class UserService {
password: string;
}) {
if (password === 'admin') {
return { code: 400, message: '密码不能设置为admin' };
return { code: 400, message: t('密码不能设置为admin') };
}
const authInfo = this.getAuthInfo();
this.updateAuthInfo(authInfo, { username, password });
return { code: 200, message: '更新成功' };
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { username, password });
return { code: 200, message: t('更新成功') };
}
public async updateAvatar(avatar: string) {
const authInfo = this.getAuthInfo();
this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: '更新成功' };
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: t('更新成功') };
}
public async getUserInfo(): Promise<any> {
const authFileExist = await fileExist(config.authConfigFile);
if (!authFileExist) {
fs.writeFileSync(
config.authConfigFile,
JSON.stringify({
username: 'admin',
password: 'admin',
}),
);
}
return this.getAuthInfo();
}
public initTwoFactor() {
public async initTwoFactor() {
const secret = authenticator.generateSecret();
const authInfo = this.getAuthInfo();
const authInfo = await this.getAuthInfo();
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
return { secret, url: otpauth };
}
public activeTwoFactor(code: string) {
const authInfo = this.getAuthInfo();
public async activeTwoFactor(code: string) {
const authInfo = await this.getAuthInfo();
const isValid = authenticator.verify({
token: code,
secret: authInfo.twoFactorSecret,
});
if (isValid) {
this.updateAuthInfo(authInfo, { twoFactorActivated: true });
await this.updateAuthInfo(authInfo, { twoFactorActivated: true });
}
return isValid;
}
@@ -283,10 +318,10 @@ export default class UserService {
}: { username: string; password: string; code: string },
req: any,
) {
const authInfo = this.getAuthInfo();
const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
if (!isTwoFactorChecking) {
return { code: 450, message: '未知错误' };
return { code: 450, message: t('未知错误') };
}
const isValid = authenticator.verify({
token: code,
@@ -295,68 +330,85 @@ export default class UserService {
if (isValid) {
return this.login({ username, password }, req, false);
} else {
const { ip, address } = await getNetIp(req);
this.updateAuthInfo(authInfo, {
const ip = requestIp.getClientIp(req) || '';
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp])
.filter(Boolean)
.join(' ');
}
await this.updateAuthInfo(authInfo, {
lastip: ip,
lastaddr: address,
platform: req.platform,
});
return { code: 430, message: '验证失败' };
return { code: 430, message: t('验证失败') };
}
}
public deactiveTwoFactor() {
const authInfo = this.getAuthInfo();
this.updateAuthInfo(authInfo, {
public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, {
twoFactorActivated: false,
twoFactorActived: false,
twoFactorSecret: '',
});
return true;
}
private getAuthInfo() {
const content = fs.readFileSync(config.authConfigFile, 'utf8');
return JSON.parse(content || '{}');
public async getAuthInfo() {
const authInfo = await shareStore.getAuthInfo();
if (authInfo) {
return authInfo;
}
const doc = await this.getDb({ type: AuthDataType.authConfig });
return (doc.info || {}) as AuthInfo;
}
private updateAuthInfo(authInfo: any, info: any) {
fs.writeFileSync(
config.authConfigFile,
JSON.stringify({ ...authInfo, ...info }),
);
private async updateAuthInfo(authInfo: AuthInfo, info: Partial<AuthInfo>) {
const result = { ...authInfo, ...info };
await shareStore.updateAuthInfo(result);
await this.updateAuthDb({
type: AuthDataType.authConfig,
info: result,
});
}
public async getNotificationMode(): Promise<NotificationInfo> {
const doc = await this.getDb({ type: AuthDataType.notification });
return (doc && doc.info) || {};
return (doc.info || {}) as NotificationInfo;
}
private async updateAuthDb(payload: AuthInfo): Promise<any> {
let doc = await AuthModel.findOne({ type: payload.type });
private async updateAuthDb(payload: SystemInfo): Promise<any> {
let doc = await SystemModel.findOne({ where: { type: payload.type } });
if (doc) {
const updateResult = await AuthModel.update(payload, {
const updateResult = await SystemModel.update(payload, {
where: { id: doc.id },
returning: true,
});
doc = updateResult[1][0];
} else {
doc = await AuthModel.create(payload, { returning: true });
doc = await SystemModel.create(payload, { returning: true });
}
return doc;
}
public async getDb(query: any): Promise<any> {
const doc: any = await AuthModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as any);
public async getDb(query: any): Promise<SystemInfo> {
const doc = await SystemModel.findOne({ where: { ...query } });
if (!doc) {
throw new Error(`${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
}
public async updateNotificationMode(notificationInfo: NotificationInfo) {
const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify(
notificationInfo,
'青龙',
`【蛟龙】测试通知 https://t.me/jiao_long`,
t('青龙'),
t('【蛟龙】测试通知 https://t.me/jiao_long'),
);
if (isSuccess) {
const result = await this.updateAuthDb({
@@ -365,7 +417,120 @@ export default class UserService {
});
return { code: 200, data: { ...result, code } };
} else {
return { code: 400, message: '通知发送失败,请检查参数' };
return { code: 400, message: t('通知发送失败,请检查参数') };
}
}
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
if (password === 'admin') {
return { code: 400, message: t('密码不能设置为admin') };
}
const authInfo = await this.getAuthInfo();
const payload = pickBy(
{
retries,
twoFactorActivated,
password,
username,
},
(x) => !isNil(x),
);
await this.updateAuthInfo(authInfo, payload);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { AuthInfo, TokenInfo } from '../data/system';
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
return (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
);
}
/**
* Validates if a token exists in the authentication info.
* Supports both legacy string tokens and new TokenInfo array format.
*
* @param authInfo - The authentication information
* @param headerToken - The token to validate
* @param platform - The platform (desktop, mobile)
* @returns true if the token is valid, false otherwise
*/
export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
if (headerToken === token) {
return true;
}
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
const platformTokens = tokens[platform];
// Handle null/undefined platformTokens
if (platformTokens === null || platformTokens === undefined) {
return false;
}
if (typeof platformTokens === 'string') {
// Legacy format: single string token
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
}
// Unexpected type - log warning and reject
return false;
}
+175
View File
@@ -0,0 +1,175 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'暂无权限': 'Access denied',
'参数错误': 'Invalid parameter',
'参数不正确': 'Invalid parameter',
'文件无法访问': 'File not accessible',
'文件不存在': 'File not found',
'路径不存在': 'Path does not exist',
'路径不正确': 'Invalid path',
'通知发送失败,请检查参数': 'Notification failed, check parameters',
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
'通知发送成功': 'Notification sent successfully',
'设置时区失败': 'Failed to set timezone',
'任务未找到': 'Task not found',
'密码不能设置为admin': 'Password cannot be admin',
'更新成功': 'Update successful',
'未知错误': 'Unknown error',
'验证失败': 'Verification failed',
'实例不存在或已停止': 'Instance does not exist or stopped',
'实例已停止': 'Instance stopped',
'确认停止实例': 'Confirm to stop instance',
'确认停止运行实例': 'Confirm to stop running instance',
'确认停止': 'Confirm to stop',
'确认停止定时任务': 'Confirm to stop scheduled task',
'确认删除': 'Confirm to delete',
'确认删除定时任务': 'Confirm to delete scheduled task',
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
'确认运行': 'Confirm to run',
'确认运行定时任务': 'Confirm to run scheduled task',
'确认保存': 'Confirm to save',
'确认保存文件': 'Confirm to save file',
'确认重启': 'Confirm restart',
'确认启用': 'Confirm to enable',
'确认禁用': 'Confirm to disable',
'确认': 'Confirm',
'删除成功': 'Deleted successfully',
'操作成功': 'Operation successful',
'参数不完整': 'Incomplete parameters',
'默认路径不支持删除': 'Default path cannot be deleted',
'必须在日志目录下': 'Must be within log directory',
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
'系统将在': 'System will',
'秒后自动刷新': 'refresh in seconds',
'生成数据中...': 'Generating data...',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
'不支持当前依赖类型': 'Unsupported dependency type',
'依赖已存在': 'Dependency already exists',
'依赖不存在': 'Dependency does not exist',
'该脚本正在运行中': 'Script is running',
'该脚本未在运行中': 'Script is not running',
'文件内容为空': 'File content is empty',
'文件名不能为空': 'File name cannot be empty',
'标签不能为空': 'Label cannot be empty',
'名称不能为空': 'Name cannot be empty',
'名称不能为保留关键字': 'Name cannot be reserved keyword',
'名称已存在': 'Name already exists',
'密码错误': 'Incorrect password',
'用户不存在': 'User does not exist',
'请输入用户名和密码': 'Please enter username and password',
'无权访问': 'Access denied',
'登录成功': 'Login successful',
'退出成功': 'Logout successful',
'Token 已失效': 'Token expired',
'Token 无效': 'Invalid token',
'两步骤验证已开启': '2FA enabled',
'两步骤验证已关闭': '2FA disabled',
'验证码错误': 'Invalid verification code',
'验证码已过期': 'Verification code expired',
'请先开启两步骤验证': 'Please enable 2FA first',
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
'用户已存在': 'User already exists',
'用户名不能为admin': 'Username cannot be admin',
'不能删除自己': 'Cannot delete yourself',
'不能禁用自己': 'Cannot disable yourself',
'文件路径无效': 'Invalid file path',
'保存成功': 'Saved successfully',
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
'订阅执行完成': 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
'绝对路径必须在日志目录内或使用 /dev/null':
'Absolute path must be within log directory or use /dev/null',
'请先登录': 'Please login first',
'运行中...': 'Running...',
'日志不存在...': 'Log does not exist...',
'未分类': 'Uncategorized',
'任务重复运行': 'Duplicate task execution',
'日志设置为忽略': 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule',
'日志名称只能包含字母、数字、下划线和连字符':
'Log name can only contain letters, numbers, underscores, and hyphens',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
'在': ' in ',
: '',
: 'login failed',
'ip地址': ', IP: ',
'任务#%s': 'Task#%s',
: 'Install',
: 'Uninstall',
: 'Succeeded',
: 'Failed',
'失败次数过多,请%s秒后重试':
'Too many failed attempts, please retry in %s seconds',
: 'Smart Assistant',
: 'More',
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒':
'Start %s dependency %s, start time %s\n\nCurrent system not supported\n\nDependency %s failed, end time %s, elapsed %s seconds',
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒':
'Already installed %s\n\n%s\n\nSkipping install\n\nDependency %s succeeded, end time %s, elapsed %s seconds',
'开始%s依赖 %s,开始时间 %s\n\n':
'Start %s dependency %s, start time %s\n\n',
'依赖%s%s,结束时间 %s,耗时 %s 秒':
'Dependency %s%s, end time %s, elapsed %s seconds',
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置':
'Task: %s, command: %s, schedule: %s, more than %d instances running, please check schedule settings',
: 'QingLong',
'【蛟龙】测试通知 https://t.me/jiao_long':
'[JiaoLong] Test notification https://t.me/jiao_long',
'生成token': 'Generate token',
'删除日志': 'Delete logs',
'## 开始执行... %s\n': '## Start executing... %s\n',
'执行before命令...': 'Execute before command...',
'执行after命令...': 'Execute after command...',
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
'调度器注册失败,任务创建已回滚':
'Scheduler registration failed, task creation rolled back',
'调度器注册失败,任务更新已回滚':
'Scheduler registration failed, task update rolled back',
'调度器注册失败,任务启用已回滚':
'Scheduler registration failed, task enable rolled back',
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
},
};
let currentLang: string = 'zh';
export function setLang(lang: string) {
currentLang = lang || 'zh';
shareStore.setLang(currentLang);
}
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en */
export function systemLang(): string {
const prefix = Intl.DateTimeFormat().resolvedOptions().locale.split('-')[0];
return prefix === 'en' ? 'en' : 'zh';
}
export function t(key: string, lang?: string): string {
const effectiveLang = lang || currentLang;
if (effectiveLang === 'en' && messages.en[key]) {
return messages.en[key];
}
return key;
}
export function tf(key: string, ...args: (string | number)[]): string {
return args.reduce<string>(
(str, arg) => str.replace(/%s|%d/, String(arg)),
t(key),
);
}
+33
View File
@@ -0,0 +1,33 @@
import { Dependence } from '../data/dependence';
import { ICron } from '../protos/cron';
export type Override<
T,
K extends Partial<{ [P in keyof T]: any }> | string,
> = K extends string
? Omit<T, K> & { [P in keyof T]: T[P] | unknown }
: Omit<T, keyof K> & K;
export type TCron = Override<Partial<ICron>, { id: string }>;
export interface IDependencyFn<T> {
(): Promise<T>;
dependency?: Dependence;
}
export interface ICronFn<T> {
(): Promise<T>;
cron?: TCron;
}
export interface ISchedule {
schedule?: string;
name?: string;
command?: string;
id: string;
}
export interface IScheduleFn<T> {
(): Promise<T>;
schedule?: ISchedule;
}
+110
View File
@@ -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();
+223 -14
View File
@@ -1,36 +1,245 @@
import pLimit from "p-limit";
import PQueue, { QueueAddOptions } from 'p-queue-cjs';
import os from 'os';
import { AuthDataType, AuthModel } from "../data/auth";
import { AuthDataType, SystemModel } from '../data/system';
import Logger from '../loaders/logger';
import { Dependence } from '../data/dependence';
import NotificationService from '../services/notify';
import { t, tf } from '../shared/i18n';
import {
ICronFn,
IDependencyFn,
ISchedule,
IScheduleFn,
TCron,
} from './interface';
import config from '../config';
import { credentials } from '@grpc/grpc-js';
import { ApiClient } from '../protos/api';
import { getGrpcCerts } from '../config/grpcCerts';
class TaskLimit {
private oneLimit = pLimit(1);
private cpuLimit = pLimit(Math.max(os.cpus().length, 4));
private dependenyLimit = new PQueue({ concurrency: 1 });
private queuedDependencyIds = new Set<number>([]);
private queuedCrons = new Map<string, ICronFn<any>[]>();
private repeatCronNotifyMap = new Map<string, number>();
private updateLogLimit = new PQueue({ concurrency: 1 });
private cronLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private manualCronoLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private subscriptionLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private scriptLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private systemLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private _client: ApiClient | null = null;
private get client(): ApiClient {
if (!this._client) {
const tlsConfig = getGrpcCerts();
const creds = tlsConfig
? credentials.createSsl(
Buffer.from(tlsConfig.caCert),
Buffer.from(tlsConfig.clientKey),
Buffer.from(tlsConfig.clientCert),
)
: credentials.createInsecure();
this._client = new ApiClient(
`localhost:${config.grpcPort}`,
creds,
{ 'grpc.enable_http_proxy': 0 },
);
}
return this._client;
}
get cronLimitActiveCount() {
return this.cronLimit.pending;
}
get cronLimitPendingCount() {
return this.cronLimit.size;
}
get firstDependencyId() {
return [...this.queuedDependencyIds.values()][0];
}
private notificationService: NotificationService = new NotificationService();
constructor() {
this.setCustomLimit();
this.handleEvents();
}
private handleEvents() {
this.cronLimit.on('add', () => {
Logger.info(
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('active', () => {
Logger.info(
`[schedule][开始处理任务] 运行中任务数: ${
this.cronLimitActiveCount + 1
}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('completed', (param) => {
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
});
this.cronLimit.on('error', (error) => {
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
});
this.cronLimit.on('next', () => {
Logger.info(
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('idle', () => {
Logger.info(`[schedule][任务队列] 空闲中...`);
});
}
public removeQueuedDependency(dependency: Dependence) {
if (this.queuedDependencyIds.has(dependency.id!)) {
this.queuedDependencyIds.delete(dependency.id!);
}
}
public removeQueuedCron(id: string) {
if (this.queuedCrons.has(id)) {
const runs = this.queuedCrons.get(id);
if (runs && runs.length > 0) {
runs.pop();
this.queuedCrons.set(id, runs);
}
}
}
public async setCustomLimit(limit?: number) {
if (limit) {
this.cpuLimit = pLimit(limit);
this.cronLimit.concurrency = limit;
this.manualCronoLimit.concurrency = limit;
return;
}
const doc = await AuthModel.findOne({ where: { type: AuthDataType.systemConfig } });
await SystemModel.sync();
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
if (doc?.info?.cronConcurrency) {
this.cpuLimit = pLimit(doc?.info?.cronConcurrency);
this.cronLimit.concurrency = doc.info.cronConcurrency;
this.manualCronoLimit.concurrency = doc.info.cronConcurrency;
}
}
public runWithCpuLimit<T>(fn: () => Promise<T>): Promise<T> {
return this.cpuLimit(() => {
return fn();
public async runWithCronLimit<T>(
cron: TCron,
fn: ICronFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.cron = cron;
let runs = this.queuedCrons.get(cron.id);
const result = runs?.length ? [...runs, fn] : [fn];
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
if (result?.length > 5) {
if (repeatTimes < 3) {
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
this.client.systemNotify(
{
title: t('任务重复运行'),
content: tf(
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置',
cron.name || '',
cron.command || '',
cron.schedule || '',
5,
),
},
(err, res) => {
if (err) {
Logger.error(
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
);
}
},
);
}
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
return;
}
this.queuedCrons.set(cron.id, result);
return this.cronLimit.add(fn, options);
}
public async manualRunWithCronLimit<T>(
fn: () => Promise<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
return this.manualCronoLimit.add(fn, options);
}
public async runWithSubscriptionLimit<T>(
schedule: TCron,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.subscriptionLimit.add(fn, options);
}
public async runWithSystemLimit<T>(
schedule: TCron,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.systemLimit.add(fn, options);
}
public async runWithScriptLimit<T>(
schedule: ISchedule,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.scriptLimit.add(fn, options);
}
public async waitDependencyQueueDone(): Promise<void> {
if (this.dependenyLimit.size === 0 && this.dependenyLimit.pending === 0) {
return;
}
return new Promise((resolve) => {
const onIdle = () => {
this.dependenyLimit.removeListener('idle', onIdle);
resolve();
};
this.dependenyLimit.on('idle', onIdle);
});
}
public runOneByOne<T>(fn: () => Promise<T>): Promise<T> {
return this.oneLimit(() => {
return fn();
});
public runDependeny<T>(
dependency: Dependence,
fn: IDependencyFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
this.queuedDependencyIds.add(dependency.id!);
fn.dependency = dependency;
return this.dependenyLimit.add(fn, options);
}
public updateDepLog<T>(
fn: () => Promise<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
return this.updateLogLimit.add(fn, options);
}
}

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