Compare commits

...
452 Commits
Author SHA1 Message Date
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
whyour 5a2caeb66b 更新版本 v2.15.17 2023-07-02 16:09:09 +08:00
whyour ee6e5bd8b4 修改任务状态值 2023-07-02 16:09:01 +08:00
whyour f970322f0a 修复定时任务状态筛选 2023-07-02 12:19:34 +08:00
whyour 1718120623 修改更新仓库逻辑 2023-07-01 20:48:28 +08:00
whyour 702c3160ec 修改并发逻辑,系统设置增加定时任务并发设置 2023-07-01 15:26:20 +08:00
whyour db227e56bf 更新版本 v2.15.16 2023-06-18 15:51:42 +08:00
whyour 84828865f7 修复依赖安装流程 2023-06-18 15:51:35 +08:00
whyour 87455cebc0 修改应用设置 loading 默认值 2023-06-17 20:26:40 +08:00
whyour 9f4435b237 修改初始化逻辑 2023-06-16 02:12:25 +08:00
whyour e5bc4b3b04 修复关闭全部任务视图,默认视图筛选错误 2023-06-14 22:38:15 +08:00
whyour 11e1b50000 修改订阅通知 2023-06-11 20:05:39 +08:00
whyour bec226ae13 增加初始化文件写入,修复 shell 变量 2023-06-11 19:58:30 +08:00
whyour c9ecdc6c97 企业微信通知增加代理地址配置 QYWX_ORIGIN 2023-06-11 15:03:07 +08:00
whyour 4cf64e7af0 增加 demo 环境变量 2023-06-10 22:20:19 +08:00
whyour 4ce01c5f0e 修复初始化界面样式 2023-06-10 16:16:36 +08:00
whyour 2eff99abe1 修复侧边栏样式 2023-06-10 13:27:21 +08:00
whyour 5bf57185d9 更新 readme 2023-06-10 12:52:53 +08:00
whyour c59dac19ae 修复 shell 变量无引号报错 2023-06-09 19:50:38 +08:00
whyour f1ef8e72ae 修复解析脚本名称逻辑 2023-06-09 19:45:25 +08:00
whyour 5b1accffb7 修改 api 限流策略,修复检查检查日志 2023-06-07 22:45:37 +08:00
whyour f64ff83414 修复列表查询字符转码 2023-06-04 22:11:48 +08:00
whyour 26c914d1db 修复侧边栏样式 2023-06-03 01:24:52 +08:00
whyour 8f75a54110 修改 textarea 样式 2023-06-03 00:48:04 +08:00
whyour 874b6e3a56 修复错误页样式 2023-06-01 23:02:36 +08:00
whyour f305d45225 修复依赖安装状态流转 2023-05-31 15:07:40 +08:00
whyour a7f97bd729 替换 spawn 为 cross-spawn 2023-05-31 14:25:20 +08:00
whyour 0bbb7c7b89 修复获取 cpu 核数 2023-05-30 17:45:47 +08:00
whyour f7465759c7 降级 p-limit,修复 esm 问题 2023-05-30 17:31:31 +08:00
whyour 998e82d323 增加 api 限流配置 2023-05-30 16:41:19 +08:00
whyour f8dfee8945 重构任务并发执行逻辑 2023-05-30 16:32:00 +08:00
whyour 86e3d8736b 更新版本 v2.15.15 2023-05-28 20:06:15 +08:00
whyour 85d32d00e1 修改打包 latest 镜像分支 2023-05-28 20:06:05 +08:00
whyour 6a52e2f804 修复 env.js 路径 2023-05-27 23:05:46 +08:00
whyour b4cb72c5d0 修改错误页面提示 2023-05-27 11:35:30 +08:00
whyour 79fd4ec58f 修复 nginx 变量替换 2023-05-26 17:08:40 +08:00
jzksnsjswkwandGitHub 49ed5c6632 修复 可执行文件 提示找不到文件的问题 (#1943) 2023-05-26 15:17:47 +08:00
whyour ffba7ee4a1 增加定时任务服务启动失败日志 2023-05-25 21:45:28 +08:00
whyour 91d080472f 修复环境变量接口前缀 2023-05-24 23:46:07 +08:00
whyour 826f214f0f 修复设置通知错误时提示不清晰 2023-05-24 23:37:44 +08:00
whyour ca150dc6a6 修复 docker-compose 配置 2023-05-22 20:20:46 +08:00
whyour ae5db60211 修复筛选可能报错 2023-05-21 12:29:29 +08:00
whyour 490bdc15f6 支持非根目录部署 2023-05-19 01:10:33 +08:00
whyour 1377fe31aa 更新版本 v2.15.14 2023-05-15 23:44:42 +08:00
whyour 3d1cf6bf2e 修改群晖名称 2023-05-15 23:44:32 +08:00
whyour 6ac755b478 修复后端 html 缓存 2023-05-15 22:34:06 +08:00
whyour e70fa83074 修复获取配置文件参数 2023-05-14 20:53:45 +08:00
whyour 41521f1d08 修复 token 任务脚本路径,容器启动 bot 2023-05-13 01:10:49 +08:00
whyour 8b7bef097c 修复拉取仓库逻辑 2023-05-12 18:28:01 +08:00
whyour 618756d4a9 修复设置代理时,服务启动失败 2023-05-10 23:17:05 +08:00
whyour 70d0c71414 修复错误日志样式 2023-05-10 23:01:51 +08:00
whyour 65eb9c806d 更新 sqlite 2023-05-10 21:58:11 +08:00
whyour 50718ba714 修复 public 服务转发 2023-05-10 01:07:46 +08:00
whyour 7c3bb90d12 修复删除依赖和移动环境变量接口数据结构 2023-05-08 21:29:25 +08:00
whyour f66390eabd 修改初始系统状态逻辑已经错误页面 2023-05-07 23:40:37 +08:00
whyour 481646501c 修复订阅更新仓库失败 2023-05-06 22:58:05 +08:00
whyour 5baa8e4391 增加 pm2 平滑重启 2023-05-06 00:09:03 +08:00
whyour 3d11210aeb pnpm 版本降至 8.3.1 2023-05-05 00:26:23 +08:00
whyour e1863407f2 修复 npm 源判断 2023-05-04 22:23:55 +08:00
whyour 0c28b63ab7 修改容器启动流程 2023-05-04 22:07:39 +08:00
whyour 8b8bd279c6 修复 nginx 转发stream接口失效,停止运行命令支持 pid 参数 2023-05-04 21:28:14 +08:00
208 changed files with 28124 additions and 14019 deletions
+7 -8
View File
@@ -1,13 +1,12 @@
PUBLIC_PORT=5400 GRPC_PORT=5500
CRON_PORT=5500
BACK_PORT=5600 BACK_PORT=5600
PORT=5700 PORT=5700
LOG_LEVEL='debug' LOG_LEVEL='info'
SECRET='whyour' JWT_SECRET=
JWT_EXPIRES_IN=
QINIU_AK='' QINIU_AK=
QINIU_SK='' QINIU_SK=
QINIU_SCOPE='' QINIU_SCOPE=
TEMP=''
+138 -51
View File
@@ -2,54 +2,55 @@ name: Build And Push Docker Image
on: on:
push: push:
paths-ignore:
- "*.md"
branches: branches:
- 'master' - "master"
- 'develop' - "develop"
tags: tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 - "v*"
schedule: schedule:
# 参考 https://jasonet.co/posts/scheduled-actions/ - cron: "00 20 * * *"
- cron: '00 14 * * *' # GMT 15:00 => 北京时间 23:00
workflow_dispatch: workflow_dispatch:
jobs: jobs:
to_gitlab: code_gitlab:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1 - uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
with: with:
target_repo_url: source-repo: https://github.com/whyour/qinglong.git
git@gitlab.com:whyour/qinglong.git destination-repo: git@gitlab.com:whyour/qinglong.git
ssh_private_key:
${{ secrets.GITLAB_SSH_PK }}
to_gitee: code_gitee:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1 - uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
with: with:
target_repo_url: source-repo: https://github.com/whyour/qinglong.git
git@gitee.com:whyour/qinglong.git destination-repo: git@gitee.com:whyour/qinglong.git
ssh_private_key:
${{ secrets.GITLAB_SSH_PK }}
build-static: build-static:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v3
with: with:
version: latest version: "8.3.1"
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
cache: 'pnpm' cache: "pnpm"
- name: build front and back - name: build front and back
run: | run: |
@@ -74,49 +75,65 @@ jobs:
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH} git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
mkdir -p ~/.ssh static_gitlab:
echo "${PRIVATE_KEY}" > ~/.ssh/id_rsa needs: build-static
chmod 600 ~/.ssh/id_rsa runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
with:
source-repo: https://github.com/whyour/qinglong-static.git
destination-repo: git@gitlab.com:whyour/qinglong-static.git
export GIT_SSH_COMMAND="ssh -v -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -l git" static_gitee:
git remote add gitee "${REPO_GITEE}" needs: build-static
git remote add gitlab "${REPO_GITLAB}" runs-on: ubuntu-latest
git push --force --quiet gitee ${GITHUB_BRANCH}:${GITHUB_BRANCH} steps:
git push --force --quiet gitlab ${GITHUB_BRANCH}:${GITHUB_BRANCH} - uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wearerequired/git-mirror-action@v1
env:
SSH_PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
with:
source-repo: https://github.com/whyour/qinglong-static.git
destination-repo: git@gitee.com:whyour/qinglong-static.git
build: build:
if: github.ref != 'refs/heads/master'
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
contents: read contents: read
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v3
with: with:
version: latest version: "8.3.1"
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
cache: 'pnpm' cache: "pnpm"
- name: Setup timezone - name: Setup timezone
uses: zcong1993/setup-timezone@master uses: szenius/set-timezone@v2.0
with: with:
timezone: Asia/Shanghai timezoneLinux: Asia/Shanghai
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -124,38 +141,42 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ github.repository }} ${{ github.repository }}
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
# generate Docker tags based on the following events/attributes # generate Docker tags based on the following events/attributes
# nightly, master, pr-2, 1.2.3, 1.2, 1 # nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: |
latest=false
tags: | tags: |
type=schedule,pattern=nightly type=schedule,pattern=nightly
type=edge type=edge
type=ref,event=branch
type=ref,event=pr 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={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Build and push - name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v3 uses: docker/build-push-action@v6
with: with:
build-args: | build-args: |
MAINTAINER=${{ github.repository_owner }} MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }} QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }} SOURCE_COMMIT=${{ github.sha }}
network: host network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386 # linux/s390x npm 暂不可用
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
context: . context: .
file: ./docker/Dockerfile file: ./docker/Dockerfile
push: true push: true
@@ -165,4 +186,70 @@ jobs:
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max cache-to: type=registry,ref=whyour/qinglong:cache,mode=max
- name: Image digest - name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }} run: |
echo ${{ steps.docker_build.outputs.digest }}
build310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v4
with:
cache: "pnpm"
- name: Setup timezone
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push python3.10
id: docker_build_310
uses: docker/build-push-action@v6
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# linux/s390x npm 暂不可用
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
context: .
file: ./docker/310.Dockerfile
push: true
tags: whyour/qinglong:python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max
- name: Image digest
run: |
echo ${{ steps.docker_build_310.outputs.digest }}
+6 -7
View File
@@ -22,10 +22,9 @@
.env .env
.history .history
.version.ts .version.ts
/.tmp
/config __pycache__
/log /shell/preload/env.*
/db /shell/preload/notify.*
/manual_log /shell/preload/*-notify.json
/scripts /shell/preload/__ql_notify__.*
/bak
-1
View File
@@ -1,2 +1 @@
sentrycli_cdnurl=https://npmmirror.com/mirrors/sentry-cli/
strict-peer-dependencies=false strict-peer-dependencies=false
+20 -4
View File
@@ -2,7 +2,23 @@
**/*.svg **/*.svg
**/*.ejs **/*.ejs
**/*.html **/*.html
package.json /.umi
.umi /.umi-production
.umi-production /.umi-test
.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
+16 -16
View File
@@ -1,26 +1,26 @@
import { defineConfig } from '@umijs/max'; import { defineConfig } from '@umijs/max';
const CompressionPlugin = require('compression-webpack-plugin'); const CompressionPlugin = require('compression-webpack-plugin');
const baseUrl = process.env.QlBaseUrl || '/';
export default defineConfig({ export default defineConfig({
hash: true, hash: true,
jsMinifier: 'terser',
antd: {}, antd: {},
locale: {
antd: true,
title: true,
baseNavigator: true,
},
outputPath: 'static/dist', outputPath: 'static/dist',
fastRefresh: true, fastRefresh: true,
favicons: ['./images/favicon.svg'], favicons: [`https://qn.whyour.cn/favicon.svg`],
mfsu: {
strategy: 'eager',
},
publicPath: process.env.NODE_ENV === 'production' ? './' : '/', publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
proxy: { proxy: {
'/api/public': { [`${baseUrl}api`]: {
target: 'http://127.0.0.1:5400/',
changeOrigin: true,
pathRewrite: { '^/api/public': '/api/' },
},
'/api': {
target: 'http://127.0.0.1:5600/', target: 'http://127.0.0.1:5600/',
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
pathRewrite: { [`^${baseUrl}api`]: '/api' },
}, },
}, },
chainWebpack: ((config: any) => { chainWebpack: ((config: any) => {
@@ -33,12 +33,12 @@ export default defineConfig({
}), }),
); );
}) as any, }) as any,
externals: { headScripts: [`./api/env.js`],
react: 'window.React', copy: [
'react-dom': 'window.ReactDOM', {
from: 'node_modules/monaco-editor/min/vs',
to: 'static/dist/monaco-editor/min/vs',
}, },
headScripts: [
'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',
], ],
npmClient: 'pnpm',
}); });
+51 -170
View File
@@ -1,30 +1,29 @@
<p align="center">
<a href="https://github.com/whyour/qinglong">
<img width="150" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
</a>
</p>
<h1 align="center">Green Dragon</h1>
<div align="center"> <div align="center">
<img width="100" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
<h1 align="center">Qinglong</h1>
[简体中文](./README.md) | English
Timed task management platform supporting Python3, JavaScript, Shell, Typescript Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[![docker version][docker-version-image]][docker-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url] [![npm version][npm-version-image]][npm-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
[npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat
[npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme
[docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat [docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat
[docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong [docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong
[docker-version-image]: https://img.shields.io/docker/v/whyour/qinglong?style=flat
[docker-version-url]: https://hub.docker.com/r/whyour/qinglong/tags?page=1&ordering=last_updated
[docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat [docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat
[docker-stars-url]: https://hub.docker.com/r/whyour/qinglong [docker-stars-url]: https://hub.docker.com/r/whyour/qinglong
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat [docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong [docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div> </div>
[![](https://user-images.githubusercontent.com/22700758/229290661-03aabe84-8780-4ef0-8e75-2146f4636130.jpeg)](https://whyour.cn) ![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
[简体中文](./README.md) | English
## Features ## Features
@@ -36,150 +35,50 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- Support dark mode - Support dark mode
- Support cell phone operation - 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.
```bash
docker pull whyour/qinglong:latest
docker pull 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 ## Deployment
### Local Deployment [View Documentation](https://qinglong.online/guide/getting-started/installation-guide)
## Built-in API
[View Documentation](https://qinglong.online/guide/user-guide/built-in-api)
## Built-in commands
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
## Development
```bash ```bash
# To be refined, see the development steps first (not supported on windows yet) 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
``` ```
### Podman Deployment Open your browser and visit <http://127.0.0.1:5700>
1. podman installation
```bash
https://podman.io/getting-started/installation
```
2. start the container
```bash
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### Docker Deployment
1. docker installation
```bash
sudo curl -sSL get.docker.com | sh
```
2. configure domestic mirror sources
```bash
mkdir -p /etc/docker
tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://0b27f0a81a00f3560fbdc00ddd2f99e0.mirror.swr.myhuaweicloud.com",
"https://ypzju6vq.mirror.aliyuncs.com",
"https://registry.docker-cn.com",
"http://hub-mirror.c.163.com",
"https://docker.mirrors.ustc.edu.cn"
]
}
EOF
systemctl daemon-reload
systemctl restart docker
```
3. start the container
```bash
docker run -dit \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
### Docker-compose Deployment
1. docker-compose installation
```bash
sudo curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
```
2. start the container
```bash
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# start
docker-compose up -d
# stop
docker-compose down
```
3. access
Open your browser and visit http://{ip}:5700
## Use
1. built-in commands
```bash
# Update and restart Green Dragon
ql update
# Run custom scripts extra.sh
ql extra
# Adding a single script file
ql raw <file_url>
# Add a specific script for a single repository
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch>
# Delete old logs
ql rmlog <days>
# Start bot
ql bot
# Detecting the Green Dragon environment and repairing it
ql check
# Reset the number of login errors
ql resetlet
# Disable two-step login
ql resettfa
# Execute in sequence, if a random delay is set, it will be randomly delayed by a certain number of seconds
task <file_path>
# Execute in sequence, regardless of whether a random delay is set, all run immediately,
# and the foreground will output the day, while recorded in the log file
task <file_path> now
# Concurrent execution, regardless of whether a random delay is set, are run immediately,
# the foreground does not generate the day, directly recorded in the log file, and can be specified account execution
task <file_path> conc <env_name> <account_number>(Optional)
# Specify the account to execute and run immediately regardless of whether a random delay is set
task <file_path> desi <env_name> <account_number>
# Set task timeout
task -m <max_time> <file_path>
# Print task log in real time, no need to carry this parameter when creating timed tasks
task -l <file_path>
```
2. parameter description
* file_url: Script address
* repo_url: Repository address
* whitelist: The whitelist when pulling the repository, i.e., the string contained in the path of the script to be pulled
* blacklist: Blacklisting when pulling repositories, i.e. strings that are not included in the path of the script to be pulled
* dependence: Pulling the dependencies needed for the repository will be copied directly from the repository to the repository directory under scripts, regardless of the blacklist
* branch: Pull the branch of the repository
* days: Number of days of logs to be kept
* file_path: File path for task execution
* env_name: The name of the environment variable that needs to be concurrent or specified at the time of task execution
* account_number: Specify the account number of an environment variable to be executed when the task is executed
* max_time: Timeout, suffix "s" for seconds (default), "m" for minutes, "h" for hours, "d" for days
## Links ## Links
@@ -191,24 +90,6 @@ task -l <file_path>
- [darkreader](https://github.com/darkreader/darkreader) - [darkreader](https://github.com/darkreader/darkreader)
- [admin-server](https://github.com/sunpu007/admin-server) - [admin-server](https://github.com/sunpu007/admin-server)
## Development
```bash
$ git clone git@github.com:whyour/qinglong.git
$ cd qinglong
$ cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
$ npm install -g pnpm
$ pnpm install
$ pnpm start
```
Open your browser and visit http://127.0.0.1:5700
## Communication
[telegram channel](https://t.me/jiao_long)
## Name Origin ## Name Origin
The Green Dragon, also known as the Canglong, is one of the four elephants and one of the [four spirits of the heavens](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5) in traditional Chinese culture. According to the Five Elements, it is a spirit animal representing the East as a green dragon, the five elements are wood, and the season represented is spring, with the eight trigrams dominating vibration. Like the Ying Long, the Cang Long has feathered wings. According to the Zhang Guo Xing Zong (Zhang Guo Xing Zong), "a true dragon is one that has complementary wings". The Green Dragon, also known as the Canglong, is one of the four elephants and one of the [four spirits of the heavens](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5) in traditional Chinese culture. According to the Five Elements, it is a spirit animal representing the East as a green dragon, the five elements are wood, and the season represented is spring, with the eight trigrams dominating vibration. Like the Ying Long, the Cang Long has feathered wings. According to the Zhang Guo Xing Zong (Zhang Guo Xing Zong), "a true dragon is one that has complementary wings".
+51 -167
View File
@@ -1,30 +1,31 @@
<p align="center"> <div align="center">
<a href="https://github.com/whyour/qinglong"> <img width="100" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
<img width="150" src="https://user-images.githubusercontent.com/22700758/191449379-f9f56204-0e31-4a16-be5a-331f52696a73.png">
</a>
</p>
<h1 align="center">青龙</h1> <h1 align="center">青龙</h1>
<div align="center"> 简体中文 | [English](./README-en.md)
支持 Python3、JavaScript、Shell、Typescript 的定时任务管理平台 支持 Python3、JavaScript、Shell、Typescript 的定时任务管理平台
[![docker version][docker-version-image]][docker-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url] Timed task management platform supporting Python3, JavaScript, Shell, Typescript
[![npm version][npm-version-image]][npm-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url]
[npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat
[npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme
[docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat [docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat
[docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong [docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong
[docker-version-image]: https://img.shields.io/docker/v/whyour/qinglong?style=flat
[docker-version-url]: https://hub.docker.com/r/whyour/qinglong/tags?page=1&ordering=last_updated
[docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat [docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat
[docker-stars-url]: https://hub.docker.com/r/whyour/qinglong [docker-stars-url]: https://hub.docker.com/r/whyour/qinglong
[docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat [docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat
[docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong [docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong
[Demo](http://demo.ninesix.cc:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong)
[演示](http://demo.ninesix.cc:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
</div> </div>
[![](https://user-images.githubusercontent.com/22700758/229290661-03aabe84-8780-4ef0-8e75-2146f4636130.jpeg)](https://whyour.cn) ![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png)
简体中文 | [English](./README-en.md)
## 功能 ## 功能
@@ -36,149 +37,50 @@
- 支持暗黑模式 - 支持暗黑模式
- 支持手机端操作 - 支持手机端操作
## 版本
### docker
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
```bash
docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian
```
### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
```bash
npm i @whyour/qinglong
```
## 部署 ## 部署
### 本机部署 [查看文档](https://qinglong.online/guide/getting-started/installation-guide)
## 内置 API
[查看文档](https://qinglong.online/guide/user-guide/built-in-api)
## 内置命令
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
## 开发
```bash ```bash
# 待完善,可先参考开发步骤 (windows暂时不支持) 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
``` ```
### podman 部署 打开你的浏览器,访问 <http://127.0.0.1:5700>
1. podman 安装
```bash
https://podman.io/getting-started/installation
```
2. 启动容器
```bash
podman run -dit \
--network bridge \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
docker.io/whyour/qinglong:latest
```
### docker 部署
1. docker 安装
```bash
sudo curl -sSL get.docker.com | sh
```
2. 配置国内镜像源
```bash
mkdir -p /etc/docker
tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": [
"https://0b27f0a81a00f3560fbdc00ddd2f99e0.mirror.swr.myhuaweicloud.com",
"https://ypzju6vq.mirror.aliyuncs.com",
"https://registry.docker-cn.com",
"http://hub-mirror.c.163.com",
"https://docker.mirrors.ustc.edu.cn"
]
}
EOF
systemctl daemon-reload
systemctl restart docker
```
3. 启动容器
```bash
docker run -dit \
-v $PWD/ql/data:/ql/data \
-p 5700:5700 \
--name qinglong \
--hostname qinglong \
--restart unless-stopped \
whyour/qinglong:latest
```
### docker-compose 部署
1. docker-compose 安装
```bash
sudo curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
```
2. 启动容器
```bash
mkdir qinglong
wget https://raw.githubusercontent.com/whyour/qinglong/master/docker/docker-compose.yml
# 启动
docker-compose up -d
# 停止
docker-compose down
```
3. 访问
打开你的浏览器,访问 http://{ip}:5700
## 使用
1. 内置命令
```bash
# 更新并重启青龙
ql update
# 运行自定义脚本extra.sh
ql extra
# 添加单个脚本文件
ql raw <file_url>
# 添加单个仓库的指定脚本
ql repo <repo_url> <whitelist> <blacklist> <dependence> <branch> <extensions>
# 删除旧日志
ql rmlog <days>
# 启动tg-bot
ql bot
# 检测青龙环境并修复
ql check
# 重置登录错误次数
ql resetlet
# 禁用两步登录
ql resettfa
# 依次执行,如果设置了随机延迟,将随机延迟一定秒数
task <file_path>
# 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日,同时记录在日志文件中
task <file_path> now
# 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日,直接记录在日志文件中,且可指定账号执行
task <file_path> conc <env_name> <account_number>(可选的)
# 指定账号执行,无论是否设置了随机延迟,均立即运行
task <file_path> desi <env_name> <account_number>
# 设置任务超时时间
task -m <max_time> <file_path>
# 实时打印任务日志,创建定时任务时,不用携带此参数
task -l <file_path>
```
2. 参数说明
* file_url: 脚本地址
* repo_url: 仓库地址
* whitelist: 拉取仓库时的白名单,即就是需要拉取的脚本的路径包含的字符串,多个竖线分割
* blacklist: 拉取仓库时的黑名单,即就是需要拉取的脚本的路径不包含的字符串,多个竖线分割
* dependence: 拉取仓库需要的依赖文件,会直接从仓库拷贝到scripts下的仓库目录,不受黑名单影响,多个竖线分割
* extensions: 拉取仓库的文件后缀,多个竖线分割
* branch: 拉取仓库的分支
* days: 需要保留的日志的天数
* file_path: 任务执行时的文件路径
* env_name: 任务执行时需要并发或者指定时的环境变量名称
* account_number: 任务执行时指定某个环境变量需要执行的账号序号
* max_time: 超时时间,后缀"s"代表秒(默认值), "m"代表分, "h"代表小时, "d"代表天
## 链接 ## 链接
@@ -190,24 +92,6 @@ task -l <file_path>
- [darkreader](https://github.com/darkreader/darkreader) - [darkreader](https://github.com/darkreader/darkreader)
- [admin-server](https://github.com/sunpu007/admin-server) - [admin-server](https://github.com/sunpu007/admin-server)
## 开发
```bash
$ git clone git@github.com:whyour/qinglong.git
$ cd qinglong
$ cp .env.example .env
# 推荐使用 pnpm https://pnpm.io/zh/installation
$ npm install -g pnpm
$ pnpm install
$ pnpm start
```
打开你的浏览器,访问 http://127.0.0.1:5700
## 交流
[telegram频道](https://t.me/jiao_long)
## 名称来源 ## 名称来源
青龙,又名苍龙,在中国传统文化中是四象之一、[天之四灵](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5)之一,根据五行学说,它是代表东方的灵兽,为青色的龙,五行属木,代表的季节是春季,八卦主震。苍龙与应龙一样,都是身具羽翼。《张果星宗》称“又有辅翼,方为真龙”。 青龙,又名苍龙,在中国传统文化中是四象之一、[天之四灵](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5)之一,根据五行学说,它是代表东方的灵兽,为青色的龙,五行属木,代表的季节是春季,八卦主震。苍龙与应龙一样,都是身具羽翼。《张果星宗》称“又有辅翼,方为真龙”。
+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.
+40 -21
View File
@@ -1,21 +1,38 @@
import { getFileContentByName, getLastModifyFilePath } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs'; import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { join } from 'path';
import { SAMPLE_FILES } from '../config/const';
import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
app.use('/configs', route); app.use('/configs', route);
route.get(
'/sample',
async (req: Request, res: Response, next: NextFunction) => {
try {
res.send({
code: 200,
data: SAMPLE_FILES,
});
} catch (e) {
return next(e);
}
},
);
route.get( route.get(
'/files', '/files',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const fileList = fs.readdirSync(config.configPath, 'utf-8'); const fileList = await fs.readdir(config.configPath, 'utf-8');
res.send({ res.send({
code: 200, code: 200,
data: fileList data: fileList
@@ -31,24 +48,11 @@ export default (app: Router) => {
); );
route.get( route.get(
'/:file', '/detail',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let content = ''; const configService = Container.get(ConfigService);
if (config.blackFileList.includes(req.params.file)) { await configService.getFile(req.query.path as string, res);
res.send({ code: 403, message: '文件无法访问' });
}
if (req.params.file.includes('sample')) {
content = getFileContentByName(
`${config.samplePath}${req.params.file}`,
);
} else {
content = getFileContentByName(
`${config.configPath}${req.params.file}`,
);
}
res.send({ code: 200, data: content });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -70,12 +74,27 @@ export default (app: Router) => {
if (config.blackFileList.includes(name)) { if (config.blackFileList.includes(name)) {
res.send({ code: 403, message: '文件无法访问' }); res.send({ code: 403, message: '文件无法访问' });
} }
const path = `${config.configPath}${name}`; let path = join(config.configPath, name);
fs.writeFileSync(path, content); if (name.startsWith('data/scripts/')) {
path = join(config.rootPath, name);
}
await writeFileWithLock(path, content);
res.send({ code: 200, message: '保存成功' }); res.send({ code: 200, message: '保存成功' });
} catch (e) { } catch (e) {
return next(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);
}
},
);
}; };
+7 -28
View File
@@ -4,7 +4,8 @@ import { Logger } from 'winston';
import CronService from '../services/cron'; import CronService from '../services/cron';
import CronViewService from '../services/cronView'; import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import cron_parser from 'cron-parser'; import { commonCronSchema } from '../validation/schedule';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -170,24 +171,14 @@ export default (app: Router) => {
route.post( route.post(
'/', '/',
celebrate({ celebrate({
body: Joi.object({ body: Joi.object(commonCronSchema),
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional(),
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
}),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
if (cron_parser.parseExpression(req.body.schedule).hasNext()) {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.create(req.body); const data = await cronService.create(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -328,27 +319,16 @@ export default (app: Router) => {
'/', '/',
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
labels: Joi.array().optional().allow(null), ...commonCronSchema,
command: Joi.string().required(),
schedule: Joi.string().required(),
name: Joi.string().optional().allow(null),
sub_id: Joi.number().optional().allow(null),
id: Joi.number().required(), id: Joi.number().required(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
if (
!req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext()
) {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.update(req.body); const data = await cronService.update(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} else {
return res.send({ code: 400, message: 'param schedule error' });
}
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -412,7 +392,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.import_crontab(); const data = await cronService.importCrontab();
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -452,13 +432,12 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const data = await cronService.status({ const data = await cronService.status({
...req.body, ...req.body,
status: parseInt(req.body.status), status: req.body.status ? parseInt(req.body.status) : undefined,
pid: parseInt(req.body.pid) || '', pid: req.body.pid ? parseInt(req.body.pid) : undefined,
}); });
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
+16 -6
View File
@@ -32,7 +32,6 @@ export default (app: Router) => {
), ),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.create(req.body); const data = await dependenceService.create(req.body);
@@ -54,7 +53,6 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.update(req.body); const data = await dependenceService.update(req.body);
@@ -71,7 +69,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()), body: Joi.array().items(Joi.number().required()),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body); const data = await dependenceService.remove(req.body);
@@ -88,7 +85,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()), body: Joi.array().items(Joi.number().required()),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.remove(req.body, true); const data = await dependenceService.remove(req.body, true);
@@ -107,7 +103,6 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.getDb({ id: req.params.id }); const data = await dependenceService.getDb({ id: req.params.id });
@@ -124,7 +119,6 @@ export default (app: Router) => {
body: Joi.array().items(Joi.number().required()), body: Joi.array().items(Joi.number().required()),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const dependenceService = Container.get(DependenceService); const dependenceService = Container.get(DependenceService);
const data = await dependenceService.reInstall(req.body); const data = await dependenceService.reInstall(req.body);
@@ -134,4 +128,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);
}
},
);
}; };
+6 -2
View File
@@ -6,6 +6,7 @@ import { celebrate, Joi } from 'celebrate';
import multer from 'multer'; import multer from 'multer';
import config from '../config'; import config from '../config';
import fs from 'fs'; import fs from 'fs';
import { safeJSONParse } from '../config/util';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -50,6 +51,9 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
if (!req.body?.length) {
return res.send({ code: 400, message: '参数不正确' });
}
const data = await envService.create(req.body); const data = await envService.create(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
@@ -200,7 +204,7 @@ export default (app: Router) => {
try { try {
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8'); const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
const parseContent = JSON.parse(fileContent); const parseContent = safeJSONParse(fileContent);
const data = Array.isArray(parseContent) const data = Array.isArray(parseContent)
? parseContent ? parseContent
: [parseContent]; : [parseContent];
@@ -216,7 +220,7 @@ export default (app: Router) => {
} else { } else {
return res.send({ return res.send({
code: 400, code: 400,
message: '文件缺少name或者value字段,参考导出文件格式', message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式',
}); });
} }
} catch (e) { } 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,
});
}
});
};
+4
View File
@@ -9,6 +9,8 @@ import open from './open';
import dependence from './dependence'; import dependence from './dependence';
import system from './system'; import system from './system';
import subscription from './subscription'; import subscription from './subscription';
import update from './update';
import health from './health';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -22,6 +24,8 @@ export default () => {
dependence(app); dependence(app);
system(app); system(app);
subscription(app); subscription(app);
update(app);
health(app);
return app; return app;
}; };
+86 -23
View File
@@ -1,11 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express'; import { celebrate, Joi } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import * as fs from 'fs';
import config from '../config'; import config from '../config';
import { emptyDir, getFileContentByName, readDirs } from '../config/util'; import {
import { join } from 'path'; getFileContentByName,
import { celebrate, Joi } from 'celebrate'; readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
const route = Router(); const route = Router();
const blacklist = ['.tmp']; const blacklist = ['.tmp'];
@@ -15,7 +19,7 @@ export default (app: Router) => {
route.get('/', async (req: Request, res: Response, next: NextFunction) => { route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const result = readDirs(config.logPath, config.logPath, blacklist); const result = await readDirs(config.logPath, config.logPath, blacklist);
res.send({ res.send({
code: 200, code: 200,
data: result, data: result,
@@ -26,20 +30,45 @@ 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: '暂无权限',
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: removeAnsi(content) });
} catch (e) {
return next(e);
}
},
);
route.get( route.get(
'/:file', '/:file',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
if (blacklist.includes(req.path)) { const logService = Container.get(LogService);
return res.send({ code: 403, message: '暂无权限' }); const finalPath = logService.checkFilePath(
} (req.query.path as string) || '',
const filePath = join( (req.params.file as string) || '',
config.logPath,
(req.query.path || '') as string,
req.params.file,
); );
const content = getFileContentByName(filePath); if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: '暂无权限',
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -53,22 +82,24 @@ export default (app: Router) => {
body: Joi.object({ body: Joi.object({
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().allow(''), path: Joi.string().allow(''),
type: Joi.string().optional() type: Joi.string().optional(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
let { filename, path, type } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string; path: string;
type: string;
}; };
const filePath = join(config.logPath, path, filename); const logService = Container.get(LogService);
if (type === 'directory') { const finalPath = logService.checkFilePath(path, filename);
emptyDir(filePath); if (!finalPath || blacklist.includes(path)) {
} else { return res.send({
fs.unlinkSync(filePath); code: 403,
message: '暂无权限',
});
} }
await rmPath(finalPath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -76,4 +107,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: '暂无权限',
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
}; };
+96 -54
View File
@@ -1,20 +1,14 @@
import { import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
fileExist,
getFileContentByName,
readDirs,
getLastModifyFilePath,
readDir,
emptyDir,
} from '../config/util';
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs'; import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import path, { join, parse } from 'path'; import path, { join, parse } from 'path';
import ScriptService from '../services/script'; import ScriptService from '../services/script';
import multer from 'multer'; import multer from 'multer';
import { writeFileWithLock } from '../shared/utils';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -33,16 +27,34 @@ export default (app: Router) => {
route.get('/', async (req: Request, res: Response, next: NextFunction) => { route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
let result = []; let result: IFile[] = [];
const blacklist = ['node_modules', '.git']; const blacklist = [
'node_modules',
'.git',
'.pnpm',
'pnpm-lock.yaml',
'yarn.lock',
'package-lock.json',
];
if (req.query.path) { if (req.query.path) {
const targetPath = path.join( result = await readDir(
config.scriptPath,
req.query.path as string, req.query.path as string,
config.scriptPath,
blacklist,
); );
result = readDir(targetPath, config.scriptPath, blacklist);
} else { } else {
result = readDirs(config.scriptPath, config.scriptPath, blacklist); 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({ res.send({
code: 200, code: 200,
@@ -54,17 +66,31 @@ export default (app: Router) => {
} }
}); });
route.get(
'/detail',
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( route.get(
'/:file', '/:file',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const filePath = join( const scriptService = Container.get(ScriptService);
config.scriptPath, const content = await scriptService.getFile(
req.query.path as string, req.query.path as string,
req.params.file, req.params.file,
); );
const content = getFileContentByName(filePath);
res.send({ code: 200, data: content }); res.send({ code: 200, data: content });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -76,7 +102,6 @@ export default (app: Router) => {
'/', '/',
upload.single('file'), upload.single('file'),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, path, content, originFilename, directory } = let { filename, path, content, originFilename, directory } =
req.body as { req.body as {
@@ -94,22 +119,22 @@ export default (app: Router) => {
path += '/'; path += '/';
} }
if (!path.startsWith('/')) { if (!path.startsWith('/')) {
path = `${config.scriptPath}${path}`; path = join(config.scriptPath, path);
} }
if (config.writePathList.every((x) => !path.startsWith(x))) { if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({ return res.send({
code: 430, code: 403,
message: '文件路径禁止访问', message: '暂无权限',
}); });
} }
if (req.file) { if (req.file) {
fs.renameSync(req.file.path, join(path, req.file.filename)); await fs.rename(req.file.path, join(path, filename));
return res.send({ code: 200 }); return res.send({ code: 200 });
} }
if (directory) { if (directory) {
fs.mkdirSync(join(path, directory), { recursive: true }); await fs.mkdir(join(path, directory), { recursive: true });
return res.send({ code: 200 }); return res.send({ code: 200 });
} }
@@ -121,16 +146,17 @@ export default (app: Router) => {
`${originFilename.replace(/\//g, '')}`, `${originFilename.replace(/\//g, '')}`,
); );
const filePath = join(path, `${filename.replace(/\//g, '')}`); const filePath = join(path, `${filename.replace(/\//g, '')}`);
if (fs.existsSync(originFilePath)) { const fileExists = await fileExist(filePath);
fs.copyFileSync( if (fileExists) {
await fs.copyFile(
originFilePath, originFilePath,
`${config.bakPath}${originFilename.replace(/\//g, '')}`, join(config.bakPath, originFilename.replace(/\//g, '')),
); );
if (filename !== originFilename) { if (filename !== originFilename) {
fs.unlinkSync(originFilePath); await rmPath(originFilePath);
} }
} }
fs.writeFileSync(filePath, content); await writeFileWithLock(filePath, content);
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -148,15 +174,21 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, content, path } = req.body as { let { filename, content, path } = req.body as {
filename: string; filename: string;
content: string; content: string;
path: string; path: string;
}; };
const filePath = join(config.scriptPath, path, filename); const scriptService = Container.get(ScriptService);
fs.writeFileSync(filePath, content); const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -174,19 +206,20 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, path, type } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string; path: string;
type: string;
}; };
const filePath = join(config.scriptPath, path, filename); const scriptService = Container.get(ScriptService);
if (type === 'directory') { const filePath = scriptService.checkFilePath(path, filename);
emptyDir(filePath); if (!filePath) {
} else { return res.send({
fs.unlinkSync(filePath); code: 403,
message: '暂无权限',
});
} }
await rmPath(filePath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -199,24 +232,30 @@ export default (app: Router) => {
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename } = req.body as { let { filename, path } = req.body as {
filename: string; filename: string;
path: string;
}; };
const filePath = `${config.scriptPath}${filename}`; if (!path) {
// const stats = fs.statSync(filePath); path = '';
// res.set({ }
// 'Content-Type': 'application/octet-stream', //告诉浏览器这是一个二进制文件 const scriptService = Container.get(ScriptService);
// 'Content-Disposition': 'attachment; filename=' + filename, //告诉浏览器这是一个需要下载的文件 const filePath = scriptService.checkFilePath(path, filename);
// 'Content-Length': stats.size //文件大小 if (!filePath) {
// }); return res.send({
// fs.createReadStream(filePath).pipe(res); code: 403,
message: '暂无权限',
});
}
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {
if (err) {
return next(err); return next(err);
}
}); });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -239,7 +278,7 @@ export default (app: Router) => {
let { filename, content, path } = req.body; let { filename, content, path } = req.body;
const { name, ext } = parse(filename); const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
fs.writeFileSync(filePath, content || '', { encoding: 'utf8' }); await writeFileWithLock(filePath, content || '');
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
const result = await scriptService.runScript(filePath); const result = await scriptService.runScript(filePath);
@@ -260,14 +299,17 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
let { filename, path, pid } = req.body; let { filename, path, pid } = req.body;
const { name, ext } = parse(filename); const { name, ext } = parse(filename);
const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`);
const logPath = join(config.logPath, path, `${name}.swap`);
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid); const result = await scriptService.stopScript(filePath, pid);
setTimeout(() => {
rmPath(logPath);
}, 3000);
res.send(result); res.send(result);
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -294,7 +336,7 @@ export default (app: Router) => {
}; };
const filePath = join(config.scriptPath, path, filename); const filePath = join(config.scriptPath, path, filename);
const newPath = join(config.scriptPath, path, newFilename); const newPath = join(config.scriptPath, path, newFilename);
fs.renameSync(filePath, newPath); await fs.rename(filePath, newPath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
+6 -1
View File
@@ -15,6 +15,7 @@ export default (app: Router) => {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.list( const data = await subscriptionService.list(
req.query.searchValue as string, req.query.searchValue as string,
req.query.ids as string,
); );
return res.send({ code: 200, data }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
@@ -210,12 +211,16 @@ export default (app: Router) => {
'/', '/',
celebrate({ celebrate({
body: Joi.array().items(Joi.number().required()), body: Joi.array().items(Joi.number().required()),
query: Joi.object({
force: Joi.boolean().optional(),
t: Joi.number(),
}),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const subscriptionService = Container.get(SubscriptionService); 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 }); return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
+271 -39
View File
@@ -1,12 +1,11 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import * as fs from 'fs'; import * as fs from 'fs/promises';
import config from '../config'; import config from '../config';
import SystemService from '../services/system'; import SystemService from '../services/system';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import UserService from '../services/user'; import UserService from '../services/user';
import { EnvModel } from '../data/env';
import { import {
getUniqPath, getUniqPath,
handleLogPath, handleLogPath,
@@ -14,8 +13,18 @@ import {
promiseExec, promiseExec,
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import multer from 'multer';
const route = Router(); 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) => { export default (app: Router) => {
app.use('/system', route); app.use('/system', route);
@@ -24,31 +33,15 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
const authInfo = await userService.getUserInfo(); const authInfo = await userService.getAuthInfo();
const envCount = await EnvModel.count(); const { version, changeLog, changeLogLink, publishTime } =
const { version, changeLog, changeLogLink } = await parseVersion( await parseVersion(config.versionFile);
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', '');
let isInitialized = true; let isInitialized = true;
if ( if (
Object.keys(authInfo).length === 2 && Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' && authInfo.username === 'admin' &&
authInfo.password === 'admin' && authInfo.password === 'admin'
envCount === 0
) { ) {
isInitialized = false; isInitialized = false;
} }
@@ -57,9 +50,8 @@ export default (app: Router) => {
data: { data: {
isInitialized, isInitialized,
version, version,
lastCommitTime: dayjs(lastCommitTime).unix(), publishTime: dayjs(publishTime).unix(),
lastCommitId, branch: process.env.QL_BRANCH || 'master',
branch,
changeLog, changeLog,
changeLogLink, changeLogLink,
}, },
@@ -71,12 +63,12 @@ export default (app: Router) => {
}); });
route.get( route.get(
'/log/remove', '/config',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const data = await systemService.getLogRemoveFrequency(); const data = await systemService.getSystemConfig();
res.send({ code: 200, data }); res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -85,19 +77,16 @@ export default (app: Router) => {
); );
route.put( route.put(
'/log/remove', '/config/log-remove-frequency',
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
frequency: Joi.number().required(), logRemoveFrequency: Joi.number().allow(null),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const result = await systemService.updateLogRemoveFrequency( const result = await systemService.updateLogRemoveFrequency(req.body);
req.body.frequency,
);
res.send(result); res.send(result);
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -105,6 +94,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( route.put(
'/update-check', '/update-check',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -133,6 +212,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( route.put(
'/notify', '/notify',
celebrate({ celebrate({
@@ -163,25 +261,32 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
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 logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`; const logPath = `${uniqPath}/${logTime}.log`;
res.setHeader('Content-type', 'application/octet-stream'); res.setHeader('Content-type', 'application/octet-stream');
await systemService.run( await systemService.run(
{ ...req.body, logPath }, { ...req.body, logPath },
{ {
onStart: async (cp, startTime) => {
res.setHeader('QL-Task-Pid', `${cp.pid}`);
res.setHeader('QL-Task-Log', `${logPath}`);
},
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
res.end(); res.end();
}, },
onError: async (message: string) => { onError: async (message: string) => {
res.write(`\n${message}`); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
fs.appendFileSync(absolutePath, `\n${message}`); await fs.appendFile(absolutePath, message);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
res.write(`\n${message}`); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
fs.appendFileSync(absolutePath, `\n${message}`); await fs.appendFile(absolutePath, message);
}, },
}, },
); );
@@ -195,7 +300,8 @@ export default (app: Router) => {
'/command-stop', '/command-stop',
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
command: Joi.string().required(), command: Joi.string().optional(),
pid: Joi.number().optional(),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -208,4 +314,130 @@ 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);
await userService.resetAuthInfo(req.body);
res.send({ code: 200, message: '更新成功' });
} 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/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);
}
},
);
};
+10 -2
View File
@@ -6,7 +6,9 @@ import { celebrate, Joi } from 'celebrate';
import multer from 'multer'; import multer from 'multer';
import path from 'path'; import path from 'path';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit';
import config from '../config'; import config from '../config';
import { isDemoEnv } from '../config/util';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -26,6 +28,10 @@ export default (app: Router) => {
route.post( route.post(
'/login', '/login',
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
}),
celebrate({ celebrate({
body: Joi.object({ body: Joi.object({
username: Joi.string().required(), username: Joi.string().required(),
@@ -67,8 +73,10 @@ export default (app: Router) => {
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try { try {
if (isDemoEnv()) {
return res.send({ code: 450, message: '未知错误' });
}
const userService = Container.get(UserService); const userService = Container.get(UserService);
await userService.updateUsernameAndPassword(req.body); await userService.updateUsernameAndPassword(req.body);
res.send({ code: 200, message: '更新成功' }); res.send({ code: 200, message: '更新成功' });
@@ -82,7 +90,7 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
const authInfo = await userService.getUserInfo(); const authInfo = await userService.getAuthInfo();
res.send({ res.send({
code: 200, code: 200,
data: { data: {
+226 -25
View File
@@ -1,30 +1,231 @@
import 'reflect-metadata'; // We need this in order to use @Decorators import 'reflect-metadata';
import config from './config'; import cluster, { type Worker } from 'cluster';
import compression from 'compression';
import cors from 'cors';
import express from 'express'; import express from 'express';
import helmet from 'helmet';
import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger'; import Logger from './loaders/logger';
import path from 'path'; import { monitoringMiddleware } from './middlewares/monitoring';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
async function startServer() { interface WorkerMetadata {
const app = express(); id: number;
pid: number;
await require('./loaders/db').default(); serviceType: string;
startTime: Date;
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(`✌️ 后端服务启动成功!`);
})
.on('error', (err) => {
Logger.error(err);
process.exit(1);
});
await require('./loaders/server').default({ server });
} }
startServer(); class Application {
private app: express.Application;
private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
constructor() {
this.app = express();
}
async start() {
try {
if (cluster.isPrimary) {
await this.initializeDatabase();
}
if (cluster.isPrimary) {
this.startMasterProcess();
} else {
await this.startWorkerProcess();
}
} catch (error) {
Logger.error('Failed to start application:', error);
process.exit(1);
}
}
private startMasterProcess() {
this.forkWorker('http');
this.forkWorker('grpc');
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...`,
);
const newWorker = this.forkWorker(metadata.serviceType);
Logger.info(
`Restarted ${metadata.serviceType} worker (New PID: ${newWorker.process.pid})`,
);
}
this.workerMetadataMap.delete(worker.id);
}
});
this.setupMasterShutdown();
}
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() {
await require('./loaders/db').default();
}
private setupMiddlewares() {
this.app.use(helmet());
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
}
private setupMasterShutdown() {
const shutdown = async () => {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
const workers = Object.values(cluster.workers || {});
const workerPromises: Promise<void>[] = [];
workers.forEach((worker) => {
if (worker) {
const exitPromise = new Promise<void>((resolve) => {
worker.once('exit', () => {
Logger.info(`Worker ${worker.process.pid} exited`);
resolve();
});
try {
worker.send('shutdown');
} catch (error) {
Logger.warn(
`Failed to send shutdown to worker ${worker.process.pid}:`,
error,
);
}
});
workerPromises.push(exitPromise);
}
});
try {
await Promise.race([
Promise.all(workerPromises),
new Promise<void>((resolve) => {
setTimeout(() => {
Logger.warn('Worker shutdown timeout reached');
resolve();
}, 10000);
}),
]);
process.exit(0);
} catch (error) {
Logger.error('Error during worker shutdown:', error);
process.exit(1);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('Invalid SERVICE_TYPE:', serviceType);
process.exit(1);
}
Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`);
try {
if (serviceType === 'http') {
await this.startHttpService();
} else {
await this.startGrpcService();
}
process.send?.('ready');
} catch (error) {
Logger.error(`${serviceType} worker failed:`, error);
process.exit(1);
}
}
private async startHttpService() {
this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
await require('./loaders/app').default({ app: this.app });
const server = await this.httpServerService.initialize(
this.app,
config.port,
);
await require('./loaders/server').default({ 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', (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
}
});
const shutdown = () => this.gracefulShutdown(serviceType);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
private async gracefulShutdown(serviceType: string) {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
try {
if (serviceType === 'http') {
await this.httpServerService?.shutdown();
} else {
await this.grpcServerService?.shutdown();
}
process.exit(0);
} catch (error) {
Logger.error(`[${serviceType}] Error during shutdown:`, error);
process.exit(1);
}
}
}
const app = new Application();
app.start().catch((error) => {
Logger.error('Application failed to start:', error);
process.exit(1);
});
+44
View File
@@ -5,3 +5,47 @@ export const QL_COMMAND = 'ql';
export const TASK_PREFIX = `${TASK_COMMAND} `; export const TASK_PREFIX = `${TASK_COMMAND} `;
export const QL_PREFIX = `${QL_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',
} as const;
+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,
};
+94 -15
View File
@@ -1,13 +1,61 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { createRandomString } from './util'; import { createRandomString } from './share';
dotenv.config({
path: path.join(__dirname, '../../.env'),
});
interface Config {
port: number;
grpcPort: number;
nodeEnv: string;
isDevelopment: boolean;
isProduction: boolean;
jwt: {
secret: string;
expiresIn?: string;
};
cors: {
origin: string[];
methods: string[];
};
logs: {
level: string;
};
api: {
prefix: string;
};
}
const config: Config = {
port: parseInt(process.env.BACK_PORT || '5600', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
jwt: {
secret: process.env.JWT_SECRET || 'whyour-secret',
expiresIn: process.env.JWT_EXPIRES_IN,
},
cors: {
origin: process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',')
: ['*'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
},
};
process.env.NODE_ENV = process.env.NODE_ENV || 'development'; process.env.NODE_ENV = process.env.NODE_ENV || 'development';
if (!process.env.QL_DIR) { if (!process.env.QL_DIR) {
// 声明QL_DIR环境变量
let qlHomePath = path.join(__dirname, '../../'); let qlHomePath = path.join(__dirname, '../../');
// 生产环境
if (qlHomePath.endsWith('/static/')) { if (qlHomePath.endsWith('/static/')) {
qlHomePath = path.join(qlHomePath, '../'); qlHomePath = path.join(qlHomePath, '../');
} }
@@ -19,17 +67,32 @@ const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
const rootPath = process.env.QL_DIR as string; const rootPath = process.env.QL_DIR as string;
const envFound = dotenv.config({ path: path.join(rootPath, '.env') }); 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 samplePath = path.join(rootPath, 'sample/');
const configPath = path.join(dataPath, 'config/'); const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/'); const scriptPath = path.join(dataPath, 'scripts/');
const repoPath = path.join(dataPath, 'repo/');
const bakPath = path.join(dataPath, 'bak/'); const bakPath = path.join(dataPath, 'bak/');
const logPath = path.join(dataPath, 'log/'); const logPath = path.join(dataPath, 'log/');
const dbPath = path.join(dataPath, 'db/'); const dbPath = path.join(dataPath, 'db/');
const uploadPath = path.join(dataPath, 'upload/'); const uploadPath = path.join(dataPath, 'upload/');
const sshdPath = path.join(dataPath, 'ssh.d/'); const sshdPath = path.join(dataPath, 'ssh.d/');
const systemLogPath = path.join(dataPath, 'syslog/');
const 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 confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list'); const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json'); const authConfigFile = path.join(configPath, 'auth.json');
@@ -42,23 +105,23 @@ const authError = '错误的用户名密码,请重试';
const loginFaild = '请先登录!'; const loginFaild = '请先登录!';
const configString = 'config sample crontab shareCode diy'; const configString = 'config sample crontab shareCode diy';
const versionFile = path.join(rootPath, 'version.yaml'); 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) { if (envFound.error) {
throw new Error("⚠️ Couldn't find .env file ⚠️"); throw new Error("⚠️ Couldn't find .env file ⚠️");
} }
export default { export default {
port: parseInt(process.env.BACK_PORT as string, 10), ...config,
cronPort: parseInt(process.env.CRON_PORT as string, 10), jwt: config.jwt,
publicPort: parseInt(process.env.PUBLIC_PORT as string, 10),
secret: process.env.SECRET || createRandomString(16, 32),
logs: {
level: process.env.LOG_LEVEL || 'silly',
},
api: {
prefix: '/api',
},
rootPath, rootPath,
tmpPath,
dataPath,
dataTgzFile,
shareShellFile,
dependenceProxyFile,
configString, configString,
loginFaild, loginFaild,
authError, authError,
@@ -70,31 +133,47 @@ export default {
sampleFile, sampleFile,
confFile, confFile,
envFile, envFile,
jsEnvFile,
pyEnvFile,
jsNotifyFile,
pyNotifyFile,
dbPath, dbPath,
uploadPath, uploadPath,
configPath, configPath,
scriptPath, scriptPath,
repoPath,
samplePath, samplePath,
blackFileList: [ blackFileList: [
'auth.json', 'auth.json',
'config.sh.sample', 'config.sh.sample',
'cookie.sh', 'cookie.sh',
'crontab.list', 'crontab.list',
'dependence-proxy.sh',
'env.sh', 'env.sh',
'env.js',
'env.py',
'token.json', 'token.json',
], ],
writePathList: [configPath, scriptPath], writePathList: [configPath, scriptPath],
bakPath, bakPath,
apiWhiteList: [ apiWhiteList: [
'/api/user/login', '/api/user/login',
'/api/health',
'/open/auth/token', '/open/auth/token',
'/api/user/two-factor/login', '/api/user/two-factor/login',
'/api/system', '/api/system',
'/api/user/init', '/api/user/init',
'/api/user/notification/init', '/api/user/notification/init',
'/open/user/login',
'/open/user/two-factor/login',
'/open/system',
'/open/user/init',
'/open/user/notification/init',
], ],
versionFile, versionFile,
lastVersionFile, lastVersionFile,
sqliteFile, sqliteFile,
sshdPath, sshdPath,
systemLogPath,
dependenceCachePath,
}; };
+28
View File
@@ -0,0 +1,28 @@
import { Request, Response } from 'express';
import pick from 'lodash/pick';
let pickedEnv: Record<string, string>;
function getPickedEnv() {
if (pickedEnv) return pickedEnv;
const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv', 'QL_DIR']);
if (picked.QlBaseUrl) {
if (!picked.QlBaseUrl.startsWith('/')) {
picked.QlBaseUrl = `/${picked.QlBaseUrl}`;
}
if (!picked.QlBaseUrl.endsWith('/')) {
picked.QlBaseUrl = `${picked.QlBaseUrl}/`;
}
}
pickedEnv = picked as Record<string, string>;
return picked;
}
export function serveEnv(_req: Request, res: Response) {
res.type('.js');
res.send(
Object.entries(getPickedEnv())
.map(([k, v]) => `window.__ENV__${k}=${JSON.stringify(v)};`)
.join('\n'),
);
}
+84
View File
@@ -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('');
}
+7 -3
View File
@@ -32,10 +32,14 @@ export function formatCommand(doc: Subscription, url?: string) {
autoDelCron, autoDelCron,
} = doc; } = doc;
if (type === 'file') { if (type === 'file') {
command += `raw "${_url}"`; command += `raw "${_url}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
} else { } else {
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${dependences || '' command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${isNil(autoAddCron) ? true : Boolean(autoAddCron) dependences || ''
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
} }
return command; return command;
+291 -273
View File
@@ -1,31 +1,40 @@
import * as fs from 'fs'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import got from 'got';
import iconv from 'iconv-lite';
import { exec } from 'child_process'; import { exec } from 'child_process';
import FormData from 'form-data'; import psTreeFun from 'ps-tree';
import psTreeFun from 'pstree.remy';
import { promisify } from 'util'; import { promisify } from 'util';
import { load } from 'js-yaml'; import { load } from 'js-yaml';
import config from './index'; import config from './index';
import { TASK_COMMAND } from './const'; import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
export function getFileContentByName(fileName: string) { export * from './share';
if (fs.existsSync(fileName)) {
return fs.readFileSync(fileName, 'utf8'); export async function getFileContentByName(fileName: string) {
const _exsit = await fileExist(fileName);
if (_exsit) {
return await fs.readFile(fileName, 'utf8');
} }
return ''; 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 = ''; let filePath = '';
if (fs.existsSync(dir)) { const _exsit = await fileExist(dir);
const arr = fs.readdirSync(dir); if (_exsit) {
const arr = await fs.readdir(dir);
arr.forEach((item) => { arr.forEach(async (item) => {
const fullpath = path.join(dir, item); const fullpath = path.join(dir, item);
const stats = fs.statSync(fullpath); const stats = await fs.lstat(fullpath);
if (stats.isFile()) { if (stats.isFile()) {
if (stats.mtimeMs >= 0) { if (stats.mtimeMs >= 0) {
filePath = fullpath; filePath = fullpath;
@@ -36,91 +45,6 @@ export function getLastModifyFilePath(dir: string) {
return filePath; 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) { export function getToken(req: any) {
const { authorization = '' } = req.headers; const { authorization = '' } = req.headers;
if (authorization && authorization.split(' ')[0] === 'Bearer') { if (authorization && authorization.split(' ')[0] === 'Bearer') {
@@ -132,69 +56,6 @@ export function getToken(req: any) {
return ''; return '';
} }
export async function getNetIp(req: any) {
const ipArray = [
...new Set([
...(req.headers['x-real-ip'] || '').split(','),
...(req.headers['x-forwarded-for'] || '').split(','),
req.ip,
...req.ips,
req.socket.remoteAddress,
]),
];
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' { export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
const ua = userAgent.toLowerCase(); const ua = userAgent.toLowerCase();
const testUa = (regexp: RegExp) => regexp.test(ua); const testUa = (regexp: RegExp) => regexp.test(ua);
@@ -216,12 +77,19 @@ export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
system = 'android'; // android系统 system = 'android'; // android系统
} else if (testUa(/ios|iphone|ipad|ipod|iwatch/g)) { } else if (testUa(/ios|iphone|ipad|ipod|iwatch/g)) {
system = 'ios'; // ios系统 system = 'ios'; // ios系统
} else if (testUa(/openharmony/g)) {
system = 'openharmony'; // openharmony系统
} }
let platform = 'desktop'; let platform = 'desktop';
if (system === 'windows' || system === 'macos' || system === 'linux') { if (system === 'windows' || system === 'macos' || system === 'linux') {
platform = 'desktop'; platform = 'desktop';
} else if (system === 'android' || system === 'ios' || testUa(/mobile/g)) { } else if (
system === 'android' ||
system === 'ios' ||
system === 'openharmony' ||
testUa(/mobile/g)
) {
platform = 'mobile'; platform = 'mobile';
} }
@@ -229,22 +97,17 @@ export function getPlatform(userAgent: string): 'mobile' | 'desktop' {
} }
export async function fileExist(file: any) { export async function fileExist(file: any) {
return new Promise((resolve) => {
try { try {
fs.accessSync(file); await fs.access(file);
resolve(true); return true;
} catch (error) { } catch (error) {
resolve(false); return false;
} }
});
} }
export async function createFile(file: string, data: string = '') { export async function createFile(file: string, data: string = '') {
return new Promise((resolve) => { await fs.mkdir(path.dirname(file), { recursive: true });
fs.mkdirSync(path.dirname(file), { recursive: true }); await writeFileWithLock(file, data);
fs.writeFileSync(file, data);
resolve(true);
});
} }
export async function handleLogPath( export async function handleLogPath(
@@ -301,111 +164,152 @@ enum FileType {
'file', 'file',
} }
interface IFile { export interface IFile {
title: string; title: string;
key: string; key: string;
type: 'directory' | 'file'; type: 'directory' | 'file';
parent: string; parent: string;
mtime: number; createTime: number;
size?: number;
children?: IFile[]; children?: IFile[];
} }
export function dirSort(a: IFile, b: IFile) { export function dirSort(a: IFile, b: IFile): number {
if (a.type !== b.type) return FileType[a.type] < FileType[b.type] ? -1 : 1; if (a.type === 'file' && b.type === 'file') {
else if (a.mtime !== b.mtime) return a.mtime > b.mtime ? -1 : 1; 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, dir: string,
baseDir: string = '', baseDir: string = '',
blacklist: string[] = [], blacklist: string[] = [],
) { sort: (a: IFile, b: IFile) => number = dirSort,
): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir); const relativePath = path.relative(baseDir, dir);
const files = fs.readdirSync(dir); const files = await fs.readdir(dir);
const result: any = files const result: IFile[] = [];
.filter((x) => !blacklist.includes(x))
.map((file: string) => { for (const file of files) {
const subPath = path.join(dir, file); const subPath = path.join(dir, file);
const stats = fs.statSync(subPath); const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file); const key = path.join(relativePath, file);
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) { if (stats.isDirectory()) {
return { const children = await readDirs(subPath, baseDir, blacklist, sort);
result.push({
title: file, title: file,
key, key,
type: 'directory', type: 'directory',
parent: relativePath, parent: relativePath,
mtime: stats.mtime.getTime(), createTime: stats.birthtime.getTime(),
children: readDirs(subPath, baseDir).sort(dirSort), children: children.sort(sort),
}; });
} } else {
return { result.push({
title: file, title: file,
type: 'file', type: 'file',
isLeaf: true,
key, key,
parent: relativePath, parent: relativePath,
mtime: stats.mtime.getTime(), size: stats.size,
}; createTime: stats.birthtime.getTime(),
}); });
return result.sort(dirSort); }
}
return result.sort(sort);
} }
export function readDir( export async function readDir(
dir: string, dir: string,
baseDir: string = '', baseDir: string = '',
blacklist: string[] = [], blacklist: string[] = [],
) { ): Promise<IFile[]> {
const relativePath = path.relative(baseDir, dir); const absoluteDir = path.join(baseDir, dir);
const files = fs.readdirSync(dir); const relativePath = path.relative(baseDir, absoluteDir);
const result: any = files
.filter((x) => !blacklist.includes(x)) try {
.map((file: string) => { const files = await fs.readdir(absoluteDir);
const subPath = path.join(dir, file); const result: IFile[] = [];
const stats = fs.statSync(subPath);
for (const file of files) {
const subPath = path.join(absoluteDir, file);
const stats = await fs.lstat(subPath);
const key = path.join(relativePath, file); const key = path.join(relativePath, file);
return {
if (blacklist.includes(file) || stats.isSymbolicLink()) {
continue;
}
if (stats.isDirectory()) {
result.push({
title: file, title: file,
type: stats.isDirectory() ? 'directory' : 'file', type: 'directory',
key, key,
parent: relativePath, parent: relativePath,
}; createTime: stats.birthtime.getTime(),
children: [],
}); });
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 { } else {
fs.unlinkSync(filePath); 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> { export async function promiseExec(command: string): Promise<string> {
return new Promise((resolve, reject) => { try {
exec( const { stderr, stdout } = await promisify(exec)(command, {
command, maxBuffer: 200 * 1024 * 1024,
{ maxBuffer: 200 * 1024 * 1024, encoding: 'utf8' }, encoding: 'utf8',
(err, stdout, stderr) => {
resolve(stdout || stderr || JSON.stringify(err));
},
);
}); });
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) { export function parseHeaders(headers: string) {
if (!headers) return {}; if (!headers) return {};
const parsed: any = {}; const parsed: any = {};
let key; let key: string;
let val; let val: string;
let i; let i: number;
headers && headers &&
headers.split('\n').forEach(function parser(line) { headers.split('\n').forEach(function parser(line) {
@@ -423,37 +327,49 @@ export function parseHeaders(headers: string) {
return parsed; 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( export function parseBody(
body: string, body: string,
contentType: contentType:
| 'application/json' | 'application/json'
| 'multipart/form-data' | '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 parsed = parseString(body, valueFormatFn);
const jsonValue = JSON.parse(val);
parsed[key] = jsonValue;
} catch (error) {
parsed[key] = val;
}
});
switch (contentType) { switch (contentType) {
case 'multipart/form-data': case 'multipart/form-data':
@@ -472,22 +388,22 @@ export function parseBody(
export function psTree(pid: number): Promise<number[]> { export function psTree(pid: number): Promise<number[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
psTreeFun(pid, (err: any, pids: number[]) => { psTreeFun(pid, (err: any, children) => {
if (err) { if (err) {
reject(err); reject(err);
} }
resolve(pids.filter((x) => !isNaN(x))); resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x)));
}); });
}); });
} }
export async function killTask(pid: number) { export async function killTask(pid: number) {
const pids = await psTree(pid); const pids = await psTree(pid);
// SIGINT 2 程序终止(interrupt)信号,不会打印额外信息
if (pids.length) { if (pids.length) {
try { try {
[pid, ...pids].forEach((x) => { [pid, ...pids].reverse().forEach((x) => {
process.kill(x, 2); process.kill(x, 15);
}); });
} catch (error) {} } catch (error) {}
} else { } else {
@@ -495,8 +411,8 @@ export async function killTask(pid: number) {
} }
} }
export async function getPid(name: string) { export async function getPid(cmd: string) {
const taskCommand = `ps -eo pid,command | grep "${name}" | grep -v grep | awk '{print $1}' | head -1 | xargs echo -n`; const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}' | head -1 | xargs echo -n`;
const pid = await promiseExec(taskCommand); const pid = await promiseExec(taskCommand);
return pid ? Number(pid) : undefined; return pid ? Number(pid) : undefined;
} }
@@ -505,27 +421,33 @@ interface IVersion {
version: string; version: string;
changeLogLink: string; changeLogLink: string;
changeLog: string; changeLog: string;
publishTime: string;
} }
export async function parseVersion(path: string): Promise<IVersion> { 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; return load(content) as IVersion;
} }
export async function getUniqPath(command: string): Promise<string> { export async function getUniqPath(
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`; command: string,
let id = await promiseExec(idStr); id: string,
): Promise<string> {
let suffix = '';
if (/^\d+$/.test(id)) {
suffix = `_${id}`;
}
if (/^\d\d*\d$/.test(id)) { let items = command.split(/ +/);
id = `_${id}`;
} else { const maxTimeCommandIndex = items.findIndex((x) => x === '-m');
id = ''; if (maxTimeCommandIndex !== -1) {
items = items.slice(maxTimeCommandIndex + 2);
} }
const items = command.split(/ +/);
let str = items[0]; let str = items[0];
if (items[0] === TASK_COMMAND) { if (items[0] === TASK_COMMAND) {
str = items[1]; str = items[1];
@@ -549,5 +471,101 @@ export async function getUniqPath(command: string): Promise<string> {
str = `${tempStr}_${str.slice(slashIndex + 1)}`; 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);
return {};
}
}
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);
}
}
export async function setSystemTimezone(timezone: string): Promise<boolean> {
try {
if (!(await fileExist(`/usr/share/zoneinfo/${timezone}`))) {
throw new Error('Invalid timezone');
}
await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`);
await promiseExec(`echo "${timezone}" > /etc/timezone`);
return true;
} catch (error) {
Logger.error('[setSystemTimezone失败]', error);
return false;
}
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
try:
from importlib.metadata import version
print(version(name))
except:
import importlib.util as u
import importlib.metadata as m
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: `apk info -es ${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]: 'apk add --no-check-certificate',
};
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]: 'apk del',
};
return `${baseCommands[type]} ${name.trim()}`;
}
export function isDemoEnv() {
return process.env.DeployEnv === 'demo';
} }
-38
View File
@@ -1,38 +0,0 @@
import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class AuthInfo {
ip?: string;
type: AuthDataType;
info?: any;
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',
}
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,
},
});
+14 -5
View File
@@ -18,10 +18,13 @@ export class Crontab {
last_running_time?: number; last_running_time?: number;
last_execution_time?: number; last_execution_time?: number;
sub_id?: number; sub_id?: number;
extra_schedules?: Array<{ schedule: string }>;
task_before?: string;
task_after?: string;
constructor(options: Crontab) { constructor(options: Crontab) {
this.name = options.name; this.name = options.name;
this.command = options.command; this.command = options.command.trim();
this.schedule = options.schedule; this.schedule = options.schedule;
this.saved = options.saved; this.saved = options.saved;
this.id = options.id; this.id = options.id;
@@ -39,17 +42,20 @@ export class Crontab {
this.last_running_time = options.last_running_time || 0; this.last_running_time = options.last_running_time || 0;
this.last_execution_time = options.last_execution_time || 0; this.last_execution_time = options.last_execution_time || 0;
this.sub_id = options.sub_id; this.sub_id = options.sub_id;
this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before;
this.task_after = options.task_after;
} }
} }
export enum CrontabStatus { export enum CrontabStatus {
'running', 'running' = 0,
'idle', 'queued' = 0.5,
'idle' = 1,
'disabled', 'disabled',
'queued',
} }
interface CronInstance extends Model<Crontab, Crontab>, Crontab {} export interface CronInstance extends Model<Crontab, Crontab>, Crontab { }
export const CrontabModel = sequelize.define<CronInstance>('Crontab', { export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
name: { name: {
unique: 'compositeIndex', unique: 'compositeIndex',
@@ -75,4 +81,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
last_running_time: DataTypes.NUMBER, last_running_time: DataTypes.NUMBER,
last_execution_time: DataTypes.NUMBER, last_execution_time: DataTypes.NUMBER,
sub_id: { type: DataTypes.NUMBER, allowNull: true }, sub_id: { type: DataTypes.NUMBER, allowNull: true },
extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING,
task_after: DataTypes.STRING,
}); });
+1 -1
View File
@@ -39,7 +39,7 @@ export class CrontabView {
} }
} }
interface CronViewInstance export interface CronViewInstance
extends Model<CrontabView, CrontabView>, extends Model<CrontabView, CrontabView>,
CrontabView {} CrontabView {}
export const CrontabViewModel = sequelize.define<CronViewInstance>( export const CrontabViewModel = sequelize.define<CronViewInstance>(
+11 -16
View File
@@ -4,9 +4,9 @@ import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Dependence { export class Dependence {
timestamp?: string; timestamp?: string;
id?: number; id?: number;
status?: DependenceStatus; status: DependenceStatus;
type?: DependenceTypes; type: DependenceTypes;
name?: number; name: string;
log?: string[]; log?: string[];
remark?: string; remark?: string;
@@ -15,10 +15,10 @@ export class Dependence {
this.status = this.status =
typeof options.status === 'number' && DependenceStatus[options.status] typeof options.status === 'number' && DependenceStatus[options.status]
? options.status ? options.status
: DependenceStatus.installing; : DependenceStatus.queued;
this.type = options.type || DependenceTypes.nodejs; this.type = options.type || DependenceTypes.nodejs;
this.timestamp = new Date().toString(); this.timestamp = new Date().toString();
this.name = options.name; this.name = options.name.trim();
this.log = options.log || []; this.log = options.log || [];
this.remark = options.remark || ''; this.remark = options.remark || '';
} }
@@ -32,6 +32,7 @@ export enum DependenceStatus {
'removed', 'removed',
'removeFailed', 'removeFailed',
'queued', 'queued',
'cancelled',
} }
export enum DependenceTypes { export enum DependenceTypes {
@@ -40,19 +41,13 @@ export enum DependenceTypes {
'linux', 'linux',
} }
export enum InstallDependenceCommandTypes { export enum versionDependenceCommandTypes {
'pnpm add -g', '@',
'pip3 install', '==',
'apk add', '=',
} }
export enum unInstallDependenceCommandTypes { export interface DependenceInstance
'pnpm remove -g',
'pip3 uninstall -y',
'apk del',
}
interface DependenceInstance
extends Model<Dependence, Dependence>, extends Model<Dependence, Dependence>,
Dependence {} Dependence {}
export const DependenceModel = sequelize.define<DependenceInstance>( export const DependenceModel = sequelize.define<DependenceInstance>(
+2 -2
View File
@@ -20,7 +20,7 @@ export class Env {
this.timestamp = new Date().toString(); this.timestamp = new Date().toString();
this.position = options.position; this.position = options.position;
this.name = options.name; this.name = options.name;
this.remarks = options.remarks; this.remarks = options.remarks || '';
} }
} }
@@ -34,7 +34,7 @@ export const initPosition = 4500000000000000;
export const stepPosition = 10000000000; export const stepPosition = 10000000000;
export const minPosition = 100; 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', { export const EnvModel = sequelize.define<EnvInstance>('Env', {
value: { type: DataTypes.STRING, unique: 'compositeIndex' }, value: { type: DataTypes.STRING, unique: 'compositeIndex' },
timestamp: DataTypes.STRING, timestamp: DataTypes.STRING,
+2 -1
View File
@@ -1,9 +1,10 @@
import { Sequelize, Transaction } from 'sequelize'; import { Sequelize, Transaction } from 'sequelize';
import config from '../config/index'; import config from '../config/index';
import { join } from 'path';
export const sequelize = new Sequelize({ export const sequelize = new Sequelize({
dialect: 'sqlite', dialect: 'sqlite',
storage: `${config.dbPath}database.sqlite`, storage: join(config.dbPath, 'database.sqlite'),
logging: false, logging: false,
retry: { retry: {
max: 10, max: 10,
+59 -8
View File
@@ -1,5 +1,3 @@
import { IncomingHttpHeaders } from 'http';
export enum NotificationMode { export enum NotificationMode {
'gotify' = 'gotify', 'gotify' = 'gotify',
'goCqHttpBot' = 'goCqHttpBot', 'goCqHttpBot' = 'goCqHttpBot',
@@ -14,9 +12,14 @@ export enum NotificationMode {
'aibotk' = 'aibotk', 'aibotk' = 'aibotk',
'iGot' = 'iGot', 'iGot' = 'iGot',
'pushPlus' = 'pushPlus', 'pushPlus' = 'pushPlus',
'wePlusBot' = 'wePlusBot',
'email' = 'email', 'email' = 'email',
'pushMe' = 'pushMe',
'feishu' = 'feishu', 'feishu' = 'feishu',
'webhook' = 'webhook', 'webhook' = 'webhook',
'chronocat' = 'Chronocat',
'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot',
} }
abstract class NotificationBaseInfo { abstract class NotificationBaseInfo {
@@ -44,9 +47,8 @@ export class PushDeerNotification extends NotificationBaseInfo {
public pushDeerUrl = ''; public pushDeerUrl = '';
} }
export class ChatNotification extends NotificationBaseInfo { export class synologyChatNotification extends NotificationBaseInfo {
public chatUrl = ''; public synologyChatUrl = '';
public chatToken = '';
} }
export class BarkNotification extends NotificationBaseInfo { export class BarkNotification extends NotificationBaseInfo {
@@ -54,6 +56,9 @@ export class BarkNotification extends NotificationBaseInfo {
public barkIcon = 'https://qn.whyour.cn/logo.png'; public barkIcon = 'https://qn.whyour.cn/logo.png';
public barkSound = ''; public barkSound = '';
public barkGroup = 'qinglong'; public barkGroup = 'qinglong';
public barkLevel = 'active';
public barkUrl = '';
public barkArchive = '';
} }
export class TelegramBotNotification extends NotificationBaseInfo { export class TelegramBotNotification extends NotificationBaseInfo {
@@ -62,7 +67,7 @@ export class TelegramBotNotification extends NotificationBaseInfo {
public telegramBotProxyHost = ''; public telegramBotProxyHost = '';
public telegramBotProxyPort = ''; public telegramBotProxyPort = '';
public telegramBotProxyAuth = ''; public telegramBotProxyAuth = '';
public telegramBotApiHost = 'api.telegram.org'; public telegramBotApiHost = 'https://api.telegram.org';
} }
export class DingtalkBotNotification extends NotificationBaseInfo { export class DingtalkBotNotification extends NotificationBaseInfo {
@@ -72,10 +77,12 @@ export class DingtalkBotNotification extends NotificationBaseInfo {
export class WeWorkBotNotification extends NotificationBaseInfo { export class WeWorkBotNotification extends NotificationBaseInfo {
public weWorkBotKey = ''; public weWorkBotKey = '';
public weWorkOrigin = '';
} }
export class WeWorkAppNotification extends NotificationBaseInfo { export class WeWorkAppNotification extends NotificationBaseInfo {
public weWorkAppKey = ''; public weWorkAppKey = '';
public weWorkOrigin = '';
} }
export class AibotkNotification extends NotificationBaseInfo { export class AibotkNotification extends NotificationBaseInfo {
@@ -91,12 +98,35 @@ export class IGotNotification extends NotificationBaseInfo {
export class PushPlusNotification extends NotificationBaseInfo { export class PushPlusNotification extends NotificationBaseInfo {
public pushPlusToken = ''; public pushPlusToken = '';
public pushPlusUser = ''; 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 { export class EmailNotification extends NotificationBaseInfo {
public emailService: string = ''; public emailService: string = '';
public emailUser: string = ''; public emailUser: string = '';
public emailPass: string = ''; public emailPass: string = '';
public emailTo: string = '';
}
export class PushMeNotification extends NotificationBaseInfo {
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 { export class WebhookNotification extends NotificationBaseInfo {
@@ -114,12 +144,28 @@ export class LarkNotification extends NotificationBaseInfo {
public larkKey = ''; public larkKey = '';
} }
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 interface NotificationInfo export interface NotificationInfo
extends GoCqHttpBotNotification, extends GoCqHttpBotNotification,
GotifyNotification, GotifyNotification,
ServerChanNotification, ServerChanNotification,
PushDeerNotification, PushDeerNotification,
ChatNotification, synologyChatNotification,
BarkNotification, BarkNotification,
TelegramBotNotification, TelegramBotNotification,
DingtalkBotNotification, DingtalkBotNotification,
@@ -128,6 +174,11 @@ export interface NotificationInfo
AibotkNotification, AibotkNotification,
IGotNotification, IGotNotification,
PushPlusNotification, PushPlusNotification,
WePlusBotNotification,
EmailNotification, EmailNotification,
PushMeNotification,
WebhookNotification, WebhookNotification,
LarkNotification {} ChronocatNotification,
LarkNotification,
NtfyNotification,
WxPusherBotNotification {}
+2 -9
View File
@@ -24,16 +24,9 @@ export interface AppToken {
expiration: number; expiration: number;
} }
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs'; export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system';
export enum CrontabStatus { export interface AppInstance extends Model<App, App>, App {}
'running',
'idle',
'disabled',
'queued',
}
interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', { export const AppModel = sequelize.define<AppInstance>('App', {
name: { type: DataTypes.STRING, unique: 'name' }, name: { type: DataTypes.STRING, unique: 'name' },
scopes: DataTypes.JSON, scopes: DataTypes.JSON,
+4 -1
View File
@@ -16,4 +16,7 @@ export type SockMessageType =
| 'uninstallDependence' | 'uninstallDependence'
| 'updateSystemVersion' | 'updateSystemVersion'
| 'manuallyRunScript' | 'manuallyRunScript'
| 'runSubscriptionEnd'; | 'runSubscriptionEnd'
| 'reloadSystem'
| 'updateNodeMirror'
| 'updateLinuxMirror';
+1 -1
View File
@@ -70,7 +70,7 @@ export enum SubscriptionStatus {
'queued', 'queued',
} }
interface SubscriptionInstance export interface SubscriptionInstance
extends Model<Subscription, Subscription>, extends Model<Subscription, Subscription>,
Subscription {} Subscription {}
export const SubscriptionModel = sequelize.define<SubscriptionInstance>( export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
+82
View File
@@ -0,0 +1,82 @@
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 {
logRemoveFrequency?: number;
cronConcurrency?: number;
dependenceProxy?: string;
nodeMirror?: string;
pythonMirror?: string;
linuxMirror?: string;
timezone?: string;
}
export interface LoginLogInfo {
timestamp?: number;
address?: string;
ip?: string;
platform?: string;
status?: LoginStatus;
}
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>;
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 { Application } from 'express';
import linkDeps from './deps'; import linkDeps from './deps';
import initTask from './initTask'; import initTask from './initTask';
import initFile from './initFile';
export default async ({ expressApp }: { expressApp: Application }) => { export default async ({ app }: { app: Application }) => {
await depInjectorLoader(); depInjectorLoader();
Logger.info('✌️ Dependency Injector loaded'); Logger.info('✌️ Dependency loaded');
await expressLoader({ app: expressApp });
Logger.info('✌️ Express loaded');
await initData();
Logger.info('✌️ init data loaded');
await linkDeps(); await linkDeps();
Logger.info('✌️ link deps loaded'); Logger.info('✌️ Link deps loaded');
initFile();
Logger.info('✌️ Init file loaded');
await initData();
Logger.info('✌️ Init data loaded');
initTask(); initTask();
Logger.info('✌️ init task loaded'); Logger.info('✌️ Init task loaded');
expressLoader({ app });
Logger.info('✌️ 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();
};
+14 -80
View File
@@ -1,15 +1,11 @@
import Logger from './logger'; import Logger from './logger';
import path from 'path';
import DataStore from 'nedb';
import { EnvModel } from '../data/env'; import { EnvModel } from '../data/env';
import { CrontabModel } from '../data/cron'; import { CrontabModel } from '../data/cron';
import { DependenceModel } from '../data/dependence'; import { DependenceModel } from '../data/dependence';
import { AppModel } from '../data/open'; import { AppModel } from '../data/open';
import { AuthModel } from '../data/auth'; import { SystemModel } from '../data/system';
import { fileExist } from '../config/util';
import { SubscriptionModel } from '../data/subscription'; import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView'; import { CrontabViewModel } from '../data/cronView';
import config from '../config';
import { sequelize } from '../data'; import { sequelize } from '../data';
export default async () => { export default async () => {
@@ -17,7 +13,7 @@ export default async () => {
await CrontabModel.sync(); await CrontabModel.sync();
await DependenceModel.sync(); await DependenceModel.sync();
await AppModel.sync(); await AppModel.sync();
await AuthModel.sync(); await SystemModel.sync();
await EnvModel.sync(); await EnvModel.sync();
await SubscriptionModel.sync(); await SubscriptionModel.sync();
await CrontabViewModel.sync(); await CrontabViewModel.sync();
@@ -49,82 +45,20 @@ export default async () => {
try { try {
await sequelize.query('alter table Crontabs add column sub_id NUMBER'); await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) {} } catch (error) {}
try {
// 2.10-2.11 升级 await sequelize.query(
const cronDbFile = path.join(config.rootPath, 'db/crontab.db'); 'alter table Crontabs add column extra_schedules JSON',
const envDbFile = path.join(config.rootPath, 'db/env.db'); );
const appDbFile = path.join(config.rootPath, 'db/app.db'); } catch (error) {}
const authDbFile = path.join(config.rootPath, 'db/auth.db'); try {
const dependenceDbFile = path.join(config.rootPath, 'db/dependence.db'); await sequelize.query('alter table Crontabs add column task_before TEXT');
const crondbExist = await fileExist(cronDbFile); } catch (error) {}
const dependenceDbExist = await fileExist(dependenceDbFile); try {
const envDbExist = await fileExist(envDbFile); await sequelize.query('alter table Crontabs add column task_after TEXT');
const appDbExist = await fileExist(appDbFile); } catch (error) {}
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 });
});
}
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('✌️ DB loaded');
} catch (error) { } catch (error) {
Logger.info('✌️ DB load failed'); Logger.error('✌️ DB load failed', error);
Logger.info(error);
} }
}; };
+14 -14
View File
@@ -1,20 +1,19 @@
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs/promises';
import chokidar from 'chokidar'; import chokidar from 'chokidar';
import config from '../config/index'; import config from '../config/index';
import { promiseExec } from '../config/util'; import { fileExist, promiseExec, rmPath } from '../config/util';
function linkToNodeModule(src: string, dst?: string) { async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src); const target = path.join(config.rootPath, 'node_modules', dst || src);
const source = path.join(config.rootPath, src); const source = path.join(config.rootPath, src);
fs.lstat(target, (err, stat) => { try {
if (!stat) { const stats = await fs.lstat(target);
fs.symlink(source, target, 'dir', (err) => { if (!stats) {
if (err) throw err; await fs.symlink(source, target, 'dir');
});
} }
}); } catch (error) {}
} }
async function linkCommand() { async function linkCommand() {
@@ -24,26 +23,27 @@ async function linkCommand() {
{ {
src: 'update.sh', src: 'update.sh',
dest: 'ql', dest: 'ql',
tmp: 'ql_tmp',
}, },
{ {
src: 'task.sh', src: 'task.sh',
dest: 'task', dest: 'task',
tmp: 'task_tmp',
}, },
]; ];
for (const link of linkShell) { for (const link of linkShell) {
const source = path.join(config.rootPath, 'shell', link.src); const source = path.join(config.rootPath, 'shell', link.src);
const target = path.join(commandDir, link.dest); const target = path.join(commandDir, link.dest);
if (fs.existsSync(target)) { const tmpTarget = path.join(commandDir, link.tmp);
fs.unlinkSync(target); await fs.symlink(source, tmpTarget);
} await fs.rename(tmpTarget, target);
fs.symlink(source, target, (err) => {});
} }
} }
export default async (src: string = 'deps') => { export default async (src: string = 'deps') => {
await linkCommand(); await linkCommand();
linkToNodeModule(src); await linkToNodeModule(src);
const source = path.join(config.rootPath, src); const source = path.join(config.rootPath, src);
const watcher = chokidar.watch(source, { const watcher = chokidar.watch(source, {
+20 -48
View File
@@ -3,39 +3,25 @@ import bodyParser from 'body-parser';
import cors from 'cors'; import cors from 'cors';
import routes from '../api'; import routes from '../api';
import config from '../config'; import config from '../config';
import jwt, { UnauthorizedError } from 'express-jwt'; import { UnauthorizedError, expressjwt } from 'express-jwt';
import fs from 'fs';
import { getPlatform, getToken } from '../config/util'; import { getPlatform, getToken } from '../config/util';
import Container from 'typedi';
import OpenService from '../services/open';
import rewrite from 'express-urlrewrite'; import rewrite from 'express-urlrewrite';
import UserService from '../services/user';
import handler from 'serve-handler';
import * as Sentry from '@sentry/node';
import { EnvModel } from '../data/env';
import { errors } from 'celebrate'; import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store';
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
app.enable('trust proxy'); app.set('trust proxy', 'loopback');
app.use(cors()); app.use(cors());
app.get(`${config.api.prefix}/env.js`, serveEnv);
app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); app.use(`${config.api.prefix}/static`, express.static(config.uploadPath));
app.use((req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/open')) {
next();
} else {
return handler(req, res, {
public: 'static/dist',
rewrites: [{ source: '**', destination: '/index.html' }],
});
}
});
app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
app.use( app.use(
jwt({ expressjwt({
secret: config.secret as string, secret: config.jwt.secret,
algorithms: ['HS384'], algorithms: ['HS384'],
}).unless({ }).unless({
path: [...config.apiWhiteList, /^\/open\//], path: [...config.apiWhiteList, /^\/open\//],
@@ -52,11 +38,13 @@ export default ({ app }: { app: Application }) => {
return next(); return next();
}); });
app.use(async (req, res, next) => { app.use(async (req: Request, res, next) => {
const headerToken = getToken(req); const headerToken = getToken(req);
if (req.path.startsWith('/open/')) { if (req.path.startsWith('/open/')) {
const openService = Container.get(OpenService); const apps = await shareStore.getApps();
const doc = await openService.findTokenByValue(headerToken); const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) { if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken); const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/); const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/);
@@ -80,9 +68,9 @@ export default ({ app }: { app: Application }) => {
return next(); return next();
} }
const data = fs.readFileSync(config.authConfigFile, 'utf8'); const authInfo = await shareStore.getAuthInfo();
if (data && headerToken) { if (authInfo && headerToken) {
const { token = '', tokens = {} } = JSON.parse(data); const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[req.platform] === headerToken) { if (headerToken === token || tokens[req.platform] === headerToken) {
return next(); return next();
} }
@@ -100,16 +88,14 @@ export default ({ app }: { app: Application }) => {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) { if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) {
return next(); return next();
} }
const userService = Container.get(UserService); const authInfo =
const authInfo = await userService.getUserInfo(); (await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']);
const envCount = await EnvModel.count();
let isInitialized = true; let isInitialized = true;
if ( if (
Object.keys(authInfo).length === 2 && Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' && authInfo.username === 'admin' &&
authInfo.password === 'admin' && authInfo.password === 'admin'
envCount === 0
) { ) {
isInitialized = false; isInitialized = false;
} }
@@ -161,8 +147,8 @@ export default ({ app }: { app: Application }) => {
.status(500) .status(500)
.send({ .send({
code: 400, code: 400,
message: `${err.name} ${err.message}`, message: `${err.message}`,
validation: err.errors, errors: err.errors,
}) })
.end(); .end();
} }
@@ -170,18 +156,6 @@ export default ({ app }: { app: Application }) => {
}, },
); );
app.use(
Sentry.Handlers.errorHandler({
shouldHandleError(error) {
// 排除 SequelizeUniqueConstraintError / NotFound
return (
!['SequelizeUniqueConstraintError'].includes(error.name) ||
!['Not Found'].includes(error.message)
);
},
}),
);
app.use( app.use(
( (
err: Error & { status: number }, err: Error & { status: number },
@@ -189,8 +163,6 @@ export default ({ app }: { app: Application }) => {
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
Sentry.captureException(err);
res.status(err.status || 500); res.status(err.status || 500);
res.json({ res.json({
code: err.status || 500, code: err.status || 500,
+112 -20
View File
@@ -9,11 +9,112 @@ import { Op } from 'sequelize';
import config from '../config'; import config from '../config';
import { CrontabViewModel, CronViewType } from '../data/cronView'; import { CrontabViewModel, CronViewType } from '../data/cronView';
import { initPosition } from '../data/env'; import { initPosition } from '../data/env';
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, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
import { AppModel } from '../data/open';
export default async () => { export default async () => {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
const dependenceService = Container.get(DependenceService); 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'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
}
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) {
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);
require('./bootAfter').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({ CrontabViewModel.findAll({
@@ -30,23 +131,7 @@ export default async () => {
}); });
// 初始化更新所有任务状态为空闲 // 初始化更新所有任务状态为空闲
await CrontabModel.update( await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
{ status: CrontabStatus.idle },
{ where: { status: [CrontabStatus.running, CrontabStatus.queued] } },
);
// 初始化时安装所有处于安装中,安装成功,安装失败的依赖
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);
});
// 初始化时执行一次所有的 ql repo 任务 // 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({ CrontabModel.findAll({
@@ -90,7 +175,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/log/`)) { if (doc.command.includes(`${config.rootPath}/log/`)) {
await CrontabModel.update( await CrontabModel.update(
{ {
command: `${config.rootPath}/data/log/${doc.command.replace( command: `${config.dataPath}/log/${doc.command.replace(
`${config.rootPath}/log/`, `${config.rootPath}/log/`,
'', '',
)}`, )}`,
@@ -101,7 +186,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/config/`)) { if (doc.command.includes(`${config.rootPath}/config/`)) {
await CrontabModel.update( await CrontabModel.update(
{ {
command: `${config.rootPath}/data/config/${doc.command.replace( command: `${config.dataPath}/config/${doc.command.replace(
`${config.rootPath}/config/`, `${config.rootPath}/config/`,
'', '',
)}`, )}`,
@@ -112,7 +197,7 @@ export default async () => {
if (doc.command.includes(`${config.rootPath}/db/`)) { if (doc.command.includes(`${config.rootPath}/db/`)) {
await CrontabModel.update( await CrontabModel.update(
{ {
command: `${config.rootPath}/data/db/${doc.command.replace( command: `${config.dataPath}/db/${doc.command.replace(
`${config.rootPath}/db/`, `${config.rootPath}/db/`,
'', '',
)}`, )}`,
@@ -127,4 +212,11 @@ export default async () => {
// 初始化保存一次ck和定时任务数据 // 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab(); await cronService.autosave_crontab();
await envService.set_envs(); 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);
}
}; };
+101 -48
View File
@@ -1,74 +1,127 @@
import fs from 'fs'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import dotenv from 'dotenv';
import Logger from './logger'; import Logger from './logger';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
const rootPath = process.env.QL_DIR as string;; const rootPath = process.env.QL_DIR as string;
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 preloadPath = path.join(rootPath, 'shell/preload/');
const configPath = path.join(dataPath, 'config/'); const configPath = path.join(dataPath, 'config/');
const scriptPath = path.join(dataPath, 'scripts/'); const scriptPath = path.join(dataPath, 'scripts/');
const logPath = path.join(dataPath, 'log/'); const logPath = path.join(dataPath, 'log/');
const uploadPath = path.join(dataPath, 'upload/'); const uploadPath = path.join(dataPath, 'upload/');
const bakPath = path.join(dataPath, 'bak/'); const bakPath = path.join(dataPath, 'bak/');
const samplePath = path.join(rootPath, 'sample/'); const samplePath = path.join(rootPath, 'sample/');
const tmpPath = path.join(logPath, '.tmp/');
const confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
const authConfigFile = path.join(configPath, 'auth.json');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh'); 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 homedir = os.homedir();
const sshPath = path.resolve(homedir, '.ssh'); const sshPath = path.resolve(homedir, '.ssh');
const sshdPath = path.join(dataPath, 'ssh.d'); const sshdPath = path.join(dataPath, 'ssh.d');
const systemLogPath = path.join(dataPath, 'syslog');
const directories = [
configPath,
scriptPath,
preloadPath,
logPath,
tmpPath,
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 () => { export default async () => {
const authFileExist = await fileExist(authConfigFile); for (const dirPath of directories) {
const confFileExist = await fileExist(confFile); if (!(await fileExist(dirPath))) {
const scriptDirExist = await fileExist(scriptPath); await fs.mkdir(dirPath);
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);
if (!configDirExist) {
fs.mkdirSync(configPath);
} }
if (!authFileExist) { for (const item of files) {
fs.writeFileSync(authConfigFile, fs.readFileSync(sampleAuthFile)); 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 =
if (!confFileExist) { item.content ||
fs.writeFileSync(confFile, fs.readFileSync(sampleConfigFile)); (await fs.readFile(item.source!, { encoding: 'utf-8' }));
await writeFileWithLock(item.target, content);
} }
if (!scriptDirExist) {
fs.mkdirSync(scriptPath);
} }
if (!logDirExist) {
fs.mkdirSync(logPath);
}
if (!uploadDirExist) {
fs.mkdirSync(uploadPath);
}
if (!sshDirExist) {
fs.mkdirSync(sshPath);
}
if (!bakDirExist) {
fs.mkdirSync(bakPath);
}
if (!sshdDirExist) {
fs.mkdirSync(sshdPath);
}
dotenv.config({ path: confFile });
Logger.info('✌️ Init file down'); Logger.info('✌️ Init file down');
}; };
+33 -15
View File
@@ -1,9 +1,10 @@
import { Container } from 'typedi'; import { Container } from 'typedi';
import SystemService from '../services/system'; import SystemService from '../services/system';
import ScheduleService from '../services/schedule'; import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import config from '../config'; import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path';
export default async () => { export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
@@ -11,8 +12,12 @@ export default async () => {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
// 生成内置token // 生成内置token
let tokenCommand = `tsx ${config.rootPath}/back/token.ts`; let tokenCommand = `ts-node-transpile-only ${join(
const tokenFile = `${config.rootPath}static/build/token.js`; config.rootPath,
'back/token.ts',
)}`;
const tokenFile = join(config.rootPath, 'static/build/token.js');
if (await fileExist(tokenFile)) { if (await fileExist(tokenFile)) {
tokenCommand = `node ${tokenFile}`; tokenCommand = `node ${tokenFile}`;
} }
@@ -20,30 +25,43 @@ export default async () => {
id: NaN, id: NaN,
name: '生成token', name: '生成token',
command: tokenCommand, command: tokenCommand,
}; runOrigin: 'system',
} as ScheduleTaskType;
await scheduleService.cancelIntervalTask(cron); await scheduleService.cancelIntervalTask(cron);
scheduleService.createIntervalTask(cron, { scheduleService.createIntervalTask(
cron,
{
days: 28, days: 28,
}); },
true,
);
// 运行删除日志任务 // 运行删除日志任务
const data = await systemService.getLogRemoveFrequency(); const data = await systemService.getSystemConfig();
if (data && data.info && data.info.frequency) { if (data && data.info) {
if (data.info.logRemoveFrequency) {
const rmlogCron = { const rmlogCron = {
id: data.id, id: data.id as number,
name: '删除日志', name: '删除日志',
command: `ql rmlog ${data.info.frequency}`, command: `ql rmlog ${data.info.logRemoveFrequency}`,
runOrigin: 'system' as const,
}; };
await scheduleService.cancelIntervalTask(rmlogCron); await scheduleService.cancelIntervalTask(rmlogCron);
scheduleService.createIntervalTask(rmlogCron, { scheduleService.createIntervalTask(
days: data.info.frequency, rmlogCron,
}); {
days: data.info.logRemoveFrequency,
},
true,
);
}
systemService.updateTimezone(data.info);
} }
// 运行所有订阅
await subscriptionService.setSshConfig(); await subscriptionService.setSshConfig();
const subs = await subscriptionService.list(); const subs = await subscriptionService.list();
for (const sub of subs) { for (const sub of subs) {
subscriptionService.handleTask(sub, !sub.is_disabled, !sub.is_disabled); subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
} }
}; };
+52 -21
View File
@@ -1,32 +1,63 @@
import winston from 'winston'; import winston from 'winston';
import 'winston-daily-rotate-file';
import config from '../config'; import config from '../config';
import path from 'path';
const transports = []; const levelMap: Record<string, string> = {
if (process.env.NODE_ENV !== 'development') { info: '', // info图标
transports.push(new winston.transports.Console()); warn: '⚠️', // 警告图标
} else { error: '❌', // 错误图标
transports.push( debug: '🐛', // debug调试图标
new winston.transports.Console({ };
format: winston.format.combine(
winston.format.cli(), const baseFormat = [
winston.format.splat(), 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({ const LoggerInstance = winston.createLogger({
level: config.logs.level, level: 'debug',
levels: winston.config.npm.levels, levels: winston.config.npm.levels,
format: winston.format.combine( transports: [consoleTransport, fileTransport],
winston.format.timestamp({ exceptionHandlers: [consoleTransport, fileTransport],
format: 'YYYY-MM-DD HH:mm:ss', rejectionHandlers: [consoleTransport, fileTransport],
}), });
winston.format.errors({ stack: true }),
winston.format.splat(), LoggerInstance.on('error', (error) => {
winston.format.json(), console.error('Logger error:', error);
),
transports,
}); });
export default LoggerInstance; export default LoggerInstance;
-26
View File
@@ -1,26 +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({
dsn: 'https://f4b5b55fb3c645b29a5dc2d70a1a4ef4@o1098464.ingest.sentry.io/6122819',
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Tracing.Integrations.Express({ app: expressApp }),
],
tracesSampleRate: 0.1,
release: version,
});
expressApp.use(Sentry.Handlers.requestHandler());
expressApp.use(Sentry.Handlers.tracingHandler());
Logger.info('✌️ Sentry loaded');
};
+5 -5
View File
@@ -6,11 +6,11 @@ export default async ({ server }: { server: Server }) => {
await Sock({ server }); await Sock({ server });
Logger.info('✌️ Sock loaded'); Logger.info('✌️ Sock loaded');
process.on('SIGINT', () => { process.on('uncaughtException', (error) => {
Logger.info('✌️ Server need close'); Logger.error('Uncaught exception:', error);
server.close(() => {
Logger.info('✌️ Server closed');
process.exit(0);
}); });
process.on('unhandledRejection', (reason, promise) => {
Logger.error('Unhandled rejection:', reason, promise);
}); });
}; };
+5 -10
View File
@@ -1,28 +1,25 @@
import sockJs from 'sockjs'; import sockJs from 'sockjs';
import { Server } from 'http'; import { Server } from 'http';
import Logger from './logger';
import { Container } from 'typedi'; import { Container } from 'typedi';
import SockService from '../services/sock'; import SockService from '../services/sock';
import config from '../config/index';
import fs from 'fs';
import { getPlatform } from '../config/util'; import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store';
export default async ({ server }: { server: Server }) => { export default async ({ server }: { server: Server }) => {
const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} }); const echo = sockJs.createServer({ prefix: '/api/ws', log: () => {} });
const sockService = Container.get(SockService); const sockService = Container.get(SockService);
echo.on('connection', (conn) => { echo.on('connection', async (conn) => {
if (!conn.headers || !conn.url || !conn.pathname) { if (!conn.headers || !conn.url || !conn.pathname) {
conn.close('404'); conn.close('404');
} }
const data = fs.readFileSync(config.authConfigFile, 'utf8'); const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (data) { if (authInfo) {
const { token = '', tokens = {} } = JSON.parse(data); const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[platform] === headerToken) { if (headerToken === token || tokens[platform] === headerToken) {
conn.write(JSON.stringify({ type: 'ping', message: 'hanhh' }));
sockService.addClient(conn); sockService.addClient(conn);
conn.on('data', (message) => { conn.on('data', (message) => {
@@ -34,8 +31,6 @@ export default async ({ server }: { server: Server }) => {
}); });
return; return;
} else {
conn.write(JSON.stringify({ type: 'ping', message: 'whyour' }));
} }
} }
+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),
};
};
+257
View File
@@ -0,0 +1,257 @@
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 CronsResponse {
int32 code = 1;
repeated CronItem data = 2;
optional string message = 3;
}
message CronResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
message CronDetailRequest { string log_path = 1; }
message CronDetailResponse {
int32 code = 1;
CronItem data = 2;
optional string message = 3;
}
enum NotificationMode {
gotify = 0;
goCqHttpBot = 1;
serverChan = 2;
pushDeer = 3;
bark = 4;
chat = 5;
telegramBot = 6;
dingtalkBot = 7;
weWorkBot = 8;
weWorkApp = 9;
aibotk = 10;
iGot = 11;
pushPlus = 12;
wePlusBot = 13;
email = 14;
pushMe = 15;
feishu = 16;
webhook = 17;
chronocat = 18;
ntfy = 19;
wxPusherBot = 20;
}
message NotificationInfo {
NotificationMode type = 1;
optional string gotifyUrl = 2;
optional string gotifyToken = 3;
optional int32 gotifyPriority = 4;
optional string goCqHttpBotUrl = 5;
optional string goCqHttpBotToken = 6;
optional string goCqHttpBotQq = 7;
optional string serverChanKey = 8;
optional string pushDeerKey = 9;
optional string pushDeerUrl = 10;
optional string synologyChatUrl = 11;
optional string barkPush = 12;
optional string barkIcon = 13;
optional string barkSound = 14;
optional string barkGroup = 15;
optional string barkLevel = 16;
optional string barkUrl = 17;
optional string barkArchive = 18;
optional string telegramBotToken = 19;
optional string telegramBotUserId = 20;
optional string telegramBotProxyHost = 21;
optional string telegramBotProxyPort = 22;
optional string telegramBotProxyAuth = 23;
optional string telegramBotApiHost = 24;
optional string dingtalkBotToken = 25;
optional string dingtalkBotSecret = 26;
optional string weWorkBotKey = 27;
optional string weWorkOrigin = 28;
optional string weWorkAppKey = 29;
optional string aibotkKey = 30;
optional string aibotkType = 31;
optional string aibotkName = 32;
optional string iGotPushKey = 33;
optional string pushPlusToken = 34;
optional string pushPlusUser = 35;
optional string pushPlusTemplate = 36;
optional string pushplusChannel = 37;
optional string pushplusWebhook = 38;
optional string pushplusCallbackUrl = 39;
optional string pushplusTo = 40;
optional string wePlusBotToken = 41;
optional string wePlusBotReceiver = 42;
optional string wePlusBotVersion = 43;
optional string emailService = 44;
optional string emailUser = 45;
optional string emailPass = 46;
optional string emailTo = 47;
optional string pushMeKey = 48;
optional string pushMeUrl = 49;
optional string chronocatURL = 50;
optional string chronocatQQ = 51;
optional string chronocatToken = 52;
optional string webhookHeaders = 53;
optional string webhookBody = 54;
optional string webhookUrl = 55;
optional string webhookMethod = 56;
optional string webhookContentType = 57;
optional string larkKey = 58;
optional string ntfyUrl = 59;
optional string ntfyTopic = 60;
optional string ntfyPriority = 61;
optional string ntfyToken = 62;
optional string ntfyUsername = 63;
optional string ntfyPassword = 64;
optional string ntfyActions = 65;
optional string wxPusherBotAppToken = 66;
optional string wxPusherBotTopicIds = 67;
optional string wxPusherBotUids = 68;
}
message SystemNotifyRequest {
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) {}
}
+4251
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); rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
} }
message ISchedule { string schedule = 1; }
message ICron { message ICron {
string id = 1; string id = 1;
string schedule = 2; string schedule = 2;
string command = 3; string command = 3;
repeated ISchedule extra_schedules = 4;
string name = 5;
} }
message AddCronRequest { repeated ICron crons = 1; } 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.17.3
// source: back/protos/cron.proto
/* eslint-disable */ /* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import { import {
CallOptions, type CallOptions,
ChannelCredentials, ChannelCredentials,
Client, Client,
ClientOptions, type ClientOptions,
ClientUnaryCall, type ClientUnaryCall,
handleUnaryCall, type handleUnaryCall,
makeGenericClientConstructor, makeGenericClientConstructor,
Metadata, Metadata,
ServiceError, type ServiceError,
UntypedServiceImplementation, type UntypedServiceImplementation,
} from '@grpc/grpc-js'; } from "@grpc/grpc-js";
import _m0 from 'protobufjs/minimal';
export const protobufPackage = 'com.ql.cron'; export const protobufPackage = "com.ql.cron";
export interface ISchedule {
schedule: string;
}
export interface ICron { export interface ICron {
id: string; id: string;
schedule: string; schedule: string;
command: string; command: string;
extra_schedules: ISchedule[];
name: string;
} }
export interface AddCronRequest { export interface AddCronRequest {
crons: ICron[]; crons: ICron[];
} }
export interface AddCronResponse {} export interface AddCronResponse {
}
export interface DeleteCronRequest { export interface DeleteCronRequest {
ids: string[]; ids: string[];
} }
export interface DeleteCronResponse {} export interface DeleteCronResponse {
function createBaseICron(): ICron {
return { id: '', schedule: '', command: '' };
} }
export const ICron = { function createBaseISchedule(): ISchedule {
encode(message: ICron, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { return { schedule: "" };
if (message.id !== '') { }
writer.uint32(10).string(message.id);
} export const ISchedule: MessageFns<ISchedule> = {
if (message.schedule !== '') { encode(message: ISchedule, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(18).string(message.schedule); if (message.schedule !== "") {
} writer.uint32(10).string(message.schedule);
if (message.command !== '') {
writer.uint32(26).string(message.command);
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): ICron { decode(input: BinaryReader | Uint8Array, length?: number): ISchedule {
const reader = const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseICron(); const message = createBaseISchedule();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag != 10) { if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
case 2:
if (tag != 18) {
break; break;
} }
message.schedule = reader.string(); message.schedule = reader.string();
continue; 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; break;
} }
message.command = reader.string(); message.command = reader.string();
continue; continue;
} }
if ((tag & 7) == 4 || tag == 0) { case 4: {
if (tag !== 34) {
break; break;
} }
reader.skipType(tag & 7);
message.extra_schedules.push(ISchedule.decode(reader, reader.uint32()));
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.name = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
} }
return message; return message;
}, },
fromJSON(object: any): ICron { fromJSON(object: any): ICron {
return { return {
id: isSet(object.id) ? String(object.id) : '', id: isSet(object.id) ? globalThis.String(object.id) : "",
schedule: isSet(object.schedule) ? String(object.schedule) : '', schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "",
command: isSet(object.command) ? String(object.command) : '', 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 { toJSON(message: ICron): unknown {
const obj: any = {}; const obj: any = {};
message.id !== undefined && (obj.id = message.id); if (message.id !== "") {
message.schedule !== undefined && (obj.schedule = message.schedule); obj.id = message.id;
message.command !== undefined && (obj.command = message.command); }
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; return obj;
}, },
create<I extends Exact<DeepPartial<ICron>, I>>(base?: I): ICron { 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 { fromPartial<I extends Exact<DeepPartial<ICron>, I>>(object: I): ICron {
const message = createBaseICron(); const message = createBaseICron();
message.id = object.id ?? ''; message.id = object.id ?? "";
message.schedule = object.schedule ?? ''; message.schedule = object.schedule ?? "";
message.command = object.command ?? ''; message.command = object.command ?? "";
message.extra_schedules = object.extra_schedules?.map((e) => ISchedule.fromPartial(e)) || [];
message.name = object.name ?? "";
return message; return message;
}, },
}; };
@@ -122,68 +235,54 @@ function createBaseAddCronRequest(): AddCronRequest {
return { crons: [] }; return { crons: [] };
} }
export const AddCronRequest = { export const AddCronRequest: MessageFns<AddCronRequest> = {
encode( encode(message: AddCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
message: AddCronRequest,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
for (const v of message.crons) { for (const v of message.crons) {
ICron.encode(v!, writer.uint32(10).fork()).ldelim(); ICron.encode(v!, writer.uint32(10).fork()).join();
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): AddCronRequest { decode(input: BinaryReader | Uint8Array, length?: number): AddCronRequest {
const reader = const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronRequest(); const message = createBaseAddCronRequest();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag != 10) { if (tag !== 10) {
break; break;
} }
message.crons.push(ICron.decode(reader, reader.uint32())); message.crons.push(ICron.decode(reader, reader.uint32()));
continue; continue;
} }
if ((tag & 7) == 4 || tag == 0) { }
if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
fromJSON(object: any): AddCronRequest { fromJSON(object: any): AddCronRequest {
return { return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
crons: Array.isArray(object?.crons)
? object.crons.map((e: any) => ICron.fromJSON(e))
: [],
};
}, },
toJSON(message: AddCronRequest): unknown { toJSON(message: AddCronRequest): unknown {
const obj: any = {}; const obj: any = {};
if (message.crons) { if (message.crons?.length) {
obj.crons = message.crons.map((e) => (e ? ICron.toJSON(e) : undefined)); obj.crons = message.crons.map((e) => ICron.toJSON(e));
} else {
obj.crons = [];
} }
return obj; return obj;
}, },
create<I extends Exact<DeepPartial<AddCronRequest>, I>>( create<I extends Exact<DeepPartial<AddCronRequest>, I>>(base?: I): AddCronRequest {
base?: I, return AddCronRequest.fromPartial(base ?? ({} as any));
): AddCronRequest {
return AddCronRequest.fromPartial(base ?? {});
}, },
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(
object: I,
): AddCronRequest {
const message = createBaseAddCronRequest(); const message = createBaseAddCronRequest();
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || []; message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
return message; return message;
@@ -194,27 +293,23 @@ function createBaseAddCronResponse(): AddCronResponse {
return {}; return {};
} }
export const AddCronResponse = { export const AddCronResponse: MessageFns<AddCronResponse> = {
encode( encode(_: AddCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
_: AddCronResponse,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): AddCronResponse { decode(input: BinaryReader | Uint8Array, length?: number): AddCronResponse {
const reader = const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddCronResponse(); const message = createBaseAddCronResponse();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
} }
if ((tag & 7) == 4 || tag == 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -228,15 +323,10 @@ export const AddCronResponse = {
return obj; return obj;
}, },
create<I extends Exact<DeepPartial<AddCronResponse>, I>>( create<I extends Exact<DeepPartial<AddCronResponse>, I>>(base?: I): AddCronResponse {
base?: I, return AddCronResponse.fromPartial(base ?? ({} as any));
): AddCronResponse {
return AddCronResponse.fromPartial(base ?? {});
}, },
fromPartial<I extends Exact<DeepPartial<AddCronResponse>, I>>(_: I): AddCronResponse {
fromPartial<I extends Exact<DeepPartial<AddCronResponse>, I>>(
_: I,
): AddCronResponse {
const message = createBaseAddCronResponse(); const message = createBaseAddCronResponse();
return message; return message;
}, },
@@ -246,68 +336,54 @@ function createBaseDeleteCronRequest(): DeleteCronRequest {
return { ids: [] }; return { ids: [] };
} }
export const DeleteCronRequest = { export const DeleteCronRequest: MessageFns<DeleteCronRequest> = {
encode( encode(message: DeleteCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
message: DeleteCronRequest,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
for (const v of message.ids) { for (const v of message.ids) {
writer.uint32(10).string(v!); writer.uint32(10).string(v!);
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): DeleteCronRequest { decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronRequest {
const reader = const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronRequest(); const message = createBaseDeleteCronRequest();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag != 10) { if (tag !== 10) {
break; break;
} }
message.ids.push(reader.string()); message.ids.push(reader.string());
continue; continue;
} }
if ((tag & 7) == 4 || tag == 0) { }
if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
fromJSON(object: any): DeleteCronRequest { fromJSON(object: any): DeleteCronRequest {
return { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.String(e)) : [] };
ids: Array.isArray(object?.ids)
? object.ids.map((e: any) => String(e))
: [],
};
}, },
toJSON(message: DeleteCronRequest): unknown { toJSON(message: DeleteCronRequest): unknown {
const obj: any = {}; const obj: any = {};
if (message.ids) { if (message.ids?.length) {
obj.ids = message.ids.map((e) => e); obj.ids = message.ids;
} else {
obj.ids = [];
} }
return obj; return obj;
}, },
create<I extends Exact<DeepPartial<DeleteCronRequest>, I>>( create<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(base?: I): DeleteCronRequest {
base?: I, return DeleteCronRequest.fromPartial(base ?? ({} as any));
): DeleteCronRequest {
return DeleteCronRequest.fromPartial(base ?? {});
}, },
fromPartial<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(object: I): DeleteCronRequest {
fromPartial<I extends Exact<DeepPartial<DeleteCronRequest>, I>>(
object: I,
): DeleteCronRequest {
const message = createBaseDeleteCronRequest(); const message = createBaseDeleteCronRequest();
message.ids = object.ids?.map((e) => e) || []; message.ids = object.ids?.map((e) => e) || [];
return message; return message;
@@ -318,27 +394,23 @@ function createBaseDeleteCronResponse(): DeleteCronResponse {
return {}; return {};
} }
export const DeleteCronResponse = { export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
encode( encode(_: DeleteCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
_: DeleteCronResponse,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): DeleteCronResponse { decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronResponse {
const reader = const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDeleteCronResponse(); const message = createBaseDeleteCronResponse();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
} }
if ((tag & 7) == 4 || tag == 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
@@ -352,15 +424,10 @@ export const DeleteCronResponse = {
return obj; return obj;
}, },
create<I extends Exact<DeepPartial<DeleteCronResponse>, I>>( create<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(base?: I): DeleteCronResponse {
base?: I, return DeleteCronResponse.fromPartial(base ?? ({} as any));
): DeleteCronResponse {
return DeleteCronResponse.fromPartial(base ?? {});
}, },
fromPartial<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(_: I): DeleteCronResponse {
fromPartial<I extends Exact<DeepPartial<DeleteCronResponse>, I>>(
_: I,
): DeleteCronResponse {
const message = createBaseDeleteCronResponse(); const message = createBaseDeleteCronResponse();
return message; return message;
}, },
@@ -369,25 +436,21 @@ export const DeleteCronResponse = {
export type CronService = typeof CronService; export type CronService = typeof CronService;
export const CronService = { export const CronService = {
addCron: { addCron: {
path: '/com.ql.cron.Cron/addCron', path: "/com.ql.cron.Cron/addCron",
requestStream: false, requestStream: false,
responseStream: false, responseStream: false,
requestSerialize: (value: AddCronRequest) => requestSerialize: (value: AddCronRequest) => Buffer.from(AddCronRequest.encode(value).finish()),
Buffer.from(AddCronRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => AddCronRequest.decode(value), requestDeserialize: (value: Buffer) => AddCronRequest.decode(value),
responseSerialize: (value: AddCronResponse) => responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()),
Buffer.from(AddCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value), responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
}, },
delCron: { delCron: {
path: '/com.ql.cron.Cron/delCron', path: "/com.ql.cron.Cron/delCron",
requestStream: false, requestStream: false,
responseStream: false, responseStream: false,
requestSerialize: (value: DeleteCronRequest) => requestSerialize: (value: DeleteCronRequest) => Buffer.from(DeleteCronRequest.encode(value).finish()),
Buffer.from(DeleteCronRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => DeleteCronRequest.decode(value), requestDeserialize: (value: Buffer) => DeleteCronRequest.decode(value),
responseSerialize: (value: DeleteCronResponse) => responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
Buffer.from(DeleteCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value), responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
}, },
} as const; } as const;
@@ -415,68 +478,48 @@ export interface CronClient extends Client {
): ClientUnaryCall; ): ClientUnaryCall;
delCron( delCron(
request: DeleteCronRequest, request: DeleteCronRequest,
callback: ( callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
error: ServiceError | null,
response: DeleteCronResponse,
) => void,
): ClientUnaryCall; ): ClientUnaryCall;
delCron( delCron(
request: DeleteCronRequest, request: DeleteCronRequest,
metadata: Metadata, metadata: Metadata,
callback: ( callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
error: ServiceError | null,
response: DeleteCronResponse,
) => void,
): ClientUnaryCall; ): ClientUnaryCall;
delCron( delCron(
request: DeleteCronRequest, request: DeleteCronRequest,
metadata: Metadata, metadata: Metadata,
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: ( callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
error: ServiceError | null,
response: DeleteCronResponse,
) => void,
): ClientUnaryCall; ): ClientUnaryCall;
} }
export const CronClient = makeGenericClientConstructor( export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
CronService, new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): CronClient;
'com.ql.cron.Cron',
) as unknown as {
new (
address: string,
credentials: ChannelCredentials,
options?: Partial<ClientOptions>,
): CronClient;
service: typeof CronService; service: typeof CronService;
serviceName: string;
}; };
type Builtin = type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
export type DeepPartial<T> = T extends Builtin export type DeepPartial<T> = T extends Builtin ? T
? T : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends Array<infer U> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
? Array<DeepPartial<U>> : T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>; : Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never; type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin export type Exact<P, I extends P> = P extends Builtin ? P
? P : P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
: P & { [K in keyof P]: Exact<P[K], I[K]> } & {
[K in Exclude<keyof I, KeysOfUnion<P>>]: never;
};
function isSet(value: any): boolean { function isSet(value: any): boolean {
return value !== null && value !== undefined; return value !== null && value !== undefined;
} }
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
+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.17.3
// source: back/protos/health.proto
/* eslint-disable */ /* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import { import {
CallOptions, type CallOptions,
ChannelCredentials, ChannelCredentials,
Client, Client,
ClientOptions, type ClientOptions,
ClientReadableStream, type ClientReadableStream,
ClientUnaryCall, type ClientUnaryCall,
handleServerStreamingCall, type handleServerStreamingCall,
handleUnaryCall, type handleUnaryCall,
makeGenericClientConstructor, makeGenericClientConstructor,
Metadata, Metadata,
ServiceError, type ServiceError,
UntypedServiceImplementation, type UntypedServiceImplementation,
} from '@grpc/grpc-js'; } from "@grpc/grpc-js";
import _m0 from 'protobufjs/minimal';
export const protobufPackage = 'com.ql.health'; export const protobufPackage = "com.ql.health";
export interface HealthCheckRequest { export interface HealthCheckRequest {
service: string; service: string;
@@ -33,107 +39,97 @@ export enum HealthCheckResponse_ServingStatus {
UNRECOGNIZED = -1, UNRECOGNIZED = -1,
} }
export function healthCheckResponse_ServingStatusFromJSON( export function healthCheckResponse_ServingStatusFromJSON(object: any): HealthCheckResponse_ServingStatus {
object: any,
): HealthCheckResponse_ServingStatus {
switch (object) { switch (object) {
case 0: case 0:
case 'UNKNOWN': case "UNKNOWN":
return HealthCheckResponse_ServingStatus.UNKNOWN; return HealthCheckResponse_ServingStatus.UNKNOWN;
case 1: case 1:
case 'SERVING': case "SERVING":
return HealthCheckResponse_ServingStatus.SERVING; return HealthCheckResponse_ServingStatus.SERVING;
case 2: case 2:
case 'NOT_SERVING': case "NOT_SERVING":
return HealthCheckResponse_ServingStatus.NOT_SERVING; return HealthCheckResponse_ServingStatus.NOT_SERVING;
case 3: case 3:
case 'SERVICE_UNKNOWN': case "SERVICE_UNKNOWN":
return HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN; return HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN;
case -1: case -1:
case 'UNRECOGNIZED': case "UNRECOGNIZED":
default: default:
return HealthCheckResponse_ServingStatus.UNRECOGNIZED; return HealthCheckResponse_ServingStatus.UNRECOGNIZED;
} }
} }
export function healthCheckResponse_ServingStatusToJSON( export function healthCheckResponse_ServingStatusToJSON(object: HealthCheckResponse_ServingStatus): string {
object: HealthCheckResponse_ServingStatus,
): string {
switch (object) { switch (object) {
case HealthCheckResponse_ServingStatus.UNKNOWN: case HealthCheckResponse_ServingStatus.UNKNOWN:
return 'UNKNOWN'; return "UNKNOWN";
case HealthCheckResponse_ServingStatus.SERVING: case HealthCheckResponse_ServingStatus.SERVING:
return 'SERVING'; return "SERVING";
case HealthCheckResponse_ServingStatus.NOT_SERVING: case HealthCheckResponse_ServingStatus.NOT_SERVING:
return 'NOT_SERVING'; return "NOT_SERVING";
case HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN: case HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN:
return 'SERVICE_UNKNOWN'; return "SERVICE_UNKNOWN";
case HealthCheckResponse_ServingStatus.UNRECOGNIZED: case HealthCheckResponse_ServingStatus.UNRECOGNIZED:
default: default:
return 'UNRECOGNIZED'; return "UNRECOGNIZED";
} }
} }
function createBaseHealthCheckRequest(): HealthCheckRequest { function createBaseHealthCheckRequest(): HealthCheckRequest {
return { service: '' }; return { service: "" };
} }
export const HealthCheckRequest = { export const HealthCheckRequest: MessageFns<HealthCheckRequest> = {
encode( encode(message: HealthCheckRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
message: HealthCheckRequest, if (message.service !== "") {
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
if (message.service !== '') {
writer.uint32(10).string(message.service); writer.uint32(10).string(message.service);
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckRequest { decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckRequest {
const reader = const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckRequest(); const message = createBaseHealthCheckRequest();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag != 10) { if (tag !== 10) {
break; break;
} }
message.service = reader.string(); message.service = reader.string();
continue; continue;
} }
if ((tag & 7) == 4 || tag == 0) { }
if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
fromJSON(object: any): HealthCheckRequest { 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 { toJSON(message: HealthCheckRequest): unknown {
const obj: any = {}; const obj: any = {};
message.service !== undefined && (obj.service = message.service); if (message.service !== "") {
obj.service = message.service;
}
return obj; return obj;
}, },
create<I extends Exact<DeepPartial<HealthCheckRequest>, I>>( create<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(base?: I): HealthCheckRequest {
base?: I, return HealthCheckRequest.fromPartial(base ?? ({} as any));
): HealthCheckRequest {
return HealthCheckRequest.fromPartial(base ?? {});
}, },
fromPartial<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(object: I): HealthCheckRequest {
fromPartial<I extends Exact<DeepPartial<HealthCheckRequest>, I>>(
object: I,
): HealthCheckRequest {
const message = createBaseHealthCheckRequest(); const message = createBaseHealthCheckRequest();
message.service = object.service ?? ''; message.service = object.service ?? "";
return message; return message;
}, },
}; };
@@ -142,65 +138,54 @@ function createBaseHealthCheckResponse(): HealthCheckResponse {
return { status: 0 }; return { status: 0 };
} }
export const HealthCheckResponse = { export const HealthCheckResponse: MessageFns<HealthCheckResponse> = {
encode( encode(message: HealthCheckResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
message: HealthCheckResponse,
writer: _m0.Writer = _m0.Writer.create(),
): _m0.Writer {
if (message.status !== 0) { if (message.status !== 0) {
writer.uint32(8).int32(message.status); writer.uint32(8).int32(message.status);
} }
return writer; return writer;
}, },
decode(input: _m0.Reader | Uint8Array, length?: number): HealthCheckResponse { decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckResponse {
const reader = const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length; let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHealthCheckResponse(); const message = createBaseHealthCheckResponse();
while (reader.pos < end) { while (reader.pos < end) {
const tag = reader.uint32(); const tag = reader.uint32();
switch (tag >>> 3) { switch (tag >>> 3) {
case 1: case 1: {
if (tag != 8) { if (tag !== 8) {
break; break;
} }
message.status = reader.int32() as any; message.status = reader.int32() as any;
continue; continue;
} }
if ((tag & 7) == 4 || tag == 0) { }
if ((tag & 7) === 4 || tag === 0) {
break; break;
} }
reader.skipType(tag & 7); reader.skip(tag & 7);
} }
return message; return message;
}, },
fromJSON(object: any): HealthCheckResponse { fromJSON(object: any): HealthCheckResponse {
return { return { status: isSet(object.status) ? healthCheckResponse_ServingStatusFromJSON(object.status) : 0 };
status: isSet(object.status)
? healthCheckResponse_ServingStatusFromJSON(object.status)
: 0,
};
}, },
toJSON(message: HealthCheckResponse): unknown { toJSON(message: HealthCheckResponse): unknown {
const obj: any = {}; const obj: any = {};
message.status !== undefined && if (message.status !== 0) {
(obj.status = healthCheckResponse_ServingStatusToJSON(message.status)); obj.status = healthCheckResponse_ServingStatusToJSON(message.status);
}
return obj; return obj;
}, },
create<I extends Exact<DeepPartial<HealthCheckResponse>, I>>( create<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(base?: I): HealthCheckResponse {
base?: I, return HealthCheckResponse.fromPartial(base ?? ({} as any));
): HealthCheckResponse {
return HealthCheckResponse.fromPartial(base ?? {});
}, },
fromPartial<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(object: I): HealthCheckResponse {
fromPartial<I extends Exact<DeepPartial<HealthCheckResponse>, I>>(
object: I,
): HealthCheckResponse {
const message = createBaseHealthCheckResponse(); const message = createBaseHealthCheckResponse();
message.status = object.status ?? 0; message.status = object.status ?? 0;
return message; return message;
@@ -210,25 +195,21 @@ export const HealthCheckResponse = {
export type HealthService = typeof HealthService; export type HealthService = typeof HealthService;
export const HealthService = { export const HealthService = {
check: { check: {
path: '/com.ql.health.Health/Check', path: "/com.ql.health.Health/Check",
requestStream: false, requestStream: false,
responseStream: false, responseStream: false,
requestSerialize: (value: HealthCheckRequest) => requestSerialize: (value: HealthCheckRequest) => Buffer.from(HealthCheckRequest.encode(value).finish()),
Buffer.from(HealthCheckRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value), requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value),
responseSerialize: (value: HealthCheckResponse) => responseSerialize: (value: HealthCheckResponse) => Buffer.from(HealthCheckResponse.encode(value).finish()),
Buffer.from(HealthCheckResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value), responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value),
}, },
watch: { watch: {
path: '/com.ql.health.Health/Watch', path: "/com.ql.health.Health/Watch",
requestStream: false, requestStream: false,
responseStream: true, responseStream: true,
requestSerialize: (value: HealthCheckRequest) => requestSerialize: (value: HealthCheckRequest) => Buffer.from(HealthCheckRequest.encode(value).finish()),
Buffer.from(HealthCheckRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value), requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value),
responseSerialize: (value: HealthCheckResponse) => responseSerialize: (value: HealthCheckResponse) => Buffer.from(HealthCheckResponse.encode(value).finish()),
Buffer.from(HealthCheckResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value), responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value),
}, },
} as const; } as const;
@@ -241,32 +222,20 @@ export interface HealthServer extends UntypedServiceImplementation {
export interface HealthClient extends Client { export interface HealthClient extends Client {
check( check(
request: HealthCheckRequest, request: HealthCheckRequest,
callback: ( callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
): ClientUnaryCall; ): ClientUnaryCall;
check( check(
request: HealthCheckRequest, request: HealthCheckRequest,
metadata: Metadata, metadata: Metadata,
callback: ( callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
): ClientUnaryCall; ): ClientUnaryCall;
check( check(
request: HealthCheckRequest, request: HealthCheckRequest,
metadata: Metadata, metadata: Metadata,
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: ( callback: (error: ServiceError | null, response: HealthCheckResponse) => void,
error: ServiceError | null,
response: HealthCheckResponse,
) => void,
): ClientUnaryCall; ): ClientUnaryCall;
watch( watch(request: HealthCheckRequest, options?: Partial<CallOptions>): ClientReadableStream<HealthCheckResponse>;
request: HealthCheckRequest,
options?: Partial<CallOptions>,
): ClientReadableStream<HealthCheckResponse>;
watch( watch(
request: HealthCheckRequest, request: HealthCheckRequest,
metadata?: Metadata, metadata?: Metadata,
@@ -274,44 +243,33 @@ export interface HealthClient extends Client {
): ClientReadableStream<HealthCheckResponse>; ): ClientReadableStream<HealthCheckResponse>;
} }
export const HealthClient = makeGenericClientConstructor( export const HealthClient = makeGenericClientConstructor(HealthService, "com.ql.health.Health") as unknown as {
HealthService, new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): HealthClient;
'com.ql.health.Health',
) as unknown as {
new (
address: string,
credentials: ChannelCredentials,
options?: Partial<ClientOptions>,
): HealthClient;
service: typeof HealthService; service: typeof HealthService;
serviceName: string;
}; };
type Builtin = type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
| Date
| Function
| Uint8Array
| string
| number
| boolean
| undefined;
export type DeepPartial<T> = T extends Builtin export type DeepPartial<T> = T extends Builtin ? T
? T : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends Array<infer U> : T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
? Array<DeepPartial<U>> : T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends {}
? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>; : Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never; type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin export type Exact<P, I extends P> = P extends Builtin ? P
? P : P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
: P & { [K in keyof P]: Exact<P[K], I[K]> } & {
[K in Exclude<keyof I, KeysOfUnion<P>>]: never;
};
function isSet(value: any): boolean { function isSet(value: any): boolean {
return value !== null && value !== undefined; return value !== null && value !== undefined;
} }
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
-33
View File
@@ -1,33 +0,0 @@
import express from 'express';
import { exec } from 'child_process';
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({ error: err });
}
return res.status(200).send({ data: response });
});
});
app
.listen(config.publicPort, async () => {
await require('./loaders/sentry').default({ expressApp: app });
await require('./loaders/db').default();
Logger.debug(`✌️ 公共服务启动成功!`);
})
.on('error', (err) => {
Logger.error(err);
process.exit(1);
});
+37 -7
View File
@@ -2,23 +2,53 @@ import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron'; import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule'; import nodeSchedule from 'node-schedule';
import { scheduleStacks } from './data'; import { scheduleStacks } from './data';
import { exec } from 'child_process'; import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger';
const addCron = ( const addCron = (
call: ServerUnaryCall<AddCronRequest, AddCronResponse>, call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
callback: sendUnaryData<AddCronResponse>, callback: sendUnaryData<AddCronResponse>,
) => { ) => {
for (const item of call.request.crons) { for (const item of call.request.crons) {
const { id, schedule, command } = item; const { id, schedule, command, extra_schedules, name } = item;
if (scheduleStacks.has(id)) { if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.cancel(); scheduleStacks.get(id)?.forEach((x) => x.cancel());
} }
scheduleStacks.set(
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id, id,
nodeSchedule.scheduleJob(id, schedule, async () => { name,
exec(`ID=${id} ${command}`); schedule,
}), command,
); );
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务], 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
command,
);
});
}
scheduleStacks.set(id, [
nodeSchedule.scheduleJob(id, schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
}),
...(extra_schedules?.length
? extra_schedules.map((x) =>
nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
}),
)
: []),
]);
} }
callback(null, null); callback(null, null);
+325
View File
@@ -0,0 +1,325 @@
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,
} 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 ?? undefined,
pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined,
};
};
export const getCronDetail = async (
call: ServerUnaryCall<CronDetailRequest, CronDetailResponse>,
callback: sendUnaryData<CronDetailResponse>,
) => {
try {
if (!call.request.log_path) {
return callback(null, {
code: 400,
data: undefined,
message: 'log_path is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.find({
log_path: call.request.log_path,
})) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const createCron = async (
call: ServerUnaryCall<CreateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const data = (await cronService.create(call.request)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const updateCron = async (
call: ServerUnaryCall<UpdateCronRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
const cronService = Container.get(CronService);
const { id, ...fields } = call.request;
const updateRequest = {
id,
...Object.entries(fields).reduce((acc: any, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {}),
} as UpdateCronRequest;
const data = (await cronService.update(updateRequest)) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(e);
}
};
export const deleteCrons = async (
call: ServerUnaryCall<DeleteCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
const cronService = Container.get(CronService);
await cronService.remove(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+2 -1
View File
@@ -10,8 +10,9 @@ import config from '../config';
class Client { class Client {
private client = new CronClient( private client = new CronClient(
`localhost:${config.cronPort}`, `0.0.0.0:${config.grpcPort}`,
credentials.createInsecure(), credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 },
); );
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> { addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
+1 -1
View File
@@ -1,6 +1,6 @@
import nodeSchedule from 'node-schedule'; import nodeSchedule from 'node-schedule';
import { ToadScheduler } from 'toad-scheduler'; 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(); export const intervalSchedule = new ToadScheduler();
+6 -1
View File
@@ -1,6 +1,7 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron'; import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron';
import { scheduleStacks } from './data'; import { scheduleStacks } from './data';
import Logger from '../loaders/logger';
const delCron = ( const delCron = (
call: ServerUnaryCall<DeleteCronRequest, DeleteCronResponse>, call: ServerUnaryCall<DeleteCronRequest, DeleteCronResponse>,
@@ -8,7 +9,11 @@ const delCron = (
) => { ) => {
for (const id of call.request.ids) { for (const id of call.request.ids) {
if (scheduleStacks.has(id)) { if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.cancel(); Logger.info(
'[schedule][取消定时任务], 任务ID: %s',
id,
);
scheduleStacks.get(id)?.forEach(x => x.cancel());
scheduleStacks.delete(id); scheduleStacks.delete(id);
} }
} }
+9 -4
View File
@@ -1,6 +1,5 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
import { HealthCheckRequest, HealthCheckResponse } from '../protos/health'; import { HealthCheckRequest, HealthCheckResponse } from '../protos/health';
import { exec } from 'child_process';
import config from '../config'; import config from '../config';
import { promiseExec } from '../config/util'; import { promiseExec } from '../config/util';
@@ -11,16 +10,22 @@ const check = async (
switch (call.request.service) { switch (call.request.service) {
case 'cron': case 'cron':
const res = await promiseExec( const res = await promiseExec(
`curl -sf http://localhost:${config.port}/api/system`, `curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`,
); );
if (res.includes('200')) { if (res.includes('200')) {
return callback(null, { status: 1 }); return callback(null, { status: 1 });
} }
const errLog = await promiseExec(
const panelErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`, `tail -n 300 ~/.pm2/logs/panel-error.log`,
); );
return callback(new Error(errLog)); const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
);
return callback(
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()),
);
default: default:
return callback(null, { status: 1 }); return callback(null, { status: 1 });
-20
View File
@@ -1,20 +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(),
() => {
server.start();
Logger.debug(`✌️ 定时服务启动成功!`);
},
);
+42
View File
@@ -0,0 +1,42 @@
import { Service, Inject } from 'typedi';
import path, { join } from 'path';
import config from '../config';
import { getFileContentByName } from '../config/util';
import { Response } from 'express';
import { request } from 'undici';
@Service()
export default class ConfigService {
constructor() {}
public async getFile(filePath: string, res: Response) {
let content = '';
const avaliablePath = [config.rootPath, config.configPath].map((x) =>
path.resolve(x, filePath),
);
if (
config.blackFileList.includes(filePath) ||
avaliablePath.every(
(x) =>
!x.startsWith(config.scriptPath) && !x.startsWith(config.configPath),
) ||
!filePath
) {
return res.send({ code: 403, message: '文件无法访问' });
}
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 });
}
}
+258 -121
View File
@@ -2,43 +2,75 @@ import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import { exec, execSync, spawn } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs'; import fs from 'fs/promises';
import cron_parser from 'cron-parser'; import cron_parser from 'cron-parser';
import { import {
getFileContentByName, getFileContentByName,
concurrentRun,
fileExist, fileExist,
killTask, killTask,
getUniqPath,
safeJSONParse,
isDemoEnv,
} from '../config/util'; } from '../config/util';
import { promises, existsSync } from 'fs'; import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize';
import { Op, where, col as colFn, FindOptions } from 'sequelize';
import path from 'path'; import path from 'path';
import { TASK_PREFIX, QL_PREFIX } from '../config/const'; import { TASK_PREFIX, QL_PREFIX } from '../config/const';
import cronClient from '../schedule/client'; 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 { ScheduleType } from '../interface/schedule';
@Service() @Service()
export default class CronService { export default class CronService {
constructor(@Inject('logger') private logger: winston.Logger) { } constructor(@Inject('logger') private logger: winston.Logger) {}
private isSixCron(cron: Crontab) { private isNodeCron(cron: Crontab) {
const { schedule } = cron; const { schedule, extra_schedules } = cron;
if (schedule?.split(/ +/).length === 6) { if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
return true; return true;
} }
return false; return false;
} }
private isOnceSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.ONCE);
}
private isBootSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.BOOT);
}
private isSpecialSchedule(schedule?: string) {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
}
public async create(payload: Crontab): Promise<Crontab> { public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload); const tab = new Crontab(payload);
tab.saved = false; tab.saved = false;
const doc = await this.insert(tab); const doc = await this.insert(tab);
if (this.isSixCron(doc)) {
if (isDemoEnv()) {
return doc;
}
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) {
await cronClient.addCron([ await cronClient.addCron([
{ id: String(doc.id), schedule: doc.schedule!, command: doc.command }, {
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]); ]);
} }
await this.set_crontab();
await this.setCrontab();
return doc; return doc;
} }
@@ -46,27 +78,33 @@ export default class CronService {
return await CrontabModel.create(payload, { returning: true }); return await CrontabModel.create(payload, { returning: true });
} }
public async update(payload: Crontab): Promise<Crontab> { public async update(payload: Partial<Crontab>): Promise<Crontab> {
const doc = await this.getDb({ id: payload.id }); const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload }); const tab = new Crontab({ ...doc, ...payload });
tab.saved = false; tab.saved = false;
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1) {
if (doc.isDisabled === 1 || isDemoEnv()) {
return newDoc; return newDoc;
} }
if (this.isSixCron(doc)) {
await cronClient.delCron([String(newDoc.id)]); if (this.isNodeCron(doc)) {
await cronClient.delCron([String(doc.id)]);
} }
if (this.isSixCron(newDoc)) {
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) {
await cronClient.addCron([ await cronClient.addCron([
{ {
name: doc.name || '',
id: String(newDoc.id), id: String(newDoc.id),
schedule: newDoc.schedule!, schedule: newDoc.schedule!,
command: newDoc.command, command: this.makeCommand(newDoc),
extra_schedules: newDoc.extra_schedules || [],
}, },
]); ]);
} }
await this.set_crontab();
await this.setCrontab();
return newDoc; return newDoc;
} }
@@ -90,7 +128,7 @@ export default class CronService {
last_running_time: number; last_running_time: number;
last_execution_time: number; last_execution_time: number;
}) { }) {
const options: any = { let options: any = {
status, status,
pid, pid,
log_path, log_path,
@@ -100,13 +138,28 @@ export default class CronService {
options.last_running_time = last_running_time; 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']);
}
await CrontabModel.update(
{ ...pickBy(options, (v) => v === 0 || !!v) },
{ where: { id } },
);
}
} }
public async remove(ids: number[]) { public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } }); await CrontabModel.destroy({ where: { id: ids } });
await cronClient.delCron(ids.map(String)); await cronClient.delCron(ids.map(String));
await this.set_crontab(); await this.setCrontab();
} }
public async pin(ids: number[]) { public async pin(ids: number[]) {
@@ -150,8 +203,8 @@ export default class CronService {
for (const col of viewQuery.filters) { for (const col of viewQuery.filters) {
const { property, value, operation } = col; const { property, value, operation } = col;
let q: any = {}; let q: any = {};
let operate2 = null; let operate2: any = null;
let operate = null; let operate: any = null;
switch (operation) { switch (operation) {
case 'Reg': case 'Reg':
operate = Op.like; operate = Op.like;
@@ -162,22 +215,39 @@ export default class CronService {
operate2 = Op.and; operate2 = Op.and;
break; break;
case 'In': case 'In':
if (
property === 'status' &&
!value.includes(CrontabStatus.disabled)
) {
q[Op.and] = [
{ [property]: Array.isArray(value) ? value : [value] },
{ isDisabled: 0 },
];
} else {
q[Op.or] = [ q[Op.or] = [
{ {
[property]: value, [property]: Array.isArray(value) ? value : [value],
}, },
property === 'status' && value.includes(2) property === 'status' && value.includes(CrontabStatus.disabled)
? { isDisabled: 1 } ? { isDisabled: 1 }
: {}, : {},
]; ];
}
break; break;
case 'Nin': case 'Nin':
q[Op.and] = [ q[Op.and] = [
{
[Op.or]: [
{ {
[property]: { [property]: {
[Op.notIn]: value, [Op.notIn]: Array.isArray(value) ? value : [value],
}, },
}, },
{
[property]: { [Op.is]: null },
},
],
},
property === 'status' && value.includes(2) property === 'status' && value.includes(2)
? { isDisabled: { [Op.ne]: 1 } } ? { isDisabled: { [Op.ne]: 1 } }
: {}, : {},
@@ -192,17 +262,13 @@ export default class CronService {
{ {
[operate2]: [ [operate2]: [
{ [operate]: `%${value}%` }, { [operate]: `%${value}%` },
{ [operate]: `%${encodeURIComponent(value)}%` }, { [operate]: `%${encodeURI(value)}%` },
], ],
}, },
{ {
[operate2]: [ [operate2]: [
where(colFn(property), operate, `%${value}%`), where(colFn(property), operate, `%${value}%`),
where( where(colFn(property), operate, `%${encodeURI(value)}%`),
colFn(property),
operate,
`%${encodeURIComponent(value)}%`,
),
], ],
}, },
], ],
@@ -229,7 +295,7 @@ export default class CronService {
q[column] = { q[column] = {
[Op.or]: [ [Op.or]: [
{ [Op.like]: `%${textArray[1]}%` }, { [Op.like]: `%${textArray[1]}%` },
{ [Op.like]: `%${encodeURIComponent(textArray[1])}%` }, { [Op.like]: `%${encodeURI(textArray[1])}%` },
], ],
}; };
break; break;
@@ -237,7 +303,7 @@ export default class CronService {
const reg = { const reg = {
[Op.or]: [ [Op.or]: [
{ [Op.like]: `%${searchText}%` }, { [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeURIComponent(searchText)}%` }, { [Op.like]: `%${encodeURI(searchText)}%` },
], ],
}; };
q[Op.or] = [ q[Op.or] = [
@@ -270,7 +336,7 @@ export default class CronService {
let q: any = {}; let q: any = {};
if (!filterQuery[key]) continue; if (!filterQuery[key]) continue;
if (key === 'status') { if (key === 'status') {
if (filterQuery[key].includes(2)) { if (filterQuery[key].includes(CrontabStatus.disabled)) {
q = { [Op.or]: [{ [key]: filterQuery[key] }, { isDisabled: 1 }] }; q = { [Op.or]: [{ [key]: filterQuery[key] }, { isDisabled: 1 }] };
} else { } else {
q = { [Op.and]: [{ [key]: filterQuery[key] }, { isDisabled: 0 }] }; q = { [Op.and]: [{ [key]: filterQuery[key] }, { isDisabled: 0 }] };
@@ -295,10 +361,10 @@ export default class CronService {
log_path, log_path,
}: { }: {
log_path: string; log_path: string;
}): Promise<Crontab | null> { }): Promise<Crontab | undefined> {
try { try {
const result = await CrontabModel.findOne({ where: { log_path } }); const result = await CrontabModel.findOne({ where: { log_path } });
return result; return result?.get({ plain: true });
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -315,9 +381,9 @@ export default class CronService {
const searchText = params?.searchValue; const searchText = params?.searchValue;
const page = Number(params?.page || '0'); const page = Number(params?.page || '0');
const size = Number(params?.size || '0'); const size = Number(params?.size || '0');
const viewQuery = JSON.parse(params?.queryString || '{}'); const viewQuery = safeJSONParse(params?.queryString);
const filterQuery = JSON.parse(params?.filters || '{}'); const filterQuery = safeJSONParse(params?.filters);
const sorterQuery = JSON.parse(params?.sorter || '{}'); const sorterQuery = safeJSONParse(params?.sorter);
let query: any = {}; let query: any = {};
let order = [ let order = [
@@ -338,9 +404,9 @@ export default class CronService {
order.unshift([field, type]); order.unshift([field, type]);
} }
} }
let condition: any = { let condition: FindOptions<Crontab> = {
where: query, where: query,
order: order, order: order as Order,
}; };
if (page && size) { if (page && size) {
condition.offset = (page - 1) * size; condition.offset = (page - 1) * size;
@@ -349,7 +415,7 @@ export default class CronService {
try { try {
const result = await CrontabModel.findAll(condition); const result = await CrontabModel.findAll(condition);
const count = await CrontabModel.count({ where: query }); const count = await CrontabModel.count({ where: query });
return { data: result, total: count }; return { data: result.map((x) => x.get({ plain: true })), total: count };
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -357,7 +423,10 @@ export default class CronService {
public async getDb(query: FindOptions<Crontab>['where']): Promise<Crontab> { public async getDb(query: FindOptions<Crontab>['where']): Promise<Crontab> {
const doc: any = await CrontabModel.findOne({ where: { ...query } }); 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[]) { public async run(ids: number[]) {
@@ -365,10 +434,9 @@ export default class CronService {
{ status: CrontabStatus.queued }, { status: CrontabStatus.queued },
{ where: { id: ids } }, { where: { id: ids } },
); );
concurrentRun( ids.forEach((id) => {
ids.map((id) => async () => await this.runSingle(id)), this.runSingle(id);
10, });
);
} }
public async stop(ids: number[]) { public async stop(ids: number[]) {
@@ -378,7 +446,7 @@ export default class CronService {
try { try {
await killTask(doc.pid); await killTask(doc.pid);
} catch (error) { } catch (error) {
this.logger.silly(error); this.logger.error(error);
} }
} }
} }
@@ -389,64 +457,78 @@ export default class CronService {
); );
} }
private async runSingle(cronId: number): Promise<number> { private async runSingle(cronId: number): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(() => {
return new Promise(async (resolve: any) => { return new Promise(async (resolve: any) => {
const cron = await this.getDb({ id: cronId }); 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) { if (cron.status !== CrontabStatus.queued) {
resolve(); resolve(params);
return; return;
} }
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
);
let { id, command, log_path } = cron; let { id, command, log_path } = cron;
const absolutePath = path.resolve(config.logPath, `${log_path}`); const uniqPath = await getUniqPath(command, `${id}`);
const logFileExist = log_path && (await fileExist(absolutePath)); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
this.logger.silly('Running job'); if (log_path?.split('/')?.every((x) => x !== uniqPath)) {
this.logger.silly('ID: ' + id); await fs.mkdir(logDirPath, { recursive: true });
this.logger.silly('Original command: ' + command);
let cmdStr = command;
if (!cmdStr.startsWith(TASK_PREFIX) && !cmdStr.startsWith(QL_PREFIX)) {
cmdStr = `${TASK_PREFIX}${cmdStr}`;
} }
if ( const logPath = `${uniqPath}/${logTime}.log`;
cmdStr.endsWith('.js') || const absolutePath = path.resolve(config.logPath, `${logPath}`);
cmdStr.endsWith('.py') || const cp = spawn(
cmdStr.endsWith('.pyc') || `real_log_path=${logPath} no_delay=true ${this.makeCommand(
cmdStr.endsWith('.sh') || cron,
cmdStr.endsWith('.ts') true,
) { )}`,
cmdStr = `${cmdStr} now`; { shell: '/bin/bash' },
} );
const cp = spawn(`ID=${id} ${cmdStr}`, { shell: '/bin/bash' });
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.running, pid: cp.pid }, { status: CrontabStatus.running, pid: cp.pid, log_path: logPath },
{ where: { id } }, { where: { id } },
); );
cp.stderr.on('data', (data) => { cp.stdout.on('data', async (data) => {
if (logFileExist) { await fs.appendFile(absolutePath, data.toString());
fs.appendFileSync(`${absolutePath}`, `${data.toString()}`);
}
}); });
cp.on('error', (err) => { cp.stderr.on('data', async (data) => {
if (logFileExist) { this.logger.info(
fs.appendFileSync(`${absolutePath}`, `${JSON.stringify(err)}`); '[panel][执行任务失败] 命令: %s, 错误信息: %j',
} command,
data.toString(),
);
await fs.appendFile(absolutePath, data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
await fs.appendFile(absolutePath, JSON.stringify(err));
}); });
cp.on('exit', async (code, signal) => { cp.on('exit', async (code) => {
this.logger.info( this.logger.info(
`任务 ${command} 进程id: ${cp.pid} 退出,退出码 ${code}`, '[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify(params),
code,
); );
});
cp.on('close', async (code) => {
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined }, { status: CrontabStatus.idle, pid: undefined },
{ where: { id } }, { where: { id } },
); );
resolve(); resolve({ ...params, pid: cp.pid, code });
});
}); });
}); });
} }
@@ -454,21 +536,27 @@ export default class CronService {
public async disabled(ids: number[]) { public async disabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await cronClient.delCron(ids.map(String)); await cronClient.delCron(ids.map(String));
await this.set_crontab(); await this.setCrontab();
} }
public async enabled(ids: number[]) { public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
const sixCron = docs const sixCron = docs
.filter((x) => this.isSixCron(x)) .filter((x) => this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule))
.map((doc) => ({ .map((doc) => ({
name: doc.name || '',
id: String(doc.id), id: String(doc.id),
schedule: doc.schedule!, schedule: doc.schedule!,
command: doc.command, command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
})); }));
if (isDemoEnv()) {
return;
}
await cronClient.addCron(sixCron); await cronClient.addCron(sixCron);
await this.set_crontab(); await this.setCrontab();
} }
public async log(id: number) { public async log(id: number) {
@@ -480,9 +568,12 @@ export default class CronService {
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath)); const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) { if (logFileExist) {
return getFileContentByName(`${absolutePath}`); return await getFileContentByName(`${absolutePath}`);
} else { } else {
return '任务未运行'; return typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? '运行中...'
: '日志不存在...';
} }
} }
@@ -494,58 +585,77 @@ export default class CronService {
const relativeDir = path.dirname(`${doc.log_path}`); const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir); const dir = path.resolve(config.logPath, relativeDir);
if (existsSync(dir)) { const dirExist = await fileExist(dir);
let files = await promises.readdir(dir); if (dirExist) {
return files let files = await fs.readdir(dir);
.map((x) => ({ return (
await Promise.all(
files.map(async (x) => ({
filename: x, filename: x,
directory: relativeDir.replace(config.logPath, ''), directory: relativeDir.replace(config.logPath, ''),
time: fs.statSync(`${dir}/${x}`).mtime.getTime(), time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
})) })),
.sort((a, b) => b.time - a.time); )
).sort((a, b) => b.time - a.time);
} else { } else {
return []; return [];
} }
} }
private make_command(tab: Crontab) { private makeCommand(tab: Crontab, realTime?: boolean) {
if ( let command = tab.command.trim();
!tab.command.startsWith(TASK_PREFIX) && if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
!tab.command.startsWith(QL_PREFIX) command = `${TASK_PREFIX}${tab.command}`;
) {
tab.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
} `;
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()}' `;
}
const crontab_job_string = `${commandVariable}${command}`;
return crontab_job_string; return crontab_job_string;
} }
private async set_crontab(data?: { data: Crontab[]; total: number }) { private async setCrontab(data?: { data: Crontab[]; total: number }) {
const tabs = data ?? (await this.crontabs()); const tabs = data ?? (await this.crontabs());
var crontab_string = ''; var crontab_string = '';
tabs.data.forEach((tab) => { tabs.data.forEach((tab) => {
const _schedule = tab.schedule && tab.schedule.split(/ +/); if (
if (tab.isDisabled === 1 || _schedule!.length !== 5) { tab.isDisabled === 1 ||
this.isNodeCron(tab) ||
this.isSpecialSchedule(tab.schedule)
) {
crontab_string += '# '; crontab_string += '# ';
crontab_string += tab.schedule; crontab_string += tab.schedule;
crontab_string += ' '; crontab_string += ' ';
crontab_string += this.make_command(tab); crontab_string += this.makeCommand(tab);
crontab_string += '\n'; crontab_string += '\n';
} else { } else {
crontab_string += tab.schedule; crontab_string += tab.schedule;
crontab_string += ' '; crontab_string += ' ';
crontab_string += this.make_command(tab); crontab_string += this.makeCommand(tab);
crontab_string += '\n'; crontab_string += '\n';
} }
}); });
this.logger.silly(crontab_string); await writeFileWithLock(config.crontabFile, crontab_string);
fs.writeFileSync(config.crontabFile, crontab_string);
execSync(`crontab ${config.crontabFile}`); execSync(`crontab ${config.crontabFile}`);
await CrontabModel.update({ saved: true }, { where: {} }); await CrontabModel.update({ saved: true }, { where: {} });
} }
public import_crontab() { public importCrontab() {
exec('crontab -l', (error, stdout, stderr) => { exec('crontab -l', (error, stdout, stderr) => {
const lines = stdout.split('\n'); const lines = stdout.split('\n');
const namePrefix = new Date().getTime(); const namePrefix = new Date().getTime();
@@ -581,15 +691,42 @@ export default class CronService {
public async autosave_crontab() { public async autosave_crontab() {
const tabs = await this.crontabs(); const tabs = await this.crontabs();
this.set_crontab(tabs); const regularCrons = tabs.data
.filter(
const sixCron = tabs.data (x) =>
.filter((x) => this.isSixCron(x) && x.isDisabled !== 1) x.isDisabled !== 1 &&
this.isNodeCron(x) &&
!this.isSpecialSchedule(x.schedule),
)
.map((doc) => ({ .map((doc) => ({
name: doc.name || '',
id: String(doc.id), id: String(doc.id),
schedule: doc.schedule!, 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;
}
await cronClient.addCron(regularCrons);
this.setCrontab(tabs);
}
public async bootTask() {
const tabs = await this.crontabs();
const bootTasks = tabs.data.filter(
(x) => !x.isDisabled && this.isBootSchedule(x.schedule),
);
if (bootTasks.length > 0) {
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: bootTasks.map((t) => t.id!) } },
);
for (const task of bootTasks) {
await this.runSingle(task.id!);
}
}
} }
} }
+8 -3
View File
@@ -32,7 +32,7 @@ export default class CronViewService {
} }
public async update(payload: CrontabView): Promise<CrontabView> { public async update(payload: CrontabView): Promise<CrontabView> {
const doc = await this.getDb({ id: payload.id }) const doc = await this.getDb({ id: payload.id });
const tab = new CrontabView({ ...doc, ...payload }); const tab = new CrontabView({ ...doc, ...payload });
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
return newDoc; return newDoc;
@@ -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 } }); 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[]) { public async disabled(ids: number[]) {
+182 -53
View File
@@ -3,17 +3,25 @@ import winston from 'winston';
import config from '../config'; import config from '../config';
import { import {
Dependence, Dependence,
InstallDependenceCommandTypes,
DependenceStatus, DependenceStatus,
DependenceTypes, DependenceTypes,
unInstallDependenceCommandTypes,
DependenceModel, DependenceModel,
versionDependenceCommandTypes,
} from '../data/dependence'; } from '../data/dependence';
import { spawn } from 'child_process'; import { spawn } from 'cross-spawn';
import SockService from './sock'; import SockService from './sock';
import { FindOptions, Op } from 'sequelize'; 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 dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
@Service() @Service()
export default class DependenceService { export default class DependenceService {
@@ -58,12 +66,26 @@ export default class DependenceService {
} }
public async remove(ids: number[], force = false): Promise<Dependence[]> { public async remove(ids: number[], force = false): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({ where: { id: ids } });
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( await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] }, { status: DependenceStatus.queued, log: [] },
{ where: { id: ids } }, { where: { id: ids } },
); );
const docs = await DependenceModel.findAll({ where: { id: ids } });
this.installDependenceOneByOne(docs, false, force); this.installDependenceOneByOne(docs, false, force);
}
return docs; return docs;
} }
@@ -72,13 +94,23 @@ export default class DependenceService {
} }
public async dependencies( public async dependencies(
{ searchValue, type }: { searchValue: string; type: string }, {
sort: any = { position: -1 }, searchValue,
type,
status,
}: { searchValue: string; type: string; status: string },
sort: any = [],
query: any = {}, query: any = {},
): Promise<Dependence[]> { ): Promise<Dependence[]> {
let condition = { ...query, type: DependenceTypes[type as any] }; let condition = {
...query,
type: DependenceTypes[type as any],
};
if (status) {
condition.status = status.split(',').map(Number);
}
if (searchValue) { if (searchValue) {
const encodeText = encodeURIComponent(searchValue); const encodeText = encodeURI(searchValue);
const reg = { const reg = {
[Op.or]: [ [Op.or]: [
{ [Op.like]: `%${searchValue}%` }, { [Op.like]: `%${searchValue}%` },
@@ -92,7 +124,7 @@ export default class DependenceService {
}; };
} }
try { try {
const result = await this.find(condition); const result = await this.find(condition, sort);
return result as any; return result as any;
} catch (error) { } catch (error) {
throw error; throw error;
@@ -103,21 +135,12 @@ export default class DependenceService {
docs: Dependence[], docs: Dependence[],
isInstall: boolean = true, isInstall: boolean = true,
force: boolean = false, force: boolean = false,
) { ): Promise<void> {
concurrentRun( docs.forEach((dep) => {
docs.map((dep) => async () => { this.installOrUninstallDependency(dep, isInstall, force);
const status = isInstall });
? DependenceStatus.installing
: DependenceStatus.removing; return taskLimit.waitDependencyQueueDone();
await DependenceModel.update({ status }, { where: { id: dep.id } });
return await this.installOrUninstallDependencies(
[dep],
isInstall,
force,
);
}),
1,
);
} }
public async reInstall(ids: number[]): Promise<Dependence[]> { public async reInstall(ids: number[]): Promise<Dependence[]> {
@@ -127,10 +150,33 @@ export default class DependenceService {
); );
const docs = await DependenceModel.findAll({ where: { id: ids } }); 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; return docs;
} }
public async cancel(ids: number[]) {
const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
const depInstallCommand = getInstallCommand(doc.type, doc.name);
const depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
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[]> { private async find(query: any, sort: any = []): Promise<Dependence[]> {
const docs = await DependenceModel.findAll({ const docs = await DependenceModel.findAll({
where: { ...query }, where: { ...query },
@@ -143,39 +189,56 @@ export default class DependenceService {
query: FindOptions<Dependence>['where'], query: FindOptions<Dependence>['where'],
): Promise<Dependence> { ): Promise<Dependence> {
const doc: any = await DependenceModel.findOne({ where: { ...query } }); 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> { private async updateLog(ids: number[], log: string): Promise<void> {
const doc = await DependenceModel.findOne({ 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]; const newLog = doc?.log ? [...doc.log, log] : [log];
await DependenceModel.update({ log: newLog }, { where: { id: ids } }); await DependenceModel.update(
{ log: newLog },
{ where: { id: doc.id } },
);
}
return null;
});
} }
public installOrUninstallDependencies( public installOrUninstallDependency(
dependencies: Dependence[], dependency: Dependence,
isInstall: boolean = true, isInstall: boolean = true,
force: boolean = false, force: boolean = false,
) { ) {
return taskLimit.runDependeny(dependency, () => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (dependencies.length === 0) { if (taskLimit.firstDependencyId !== dependency.id) {
resolve(null); return resolve(null);
return;
} }
const socketMessageType = !force
taskLimit.removeQueuedDependency(dependency);
const depIds = [dependency.id!];
const status = isInstall
? DependenceStatus.installing
: DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } });
const socketMessageType = isInstall
? 'installDependence' ? 'installDependence'
: 'uninstallDependence'; : 'uninstallDependence';
const depNames = dependencies.map((x) => x.name).join(' '); let depName = dependency.name.trim();
const depRunCommand = ( const command = isInstall
isInstall ? getInstallCommand(dependency.type, depName)
? InstallDependenceCommandTypes : getUninstallCommand(dependency.type, depName);
: unInstallDependenceCommandTypes
)[dependencies[0].type as any];
const actionText = isInstall ? '安装' : '删除'; const actionText = isInstall ? '安装' : '删除';
const depIds = dependencies.map((x) => x.id) as number[];
const startTime = dayjs(); const startTime = dayjs();
const message = `开始${actionText}依赖 ${depNames},开始时间 ${startTime.format( const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
)}\n\n`; )}\n\n`;
this.sockService.sendMessage({ this.sockService.sendMessage({
@@ -183,9 +246,65 @@ export default class DependenceService {
message, message,
references: depIds, references: depIds,
}); });
await this.updateLog(depIds, message); this.updateLog(depIds, message);
const cp = spawn(`${depRunCommand} ${depNames}`, { shell: '/bin/bash' }); // 判断是否已经安装过依赖
if (isInstall && !force) {
const getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
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 isNodeDependence = dependency.type === DependenceTypes.nodejs;
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence =
dependency.type === DependenceTypes.python3;
const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ')
.replace(/\s+$/, '');
if (
depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
(isLinuxDependence &&
depInfo.toLocaleLowerCase().includes('installed')) ||
isPythonDependence) &&
(!depVersion || depInfo.includes(depVersion))
) {
const endTime = dayjs();
const _message = `检测到已经安装 ${depName}\n\n${depInfo}\n\n跳过安装\n\n依赖${actionText}成功,结束时间 ${endTime.format(
'YYYY-MM-DD HH:mm:ss',
)},耗时 ${endTime.diff(startTime, 'second')}`;
this.sockService.sendMessage({
type: socketMessageType,
message: _message,
references: depIds,
});
this.updateLog(depIds, _message);
await DependenceModel.update(
{ status: DependenceStatus.installed },
{ where: { id: depIds } },
);
return resolve(null);
}
}
const dependenceProxyFileExist = await fileExist(
config.dependenceProxyFile,
);
const proxyStr = dependenceProxyFileExist
? `source ${config.dependenceProxyFile} &&`
: '';
const cp = spawn(`${proxyStr} ${command}`, {
shell: '/bin/bash',
});
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
@@ -193,7 +312,7 @@ export default class DependenceService {
message: data.toString(), message: data.toString(),
references: depIds, references: depIds,
}); });
await this.updateLog(depIds, data.toString()); this.updateLog(depIds, data.toString());
}); });
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
@@ -202,7 +321,7 @@ export default class DependenceService {
message: data.toString(), message: data.toString(),
references: depIds, references: depIds,
}); });
await this.updateLog(depIds, data.toString()); this.updateLog(depIds, data.toString());
}); });
cp.on('error', async (err) => { cp.on('error', async (err) => {
@@ -211,11 +330,10 @@ export default class DependenceService {
message: JSON.stringify(err), message: JSON.stringify(err),
references: depIds, references: depIds,
}); });
await this.updateLog(depIds, JSON.stringify(err)); this.updateLog(depIds, JSON.stringify(err));
resolve(null);
}); });
cp.on('close', async (code) => { cp.on('exit', async (code) => {
const endTime = dayjs(); const endTime = dayjs();
const isSucceed = code === 0; const isSucceed = code === 0;
const resultText = isSucceed ? '成功' : '失败'; const resultText = isSucceed ? '成功' : '失败';
@@ -228,9 +346,9 @@ export default class DependenceService {
message, message,
references: depIds, references: depIds,
}); });
await this.updateLog(depIds, message); this.updateLog(depIds, message);
let status = null; let status: number;
if (isSucceed) { if (isSucceed) {
status = isInstall status = isInstall
? DependenceStatus.installed ? DependenceStatus.installed
@@ -240,7 +358,17 @@ export default class DependenceService {
? DependenceStatus.installFailed ? DependenceStatus.installFailed
: DependenceStatus.removeFailed; : DependenceStatus.removeFailed;
} }
await DependenceModel.update({ status }, { where: { id: depIds } }); 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 },
{ where: { id: _docIds } },
);
}
// 如果删除依赖成功或者强制删除 // 如果删除依赖成功或者强制删除
if ((isSucceed || force) && !isInstall) { if ((isSucceed || force) && !isInstall) {
@@ -250,5 +378,6 @@ export default class DependenceService {
resolve(null); resolve(null);
}); });
}); });
});
} }
} }
+33 -15
View File
@@ -1,7 +1,7 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs'; import * as fs from 'fs/promises';
import { import {
Env, Env,
EnvModel, EnvModel,
@@ -13,6 +13,7 @@ import {
} from '../data/env'; } from '../data/env';
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize'; import { FindOptions, Op } from 'sequelize';
import { writeFileWithLock } from '../shared/utils';
@Service() @Service()
export default class EnvService { export default class EnvService {
@@ -40,7 +41,7 @@ export default class EnvService {
} }
public async insert(payloads: Env[]): Promise<Env[]> { public async insert(payloads: Env[]): Promise<Env[]> {
const result = []; const result: Env[] = [];
for (const env of payloads) { for (const env of payloads) {
const doc = await EnvModel.create(env, { returning: true }); const doc = await EnvModel.create(env, { returning: true });
result.push(doc); result.push(doc);
@@ -61,7 +62,7 @@ export default class EnvService {
return await this.getDb({ id: payload.id }); return await this.getDb({ id: payload.id });
} }
public async remove(ids: string[]) { public async remove(ids: number[]) {
await EnvModel.destroy({ where: { id: ids } }); await EnvModel.destroy({ where: { id: ids } });
await this.set_envs(); await this.set_envs();
} }
@@ -121,7 +122,7 @@ export default class EnvService {
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> { public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
let condition = { ...query }; let condition = { ...query };
if (searchText) { if (searchText) {
const encodeText = encodeURIComponent(searchText); const encodeText = encodeURI(searchText);
const reg = { const reg = {
[Op.or]: [ [Op.or]: [
{ [Op.like]: `%${searchText}%` }, { [Op.like]: `%${searchText}%` },
@@ -149,7 +150,7 @@ export default class EnvService {
['position', 'DESC'], ['position', 'DESC'],
['createdAt', 'ASC'], ['createdAt', 'ASC'],
]); ]);
return result as any; return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -160,15 +161,18 @@ export default class EnvService {
where: { ...query }, where: { ...query },
order: [...sort], order: [...sort],
}); });
return docs; return docs.map((x) => x.get({ plain: true }));
} }
public async getDb(query: FindOptions<Env>['where']): Promise<Env> { public async getDb(query: FindOptions<Env>['where']): Promise<Env> {
const doc: any = await EnvModel.findOne({ where: { ...query } }); 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( await EnvModel.update(
{ status: EnvStatus.disabled }, { status: EnvStatus.disabled },
{ where: { id: ids } }, { where: { id: ids } },
@@ -176,12 +180,12 @@ export default class EnvService {
await this.set_envs(); await this.set_envs();
} }
public async enabled(ids: string[]) { public async enabled(ids: number[]) {
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } }); await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
await this.set_envs(); 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 EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs(); await this.set_envs();
} }
@@ -193,6 +197,8 @@ export default class EnvService {
}); });
const groups = groupBy(envs, 'name'); const groups = groupBy(envs, 'name');
let env_string = ''; let env_string = '';
let js_env_string = '';
let py_env_string = 'import os\n';
for (const key in groups) { for (const key in groups) {
if (Object.prototype.hasOwnProperty.call(groups, key)) { if (Object.prototype.hasOwnProperty.call(groups, key)) {
const group = groups[key]; const group = groups[key];
@@ -202,14 +208,26 @@ export default class EnvService {
let value = group let value = group
.map((x) => x.value) .map((x) => x.value)
.join('&') .join('&')
.replace(/(\\)[^n]/g, '\\\\') .replace(/'/g, "'\\''")
.replace(/(\\$)/, '\\\\')
.replace(/"/g, '\\"')
.trim(); .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);
} }
} }
+64
View File
@@ -0,0 +1,64 @@
import { Server, ServerCredentials } from '@grpc/grpc-js';
import { CronService } from '../protos/cron';
import { HealthService } from '../protos/health';
import { ApiService } from '../protos/api';
import { addCron } from '../schedule/addCron';
import { delCron } from '../schedule/delCron';
import { check } from '../schedule/health';
import * as Api from '../schedule/api';
import Logger from '../loaders/logger';
import { promisify } from 'util';
import config from '../config';
import { metricsService } from './metrics';
import { Service } from 'typedi';
@Service()
export class GrpcServerService {
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
async initialize() {
try {
this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron });
this.server.addService(ApiService, Api);
const grpcPort = config.grpcPort;
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
await bindAsync(
`0.0.0.0:${grpcPort}`,
ServerCredentials.createInsecure(),
);
Logger.debug(`✌️ gRPC service started successfully`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
});
return grpcPort;
} catch (err) {
Logger.error('Failed to start gRPC service:', err);
throw err;
}
}
async shutdown() {
try {
if (this.server) {
await new Promise((resolve) => {
this.server.tryShutdown(() => {
Logger.debug('gRPC service stopped');
metricsService.record('grpc_service_stop', 1);
resolve(null);
});
});
}
} catch (err) {
Logger.error('Error while shutting down gRPC service:', err);
throw err;
}
}
getServer() {
return this.server;
}
}
+72
View File
@@ -0,0 +1,72 @@
import { Service } from 'typedi';
import Logger from '../loaders/logger';
import { GrpcServerService } from './grpc';
import { HttpServerService } from './http';
interface HealthStatus {
status: 'ok' | 'error';
services: {
http: boolean;
grpc: boolean;
};
metrics: {
uptime: number;
memory: {
used: number;
total: number;
};
};
}
@Service()
export class HealthService {
private startTime = Date.now();
constructor(
private grpcServerService: GrpcServerService,
private httpServerService: HttpServerService,
) {}
async check(): Promise<HealthStatus> {
const status: HealthStatus = {
status: 'ok',
services: {
http: true,
grpc: true,
},
metrics: {
uptime: Math.floor((Date.now() - this.startTime) / 1000),
memory: {
used: process.memoryUsage().heapUsed,
total: process.memoryUsage().heapTotal,
},
},
};
try {
const httpServer = this.httpServerService.getServer();
if (!httpServer) {
status.services.http = false;
status.status = 'error';
}
} catch (err) {
status.services.http = false;
status.status = 'error';
Logger.error('HTTP server check failed:', err);
}
try {
const grpcServer = this.grpcServerService.getServer();
if (!grpcServer) {
status.services.grpc = false;
status.status = 'error';
}
} catch (err) {
status.services.grpc = false;
status.status = 'error';
Logger.error('gRPC server check failed:', err);
}
return status;
}
}
+53
View File
@@ -0,0 +1,53 @@
import express from 'express';
import Logger from '../loaders/logger';
import { metricsService } from './metrics';
import { Service } from 'typedi';
import { Server } from 'http';
@Service()
export class HttpServerService {
private server?: Server = undefined;
async initialize(expressApp: express.Application, port: number) {
try {
return new Promise((resolve, reject) => {
this.server = expressApp.listen(port, '0.0.0.0', () => {
Logger.debug(`✌️ HTTP service started successfully`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
});
resolve(this.server);
});
this.server?.on('error', (err: Error) => {
Logger.error('Failed to start HTTP service:', err);
reject(err);
});
});
} catch (err) {
Logger.error('Failed to start HTTP service:', err);
throw err;
}
}
async shutdown() {
try {
if (this.server) {
await new Promise((resolve) => {
this.server?.close(() => {
Logger.debug('HTTP service stopped');
metricsService.record('http_service_stop', 1);
resolve(null);
});
});
}
} catch (err) {
Logger.error('Error while shutting down HTTP service:', err);
throw err;
}
}
getServer() {
return this.server;
}
}
+14
View File
@@ -0,0 +1,14 @@
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
@Service()
export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.logPath, filePath, fileName);
return finalPath.startsWith(config.logPath) ? finalPath : '';
}
}
+92
View File
@@ -0,0 +1,92 @@
import { performance } from 'perf_hooks';
import Logger from '../loaders/logger';
interface Metric {
name: string;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
class MetricsService {
private metrics: Metric[] = [];
private static instance: MetricsService;
private constructor() {
// 定期清理旧数据
setInterval(() => {
const oneHourAgo = Date.now() - 3600000;
this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo);
}, 60000);
}
static getInstance(): MetricsService {
if (!MetricsService.instance) {
MetricsService.instance = new MetricsService();
}
return MetricsService.instance;
}
record(name: string, value: number, tags?: Record<string, string>) {
this.metrics.push({
name,
value,
timestamp: Date.now(),
tags,
});
}
measure(name: string, fn: () => void, tags?: Record<string, string>) {
const start = performance.now();
try {
fn();
} finally {
const duration = performance.now() - start;
this.record(name, duration, tags);
}
}
async measureAsync(name: string, fn: () => Promise<void>, tags?: Record<string, string>) {
const start = performance.now();
try {
await fn();
} finally {
const duration = performance.now() - start;
this.record(name, duration, tags);
}
}
getMetrics(name?: string, tags?: Record<string, string>) {
let filtered = this.metrics;
if (name) {
filtered = filtered.filter(m => m.name === name);
}
if (tags) {
filtered = filtered.filter(m => {
if (!m.tags) return false;
return Object.entries(tags).every(([key, value]) => m.tags![key] === value);
});
}
return {
count: filtered.length,
average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length,
min: Math.min(...filtered.map(m => m.value)),
max: Math.max(...filtered.map(m => m.value)),
metrics: filtered,
};
}
report() {
const report = {
timestamp: Date.now(),
metrics: this.getMetrics(),
};
Logger.info('性能指标报告:', report);
return report;
}
}
export const metricsService = MetricsService.getInstance();
+433 -151
View File
@@ -1,12 +1,11 @@
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 crypto from 'crypto';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
import { parseBody, parseHeaders } from '../config/util'; import { parseBody, parseHeaders } from '../config/util';
import { NotificationInfo } from '../data/notify';
import UserService from './user';
import { httpClient } from '../config/http';
import { ProxyAgent } from 'undici';
@Service() @Service()
export default class NotificationService { export default class NotificationService {
@@ -27,9 +26,14 @@ export default class NotificationService {
['aibotk', this.aibotk], ['aibotk', this.aibotk],
['iGot', this.iGot], ['iGot', this.iGot],
['pushPlus', this.pushPlus], ['pushPlus', this.pushPlus],
['wePlusBot', this.wePlusBot],
['email', this.email], ['email', this.email],
['pushMe', this.pushMe],
['webhook', this.webhook], ['webhook', this.webhook],
['lark', this.lark], ['lark', this.lark],
['chronocat', this.chronocat],
['ntfy', this.ntfy],
['wxPusherBot', this.wxPusherBot],
]); ]);
private title = ''; private title = '';
@@ -40,22 +44,31 @@ export default class NotificationService {
retry: 1, retry: 1,
}; };
constructor(@Inject('logger') private logger: winston.Logger) {} constructor() {}
public async notify( public async notify(
title: string, title: string,
content: string, content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
const { type, ...rest } = await this.userService.getNotificationMode(); let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) { if (type) {
this.title = title; this.title = title;
this.content = content; this.content = content;
this.params = rest; let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type); const notificationModeAction = this.modeMap.get(type);
try { try {
return await notificationModeAction?.call(this); return await notificationModeAction?.call(this);
} catch (error: any) { } catch (error: any) {
return false; console.error(error);
} }
} }
return false; return false;
@@ -80,8 +93,9 @@ export default class NotificationService {
private async gotify() { private async gotify() {
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params; const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try { try {
const res: any = await got const res = await httpClient.post(
.post(`${gotifyUrl}/message?token=${gotifyToken}`, { `${gotifyUrl}/message?token=${gotifyToken}`,
{
...this.gotOption, ...this.gotOption,
body: `title=${encodeURIComponent( body: `title=${encodeURIComponent(
this.title, this.title,
@@ -91,9 +105,13 @@ export default class NotificationService {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
}) },
.json(); );
return typeof res.id === 'number'; if (typeof res.id === 'number') {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -102,14 +120,16 @@ export default class NotificationService {
private async goCqHttpBot() { private async goCqHttpBot() {
const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params; const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params;
try { try {
const res: any = await got const res = await httpClient.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, {
...this.gotOption, ...this.gotOption,
json: { message: `${this.title}\n${this.content}` }, json: { message: `${this.title}\n${this.content}` },
headers: { Authorization: 'Bearer ' + goCqHttpBotToken }, headers: { Authorization: 'Bearer ' + goCqHttpBotToken },
}) });
.json(); if (res.retcode === 0) {
return res.retcode === 0; return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -117,18 +137,25 @@ export default class NotificationService {
private async serverChan() { private async serverChan() {
const { serverChanKey } = this.params; const { serverChanKey } = this.params;
const url = serverChanKey.startsWith('SCT') const matchResult = serverChanKey.match(/^sctp(\d+)t/i);
? `https://sctapi.ftqq.com/${serverChanKey}.send` const url =
: `https://sc.ftqq.com/${serverChanKey}.send`; matchResult && matchResult[1]
? `https://${matchResult[1]}.push.ft07.com/send/${serverChanKey}.send`
: `https://sctapi.ftqq.com/${serverChanKey}.send`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `title=${this.title}&desp=${this.content}`, body: `title=${encodeURIComponent(
this.title,
)}&desp=${encodeURIComponent(this.content)}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}) });
.json(); if (res.errno === 0 || res.data.errno === 0) {
return res.errno === 0 || res.data.errno === 0; return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -138,59 +165,79 @@ export default class NotificationService {
const { pushDeerKey, pushDeerUrl } = this.params; const { pushDeerKey, pushDeerUrl } = this.params;
const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`; const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `pushkey=${pushDeerKey}&text=${encodeURIComponent( body: `pushkey=${pushDeerKey}&text=${encodeURIComponent(
this.title, this.title,
)}&desp=${encodeURIComponent(this.content)}&type=markdown`, )}&desp=${encodeURIComponent(this.content)}&type=markdown`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}) });
.json(); if (
return ( res.content.result.length !== undefined &&
res.content.result.length !== undefined && res.content.result.length > 0 res.content.result.length > 0
); ) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
} }
private async chat() { private async chat() {
const { chatUrl, chatToken } = this.params; const { synologyChatUrl } = this.params;
const url = `${chatUrl}${chatToken}`;
try { try {
const res: any = await got const res = await httpClient.post(synologyChatUrl, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `payload={"text":"${this.title}\n${this.content}"}`, body: `payload={"text":"${this.title}\n${this.content}"}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}) });
.json(); if (res.success) {
return res.success; return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
} }
private async bark() { private async bark() {
let { barkPush, barkIcon, barkSound, barkGroup } = this.params; let {
barkPush,
barkIcon = '',
barkSound = '',
barkGroup = '',
barkLevel = '',
barkUrl = '',
barkArchive = '',
} = this.params;
if (!barkPush.startsWith('http')) { if (!barkPush.startsWith('http')) {
barkPush = `https://api.day.app/${barkPush}`; barkPush = `https://api.day.app/${barkPush}`;
} }
const url = `${barkPush}/${encodeURIComponent( const url = `${barkPush}`;
this.title, const body = {
)}/${encodeURIComponent( title: this.title,
this.content, body: this.content,
)}?icon=${barkIcon}&sound=${barkSound}&group=${barkGroup}`; icon: barkIcon,
sound: barkSound,
group: barkGroup,
isArchive: barkArchive,
level: barkLevel,
url: barkUrl,
};
try { try {
const res: any = await got const res = await httpClient.post(url, {
.get(url, {
...this.gotOption, ...this.gotOption,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, json: body,
}) headers: { 'Content-Type': 'application/json' },
.json(); });
return res.code === 200; if (res.code === 200) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -206,35 +253,27 @@ export default class NotificationService {
telegramBotUserId, telegramBotUserId,
} = this.params; } = this.params;
const authStr = telegramBotProxyAuth ? `${telegramBotProxyAuth}@` : ''; const authStr = telegramBotProxyAuth ? `${telegramBotProxyAuth}@` : '';
const url = `https://${ const url = `${
telegramBotApiHost ? telegramBotApiHost : 'api.telegram.org' telegramBotApiHost ? telegramBotApiHost : 'https://api.telegram.org'
}/bot${telegramBotToken}/sendMessage`; }/bot${telegramBotToken}/sendMessage`;
let agent; let agent;
if (telegramBotProxyHost && telegramBotProxyPort) { if (telegramBotProxyHost && telegramBotProxyPort) {
const options: any = { agent = new ProxyAgent({
keepAlive: true, uri: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
keepAliveMsecs: 1000, });
maxSockets: 256,
maxFreeSockets: 256,
proxy: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`,
};
const httpAgent = new HttpProxyAgent(options);
const httpsAgent = new HttpsProxyAgent(options);
agent = {
http: httpAgent,
https: httpsAgent,
};
} }
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`, body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
agent, dispatcher: agent,
}) });
.json(); if (res.ok) {
return !!res.ok; return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -252,8 +291,7 @@ export default class NotificationService {
} }
const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`; const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
msgtype: 'text', msgtype: 'text',
@@ -261,20 +299,23 @@ export default class NotificationService {
content: ` ${this.title}\n\n${this.content}`, content: ` ${this.title}\n\n${this.content}`,
}, },
}, },
}) });
.json(); if (res.errcode === 0) {
return res.errcode === 0; return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
} }
private async weWorkBot() { private async weWorkBot() {
const { weWorkBotKey } = this.params; const { weWorkBotKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } =
const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${weWorkBotKey}`; this.params;
const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
msgtype: 'text', msgtype: 'text',
@@ -282,28 +323,30 @@ export default class NotificationService {
content: ` ${this.title}\n\n${this.content}`, content: ` ${this.title}\n\n${this.content}`,
}, },
}, },
}) });
.json(); if (res.errcode === 0) {
return res.errcode === 0; return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
} }
private async weWorkApp() { private async weWorkApp() {
const { weWorkAppKey } = this.params; const { weWorkAppKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } =
this.params;
const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] = const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] =
weWorkAppKey.split(','); weWorkAppKey.split(',');
const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken`; const url = `${weWorkOrigin}/cgi-bin/gettoken`;
const tokenRes: any = await got const tokenRes = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
corpid, corpid,
corpsecret, corpsecret,
}, },
}) });
.json();
let options: any = { let options: any = {
msgtype: 'mpnews', msgtype: 'mpnews',
@@ -344,9 +387,8 @@ export default class NotificationService {
} }
try { try {
const res: any = await got const res = await httpClient.post(
.post( `${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${tokenRes.access_token}`,
{ {
...this.gotOption, ...this.gotOption,
json: { json: {
@@ -356,10 +398,13 @@ export default class NotificationService {
...options, ...options,
}, },
}, },
) );
.json();
return res.errcode === 0; if (res.errcode === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -395,16 +440,17 @@ export default class NotificationService {
} }
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
...json, ...json,
}, },
}) });
.json(); if (res.code === 0) {
return true;
return res.code === 0; } else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -414,37 +460,90 @@ export default class NotificationService {
const { iGotPushKey } = this.params; const { iGotPushKey } = this.params;
const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`; const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`;
try { try {
const res: any = await got const res = await httpClient.post(url, {
.post(url, {
...this.gotOption, ...this.gotOption,
body: `title=${this.title}&content=${this.content}`, body: `title=${this.title}&content=${this.content}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}) });
.json();
return res.ret === 0; if (res.ret === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
} }
private async pushPlus() { 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`; const url = `https://www.pushplus.plus/send`;
try { try {
const res: any = await got let body = {
.post(url, {
...this.gotOption, ...this.gotOption,
json: { json: {
token: `${pushPlusToken}`, token: `${pushPlusToken}`,
title: `${this.title}`, title: `${this.title}`,
content: `${this.content.replace(/[\n\r]/g, '<br>')}`, content: `${this.content.replace(/[\n\r]/g, '<br>')}`,
topic: `${pushPlusUser || ''}`, topic: `${pushPlusUser || ''}`,
template: `${pushPlusTemplate || 'html'}`,
channel: `${pushplusChannel || 'wechat'}`,
webhook: `${pushplusWebhook || ''}`,
callbackUrl: `${pushplusCallbackUrl || ''}`,
to: `${pushplusTo || ''}`,
}, },
}) };
.json();
return res.code === 200; const res = await httpClient.post(url, body);
if (res.code === 200) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async wePlusBot() {
const { wePlusBotToken, wePlusBotReceiver, wePlusBotVersion } = this.params;
let content = this.content;
let template = 'txt';
if (this.content.length > 800) {
template = 'html';
content = content.replace(/[\n\r]/g, '<br>');
}
const url = `https://www.weplusbot.com/send`;
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: {
token: `${wePlusBotToken}`,
title: `${this.title}`,
template: `${template}`,
content: `${content}`,
receiver: `${wePlusBotReceiver || ''}`,
version: `${wePlusBotVersion || 'pro'}`,
},
});
if (res.code === 200) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -458,24 +557,26 @@ export default class NotificationService {
} }
try { try {
const res: any = await got const res = await httpClient.post(larkKey, {
.post(larkKey, {
...this.gotOption, ...this.gotOption,
json: { json: {
msg_type: 'text', msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` }, content: { text: `${this.title}\n\n${this.content}` },
}, },
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
.json(); if (res.StatusCode === 0 || res.code === 0) {
return res.StatusCode === 0; return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
} }
private async email() { private async email() {
const { emailPass, emailService, emailUser } = this.params; const { emailPass, emailService, emailUser, emailTo } = this.params;
try { try {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
@@ -488,14 +589,201 @@ export default class NotificationService {
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`, from: `"青龙快讯" <${emailUser}>`,
to: `${emailUser}`, to: emailTo ? emailTo.split(';') : emailUser,
subject: `${this.title}`, subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`, html: `${this.content.replace(/\n/g, '<br/>')}`,
}); });
transporter.close(); transporter.close();
return !!info.messageId; if (info.messageId) {
return true;
} else {
throw new Error(JSON.stringify(info));
}
} catch (error: any) {
throw error;
}
}
private async pushMe() {
const { pushMeKey, pushMeUrl } = this.params;
try {
const res = await httpClient.post<'text'>(
pushMeUrl || 'https://push.i-i.me/',
{
...this.gotOption,
json: {
push_key: pushMeKey,
title: this.title,
content: this.content,
},
headers: { 'Content-Type': 'application/json' },
},
);
if (res === 'success') {
return true;
} else {
throw new Error(res);
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async ntfy() {
const {
ntfyUrl,
ntfyTopic,
ntfyPriority,
ntfyToken,
ntfyUsername,
ntfyPassword,
ntfyActions,
} = this.params;
// 编码函数
const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => {
const encodedText = Buffer.from(text).toString('base64');
return `=?${charset}?B?${encodedText}?=`;
};
try {
const headers: Record<string, string> = {
Title: encodeRfc2047(this.title),
Priority: `${ntfyPriority || '3'}`,
Icon: 'https://qn.whyour.cn/logo.png',
};
if (ntfyToken) {
headers['Authorization'] = `Bearer ${ntfyToken}`;
} else if (ntfyUsername && ntfyPassword) {
headers['Authorization'] = `Basic ${Buffer.from(
`${ntfyUsername}:${ntfyPassword}`,
).toString('base64')}`;
}
if (ntfyActions) {
headers['Actions'] = encodeRfc2047(ntfyActions);
}
const res = await httpClient.request(
`${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`,
{
...this.gotOption,
body: `${this.content}`,
headers: headers,
method: 'POST',
},
);
if (res.statusCode === 200) {
return true;
} else {
throw new Error(await res.body.text());
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async wxPusherBot() {
const { wxPusherBotAppToken, wxPusherBotTopicIds, wxPusherBotUids } =
this.params;
// 处理 topicIds,将分号分隔的字符串转为数组
const topicIds = wxPusherBotTopicIds
? wxPusherBotTopicIds
.split(';')
.map((id) => id.trim())
.filter((id) => id)
.map((id) => parseInt(id))
: [];
// 处理 uids,将分号分隔的字符串转为数组
const uids = wxPusherBotUids
? wxPusherBotUids
.split(';')
.map((uid) => uid.trim())
.filter((uid) => uid)
: [];
// topic_ids 和 uids 至少要有一个
if (!topicIds.length && !uids.length) {
throw new Error('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 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) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -510,15 +798,14 @@ export default class NotificationService {
webhookContentType, webhookContentType,
} = this.params; } = this.params;
const { formatBody, formatUrl } = this.formatNotifyContent( if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
webhookUrl, throw new Error('Url 或者 Body 中必须包含 $title');
webhookBody,
);
if (!formatUrl && !formatBody) {
return false;
} }
const headers = parseHeaders(webhookHeaders); 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 bodyParam = this.formatBody(webhookContentType, body);
const options = { const options = {
method: webhookMethod, method: webhookMethod,
@@ -527,9 +814,18 @@ export default class NotificationService {
allowGetBody: true, allowGetBody: true,
...bodyParam, ...bodyParam,
}; };
try { try {
const res = await got(formatUrl, options); const formatUrl = webhookUrl
return String(res.statusCode).startsWith('20'); ?.replaceAll('$title', encodeURIComponent(this.title))
?.replaceAll('$content', encodeURIComponent(this.content));
const res = await httpClient.request(formatUrl, options);
const text = await res.body.text();
if (String(res.statusCode).startsWith('20')) {
return true;
} else {
throw new Error(await res.body.text());
}
} catch (error: any) { } catch (error: any) {
throw new Error(error.response ? error.response.body : error); throw new Error(error.response ? error.response.body : error);
} }
@@ -543,23 +839,9 @@ export default class NotificationService {
case 'multipart/form-data': case 'multipart/form-data':
return { form: body }; return { form: body };
case 'application/x-www-form-urlencoded': case 'application/x-www-form-urlencoded':
case 'text/plain':
return { body }; return { body };
} }
return {}; 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),
};
}
} }
+34 -23
View File
@@ -1,19 +1,18 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import { createRandomString } from '../config/util'; import { createRandomString } from '../config/util';
import config from '../config';
import { App, AppModel } from '../data/open'; import { App, AppModel } from '../data/open';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import sequelize, { Op } from 'sequelize'; import sequelize, { Op } from 'sequelize';
import { shareStore } from '../shared/store';
@Service() @Service()
export default class OpenService { export default class OpenService {
constructor(@Inject('logger') private logger: winston.Logger) {} 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 docs = await this.find({});
const doc = docs.filter((x) => x.tokens?.find((y) => y.value === token)); return docs;
return doc[0];
} }
public async create(payload: App): Promise<App> { public async create(payload: App): Promise<App> {
@@ -21,12 +20,14 @@ export default class OpenService {
tab.client_id = createRandomString(12, 12); tab.client_id = createRandomString(12, 12);
tab.client_secret = createRandomString(24, 24); tab.client_secret = createRandomString(24, 24);
const doc = await this.insert(tab); const doc = await this.insert(tab);
const apps = await this.find({});
await shareStore.updateApps(apps);
return { ...doc, tokens: [] }; return { ...doc, tokens: [] };
} }
public async insert(payload: App): Promise<App> { public async insert(payload: App): Promise<App> {
const doc = await AppModel.create(payload, { returning: true }); 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> { public async update(payload: App): Promise<App> {
@@ -34,26 +35,33 @@ export default class OpenService {
name: payload.name, name: payload.name,
scopes: payload.scopes, scopes: payload.scopes,
id: payload.id, id: payload.id,
} as any); } as App);
return { ...newDoc, tokens: [] }; 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 } }); 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> { public async getDb(query: Record<string, any>): Promise<App> {
const doc: any = await AppModel.findOne({ where: query }); const doc = await AppModel.findOne({ where: query });
return doc && (doc.get({ plain: true }) as App); if (!doc) {
throw new Error(`App ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
} }
public async remove(ids: number[]) { public async remove(ids: number[]) {
await AppModel.destroy({ where: { id: ids } }); await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
} }
public async resetSecret(id: number): Promise<App> { public async resetSecret(id: number): Promise<App> {
const tab: any = { const tab: Partial<App> = {
client_secret: createRandomString(24, 24), client_secret: createRandomString(24, 24),
tokens: [], tokens: [],
id, id,
@@ -71,11 +79,11 @@ export default class OpenService {
public async list( public async list(
searchText: string = '', searchText: string = '',
sort: any = {}, sort: any = {},
query: any = {}, query: Record<string, any> = {},
): Promise<App[]> { ): Promise<App[]> {
let condition = { ...query }; let condition = { ...query };
if (searchText) { if (searchText) {
const encodeText = encodeURIComponent(searchText); const encodeText = encodeURI(searchText);
const reg = { const reg = {
[Op.or]: [ [Op.or]: [
{ [Op.like]: `%${searchText}%` }, { [Op.like]: `%${searchText}%` },
@@ -98,7 +106,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 } }); const docs = await AppModel.findAll({ where: { ...query } });
return docs.map((x) => x.get({ plain: true })); return docs.map((x) => x.get({ plain: true }));
} }
@@ -132,6 +140,8 @@ export default class OpenService {
{ tokens }, { tokens },
{ where: { client_id, client_secret } }, { where: { client_id, client_secret } },
); );
const apps = await this.find({});
await shareStore.updateApps(apps);
return { return {
code: 200, code: 200,
data: { data: {
@@ -141,7 +151,7 @@ export default class OpenService {
}, },
}; };
} else { } else {
return { code: 400, message: 'client_idclient_seret有误' }; return { code: 400, message: 'client_idclient_seret 有误' };
} }
} }
@@ -149,14 +159,15 @@ export default class OpenService {
value: string; value: string;
expiration: number; expiration: number;
}> { }> {
let systemApp = (await AppModel.findOne({ const apps = await shareStore.getApps();
where: { name: 'system' }, const systemApp = apps?.find((x) => x.name === 'system');
})) as App;
if (!systemApp) { if (!systemApp) {
systemApp = await this.create({ throw new Error('system app not found');
name: 'system', }
scopes: ['crons', 'system'], const now = Math.round(Date.now() / 1000);
} as App); const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
if (currentToken) {
return currentToken;
} }
const { data } = await this.authToken({ const { data } = await this.authToken({
client_id: systemApp.client_id, client_id: systemApp.client_id,
+79 -31
View File
@@ -1,7 +1,7 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import nodeSchedule from 'node-schedule'; import nodeSchedule from 'node-schedule';
import { ChildProcessWithoutNullStreams, exec, spawn } from 'child_process'; import { ChildProcessWithoutNullStreams } from 'child_process';
import { import {
ToadScheduler, ToadScheduler,
LongIntervalJob, LongIntervalJob,
@@ -9,12 +9,15 @@ import {
Task, Task,
} from 'toad-scheduler'; } from 'toad-scheduler';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
interface ScheduleTaskType { export interface ScheduleTaskType {
id: number; id?: number;
command: string; command: string;
name?: string; name?: string;
schedule?: string; schedule?: string;
runOrigin: 'subscription' | 'system' | 'script';
} }
export interface TaskCallbacks { export interface TaskCallbacks {
@@ -38,23 +41,43 @@ export default class ScheduleService {
private intervalSchedule = new ToadScheduler(); 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( async runTask(
command: string, command: string,
callbacks: TaskCallbacks = {}, callbacks: TaskCallbacks = {},
params: {
schedule?: string;
name?: string;
command?: string;
id: string;
runOrigin: 'subscription' | 'system' | 'script';
},
completionTime: 'start' | 'end' = 'end', completionTime: 'start' | 'end' = 'end',
) { ) {
const { runOrigin, ...others } = params;
return taskLimit[this.taskLimitMap[runOrigin]](others, () => {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify({
...others,
command,
})}`,
);
try { try {
const startTime = dayjs(); const startTime = dayjs();
await callbacks.onBefore?.(startTime); await callbacks.onBefore?.(startTime);
const cp = spawn(command, { shell: '/bin/bash' }); const cp = spawn(command, { shell: '/bin/bash' });
// TODO:
callbacks.onStart?.(cp, startTime); callbacks.onStart?.(cp, startTime);
completionTime === 'start' && resolve(cp.pid); completionTime === 'start' && resolve(cp.pid);
@@ -64,9 +87,8 @@ export default class ScheduleService {
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
this.logger.info( this.logger.info(
'[执行任务失败] %s,时间:%s, 错误信息%j', '[panel][执行任务失败] 命令: %s, 错误信息: %j',
command, command,
new Date().toLocaleString(),
data.toString(), data.toString(),
); );
await callbacks.onError?.(data.toString()); await callbacks.onError?.(data.toString());
@@ -74,49 +96,50 @@ export default class ScheduleService {
cp.on('error', async (err) => { cp.on('error', async (err) => {
this.logger.error( this.logger.error(
'[创建任务失败] %s,时间:%s, 错误信息%j', '[panel][创建任务失败] 命令: %s, 错误信息: %j',
command, command,
new Date().toLocaleString(),
err, err,
); );
await callbacks.onError?.(JSON.stringify(err)); await callbacks.onError?.(JSON.stringify(err));
}); });
cp.on('exit', async (code, signal) => { cp.on('exit', async (code) => {
this.logger.info( this.logger.info(
`[任务退出] ${command} 进程id: ${cp.pid},退出码 ${code}`, '[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...others,
command,
}),
code,
); );
});
cp.on('close', async (code) => {
const endTime = dayjs(); const endTime = dayjs();
await callbacks.onEnd?.( await callbacks.onEnd?.(
cp, cp,
endTime, endTime,
endTime.diff(startTime, 'seconds'), endTime.diff(startTime, 'seconds'),
); );
resolve(null); resolve({ ...others, pid: cp.pid, code });
}); });
} catch (error) { } catch (error) {
await this.logger.error( this.logger.error(
'执行任务%s失败,时间:%s, 错误信息%j', '[panel][执行任务失败] 命令: %s, 错误信息: %j',
command, command,
new Date().toLocaleString(),
error, error,
); );
await callbacks.onError?.(JSON.stringify(error)); await callbacks.onError?.(JSON.stringify(error));
} }
}); });
});
} }
async createCronTask( async createCronTask(
{ id = 0, command, name, schedule = '' }: ScheduleTaskType, { id = 0, command, name, schedule = '', runOrigin }: ScheduleTaskType,
callbacks?: TaskCallbacks, callbacks?: TaskCallbacks,
runImmediately = false, runImmediately = false,
) { ) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[创建cron任务]任务ID: %scron: %s任务名: %s执行命令: %s', '[panel][创建cron任务], 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s',
_id, _id,
schedule, schedule,
name, name,
@@ -126,18 +149,30 @@ export default class ScheduleService {
this.scheduleStacks.set( this.scheduleStacks.set(
_id, _id,
nodeSchedule.scheduleJob(_id, schedule, async () => { nodeSchedule.scheduleJob(_id, schedule, async () => {
await this.runTask(command, callbacks); this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
});
}), }),
); );
if (runImmediately) { if (runImmediately) {
await this.runTask(command, callbacks); this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
});
} }
} }
async cancelCronTask({ id = 0, name }: ScheduleTaskType) { async cancelCronTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info('[取消定时任务]任务名%s', name); this.logger.info('[panel][取消定时任务], 任务名: %s', name);
if (this.scheduleStacks.has(_id)) { if (this.scheduleStacks.has(_id)) {
this.scheduleStacks.get(_id)?.cancel(); this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.delete(_id); this.scheduleStacks.delete(_id);
@@ -145,14 +180,14 @@ export default class ScheduleService {
} }
async createIntervalTask( async createIntervalTask(
{ id = 0, command, name = '' }: ScheduleTaskType, { id = 0, command, name = '', runOrigin }: ScheduleTaskType,
schedule: SimpleIntervalSchedule, schedule: SimpleIntervalSchedule,
runImmediately = true, runImmediately = true,
callbacks?: TaskCallbacks, callbacks?: TaskCallbacks,
) { ) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info( this.logger.info(
'[创建interval任务]任务ID: %s任务名: %s执行命令: %s', '[panel][创建interval任务], 任务ID: %s, 任务名: %s, 执行命令: %s',
_id, _id,
name, name,
command, command,
@@ -160,13 +195,17 @@ export default class ScheduleService {
const task = new Task( const task = new Task(
name, name,
() => { () => {
this.runTask(command, callbacks); this.runTask(command, callbacks, {
name,
command,
id: _id,
runOrigin,
});
}, },
(err) => { (err) => {
this.logger.error( this.logger.error(
'执行任务%s失败,时间:%s, 错误信息%j', '[panel][执行任务失败] 命令: %s, 错误信息: %j',
command, command,
new Date().toLocaleString(),
err, err,
); );
}, },
@@ -175,19 +214,28 @@ export default class ScheduleService {
const job = new LongIntervalJob( const job = new LongIntervalJob(
{ runImmediately: false, ...schedule }, { runImmediately: false, ...schedule },
task, task,
_id, { id: _id },
); );
this.intervalSchedule.addIntervalJob(job); this.intervalSchedule.addIntervalJob(job);
if (runImmediately) { if (runImmediately) {
this.runTask(command, callbacks); this.runTask(command, callbacks, {
name,
command,
id: _id,
runOrigin,
});
} }
} }
async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) { async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) {
const _id = this.formatId(id); const _id = this.formatId(id);
this.logger.info('[取消interval任务],任务ID: %s,任务名:%s', _id, name); this.logger.info(
'[panel][取消interval任务], 任务ID: %s, 任务名: %s',
_id,
name,
);
this.intervalSchedule.removeById(_id); this.intervalSchedule.removeById(_id);
} }
+25 -10
View File
@@ -1,13 +1,13 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import fs from 'fs'; import path, { join } from 'path';
import path from 'path';
import SockService from './sock'; import SockService from './sock';
import CronService from './cron'; import CronService from './cron';
import ScheduleService, { TaskCallbacks } from './schedule'; import ScheduleService, { TaskCallbacks } from './schedule';
import config from '../config'; import config from '../config';
import { TASK_COMMAND } from '../config/const'; 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() @Service()
export default class ScriptService { export default class ScriptService {
@@ -21,9 +21,7 @@ export default class ScriptService {
private taskCallbacks(filePath: string): TaskCallbacks { private taskCallbacks(filePath: string): TaskCallbacks {
return { return {
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
try { await rmPath(filePath);
fs.unlinkSync(filePath);
} catch (error) {}
}, },
onError: async (message: string) => { onError: async (message: string) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
@@ -42,10 +40,11 @@ export default class ScriptService {
public async runScript(filePath: string) { public async runScript(filePath: string) {
const relativePath = path.relative(config.scriptPath, filePath); 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( const pid = await this.scheduleService.runTask(
command, `real_time=true ${command}`,
this.taskCallbacks(filePath), this.taskCallbacks(filePath),
{ command, id: relativePath.replace(/ /g, '-'), runOrigin: 'script' },
'start', 'start',
); );
@@ -53,10 +52,10 @@ export default class ScriptService {
} }
public async stopScript(filePath: string, pid: number) { public async stopScript(filePath: string, pid: number) {
let str = '';
if (!pid) { if (!pid) {
const relativePath = path.relative(config.scriptPath, filePath); 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 { try {
await killTask(pid); await killTask(pid);
@@ -64,4 +63,20 @@ export default class ScriptService {
return { code: 200 }; 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 { export default class SockService {
private clients: Connection[] = []; private clients: Connection[] = [];
constructor(@Inject('logger') private logger: winston.Logger) {} constructor(@Inject('logger') private logger: winston.Logger) { }
public getClients() { public getClients() {
return this.clients; return this.clients;
+52 -36
View File
@@ -1,11 +1,13 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import fs, { existsSync } from 'fs'; import fs from 'fs/promises';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { Subscription } from '../data/subscription'; import { Subscription } from '../data/subscription';
import { formatUrl } from '../config/subscription'; import { formatUrl } from '../config/subscription';
import config from '../config'; import config from '../config';
import { fileExist, rmPath } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
@Service() @Service()
export default class SshKeyService { export default class SshKeyService {
@@ -18,96 +20,110 @@ export default class SshKeyService {
this.initSshConfigFile(); this.initSshConfigFile();
} }
private initSshConfigFile() { private async initSshConfigFile() {
let config = ''; let config = '';
if (existsSync(this.sshConfigFilePath)) { const _exist = await fileExist(this.sshConfigFilePath);
config = fs.readFileSync(this.sshConfigFilePath, { encoding: 'utf-8' }); if (_exist) {
config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' });
} else { } else {
fs.writeFileSync(this.sshConfigFilePath, ''); await writeFileWithLock(this.sshConfigFilePath, '');
} }
if (!config.includes(this.sshConfigHeader)) { if (!config.includes(this.sshConfigHeader)) {
fs.writeFileSync( await writeFileWithLock(
this.sshConfigFilePath, this.sshConfigFilePath,
`${this.sshConfigHeader}\n\n${config}`, `${this.sshConfigHeader}\n\n${config}`,
{ encoding: 'utf-8' },
); );
} }
} }
private generatePrivateKeyFile(alias: string, key: string): void { private async generatePrivateKeyFile(
alias: string,
key: string,
): Promise<void> {
try { try {
fs.writeFileSync(path.join(this.sshPath, alias), `${key}${os.EOL}`, { await writeFileWithLock(
path.join(this.sshPath, alias),
`${key}${os.EOL}`,
{
encoding: 'utf8', encoding: 'utf8',
mode: '400', mode: '400',
}); },
);
} catch (error) { } catch (error) {
this.logger.error('生成私钥文件失败', error); this.logger.error('生成私钥文件失败', error);
} }
} }
private removePrivateKeyFile(alias: string): void { private async removePrivateKeyFile(alias: string): Promise<void> {
try { try {
const filePath = path.join(this.sshPath, alias); const filePath = path.join(this.sshPath, alias);
if (existsSync(filePath)) { await rmPath(filePath);
fs.unlinkSync(filePath);
}
} catch (error) { } catch (error) {
this.logger.error('删除私钥文件失败', 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') { 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\n` : ''; const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
: '';
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join( const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join(
this.sshPath, this.sshPath,
alias, alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`; )}\n StrictHostKeyChecking no\n${proxyStr}`;
fs.writeFileSync(`${path.join(this.sshPath, `${alias}.config`)}`, config, { await writeFileWithLock(
encoding: 'utf8', `${path.join(this.sshPath, `${alias}.config`)}`,
}); config,
);
} }
private removeSshConfig(alias: string) { private async removeSshConfig(alias: string) {
try { try {
const filePath = path.join(this.sshPath, `${alias}.config`); const filePath = path.join(this.sshPath, `${alias}.config`);
if (existsSync(filePath)) { await rmPath(filePath);
fs.unlinkSync(filePath);
}
} catch (error) { } catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error); this.logger.error(`删除ssh配置文件${alias}失败`, error);
} }
} }
public addSSHKey( public async addSSHKey(
key: string, key: string,
alias: string, alias: string,
host: string, host: string,
proxy?: string, proxy?: string,
): void { ): Promise<void> {
this.generatePrivateKeyFile(alias, key); await this.generatePrivateKeyFile(alias, key);
this.generateSingleSshConfig(alias, host, proxy); await this.generateSingleSshConfig(alias, host, proxy);
} }
public removeSSHKey(alias: string, host: string, proxy?: string): void { public async removeSSHKey(
this.removePrivateKeyFile(alias); alias: string,
this.removeSshConfig(alias); 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) { for (const doc of docs) {
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') { if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
const { alias, proxy } = doc; const { alias, proxy } = doc;
const { host } = formatUrl(doc); const { host } = formatUrl(doc);
this.removePrivateKeyFile(alias); await this.removePrivateKeyFile(alias);
this.removeSshConfig(alias); await this.removeSshConfig(alias);
this.generatePrivateKeyFile( await this.generatePrivateKeyFile(
alias, alias,
(doc.pull_option as any).private_key, (doc.pull_option as any).private_key,
); );
this.generateSingleSshConfig(alias, host, proxy); await this.generateSingleSshConfig(alias, host, proxy);
} }
} }
} }
+76 -70
View File
@@ -3,16 +3,11 @@ import winston from 'winston';
import config from '../config'; import config from '../config';
import { import {
Subscription, Subscription,
SubscriptionInstance,
SubscriptionModel, SubscriptionModel,
SubscriptionStatus, SubscriptionStatus,
} from '../data/subscription'; } from '../data/subscription';
import { import { ChildProcessWithoutNullStreams } from 'child_process';
ChildProcessWithoutNullStreams,
exec,
execSync,
spawn,
} from 'child_process';
import fs from 'fs';
import { import {
getFileContentByName, getFileContentByName,
concurrentRun, concurrentRun,
@@ -20,10 +15,12 @@ import {
createFile, createFile,
killTask, killTask,
handleLogPath, handleLogPath,
promiseExec,
rmPath,
} from '../config/util'; } from '../config/util';
import { promises, existsSync } from 'fs'; import fs from 'fs/promises';
import { FindOptions, Op } from 'sequelize'; import { FindOptions, Op } from 'sequelize';
import path from 'path'; import path, { join } from 'path';
import ScheduleService, { TaskCallbacks } from './schedule'; import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler'; import { SimpleIntervalSchedule } from 'toad-scheduler';
import SockService from './sock'; import SockService from './sock';
@@ -31,6 +28,9 @@ import SshKeyService from './sshKey';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LOG_END_SYMBOL } from '../config/const'; import { LOG_END_SYMBOL } from '../config/const';
import { formatCommand, formatUrl } from '../config/subscription'; import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
@@ -39,15 +39,20 @@ export default class SubscriptionService {
private scheduleService: ScheduleService, private scheduleService: ScheduleService,
private sockService: SockService, private sockService: SockService,
private sshKeyService: SshKeyService, private sshKeyService: SshKeyService,
private crontabService: CrontabService,
) {} ) {}
public async list(searchText?: string): Promise<Subscription[]> { public async list(
searchText?: string,
ids?: string,
): Promise<SubscriptionInstance[]> {
let query = {}; let query = {};
const subIds = JSON.parse(ids || '[]');
if (searchText) { if (searchText) {
const reg = { const reg = {
[Op.or]: [ [Op.or]: [
{ [Op.like]: `%${searchText}%` }, { [Op.like]: `%${searchText}%` },
{ [Op.like]: `%${encodeURIComponent(searchText)}%` }, { [Op.like]: `%${encodeURI(searchText)}%` },
], ],
}; };
query = { query = {
@@ -63,7 +68,7 @@ export default class SubscriptionService {
} }
try { try {
const result = await SubscriptionModel.findAll({ const result = await SubscriptionModel.findAll({
where: query, where: { ...query, ...(ids ? { id: subIds } : undefined) },
order: [ order: [
['is_disabled', 'ASC'], ['is_disabled', 'ASC'],
['createdAt', 'DESC'], ['createdAt', 'DESC'],
@@ -88,16 +93,16 @@ export default class SubscriptionService {
this.scheduleService.cancelCronTask(doc as any); this.scheduleService.cancelCronTask(doc as any);
needCreate && needCreate &&
(await this.scheduleService.createCronTask( (await this.scheduleService.createCronTask(
doc as any, { ...doc, runOrigin: 'subscription' } as any,
this.taskCallbacks(doc), this.taskCallbacks(doc),
runImmediately, runImmediately,
)); ));
} else { } else if (doc.interval_schedule) {
this.scheduleService.cancelIntervalTask(doc as any); this.scheduleService.cancelIntervalTask(doc as any);
const { type, value } = doc.interval_schedule as any; const { type, value } = doc.interval_schedule;
needCreate && needCreate &&
(await this.scheduleService.createIntervalTask( (await this.scheduleService.createIntervalTask(
doc as any, { ...doc, runOrigin: 'subscription' } as any,
{ [type]: value } as SimpleIntervalSchedule, { [type]: value } as SimpleIntervalSchedule,
runImmediately, runImmediately,
this.taskCallbacks(doc), this.taskCallbacks(doc),
@@ -107,19 +112,7 @@ export default class SubscriptionService {
public async setSshConfig() { public async setSshConfig() {
const docs = await SubscriptionModel.findAll(); const docs = await SubscriptionModel.findAll();
this.sshKeyService.setSshConfig(docs); await this.sshKeyService.setSshConfig(docs);
}
private async 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));
},
);
});
} }
private taskCallbacks(doc: Subscription): TaskCallbacks { private taskCallbacks(doc: Subscription): TaskCallbacks {
@@ -143,15 +136,15 @@ export default class SubscriptionService {
let beforeStr = ''; let beforeStr = '';
try { try {
if (doc.sub_before) { if (doc.sub_before) {
fs.appendFileSync(absolutePath, `\n## 执行before命令...\n\n`); await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`);
beforeStr = await this.promiseExec(doc.sub_before); beforeStr = await promiseExec(doc.sub_before);
} }
} catch (error: any) { } catch (error: any) {
beforeStr = beforeStr =
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (beforeStr) { if (beforeStr) {
fs.appendFileSync(absolutePath, `${beforeStr}\n`); await fs.appendFile(absolutePath, `${beforeStr}\n`);
} }
}, },
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => { onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -170,18 +163,18 @@ export default class SubscriptionService {
let afterStr = ''; let afterStr = '';
try { try {
if (sub.sub_after) { if (sub.sub_after) {
fs.appendFileSync(absolutePath, `\n\n## 执行after命令...\n\n`); await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`);
afterStr = await this.promiseExec(sub.sub_after); afterStr = await promiseExec(sub.sub_after);
} }
} catch (error: any) { } catch (error: any) {
afterStr = afterStr =
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (afterStr) { if (afterStr) {
fs.appendFileSync(absolutePath, `${afterStr}\n`); await fs.appendFile(absolutePath, `${afterStr}\n`);
} }
fs.appendFileSync( await fs.appendFile(
absolutePath, absolutePath,
`\n## 执行结束... ${endTime.format( `\n## 执行结束... ${endTime.format(
'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm:ss',
@@ -202,12 +195,12 @@ export default class SubscriptionService {
onError: async (message: string) => { onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, `\n${message}`); await fs.appendFile(absolutePath, `\n${message}`);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
fs.appendFileSync(absolutePath, `\n${message}`); await fs.appendFile(absolutePath, `\n${message}`);
}, },
}; };
} }
@@ -215,12 +208,12 @@ export default class SubscriptionService {
public async create(payload: Subscription): Promise<Subscription> { public async create(payload: Subscription): Promise<Subscription> {
const tab = new Subscription(payload); const tab = new Subscription(payload);
const doc = await this.insert(tab); const doc = await this.insert(tab);
await this.handleTask(doc); await this.handleTask(doc.get({ plain: true }));
await this.setSshConfig(); await this.setSshConfig();
return doc; return doc;
} }
public async insert(payload: Subscription): Promise<Subscription> { public async insert(payload: Subscription): Promise<SubscriptionInstance> {
return await SubscriptionModel.create(payload, { returning: true }); return await SubscriptionModel.create(payload, { returning: true });
} }
@@ -269,20 +262,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 } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) { 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 SubscriptionModel.destroy({ where: { id: ids } });
await this.setSshConfig(); 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( public async getDb(
query: FindOptions<Subscription>['where'], query: FindOptions<Subscription>['where'],
): Promise<Subscription> { ): Promise<Subscription> {
const doc: any = await SubscriptionModel.findOne({ where: { ...query } }); const doc = await SubscriptionModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as Subscription); if (!doc) {
throw new Error(`Subscription ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
} }
public async run(ids: number[]) { public async run(ids: number[]) {
@@ -290,10 +299,9 @@ export default class SubscriptionService {
{ status: SubscriptionStatus.queued }, { status: SubscriptionStatus.queued },
{ where: { id: ids } }, { where: { id: ids } },
); );
concurrentRun( ids.forEach((id) => {
ids.map((id) => async () => await this.runSingle(id)), this.runSingle(id);
10, });
);
} }
public async stop(ids: number[]) { public async stop(ids: number[]) {
@@ -303,17 +311,9 @@ export default class SubscriptionService {
try { try {
await killTask(doc.pid); await killTask(doc.pid);
} catch (error) { } 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( await SubscriptionModel.update(
@@ -330,10 +330,13 @@ export default class SubscriptionService {
const command = formatCommand(subscription); const command = formatCommand(subscription);
await this.scheduleService.runTask( this.scheduleService.runTask(command, this.taskCallbacks(subscription), {
name: subscription.name,
schedule: subscription.schedule,
command, command,
this.taskCallbacks(subscription), id: String(subscription.id),
); runOrigin: 'subscription',
});
} }
public async disabled(ids: number[]) { public async disabled(ids: number[]) {
@@ -341,7 +344,7 @@ export default class SubscriptionService {
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.setSshConfig(); await this.setSshConfig();
for (const doc of docs) { for (const doc of docs) {
await this.handleTask(doc, false); await this.handleTask(doc.get({ plain: true }), false);
} }
} }
@@ -350,7 +353,7 @@ export default class SubscriptionService {
const docs = await SubscriptionModel.findAll({ where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } });
await this.setSshConfig(); await this.setSshConfig();
for (const doc of docs) { for (const doc of docs) {
await this.handleTask(doc); await this.handleTask(doc.get({ plain: true }));
} }
} }
@@ -361,7 +364,7 @@ export default class SubscriptionService {
} }
const absolutePath = await handleLogPath(doc.log_path as string); const absolutePath = await handleLogPath(doc.log_path as string);
return getFileContentByName(absolutePath); return await getFileContentByName(absolutePath);
} }
public async logs(id: number) { public async logs(id: number) {
@@ -373,15 +376,18 @@ export default class SubscriptionService {
if (doc.log_path) { if (doc.log_path) {
const relativeDir = path.dirname(`${doc.log_path}`); const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir); const dir = path.resolve(config.logPath, relativeDir);
if (existsSync(dir)) { const _exist = await fileExist(dir);
let files = await promises.readdir(dir); if (_exist) {
return files let files = await fs.readdir(dir);
.map((x) => ({ return (
await Promise.all(
files.map(async (x) => ({
filename: x, filename: x,
directory: relativeDir.replace(config.logPath, ''), directory: relativeDir.replace(config.logPath, ''),
time: fs.statSync(`${dir}/${x}`).mtime.getTime(), time: (await fs.lstat(`${dir}/${x}`)).mtime.getTime(),
})) })),
.sort((a, b) => b.time - a.time); )
).sort((a, b) => b.time - a.time);
} }
} }
} }
+388 -50
View File
@@ -1,21 +1,42 @@
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 winston from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs'; import { NotificationModeStringMap, TASK_COMMAND } from '../config/const';
import { AuthDataType, AuthInfo, AuthModel, LoginStatus } from '../data/auth';
import { NotificationInfo } from '../data/notify';
import NotificationService from './notify';
import ScheduleService, { TaskCallbacks } from './schedule';
import { spawn } from 'child_process';
import SockService from './sock';
import got from 'got';
import { import {
getPid, getPid,
killTask, killTask,
parseContentVersion, parseContentVersion,
parseVersion, parseVersion,
promiseExec,
readDirs,
rmPath,
setSystemTimezone,
} from '../config/util'; } from '../config/util';
import { TASK_COMMAND } from '../config/const'; 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';
@Service() @Service()
export default class SystemService { export default class SystemService {
@@ -26,22 +47,29 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger, @Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService, private scheduleService: ScheduleService,
private sockService: SockService, private sockService: SockService,
) {} ) { }
public async getLogRemoveFrequency() { public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.removeLogFrequency }); 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> { private async updateAuthDb(payload: SystemInfo): Promise<SystemInfo> {
await AuthModel.upsert({ ...payload }); const { id, ...others } = payload;
const doc = await this.getDb({ type: payload.type }); await SystemModel.update(others, { where: { id } });
const doc = await this.getDb({ id });
return doc; return doc;
} }
public async getDb(query: any): Promise<any> { public async getDb(query: any): Promise<SystemInfo> {
const doc: any = await AuthModel.findOne({ where: { ...query } }); const doc = await SystemModel.findOne({ where: query });
return doc && (doc.get({ plain: true }) as any); if (!doc) {
throw new Error(`System ${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
} }
public async updateNotificationMode(notificationInfo: NotificationInfo) { public async updateNotificationMode(notificationInfo: NotificationInfo) {
@@ -62,25 +90,184 @@ export default class SystemService {
} }
} }
public async updateLogRemoveFrequency(frequency: number) { public async updateLogRemoveFrequency(info: SystemModelInfo) {
const oDoc = await this.getLogRemoveFrequency(); const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
...oDoc, ...oDoc,
type: AuthDataType.removeLogFrequency, info: { ...oDoc.info, ...info },
info: { frequency },
}); });
const cron = { const cron = {
id: result.id, id: result.id as number,
name: '删除日志', name: '删除日志',
command: `ql rmlog ${frequency}`, command: `ql rmlog ${info.logRemoveFrequency}`,
runOrigin: 'system' as const,
}; };
if (oDoc.info?.logRemoveFrequency) {
await this.scheduleService.cancelIntervalTask(cron); await this.scheduleService.cancelIntervalTask(cron);
if (frequency > 0) {
this.scheduleService.createIntervalTask(cron, {
days: frequency,
});
} }
return { code: 200, data: { ...cron } }; if (info.logRemoveFrequency && info.logRemoveFrequency > 0) {
this.scheduleService.createIntervalTask(
cron,
{
days: info.logRemoveFrequency,
},
true,
);
}
return { code: 200, data: info };
}
public async updateCronConcurrency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
if (info.cronConcurrency) {
await taskLimit.setCustomLimit(info.cronConcurrency);
}
return { code: 200, data: info };
}
public async updateDependenceProxy(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
}
return { code: 200, data: info };
}
public async updateNodeMirror(info: SystemModelInfo, res?: Response) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let cmd = 'pnpm config delete registry';
if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`;
}
let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({
where: {
type: DependenceTypes.nodejs,
status: DependenceStatus.installed,
},
});
if (docs.length > 0) {
command += ` && pnpm i -g`;
}
this.scheduleService.runTask(
command,
{
onStart: async (cp) => {
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end();
},
onEnd: async () => {
this.sockService.sendMessage({
type: 'updateNodeMirror',
message: 'update node mirror end',
});
},
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();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let defaultDomain = 'https://dl-cdn.alpinelinux.org';
let targetDomain = 'https://dl-cdn.alpinelinux.org';
if (os.platform() !== 'linux') {
return;
}
const content = await fs.promises.readFile('/etc/apk/repositories', {
encoding: 'utf-8',
});
const domainMatch = content.match(/(http.*)\/alpine\/.*/);
if (domainMatch) {
defaultDomain = domainMatch[1];
}
if (info.linuxMirror) {
targetDomain = info.linuxMirror;
}
const command = `sed -i 's/${defaultDomain.replace(
/\//g,
'\\/',
)}/${targetDomain.replace(
/\//g,
'\\/',
)}/g' /etc/apk/repositories && apk update -f`;
this.scheduleService.runTask(
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',
});
onEnd?.();
},
onError: async (message: string) => {
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() { public async checkUpdate() {
@@ -89,14 +276,18 @@ export default class SystemService {
let lastVersionContent; let lastVersionContent;
try { try {
const result = await got.get( const { body } = await request(
`${config.lastVersionFile}?t=${Date.now()}`, `${config.lastVersionFile}?t=${Date.now()}`,
{ {
timeout: 30000, dispatcher: new Agent({
keepAliveTimeout: 30000,
keepAliveMaxTimeout: 30000,
}),
}, },
); );
lastVersionContent = await parseContentVersion(result.body); const text = await body.text();
} catch (error) {} lastVersionContent = parseContentVersion(text);
} catch (error) { }
if (!lastVersionContent) { if (!lastVersionContent) {
lastVersionContent = currentVersionContent; lastVersionContent = currentVersionContent;
@@ -142,7 +333,7 @@ export default class SystemService {
} }
public async updateSystem() { 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) => { cp.stdout.on('data', (data) => {
this.sockService.sendMessage({ this.sockService.sendMessage({
@@ -168,8 +359,41 @@ export default class SystemService {
return { code: 200 }; return { code: 200 };
} }
public async notify({ title, content }: { title: string; content: string }) { public async reloadSystem(target?: 'system' | 'data') {
const isSuccess = await this.notificationService.notify(title, content); 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) { if (isSuccess) {
return { code: 200, message: '通知发送成功' }; return { code: 200, message: '通知发送成功' };
} else { } else {
@@ -177,29 +401,143 @@ export default class SystemService {
} }
} }
public async run( public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
{ command, logPath }: { command: string; logPath: string },
callback: TaskCallbacks,
) {
if (!command.startsWith(TASK_COMMAND)) { if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`; command = `${TASK_COMMAND} ${command}`;
} }
this.scheduleService.runTask( const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
`real_log_path=${logPath} real_time=true ${command}`, this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
callback, command,
); id: command.replace(/ /g, '-'),
runOrigin: 'system',
});
}
public async stop({ command, pid }: { command: string; pid: number }) {
if (!pid && !command) {
return { code: 400, message: '参数错误' };
}
if (pid) {
await killTask(pid);
return { code: 200 };
} }
public async stop({ command }: { command: string }) {
if (!command.startsWith(TASK_COMMAND)) { if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`; command = `${TASK_COMMAND} ${command}`;
} }
const pid = await getPid(command); const _pid = await getPid(command);
if (pid) { if (_pid) {
await killTask(pid); await killTask(_pid);
return { code: 200 }; return { code: 200 };
} else { } else {
return { code: 400, message: '任务未找到' }; return { code: 400, message: '任务未找到' };
} }
} }
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: '设置时区失败' };
}
}
public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' };
}
try {
const finalPath = path.join(config.dependenceCachePath, type);
await fs.promises.rm(finalPath, { recursive: true });
} catch (error) { }
return { code: 200 };
}
} }
+120 -103
View File
@@ -1,18 +1,29 @@
import { Service, Inject } from 'typedi'; import { Service, Inject } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import { createRandomString, getNetIp, getPlatform } from '../config/util'; import { createRandomString } from '../config/util';
import config from '../config'; import config from '../config';
import * as fs from 'fs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { authenticator } from '@otplib/preset-default'; import { authenticator } from '@otplib/preset-default';
import { AuthDataType, AuthInfo, AuthModel, LoginStatus } from '../data/auth'; import {
AuthDataType,
SystemInfo,
SystemModel,
SystemModelInfo,
LoginStatus,
AuthInfo,
} from '../data/system';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import NotificationService from './notify'; import NotificationService from './notify';
import { Request } from 'express'; import { Request } from 'express';
import ScheduleService from './schedule'; import ScheduleService from './schedule';
import { spawn } from 'child_process';
import SockService from './sock'; import SockService from './sock';
import dayjs from 'dayjs'; 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';
@Service() @Service()
export default class UserService { export default class UserService {
@@ -33,14 +44,9 @@ export default class UserService {
req: Request, req: Request,
needTwoFactor = true, needTwoFactor = true,
): Promise<any> { ): Promise<any> {
if (!fs.existsSync(config.authConfigFile)) {
return this.initAuthInfo();
}
let { username, password } = payloads; let { username, password } = payloads;
const content = this.getAuthInfo(); const content = await this.getAuthInfo();
const timestamp = Date.now(); const timestamp = Date.now();
if (content) {
let { let {
username: cUsername, username: cUsername,
password: cPassword, password: cPassword,
@@ -49,21 +55,9 @@ export default class UserService {
lastip, lastip,
lastaddr, lastaddr,
twoFactorActivated, twoFactorActivated,
twoFactorActived,
tokens = {}, tokens = {},
platform, platform,
} = content; } = content;
// patch old field
twoFactorActivated = twoFactorActivated || twoFactorActived;
if (
(cUsername === 'admin' && cPassword === 'admin') ||
!cUsername ||
!cPassword
) {
return this.initAuthInfo();
}
const retriesTime = Math.pow(3, retries) * 1000; const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) { if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil( const waitTime = Math.ceil(
@@ -82,7 +76,7 @@ export default class UserService {
twoFactorActivated && twoFactorActivated &&
needTwoFactor needTwoFactor
) { ) {
this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
isTwoFactorChecking: true, isTwoFactorChecking: true,
}); });
return { return {
@@ -91,16 +85,23 @@ export default class UserService {
}; };
} }
const { ip, address } = await getNetIp(req); const ip = requestIp.getClientIp(req) || '';
const query = new IP2Region();
const ipAddress = query.search(ip);
let address = '';
if (ipAddress) {
const { country, province, city, isp } = ipAddress;
address = uniq([country, province, city, isp]).filter(Boolean).join(' ');
}
if (username === cUsername && password === cPassword) { if (username === cUsername && password === cPassword) {
const data = createRandomString(50, 100); const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? 60 : 20; const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.secret as any, { let token = jwt.sign({ data }, config.jwt.secret, {
expiresIn: 60 * 60 * 24 * expiration, expiresIn: config.jwt.expiresIn || expiration,
algorithm: 'HS384', algorithm: 'HS384',
}); });
this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
token, token,
tokens: { tokens: {
...tokens, ...tokens,
@@ -113,13 +114,12 @@ export default class UserService {
platform: req.platform, platform: req.platform,
isTwoFactorChecking: false, isTwoFactorChecking: false,
}); });
await this.notificationService.notify( this.notificationService.notify(
'登录通知', '登录通知',
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${ `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${
req.platform req.platform
}端 登录成功,ip地址 ${ip}`, }端 登录成功,ip地址 ${ip}`,
); );
await this.getLoginLog();
await this.insertDb({ await this.insertDb({
type: AuthDataType.loginLog, type: AuthDataType.loginLog,
info: { info: {
@@ -130,25 +130,32 @@ export default class UserService {
status: LoginStatus.success, status: LoginStatus.success,
}, },
}); });
this.getLoginLog();
return { return {
code: 200, code: 200,
data: { token, lastip, lastaddr, lastlogon, retries, platform }, data: {
token,
lastip,
lastaddr,
lastlogon,
retries,
platform,
},
}; };
} else { } else {
this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
retries: retries + 1, retries: retries + 1,
lastlogon: timestamp, lastlogon: timestamp,
lastip: ip, lastip: ip,
lastaddr: address, lastaddr: address,
platform: req.platform, platform: req.platform,
}); });
await this.notificationService.notify( this.notificationService.notify(
'登录通知', '登录通知',
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${ `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${
req.platform req.platform
}端 登录失败,ip地址 ${ip}`, }端 登录失败,ip地址 ${ip}`,
); );
await this.getLoginLog();
await this.insertDb({ await this.insertDb({
type: AuthDataType.loginLog, type: AuthDataType.loginLog,
info: { info: {
@@ -159,6 +166,7 @@ export default class UserService {
status: LoginStatus.fail, status: LoginStatus.fail,
}, },
}); });
this.getLoginLog();
if (retries > 2) { if (retries > 2) {
const waitTime = Math.round(Math.pow(3, retries + 1)); const waitTime = Math.round(Math.pow(3, retries + 1));
return { return {
@@ -170,28 +178,28 @@ export default class UserService {
return { code: 400, message: config.authError }; return { code: 400, message: config.authError };
} }
} }
} else {
return this.initAuthInfo();
}
} }
public async logout(platform: string): Promise<any> { public async logout(platform: string): Promise<any> {
const authInfo = this.getAuthInfo(); const authInfo = await this.getAuthInfo();
this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
token: '', token: '',
tokens: { ...authInfo.tokens, [platform]: '' }, tokens: { ...authInfo.tokens, [platform]: '' },
}); });
} }
public async getLoginLog(): Promise<AuthInfo[]> { public async getLoginLog(): Promise<Array<SystemModelInfo | undefined>> {
const docs = await AuthModel.findAll({ const docs = await SystemModel.findAll({
where: { type: AuthDataType.loginLog }, where: { type: AuthDataType.loginLog },
}); });
if (docs && docs.length > 0) { 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) { if (result.length > 100) {
await AuthModel.destroy({ const ids = result.slice(100).map((x) => x.id!);
where: { id: result[result.length - 1].id }, await SystemModel.destroy({
where: { id: ids },
}); });
} }
return result.map((x) => x.info); return result.map((x) => x.info);
@@ -199,26 +207,11 @@ export default class UserService {
return []; return [];
} }
private async insertDb(payload: AuthInfo): Promise<AuthInfo> { private async insertDb(payload: SystemInfo): Promise<SystemInfo> {
const doc = await AuthModel.create({ ...payload }, { returning: true }); const doc = await SystemModel.create({ ...payload }, { returning: true });
return doc; return doc;
} }
private initAuthInfo() {
const newPassword = createRandomString(16, 22);
fs.writeFileSync(
config.authConfigFile,
JSON.stringify({
username: 'admin',
password: newPassword,
}),
);
return {
code: 100,
message: '已初始化密码,请前往auth.json查看并重新登录',
};
}
public async updateUsernameAndPassword({ public async updateUsernameAndPassword({
username, username,
password, password,
@@ -229,42 +222,33 @@ export default class UserService {
if (password === 'admin') { if (password === 'admin') {
return { code: 400, message: '密码不能设置为admin' }; return { code: 400, message: '密码不能设置为admin' };
} }
const authInfo = this.getAuthInfo(); const authInfo = await this.getAuthInfo();
this.updateAuthInfo(authInfo, { username, password }); await this.updateAuthInfo(authInfo, { username, password });
return { code: 200, message: '更新成功' }; return { code: 200, message: '更新成功' };
} }
public async updateAvatar(avatar: string) { public async updateAvatar(avatar: string) {
const authInfo = this.getAuthInfo(); const authInfo = await this.getAuthInfo();
this.updateAuthInfo(authInfo, { avatar }); await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: '更新成功' }; return { code: 200, data: avatar, message: '更新成功' };
} }
public getUserInfo(): Promise<any> { public async initTwoFactor() {
return new Promise((resolve) => {
fs.readFile(config.authConfigFile, 'utf8', (err, data) => {
if (err) console.log(err);
resolve(JSON.parse(data));
});
});
}
public initTwoFactor() {
const secret = authenticator.generateSecret(); const secret = authenticator.generateSecret();
const authInfo = this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret); const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret);
this.updateAuthInfo(authInfo, { twoFactorSecret: secret }); await this.updateAuthInfo(authInfo, { twoFactorSecret: secret });
return { secret, url: otpauth }; return { secret, url: otpauth };
} }
public activeTwoFactor(code: string) { public async activeTwoFactor(code: string) {
const authInfo = this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const isValid = authenticator.verify({ const isValid = authenticator.verify({
token: code, token: code,
secret: authInfo.twoFactorSecret, secret: authInfo.twoFactorSecret,
}); });
if (isValid) { if (isValid) {
this.updateAuthInfo(authInfo, { twoFactorActivated: true }); await this.updateAuthInfo(authInfo, { twoFactorActivated: true });
} }
return isValid; return isValid;
} }
@@ -277,7 +261,7 @@ export default class UserService {
}: { username: string; password: string; code: string }, }: { username: string; password: string; code: string },
req: any, req: any,
) { ) {
const authInfo = this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo; const { isTwoFactorChecking, twoFactorSecret } = authInfo;
if (!isTwoFactorChecking) { if (!isTwoFactorChecking) {
return { code: 450, message: '未知错误' }; return { code: 450, message: '未知错误' };
@@ -289,8 +273,17 @@ export default class UserService {
if (isValid) { if (isValid) {
return this.login({ username, password }, req, false); return this.login({ username, password }, req, false);
} else { } else {
const { ip, address } = await getNetIp(req); const ip = requestIp.getClientIp(req) || '';
this.updateAuthInfo(authInfo, { 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, lastip: ip,
lastaddr: address, lastaddr: address,
platform: req.platform, platform: req.platform,
@@ -299,50 +292,58 @@ export default class UserService {
} }
} }
public deactiveTwoFactor() { public async deactiveTwoFactor() {
const authInfo = this.getAuthInfo(); const authInfo = await this.getAuthInfo();
this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
twoFactorActivated: false, twoFactorActivated: false,
twoFactorActived: false,
twoFactorSecret: '', twoFactorSecret: '',
}); });
return true; return true;
} }
private getAuthInfo() { public async getAuthInfo() {
const content = fs.readFileSync(config.authConfigFile, 'utf8'); const authInfo = await shareStore.getAuthInfo();
return JSON.parse(content || '{}'); if (authInfo) {
return authInfo;
}
const doc = await this.getDb({ type: AuthDataType.authConfig });
return (doc.info || {}) as AuthInfo;
} }
private updateAuthInfo(authInfo: any, info: any) { private async updateAuthInfo(authInfo: AuthInfo, info: Partial<AuthInfo>) {
fs.writeFileSync( const result = { ...authInfo, ...info };
config.authConfigFile, await shareStore.updateAuthInfo(result);
JSON.stringify({ ...authInfo, ...info }), await this.updateAuthDb({
); type: AuthDataType.authConfig,
info: result,
});
} }
public async getNotificationMode(): Promise<NotificationInfo> { public async getNotificationMode(): Promise<NotificationInfo> {
const doc = await this.getDb({ type: AuthDataType.notification }); const doc = await this.getDb({ type: AuthDataType.notification });
return (doc && doc.info) || {}; return (doc.info || {}) as NotificationInfo;
} }
private async updateAuthDb(payload: AuthInfo): Promise<any> { private async updateAuthDb(payload: SystemInfo): Promise<any> {
let doc = await AuthModel.findOne({ type: payload.type }); let doc = await SystemModel.findOne({ where: { type: payload.type } });
if (doc) { if (doc) {
const updateResult = await AuthModel.update(payload, { const updateResult = await SystemModel.update(payload, {
where: { id: doc.id }, where: { id: doc.id },
returning: true, returning: true,
}); });
doc = updateResult[1][0]; doc = updateResult[1][0];
} else { } else {
doc = await AuthModel.create(payload, { returning: true }); doc = await SystemModel.create(payload, { returning: true });
} }
return doc; return doc;
} }
public async getDb(query: any): Promise<any> { public async getDb(query: any): Promise<SystemInfo> {
const doc: any = await AuthModel.findOne({ where: { ...query } }); const doc = await SystemModel.findOne({ where: { ...query } });
return doc && (doc.get({ plain: true }) as any); if (!doc) {
throw new Error(`${JSON.stringify(query)} not found`);
}
return doc.get({ plain: true });
} }
public async updateNotificationMode(notificationInfo: NotificationInfo) { public async updateNotificationMode(notificationInfo: NotificationInfo) {
@@ -362,4 +363,20 @@ export default class UserService {
return { code: 400, message: '通知发送失败,请检查参数' }; return { code: 400, message: '通知发送失败,请检查参数' };
} }
} }
public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info;
const authInfo = await this.getAuthInfo();
const payload = pickBy(
{
retries,
twoFactorActivated,
password,
username,
},
(x) => !isNil(x),
);
await this.updateAuthInfo(authInfo, payload);
}
} }
+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;
}
+223
View File
@@ -0,0 +1,223 @@
import PQueue, { QueueAddOptions } from 'p-queue-cjs';
import os from 'os';
import { AuthDataType, SystemModel } from '../data/system';
import Logger from '../loaders/logger';
import { Dependence } from '../data/dependence';
import NotificationService from '../services/notify';
import {
ICronFn,
IDependencyFn,
ISchedule,
IScheduleFn,
TCron,
} from './interface';
import config from '../config';
import { credentials } from '@grpc/grpc-js';
import { ApiClient } from '../protos/api';
class TaskLimit {
private dependenyLimit = new PQueue({ concurrency: 1 });
private queuedDependencyIds = new Set<number>([]);
private queuedCrons = new Map<string, ICronFn<any>[]>();
private repeatCronNotifyMap = new Map<string, number>();
private updateLogLimit = new PQueue({ concurrency: 1 });
private cronLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private manualCronoLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private subscriptionLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private scriptLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private systemLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4),
});
private client = new ApiClient(
`0.0.0.0:${config.grpcPort}`,
credentials.createInsecure(),
{ 'grpc.enable_http_proxy': 0 },
);
get cronLimitActiveCount() {
return this.cronLimit.pending;
}
get cronLimitPendingCount() {
return this.cronLimit.size;
}
get firstDependencyId() {
return [...this.queuedDependencyIds.values()][0];
}
private notificationService: NotificationService = new NotificationService();
constructor() {
this.setCustomLimit();
this.handleEvents();
}
private handleEvents() {
this.cronLimit.on('add', () => {
Logger.info(
`[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('active', () => {
Logger.info(
`[schedule][开始处理任务] 运行中任务数: ${
this.cronLimitActiveCount + 1
}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('completed', (param) => {
Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`);
});
this.cronLimit.on('error', (error) => {
Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`);
});
this.cronLimit.on('next', () => {
Logger.info(
`[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`,
);
});
this.cronLimit.on('idle', () => {
Logger.info(`[schedule][任务队列] 空闲中...`);
});
}
public removeQueuedDependency(dependency: Dependence) {
if (this.queuedDependencyIds.has(dependency.id!)) {
this.queuedDependencyIds.delete(dependency.id!);
}
}
public removeQueuedCron(id: string) {
if (this.queuedCrons.has(id)) {
const runs = this.queuedCrons.get(id);
if (runs && runs.length > 0) {
runs.pop();
this.queuedCrons.set(id, runs);
}
}
}
public async setCustomLimit(limit?: number) {
if (limit) {
this.cronLimit.concurrency = limit;
this.manualCronoLimit.concurrency = limit;
return;
}
await SystemModel.sync();
const doc = await SystemModel.findOne({
where: { type: AuthDataType.systemConfig },
});
if (doc?.info?.cronConcurrency) {
this.cronLimit.concurrency = doc.info.cronConcurrency;
this.manualCronoLimit.concurrency = doc.info.cronConcurrency;
}
}
public async runWithCronLimit<T>(
cron: TCron,
fn: ICronFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.cron = cron;
let runs = this.queuedCrons.get(cron.id);
const result = runs?.length ? [...runs, fn] : [fn];
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
if (result?.length > 5) {
if (repeatTimes < 3) {
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
this.client.systemNotify(
{
title: '任务重复运行',
content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`,
},
(err, res) => {
if (err) {
Logger.error(
`[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`,
);
}
},
);
}
Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`);
return;
}
this.queuedCrons.set(cron.id, result);
return this.cronLimit.add(fn, options);
}
public async manualRunWithCronLimit<T>(
fn: () => Promise<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
return this.manualCronoLimit.add(fn, options);
}
public async runWithSubscriptionLimit<T>(
schedule: TCron,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.subscriptionLimit.add(fn, options);
}
public async runWithSystemLimit<T>(
schedule: TCron,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.systemLimit.add(fn, options);
}
public async runWithScriptLimit<T>(
schedule: ISchedule,
fn: IScheduleFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
fn.schedule = schedule;
return this.scriptLimit.add(fn, options);
}
public async waitDependencyQueueDone(): Promise<void> {
if (this.dependenyLimit.size === 0 && this.dependenyLimit.pending === 0) {
return;
}
return new Promise((resolve) => {
const onIdle = () => {
this.dependenyLimit.removeListener('idle', onIdle);
resolve();
};
this.dependenyLimit.on('idle', onIdle);
});
}
public runDependeny<T>(
dependency: Dependence,
fn: IDependencyFn<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
this.queuedDependencyIds.add(dependency.id!);
fn.dependency = dependency;
return this.dependenyLimit.add(fn, options);
}
public updateDepLog<T>(
fn: () => Promise<T>,
options?: Partial<QueueAddOptions>,
): Promise<T | void> {
return this.updateLogLimit.add(fn, options);
}
}
export default new TaskLimit();
+46
View File
@@ -0,0 +1,46 @@
import { spawn } from 'cross-spawn';
import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => {
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
command: cmd,
})}`,
);
const cp = spawn(cmd, { shell: '/bin/bash' });
cp.stderr.on('data', (data) => {
Logger.info(
'[schedule][执行任务失败] 命令: %s, 错误信息: %j',
cmd,
data.toString(),
);
});
cp.on('error', (err) => {
Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误信息: %j',
cmd,
err,
);
});
cp.on('exit', async (code) => {
taskLimit.removeQueuedCron(cron.id);
Logger.info(
'[schedule][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...cron,
command: cmd,
}),
code,
);
resolve({ ...cron, command: cmd, pid: cp.pid, code });
});
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import { AuthInfo } from '../data/system';
import { App } from '../data/open';
import Keyv from 'keyv';
import KeyvSqlite from '@keyv/sqlite';
import config from '../config';
import path from 'path';
export enum EKeyv {
'apps' = 'apps',
'authInfo' = 'authInfo',
}
export interface IKeyvStore {
apps: App[];
authInfo: AuthInfo;
}
const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite'));
export const keyvStore = new Keyv<IKeyvStore>({ store: keyvSqlite });
export const shareStore = {
getAuthInfo() {
return keyvStore.get<IKeyvStore['authInfo']>(EKeyv.authInfo);
},
updateAuthInfo(value: IKeyvStore['authInfo']) {
return keyvStore.set<IKeyvStore['authInfo']>(EKeyv.authInfo, value);
},
getApps() {
return keyvStore.get<IKeyvStore['apps']>(EKeyv.apps);
},
updateApps(apps: App[]) {
return keyvStore.set<IKeyvStore['apps']>(EKeyv.apps, apps);
},
};
+42
View File
@@ -0,0 +1,42 @@
import { lock } from 'proper-lockfile';
import os from 'os';
import path from 'path';
import { writeFile, open, chmod } from 'fs/promises';
import { fileExist } from '../config/util';
function getUniqueLockPath(filePath: string) {
const sanitizedPath = filePath
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/^_/, '');
return path.join(os.tmpdir(), `${sanitizedPath}.ql_lock`);
}
export async function writeFileWithLock(
filePath: string,
content: string,
options: Parameters<typeof writeFile>[2] = {},
) {
if (typeof options === 'string') {
options = { encoding: options };
}
if (!(await fileExist(filePath))) {
const fileHandle = await open(filePath, 'w');
fileHandle.close();
}
const lockfilePath = getUniqueLockPath(filePath);
const release = await lock(filePath, {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 3000,
},
lockfilePath,
});
await writeFile(filePath, content, { encoding: 'utf8', ...options });
if (options?.mode) {
await chmod(filePath, options.mode);
}
await release();
}
+2 -11
View File
@@ -2,10 +2,10 @@ import 'reflect-metadata';
import OpenService from './services/open'; import OpenService from './services/open';
import { Container } from 'typedi'; import { Container } from 'typedi';
import LoggerInstance from './loaders/logger'; import LoggerInstance from './loaders/logger';
import fs from 'fs';
import config from './config'; import config from './config';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { writeFileWithLock } from './shared/utils';
const tokenFile = path.join(config.configPath, 'token.json'); const tokenFile = path.join(config.configPath, 'token.json');
@@ -25,16 +25,7 @@ async function getToken() {
} }
async function writeFile(data: any) { async function writeFile(data: any) {
return new Promise<void>((resolve, reject) => { await writeFileWithLock(tokenFile, `${JSON.stringify(data)}${os.EOL}`);
fs.writeFile(
tokenFile,
`${JSON.stringify(data)}${os.EOL}`,
{ encoding: 'utf8' },
() => {
resolve();
},
);
});
} }
getToken(); getToken();
+9 -10
View File
@@ -1,28 +1,27 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2017", "target": "es2017",
"lib": ["es2021", "esnext.asynciterable", "DOM"], "lib": ["ESNext"],
"typeRoots": [ "typeRoots": [
"./node_modules/@types", "./types",
"./src/types", "../node_modules/celebrate/lib",
"./node_modules/celebrate/lib/index.d.ts" "../node_modules/@types"
], ],
"paths": {
"@/*": ["./back/*"],
},
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"moduleResolution": "node", "moduleResolution": "node",
"module": "commonjs", "module": "commonjs",
"pretty": true, "pretty": true,
"sourceMap": true, "sourceMap": true,
"outDir": "./static/build", "outDir": "../static/build",
"allowJs": true, "allowJs": true,
"noEmit": false, "noEmit": false,
"esModuleInterop": true "esModuleInterop": true
}, },
"include": ["./back/**/*"], "include": ["./**/*"],
"exclude": ["node_modules", "tests"] "exclude": ["node_modules"]
} }
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="express" />
export {};
declare global {
namespace Express {
interface Request {
platform: 'desktop' | 'mobile';
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import { Joi } from 'celebrate';
import cron_parser from 'cron-parser';
import { ScheduleType } from '../interface/schedule';
const validateSchedule = (value: string, helpers: any) => {
if (
value.startsWith(ScheduleType.ONCE) ||
value.startsWith(ScheduleType.BOOT)
) {
return value;
}
try {
if (cron_parser.parseExpression(value).hasNext()) {
return value;
}
} catch (e) {
return helpers.error('any.invalid');
}
return helpers.error('any.invalid');
};
export const scheduleSchema = Joi.string()
.required()
.custom(validateSchedule)
.messages({
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
});
export const commonCronSchema = {
name: Joi.string().optional(),
command: Joi.string().required(),
schedule: scheduleSchema,
labels: Joi.array().optional(),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
};
-13
View File
@@ -1,13 +0,0 @@
import { CommandModule } from 'yargs';
export const updateCommand: CommandModule = {
command: 'update',
describe: 'Update and restart qinglong',
builder: (yargs) => {
return yargs.option('repositority', {
type: 'string',
alias: 'r',
describe: `Specify the release warehouse address of the package`,
});
},
handler: async (argv) => {},
};
-13
View File
@@ -1,13 +0,0 @@
import * as yargs from 'yargs';
import { green, red } from 'chalk';
import { updateCommand } from './commands/update';
yargs
.usage('Usage: ql [command] <options>')
.command(updateCommand)
.fail((err) => {
console.error(`${red(err)}`);
})
.alias('h', 'help')
.showHelp()
.recommendCommands().argv;
+89
View File
@@ -0,0 +1,89 @@
FROM python:3.10-alpine3.18 AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \
&& apk update \
&& apk add nodejs npm git \
&& npm i -g pnpm@8.3.1 pm2 ts-node \
&& cd /tmp/build \
&& pnpm install --prod
FROM python:3.10-alpine
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.10
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /usr/local/bin/. /usr/local/bin/
RUN set -x \
&& apk update -f \
&& apk upgrade \
&& apk --no-cache add -f bash \
coreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
nginx \
nodejs \
jq \
openssh \
procps \
netcat-openbsd \
unzip \
npm \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@users.noreply.github.com" \
&& git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \
&& rm -rf /root/.cache \
&& ulimit -c 0
ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cd ${QL_DIR} \
&& cp -f .env.example .env \
&& chmod 777 ${QL_DIR}/shell/*.sh \
&& chmod 777 ${QL_DIR}/docker/*.sh \
&& git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \
&& mkdir -p ${QL_DIR}/static \
&& cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5600/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+31 -19
View File
@@ -1,35 +1,38 @@
FROM python:3.10-alpine as builder FROM python:3.11-alpine3.18 AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
RUN set -x \ RUN set -x \
&& apk update \ && apk update \
&& apk add nodejs npm git \ && apk add nodejs npm git \
&& npm i -g pnpm \ && npm i -g pnpm@8.3.1 pm2 ts-node \
&& cd /tmp/build \ && cd /tmp/build \
&& pnpm install --prod && pnpm install --prod
FROM python:3.10-alpine FROM python:3.11-alpine
ARG QL_MAINTAINER="whyour" ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}" LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
ARG QL_BRANCH=develop ARG QL_BRANCH=develop
ARG PYTHON_SHORT_VERSION=3.11
ENV PNPM_HOME=/root/.local/share/pnpm \ ENV QL_DIR=/ql \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/share/pnpm:/root/.local/share/pnpm/global/5/node_modules:$PNPM_HOME \ QL_BRANCH=${QL_BRANCH} \
NODE_PATH=/usr/local/bin:/usr/local/pnpm-global/5/node_modules:/usr/local/lib/node_modules:/root/.local/share/pnpm/global/5/node_modules \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
SHELL=/bin/bash \ SHELL=/bin/bash \
PS1="\u@\h:\w \$ " \ PS1="\u@\h:\w \$ "
QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} VOLUME /ql/data
EXPOSE 5700
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /usr/local/bin/. /usr/local/bin/
RUN set -x \ RUN set -x \
&& sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update -f \ && apk update -f \
&& apk upgrade \ && apk upgrade \
&& apk --no-cache add -f bash \ && apk --no-cache add -f bash \
coreutils \ coreutils \
moreutils \
git \ git \
curl \ curl \
wget \ wget \
@@ -41,20 +44,18 @@ RUN set -x \
jq \ jq \
openssh \ openssh \
procps \ procps \
netcat-openbsd \
unzip \
npm \ npm \
&& rm -rf /var/cache/apk/* \ && rm -rf /var/cache/apk/* \
&& apk update \ && apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \ && echo "Asia/Shanghai" > /etc/timezone \
&& git config --global user.email "qinglong@@users.noreply.github.com" \ && git config --global user.email "qinglong@users.noreply.github.com" \
&& git config --global user.name "qinglong" \ && git config --global user.name "qinglong" \
&& git config --global http.postBuffer 524288000 \ && git config --global http.postBuffer 524288000 \
&& npm install -g pnpm \
&& pnpm add -g pm2 tsx \
&& rm -rf /root/.pnpm-store \
&& rm -rf /root/.local/share/pnpm/store \
&& rm -rf /root/.cache \ && rm -rf /root/.cache \
&& rm -rf /root/.npm && ulimit -c 0
ARG SOURCE_COMMIT ARG SOURCE_COMMIT
RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
@@ -67,11 +68,22 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
&& cp -rf /static/* ${QL_DIR}/static \ && cp -rf /static/* ${QL_DIR}/static \
&& rm -rf /static && rm -rf /static
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
WORKDIR ${QL_DIR} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=10 \ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf http://127.0.0.1:5400/api/health || exit 1 CMD curl -sf --noproxy '*' http://127.0.0.1:5600/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"] ENTRYPOINT ["./docker/docker-entrypoint.sh"]

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