Compare commits

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

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

* Fix env variable edit validation error when labels is null

---------

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

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

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

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

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

* 过滤 delCron 中 scheduleStacks 的 null job

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

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

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

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

---------

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

* Fix HITOKOTO parameter type mismatch in notify scripts

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

* Refactor JavaScript HITOKOTO check for consistency with Python

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

---------

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

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

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

---------

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

* 增加 debian-slim 基础镜像

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

* 更新 npm 版本 0.7.7

* 更新 npm v0.8.4

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

* 修复拉取私有仓库

* 修复 shell check_server

* 修复 qinglong 命令

* 更新 npm 版本 v0.13.2

* 增加 debian 开发版本

* 修改切换 linux 镜像源

* 修复 qinglong 命令

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

* 修复 workflow

* 更新 npm 版本 v0.14.5

* 增加 npx 命令

* 更新 workflow action 版本

* 更新 npm 版本 v0.16.0

* 修复 linux 镜像源

* 更新 npm 版本 v0.17.0

* 更新 npm 版本 v0.18.0

* 修改 npm 安装启动命令

* 更新 npm 版本 v0.19.9

* 修复 debian netcat 包名

* 更新 npm 版本 v0.20.4

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

* 修改 apt 命令

* 更新 npm 版本 v0.21.2

* 修改 ts 文件执行依赖

* npm 启动增加 reload 逻辑

* 更新 npm 版本 v2.17.8

* 修复 qinglong 命令

* 更新 npm 版本 v2.17.9

* 更新 npm 版本 v2.17.10

* 更新 npm 版本 v2.17.11

* 修改 debian 版本为 12 bookworm

* 更新 npm 版本 v2.17.12

* 修改本地服务启动提示

* 更新 npm 版本 v2.17.13

* 写入文件增加文件锁

* 修复系统安装依赖提示

* 更新 npm 版本 v2.18.2-6

* 更新 nodejs 版本

* 更新 npm 版本 v2.18.3-3

* 修复 command 变量

* 移除自动清除 deb

* 修复 npm 启动脚本

* 修复发布 npm包依赖文件

* 修改 linux 启动文件逻辑

* 更新 npm 版本 v2.19.0-10

* 修复 apt 命令

* 更新 npm 版本 v2.19.1-0

* 更新 npm 版本 v2.19.2-2

* 增加 packageManager

* 增加用户 qinglong

* 更新 pipeline

* 移除 init_nginx

* 更新 npm 版本 v2.20.0

* 更新 npm 版本 2.20.1

* 更新 npm 版本 2.20.2

* fix: 修复非 root 用户启动

* chore: 合并 debian 和 alpine 逻辑

---------

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

* Fix CodeMirror multiple instances error by pinning to single versions

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

---------

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

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

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

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

* Add OpeniLink notification channel support

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

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

* Add context_token and hub_url to OpeniLink notification channel

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 16:01:36 +08:00
07bf0c705b fix: respect QlPort env var in Docker health check (#2963)
* Initial plan

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

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

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

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

---------

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

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

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

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

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

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

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

* Add QlBaseUrl support to backend routes

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

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

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

* Update websocket and frontend to support base URL

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

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

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

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

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

* Add clarifying comments and improve code readability

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

* Apply code review suggestions: improve clarity and simplify logic

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

* Simplify baseUrl implementation using URL rewrite

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 23:44:29 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
3db716763d Fix cron-parser v5 bundling incompatibility causing validation failures (#2877)
* Initial plan

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

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 23:43:54 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
fae226745e Add missing larkSecret field to gRPC NotificationInfo proto (#2880)
* Initial plan

* Add larkSecret field to NotificationInfo proto definition

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-22 23:38:42 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
9330650163 Fix TG_PROXY_AUTH concatenation in notify.js - add missing @ separator (#2882)
* Initial plan

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

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

* Apply prettier formatting to notify.js

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

---------

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

* Initial plan

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

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

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

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

---------

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

* Fix: Prevent Python3 dependencies from appearing in NodeJs tab

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-12-15 18:21:14 +08:00
whyour 33fa3aca99 更新版本 2.20.0 2025-12-11 01:53:17 +08:00
whyour c772fc9527 修复脚本调试保存文件错误 2025-12-11 01:52:47 +08:00
whyour c5d2aa3aba 更新 pipeline 2025-12-10 00:34:35 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
02a05f06bd Add signature verification support for Feishu bot notifications (#2856)
* Initial plan

* Add signature verification support for Feishu bot notifications

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

* Add clarifying comments about Feishu signature algorithm

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

* Add i18n translations for larkSecret configuration field

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

---------

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

* Fix null log_name handling in runSingle method

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

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

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

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

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

* Fix uniqPath calculation in runSingle for null log_name

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

* Improve comment clarity in makeCommand

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

* Refactor: Move no_tee and ID to initial commandVariable declaration

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

* Refactor: Simplify uniqPath ternary expression

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

---------

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

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

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

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-21 01:53:58 +08:00
CopilotGitHubwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
ee2fbe5335 Add global SSH key configuration in system settings (#2840)
* Initial plan

* Add backend support for global SSH keys

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

* Add frontend UI for global SSH keys management

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

* Add SshKeyModel to database initialization

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

* Add SSH config generation for global SSH keys

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

* Add internationalization support for SSH key management UI

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

* Simplify to single global SSH key in system settings

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

---------

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

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

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

* Address code review feedback - extract constants and utility functions

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

* Add validation and logging improvements based on code review

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

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

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

---------

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

* Stop running tasks before starting new scheduled instance

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

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

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

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

* Add UI support for allow_multiple_instances configuration

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

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

* Add allow_multiple_instances column migration and optimize db.ts

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

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

---------

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

* Implement LogStreamManager for optimized log writing

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

* Fix error handler in LogStreamManager to avoid race conditions

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

---------

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

* Add fallback mechanism for PM2 startup failures on incompatible hardware

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

---------

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

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

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

---------

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

* Fix env variable copy issue by using onCopy callback

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

---------

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

* Add non-root user guide and improve error messages

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

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

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

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

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

* Document symlink permission limitation for non-root users

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

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

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

* Add error handling for symlink creation to prevent worker crashes

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

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

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

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

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

* 修复链接 task 命令

---------

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

* Add cron management capabilities to QLAPI

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

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

* Add documentation for QLAPI cron management features

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

* Specify pnpm version 8.3.1 in package.json

Add packageManager field to enforce pnpm@8.3.1 as requested

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

* Revert pnpm-lock.yaml to original state

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

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

* Regenerate proto files with correct tool versions

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

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

---------

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

* Fix host mode port configuration by using QlPort environment variable

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

* Fix GRPC_PORT conflict in host network mode

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

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

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

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

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

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

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

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

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

---------

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

* Fix race condition causing scheduled tasks not to run

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

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

* Add timeout protection for gRPC worker initialization

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

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

* Refactor worker ready logic and improve restart handling

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

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

* Improve logging accuracy for worker restarts

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

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

* Revert unnecessary pnpm-lock.yaml changes

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

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

* Re-register cron jobs when gRPC worker restarts

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

* Add pin to top feature for environment variables

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

* Format code with prettier

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

* Add database migration for isPinned column in Envs table

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

* Use snake_case naming (is_pinned) for database column

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

---------

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

* Add log_name field to enable custom log folder naming

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

* Add database migration for log_name column

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

* Add security validation to prevent path traversal attacks

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

* Apply prettier formatting to modified files

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

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

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

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2025-11-09 19:32:40 +08:00
whyour 0e28e1b6c4 修复 pm2 日志目录 2025-11-09 17:52:38 +08:00
139 changed files with 19737 additions and 16293 deletions
+5
View File
@@ -1,6 +1,11 @@
GRPC_PORT=5500 GRPC_PORT=5500
BACK_PORT=5700 BACK_PORT=5700
# 服务绑定地址,默认 ::(IPv6 通配,双栈系统同时支持 IPv4/IPv6)
# 纯 IPv4 环境自动 fallback 到 0.0.0.0,也可手动指定
# BIND_HOST=0.0.0.0
# BIND_HOST_GRPC=0.0.0.0
LOG_LEVEL='info' LOG_LEVEL='info'
JWT_SECRET= JWT_SECRET=
+311 -109
View File
@@ -9,52 +9,71 @@ on:
- "develop" - "develop"
tags: tags:
- "v*" - "v*"
schedule:
- cron: "00 20 * * *"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
code_gitlab: code_gitlab:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - name: Push to GitLab
with: run: |
src: github/whyour set +e
dst: gitlab/whyour mkdir -p ~/.ssh
dst_key: ${{ secrets.GITLAB_SSH_PK }} printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
dst_token: ${{ secrets.GITLAB_TOKEN }} chmod 600 ~/.ssh/id_rsa
static_list: "qinglong" ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
force_update: true git remote add gitlab git@gitlab.com:whyour/qinglong.git 2>/dev/null
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
code_gitee: code_gitee:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: Yikun/hub-mirror-action@master - name: Push to Gitee
with: env:
src: github/whyour GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
dst: gitee/whyour run: |
dst_key: ${{ secrets.GITLAB_SSH_PK }} set +e
dst_token: ${{ secrets.GITEE_TOKEN }} mkdir -p ~/.ssh
static_list: "qinglong" printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
force_update: true chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee push --all succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
build-static: build-static:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v6
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: build front and back - name: build front and back
run: | run: |
@@ -66,9 +85,6 @@ jobs:
env: env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }} GITHUB_BRANCH: ${{ github.ref_name }}
REPO_GITEE: git@gitee.com:whyour/qinglong-static.git
REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git
PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: | run: |
mkdir -p tmp mkdir -p tmp
cd ./tmp cd ./tmp
@@ -78,70 +94,88 @@ jobs:
git config --local user.email 'github-actions[bot]@users.noreply.github.com' git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" git 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}
static_gitlab: static_gitlab:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Push qinglong-static to GitLab
with: run: |
fetch-depth: 0 set +e
- uses: Yikun/hub-mirror-action@master mkdir -p ~/.ssh
with: printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
src: github/whyour chmod 600 ~/.ssh/id_rsa
dst: gitlab/whyour ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
dst_key: ${{ secrets.GITLAB_SSH_PK }} git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
dst_token: ${{ secrets.GITLAB_TOKEN }} cd static-mirror
static_list: "qinglong-static" git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
force_update: true git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
static_gitee: static_gitee:
needs: build-static needs: build-static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Push qinglong-static to Gitee
with: env:
fetch-depth: 0 GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
- uses: Yikun/hub-mirror-action@master run: |
with: set +e
src: github/whyour mkdir -p ~/.ssh
dst: gitee/whyour printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
dst_key: ${{ secrets.GITLAB_SSH_PK }} chmod 600 ~/.ssh/id_rsa
dst_token: ${{ secrets.GITEE_TOKEN }} ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
static_list: "qinglong-static" git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
force_update: true cd static-mirror
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
build: if git push --force --all gitee 2>&1; then
echo "::notice::Gitee static push succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong-static","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static needs: build-static
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v6
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone - name: Setup timezone
uses: szenius/set-timezone@v2.0 run: sudo timedatectl set-timezone Asia/Shanghai
with:
timezoneLinux: Asia/Shanghai
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
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@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -149,115 +183,283 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
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
# nightly, master, pr-2, 1.2.3, 1.2, 1
flavor: | flavor: |
latest=false latest=false
tags: | tags: |
type=schedule,pattern=nightly type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=edge
type=ref,event=pr
type=ref,event=branch,enable=${{ github.ref != format('refs/heads/{0}', 'master') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }},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}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Build and push - name: Build and push (Alpine)
id: docker_build uses: docker/build-push-action@v7
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
# linux/s390x npm 暂不可用 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
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
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache cache-from: type=registry,ref=whyour/qinglong:cache-alpine
cache-to: type=registry,ref=whyour/qinglong:cache,mode=max cache-to: type=registry,ref=whyour/qinglong:cache-alpine,mode=max
- name: Image digest build-debian:
run: | if: ${{ !startsWith(github.ref, 'refs/tags/') }}
echo ${{ steps.docker_build.outputs.digest }}
build310:
if: ${{ github.ref_name == 'master' }}
needs: build-static needs: build-static
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v3 - uses: pnpm/action-setup@v6
with: with:
version: "8.3.1" version: "8.3.1"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
cache: "pnpm" cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone - name: Setup timezone
uses: szenius/set-timezone@v2.0 run: sudo timedatectl set-timezone Asia/Shanghai
with:
timezoneLinux: Asia/Shanghai
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
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@v3 uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
ghcr.io/${{ github.repository }}
flavor: |
latest=false
tags: |
type=raw,value=debian-dev,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }}
type=raw,value=debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=raw,value=${{ steps.version.outputs.version }}-debian,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Build and push python3.10 - name: Build and push (Alpine Python 3.10)
id: docker_build_310 uses: docker/build-push-action@v7
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
# linux/s390x npm 暂不可用 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386
context: . context: .
file: ./docker/310.Dockerfile file: ./docker/Dockerfile.310
push: true push: true
tags: whyour/qinglong:python3.10 provenance: mode=max
cache-from: type=registry,ref=whyour/qinglong:cache-python3.10 sbom: true
cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
- name: Image digest build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: | run: |
echo ${{ steps.docker_build_310.outputs.digest }} VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
# Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973).
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
publish:
if: ${{ github.ref_name == 'master' }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: publish npm package
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
npm publish
+7
View File
@@ -28,3 +28,10 @@ __pycache__
/shell/preload/notify.* /shell/preload/notify.*
/shell/preload/*-notify.json /shell/preload/*-notify.json
/shell/preload/__ql_notify__.* /shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
.deepseek/
.claude/
# local Kubernetes overlays
/deploy/kubernetes/overlays/local/
+22
View File
@@ -0,0 +1,22 @@
/.tmp/
/.github/
/.vscode/
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
+43
View File
@@ -0,0 +1,43 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **qinglong** (2778 symbols, 6698 relationships, 233 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "develop"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/qinglong/context` | Codebase overview, check index freshness |
| `gitnexus://repo/qinglong/clusters` | All functional areas |
| `gitnexus://repo/qinglong/processes` | All execution flows |
| `gitnexus://repo/qinglong/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+13
View File
@@ -41,11 +41,24 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image. The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image.
**⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges.
```bash ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
``` ```
When running the `debian` image as a non-root user, specify `--user qinglong`:
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm ### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed. The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
+13
View File
@@ -43,11 +43,24 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
`latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像 `latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像
**⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。
```bash ```bash
docker pull whyour/qinglong:latest docker pull whyour/qinglong:latest
docker pull whyour/qinglong:debian docker pull whyour/qinglong:debian
``` ```
使用 `debian` 镜像以非 root 用户运行时,需指定 `--user qinglong`
```bash
docker run -d \
-v /path/to/ql/data:/ql/data \
-p 5700:5700 \
--user qinglong \
--name qinglong \
whyour/qinglong:debian
```
### npm ### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm` npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
+19 -9
View File
@@ -4,8 +4,9 @@ import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { join } from 'path'; import { join, basename } from 'path';
import { SAMPLE_FILES } from '../config/const'; import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config'; import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
const route = Router(); const route = Router();
@@ -14,7 +15,7 @@ export default (app: Router) => {
app.use('/configs', route); app.use('/configs', route);
route.get( route.get(
'/sample', '/samples',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
res.send({ res.send({
@@ -71,15 +72,24 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const { name, content } = req.body; const { name, content } = req.body;
if (config.blackFileList.includes(name)) { // Resolve path first to prevent traversal attacks
res.send({ code: 403, message: '文件无法访问' }); let basePath = config.configPath;
}
let path = join(config.configPath, name);
if (name.startsWith('data/scripts/')) { if (name.startsWith('data/scripts/')) {
path = join(config.rootPath, name); basePath = join(config.rootPath, 'data/scripts');
} }
await writeFileWithLock(path, content); const cleanName = name.replace(/^data\/scripts\//, '');
res.send({ code: 200, message: '保存成功' }); const resolvedPath = join(basePath, cleanName);
const normalized = join(resolvedPath);
// Verify the resolved path stays within allowed directory
if (!normalized.startsWith(basePath)) {
return res.send({ code: 403, message: t('文件路径无效') });
}
// Check blacklist on actual filename (not user input)
if (config.blackFileList.includes(basename(normalized))) {
return res.send({ code: 403, message: t('文件无法访问') });
}
await writeFileWithLock(normalized, content);
res.send({ code: 200, message: t('保存成功') });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
+51 -3
View File
@@ -5,6 +5,11 @@ import CronService from '../services/cron';
import CronViewService from '../services/cronView'; import CronViewService from '../services/cronView';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { commonCronSchema } from '../validation/schedule'; import { commonCronSchema } from '../validation/schedule';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { t } from '../shared/i18n';
const route = Router(); const route = Router();
@@ -60,7 +65,7 @@ export default (app: Router) => {
try { try {
const cronViewService = Container.get(CronViewService); const cronViewService = Container.get(CronViewService);
if (req.body.type === 1) { if (req.body.type === 1) {
return res.send({ code: 400, message: '参数错误' }); return res.send({ code: 400, message: t('参数错误') });
} else { } else {
const data = await cronViewService.update(req.body); const data = await cronViewService.update(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
@@ -307,8 +312,8 @@ 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.log(req.params.id); const result = await cronService.log(req.params.id);
return res.send({ code: 200, data }); return res.send({ code: 200, data: result.content, logStatus: result.status });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -429,6 +434,7 @@ export default (app: Router) => {
log_path: Joi.string().optional().allow(null), log_path: Joi.string().optional().allow(null),
last_running_time: Joi.number().optional().allow(null), last_running_time: Joi.number().optional().allow(null),
last_execution_time: Joi.number().optional().allow(null), last_execution_time: Joi.number().optional().allow(null),
exit_code: Joi.number().optional().allow(null),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -446,6 +452,48 @@ export default (app: Router) => {
}, },
); );
route.get(
'/:id/instances',
celebrate({
params: Joi.object({
id: Joi.number().required(),
}),
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const instances = await RunningInstanceModel.findAll({
where: {
cron_id: req.params.id,
},
order: [['started_at', 'DESC']],
raw: true,
});
return res.send({ code: 200, data: instances });
} catch (e) {
return next(e);
}
},
);
route.post(
'/:id/instances/:instanceId/stop',
celebrate({
params: Joi.object({
id: Joi.number().required(),
instanceId: Joi.number().required(),
}),
}),
async (req: Request<{ id: number; instanceId: number }>, res: Response, next: NextFunction) => {
try {
const cronService = Container.get(CronService);
const data = await cronService.stopInstance(req.params.instanceId);
return res.send(data);
} catch (e) {
return next(e);
}
},
);
route.get( route.get(
'/:id/logs', '/:id/logs',
celebrate({ celebrate({
+397
View File
@@ -0,0 +1,397 @@
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { fn, col, where, Op } from 'sequelize';
import { CrontabModel } from '../data/cron';
import { CrontabStatModel } from '../data/cronStats';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs';
import os from 'os';
import { isEmpty } from 'lodash';
import { t, tf } from '../shared/i18n';
const route = Router();
export default (app: Router) => {
app.use('/dashboard', route);
route.post('/record', async (req: Request, res: Response) => {
try {
const { ref_id, code, elapsed } = req.body;
if (!ref_id) return res.send({ code: 400, message: 'ref_id required' });
const today = dayjs().format('YYYY-MM-DD');
const isSuccess = code === 0 ? 1 : 0;
const isFail = code !== 0 ? 1 : 0;
const elapsedMs = (Number(elapsed) || 0) * 1000;
const existing = await CrontabStatModel.findOne({
where: { ref_id: Number(ref_id), date: today },
});
if (existing) {
await CrontabStatModel.update(
{
run_count: (existing.run_count || 0) + 1,
success_count: (existing.success_count || 0) + isSuccess,
fail_count: (existing.fail_count || 0) + isFail,
total_time: (existing.total_time || 0) + elapsedMs,
max_time: Math.max(existing.max_time || 0, elapsedMs),
},
{ where: { id: existing.id } },
);
} else {
await CrontabStatModel.create({
ref_id: Number(ref_id),
date: today,
run_count: 1,
success_count: isSuccess,
fail_count: isFail,
total_time: elapsedMs,
max_time: elapsedMs,
});
}
res.send({ code: 200 });
} catch (e) {
res.send({ code: 500 });
}
});
route.get(
'/overview',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [total, enabled, disabled, stats] = await Promise.all([
CrontabModel.count(),
CrontabModel.count({ where: { isDisabled: 0 } }),
CrontabModel.count({ where: { isDisabled: 1 } }),
CrontabStatModel.findOne({
attributes: [
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
[fn('SUM', col('total_time')), 'total_time'],
],
where: { date: today },
raw: true,
}),
]);
const row = stats as any;
const totalRuns = Number(row?.total_runs) || 0;
const totalSuccess = Number(row?.total_success) || 0;
const totalFail = Number(row?.total_fail) || 0;
const totalTime = Number(row?.total_time) || 0;
res.send({
code: 200,
data: {
total,
enabled,
disabled,
todayRuns: totalRuns,
todaySuccess: totalSuccess,
todayFail: totalFail,
successRate: totalRuns > 0 ? ((totalSuccess / totalRuns) * 100).toFixed(1) : '0',
avgTime: totalRuns > 0 ? Math.round(totalTime / totalRuns) : 0,
},
});
} catch (e) {
next(e);
}
},
);
route.get(
'/trend',
async (req: Request, res: Response, next: NextFunction) => {
try {
const days = parseInt(req.query.days as string) || 7;
const dates: string[] = [];
for (let i = days - 1; i >= 0; i--) {
dates.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD'));
}
const rows = (await CrontabStatModel.findAll({
attributes: [
'date',
[fn('SUM', col('run_count')), 'total_runs'],
[fn('SUM', col('success_count')), 'total_success'],
[fn('SUM', col('fail_count')), 'total_fail'],
],
where: {
date: { [Op.in]: dates },
},
group: ['date'],
order: [['date', 'ASC']],
raw: true,
})) as any[];
const dataMap: Record<string, any> = {};
rows.forEach((r: any) => {
dataMap[r.date] = {
total: Number(r.total_runs) || 0,
success: Number(r.total_success) || 0,
fail: Number(r.total_fail) || 0,
};
});
const data = dates.map((d) => ({
date: dayjs(d).format('MM-DD'),
...(dataMap[d] || { total: 0, success: 0, fail: 0 }),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-time',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('run_count')), 'run_count'],
[fn('MAX', col('max_time')), 'max_time'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('total_time')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
maxTime: Number(r.max_time),
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/top-count',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const rows = (await CrontabStatModel.findAll({
attributes: [
'ref_id',
[fn('SUM', col('run_count')), 'run_count'],
[fn('SUM', col('total_time')), 'total_time'],
[fn('SUM', col('success_count')), 'success_count'],
],
where: { date: today, run_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('run_count')), 'DESC']],
limit: 5,
raw: true,
})) as any[];
const ids = rows.map((r) => Number(r.ref_id));
const crons = await CrontabModel.findAll({
where: { id: { [Op.in]: ids } },
raw: true,
});
const nameMap: Record<number, string> = {};
crons.forEach((c: any) => { nameMap[c.id] = c.name || c.command; });
const data = rows.map((r: any, i) => ({
rank: i + 1,
name: nameMap[Number(r.ref_id)] || tf('任务#%s', r.ref_id),
runCount: Number(r.run_count),
avgTime: Math.round(Number(r.total_time) / Number(r.run_count)),
successRate:
Number(r.run_count) > 0
? ((Number(r.success_count) / Number(r.run_count)) * 100).toFixed(1)
: '0',
}));
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/runtime',
async (req: Request, res: Response, next: NextFunction) => {
try {
const runningInstances = await RunningInstanceModel.findAll({
where: {
status: InstanceStatus.running,
},
raw: true,
});
const queuedCrons = await CrontabModel.findAll({
where: {
status: 3, // queued
},
raw: true,
});
// Fetch cron names for running instances
const cronIds = [
...new Set(runningInstances.map((i: any) => i.cron_id)),
];
const crons =
cronIds.length > 0
? await CrontabModel.findAll({
where: { id: cronIds },
raw: true,
})
: [];
const cronMap = new Map(crons.map((c: any) => [c.id, c]));
const now = dayjs().unix();
const running = runningInstances.map((inst: any) => {
const cron = cronMap.get(inst.cron_id);
return {
instanceId: inst.id,
id: inst.cron_id,
name: cron?.name || cron?.command || tf('任务#%s', inst.cron_id),
pid: inst.pid,
elapsed: inst.started_at ? now - inst.started_at : 0,
logPath: inst.log_path,
};
});
const dayAgo = dayjs().subtract(24, 'hour').unix();
const idleTasks = await CrontabModel.findAll({
where: {
isDisabled: 0,
status: 1,
last_execution_time: { [Op.lt]: dayAgo },
},
order: [['last_execution_time', 'ASC']],
limit: 5,
raw: true,
});
res.send({
code: 200,
data: {
runningCount: running.length,
queuedCount: queuedCrons.length,
running,
idleTasks: idleTasks.map((c: any) => ({
id: c.id,
name: c.name || c.command || tf('任务#%s', c.id),
lastRun: c.last_execution_time
? dayjs.unix(c.last_execution_time).format('MM-DD HH:mm')
: '-',
})),
},
});
} catch (e) {
next(e);
}
},
);
route.get(
'/labels',
async (req: Request, res: Response, next: NextFunction) => {
try {
const today = dayjs().format('YYYY-MM-DD');
const [crons, stats] = (await Promise.all([
CrontabModel.findAll({ where: { isDisabled: 0 }, raw: true }),
CrontabStatModel.findAll({ where: { date: today }, raw: true }),
]));
const statMap: Record<number, any> = {};
stats.forEach((s: any) => { statMap[s.ref_id] = s; });
const labelMap: Record<string, { count: number; runs: number; success: number; totalTime: number }> = {};
crons.forEach((c) => {
let rawLabels = c.labels;
if (typeof rawLabels === 'string') rawLabels = JSON.parse(rawLabels);
const labels: string[] = Array.isArray(rawLabels)
? [...new Set((rawLabels as string[]).filter((l: string) => !isEmpty(l)))]
: [];
if (labels.length === 0) {
labels.push(t('未分类'));
}
const st = statMap[c.id!];
labels.forEach((label: string) => {
if (!labelMap[label]) labelMap[label] = { count: 0, runs: 0, success: 0, totalTime: 0 };
labelMap[label].count += 1;
if (st) {
labelMap[label].runs += Number(st.run_count) || 0;
labelMap[label].success += Number(st.success_count) || 0;
labelMap[label].totalTime += Number(st.total_time) || 0;
}
});
});
const data = Object.entries(labelMap)
.map(([label, v]) => ({
label,
count: v.count,
todayRuns: v.runs,
successRate: v.runs > 0 ? ((v.success / v.runs) * 100).toFixed(1) : '0',
avgTime: v.runs > 0 ? Math.round(v.totalTime / v.runs) : 0,
}))
.sort((a, b) => b.todayRuns - a.todayRuns);
res.send({ code: 200, data });
} catch (e) {
next(e);
}
},
);
route.get(
'/system',
async (req: Request, res: Response, next: NextFunction) => {
try {
const memUsage = process.memoryUsage();
res.send({
code: 200,
data: {
platform: os.platform(),
uptime: Math.floor(process.uptime()),
memTotal: os.totalmem(),
memFree: os.freemem(),
memUsagePercent: ((1 - os.freemem() / os.totalmem()) * 100).toFixed(1),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
loadAvg: os.loadavg().map((v) => Number(v.toFixed(2))),
cpus: os.cpus().length,
},
});
} catch (e) {
next(e);
}
},
);
};
+1 -1
View File
@@ -16,7 +16,7 @@ export default (app: Router) => {
searchValue: Joi.string().optional().allow(''), searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''), type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''), status: Joi.string().optional().allow(''),
}), }).unknown(true),
}), }),
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');
+89 -9
View File
@@ -1,12 +1,13 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Joi, celebrate } from 'celebrate';
import { Container } from 'typedi'; import { NextFunction, Request, Response, Router } from 'express';
import EnvService from '../services/env';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import multer from 'multer';
import config from '../config';
import fs from 'fs'; import fs from 'fs';
import multer from 'multer';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util'; import { safeJSONParse } from '../config/util';
import { t } from '../shared/i18n';
import EnvService from '../services/env';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -18,6 +19,10 @@ const storage = multer.diskStorage({
}, },
}); });
const upload = multer({ storage: storage }); const upload = multer({ storage: storage });
const labelSchema = Joi.array()
.items(Joi.string().trim().required())
.min(1)
.required();
export default (app: Router) => { export default (app: Router) => {
app.use('/envs', route); app.use('/envs', route);
@@ -44,6 +49,7 @@ export default (app: Router) => {
.required() .required()
.pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/), .pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/),
remarks: Joi.string().optional().allow(''), remarks: Joi.string().optional().allow(''),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}), }),
), ),
}), }),
@@ -52,7 +58,7 @@ export default (app: Router) => {
try { try {
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
if (!req.body?.length) { if (!req.body?.length) {
return res.send({ code: 400, message: '参数不正确' }); return res.send({ code: 400, message: t('参数不正确') });
} }
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 });
@@ -70,6 +76,7 @@ export default (app: Router) => {
name: Joi.string().required(), name: Joi.string().required(),
remarks: Joi.string().optional().allow('').allow(null), remarks: Joi.string().optional().allow('').allow(null),
id: Joi.number().required(), id: Joi.number().required(),
labels: Joi.array().items(Joi.string().trim()).optional().allow(null),
}), }),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -196,6 +203,78 @@ export default (app: Router) => {
}, },
); );
route.put(
'/pin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.pin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.put(
'/unpin',
celebrate({
body: Joi.array().items(Joi.number().required()),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const data = await envService.unPin(req.body);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.addLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/labels',
celebrate({
body: Joi.object({
ids: Joi.array().items(Joi.number().required()).min(1).required(),
labels: labelSchema,
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const envService = Container.get(EnvService);
const data = await envService.removeLabels(req.body.ids, req.body.labels);
return res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.post( route.post(
'/upload', '/upload',
upload.single('env'), upload.single('env'),
@@ -214,13 +293,14 @@ export default (app: Router) => {
name: x.name, name: x.name,
value: x.value, value: x.value,
remarks: x.remarks, remarks: x.remarks,
labels: x.labels,
})), })),
); );
return res.send({ code: 200, data: result }); return res.send({ code: 200, data: result });
} else { } else {
return res.send({ return res.send({
code: 400, code: 400,
message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式', message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'),
}); });
} }
} catch (e) { } catch (e) {
+2
View File
@@ -10,6 +10,7 @@ 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 update from './update';
import dashboard from './dashboard';
import health from './health'; import health from './health';
export default () => { export default () => {
@@ -25,6 +26,7 @@ export default () => {
system(app); system(app);
subscription(app); subscription(app);
update(app); update(app);
dashboard(app);
health(app); health(app);
return app; return app;
+16 -5
View File
@@ -3,6 +3,7 @@ import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import { t } from '../shared/i18n';
import { import {
getFileContentByName, getFileContentByName,
readDirs, readDirs,
@@ -10,6 +11,7 @@ import {
rmPath, rmPath,
} from '../config/util'; } from '../config/util';
import LogService from '../services/log'; import LogService from '../services/log';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
const route = Router(); const route = Router();
const blacklist = ['.tmp']; const blacklist = ['.tmp'];
@@ -42,11 +44,20 @@ export default (app: Router) => {
if (!finalPath || blacklist.includes(req.query.path as string)) { if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
const logPath = `${req.query.path as string}/${req.query.file as string}`;
const runningInstance = await RunningInstanceModel.findOne({
where: { log_path: logPath, status: InstanceStatus.running },
});
const content = await getFileContentByName(finalPath); const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: removeAnsi(content) }); res.send({
code: 200,
data: removeAnsi(content),
logStatus: runningInstance ? 'running' : undefined,
});
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -65,7 +76,7 @@ export default (app: Router) => {
if (!finalPath || blacklist.includes(req.query.path as string)) { if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
const content = await getFileContentByName(finalPath); const content = await getFileContentByName(finalPath);
@@ -96,7 +107,7 @@ export default (app: Router) => {
if (!finalPath || blacklist.includes(path)) { if (!finalPath || blacklist.includes(path)) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await rmPath(finalPath); await rmPath(finalPath);
@@ -126,7 +137,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {
+40 -15
View File
@@ -7,10 +7,16 @@ 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 { t } from '../shared/i18n';
import multer from 'multer'; import multer from 'multer';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
const route = Router(); const route = Router();
function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return config.writePathList.some((x) => resolved.startsWith(x));
}
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: function (req, file, cb) { destination: function (req, file, cb) {
cb(null, config.scriptPath); cb(null, config.scriptPath);
@@ -29,7 +35,7 @@ export default (app: Router) => {
celebrate({ celebrate({
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
}), }).unknown(true),
}), }),
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');
@@ -79,7 +85,7 @@ export default (app: Router) => {
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
file: Joi.string().required(), file: Joi.string().required(),
}), }).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -103,7 +109,7 @@ export default (app: Router) => {
}), }),
query: Joi.object({ query: Joi.object({
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
}), }).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -129,7 +135,8 @@ export default (app: Router) => {
content: Joi.string().optional().allow(''), content: Joi.string().optional().allow(''),
originFilename: Joi.string().optional().allow(''), originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''),
}), file: Joi.string().optional().allow(''),
}).unknown(true),
}), }),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@@ -154,28 +161,37 @@ export default (app: Router) => {
if (config.writePathList.every((x) => !path.startsWith(x))) { if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
if (req.file) { if (req.file) {
await fs.rename(req.file.path, join(path, filename)); const uploadPath = join(path, filename);
if (!isPathAllowed(uploadPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(req.file.path, uploadPath);
return res.send({ code: 200 }); return res.send({ code: 200 });
} }
if (directory) { if (directory) {
await fs.mkdir(join(path, directory), { recursive: true }); const dirPath = join(path, directory);
if (!isPathAllowed(dirPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(dirPath, { recursive: true });
return res.send({ code: 200 }); return res.send({ code: 200 });
} }
if (!originFilename) { if (!originFilename) {
originFilename = filename; originFilename = filename;
} }
const originFilePath = join( const originFilePath = join(path, originFilename);
path, const filePath = join(path, filename);
`${originFilename.replace(/\//g, '')}`, if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
); return res.send({ code: 403, message: t('暂无权限') });
const filePath = join(path, `${filename.replace(/\//g, '')}`); }
await fs.mkdir(path, { recursive: true });
const fileExists = await fileExist(filePath); const fileExists = await fileExist(filePath);
if (fileExists) { if (fileExists) {
await fs.copyFile( await fs.copyFile(
@@ -215,7 +231,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await writeFileWithLock(filePath, content); await writeFileWithLock(filePath, content);
@@ -249,7 +265,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await rmPath(filePath); await rmPath(filePath);
@@ -282,7 +298,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {
@@ -314,6 +330,9 @@ export default (app: Router) => {
} }
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}`);
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await writeFileWithLock(filePath, content || ''); await writeFileWithLock(filePath, content || '');
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
@@ -342,6 +361,9 @@ export default (app: Router) => {
} }
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}`);
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
const logPath = join(config.logPath, path, `${name}.swap`); const logPath = join(config.logPath, path, `${name}.swap`);
const scriptService = Container.get(ScriptService); const scriptService = Container.get(ScriptService);
@@ -377,6 +399,9 @@ 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);
if (!isPathAllowed(filePath) || !isPathAllowed(newPath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.rename(filePath, newPath); await fs.rename(filePath, newPath);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
+3 -3
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import cron_parser from 'cron-parser'; import CronExpressionParser from 'cron-parser';
const route = Router(); const route = Router();
export default (app: Router) => { export default (app: Router) => {
@@ -60,7 +60,7 @@ export default (app: Router) => {
try { try {
if ( if (
!req.body.schedule || !req.body.schedule ||
cron_parser.parseExpression(req.body.schedule).hasNext() CronExpressionParser.parse(req.body.schedule).hasNext()
) { ) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body); const data = await subscriptionService.create(req.body);
@@ -193,7 +193,7 @@ export default (app: Router) => {
if ( if (
!req.body.schedule || !req.body.schedule ||
typeof req.body.schedule === 'object' || typeof req.body.schedule === 'object' ||
cron_parser.parseExpression(req.body.schedule).hasNext() CronExpressionParser.parse(req.body.schedule).hasNext()
) { ) {
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body); const data = await subscriptionService.update(req.body);
+67 -12
View File
@@ -6,6 +6,8 @@ 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 { t } from '../shared/i18n';
import { isDefaultAuthInfo } from '../shared/auth';
import { import {
getUniqPath, getUniqPath,
handleLogPath, handleLogPath,
@@ -14,6 +16,7 @@ import {
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import multer from 'multer'; import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -37,14 +40,7 @@ export default (app: Router) => {
const { version, changeLog, changeLogLink, publishTime } = const { version, changeLog, changeLogLink, publishTime } =
await parseVersion(config.versionFile); await parseVersion(config.versionFile);
let isInitialized = true; const isInitialized = !isDefaultAuthInfo(authInfo);
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
res.send({ res.send({
code: 200, code: 200,
data: { data: {
@@ -276,17 +272,19 @@ export default (app: Router) => {
res.setHeader('QL-Task-Log', `${logPath}`); res.setHeader('QL-Task-Log', `${logPath}`);
}, },
onEnd: async (cp, endTime, diff) => { onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end(); res.end();
}, },
onError: async (message: string) => { onError: async (message: string) => {
res.write(message); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, message); await logStreamManager.write(absolutePath, message);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
res.write(message); res.write(message);
const absolutePath = await handleLogPath(logPath); const absolutePath = await handleLogPath(logPath);
await fs.appendFile(absolutePath, message); await logStreamManager.write(absolutePath, message);
}, },
}, },
); );
@@ -397,8 +395,11 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
await userService.resetAuthInfo(req.body); const result = await userService.resetAuthInfo(req.body);
res.send({ code: 200, message: '更新成功' }); if (result) {
return res.send(result);
}
res.send({ code: 200, message: t('更新成功') });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -423,6 +424,60 @@ export default (app: Router) => {
}, },
); );
route.put(
'/config/lang',
celebrate({
body: Joi.object({
lang: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateLanguage(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/panel-title',
celebrate({
body: Joi.object({
panelTitle: Joi.string().max(100).allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updatePanelTitle(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put(
'/config/global-ssh-key',
celebrate({
body: Joi.object({
globalSshKey: Joi.string().allow('').allow(null),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const result = await systemService.updateGlobalSshKey(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put( route.put(
'/config/dependence-clean', '/config/dependence-clean',
celebrate({ celebrate({
+9 -7
View File
@@ -8,7 +8,8 @@ import path from 'path';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import config from '../config'; import config from '../config';
import { isDemoEnv } from '../config/util'; import { t } from '../shared/i18n';
import { isDemoEnv, getToken } from '../config/util';
const route = Router(); const route = Router();
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -56,7 +57,8 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger'); const logger: Logger = Container.get('logger');
try { try {
const userService = Container.get(UserService); const userService = Container.get(UserService);
await userService.logout(req.platform); const token = getToken(req);
await userService.logout(req.platform, token);
res.send({ code: 200 }); res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -75,11 +77,11 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
if (isDemoEnv()) { if (isDemoEnv()) {
return res.send({ code: 450, message: '未知错误' }); return res.send({ code: 450, message: t('未知错误') });
} }
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: t('更新成功') });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
@@ -139,12 +141,12 @@ export default (app: Router) => {
); );
route.put( route.put(
'/two-factor/deactive', '/two-factor/deactivate',
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 userService = Container.get(UserService); const userService = Container.get(UserService);
const data = await userService.deactiveTwoFactor(); const data = await userService.deactivateTwoFactor();
res.send({ code: 200, data }); res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return next(e);
@@ -228,7 +230,7 @@ export default (app: Router) => {
try { try {
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: t('更新成功') });
} catch (e) { } catch (e) {
return next(e); return next(e);
} }
+84 -18
View File
@@ -8,6 +8,7 @@ import { Container } from 'typedi';
import config from './config'; import config from './config';
import Logger from './loaders/logger'; import Logger from './loaders/logger';
import { monitoringMiddleware } from './middlewares/monitoring'; import { monitoringMiddleware } from './middlewares/monitoring';
import { errStack } from './config/util';
import { type GrpcServerService } from './services/grpc'; import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http'; import { type HttpServerService } from './services/http';
@@ -24,6 +25,7 @@ class Application {
private grpcServerService?: GrpcServerService; private grpcServerService?: GrpcServerService;
private isShuttingDown = false; private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>(); private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() { constructor() {
this.app = express(); this.app = express();
@@ -47,14 +49,25 @@ class Application {
await this.startWorkerProcess(); await this.startWorkerProcess();
} }
} catch (error) { } catch (error) {
Logger.error('Failed to start application:', error); Logger.error(`Failed to start application:\n${errStack(error)}`);
process.exit(1); process.exit(1);
} }
} }
private startMasterProcess() { private startMasterProcess() {
this.forkWorker('http'); // Fork gRPC worker first and wait for it to be ready
this.forkWorker('grpc'); const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
.then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
})
.catch((error) => {
Logger.error(`[boot] Failed to wait for gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
cluster.on('exit', (worker, code, signal) => { cluster.on('exit', (worker, code, signal) => {
const metadata = this.workerMetadataMap.get(worker.id); const metadata = this.workerMetadataMap.get(worker.id);
@@ -64,10 +77,32 @@ class Application {
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code `${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
}). Restarting...`, }). Restarting...`,
); );
const newWorker = this.forkWorker(metadata.serviceType); // If gRPC worker died, restart it and wait for it to be ready
Logger.info( if (metadata.serviceType === 'grpc') {
`Restarted ${metadata.serviceType} worker (New PID: ${newWorker.process.pid})`, const newGrpcWorker = this.forkWorker('grpc');
); this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
if (this.httpWorker) {
try {
this.httpWorker.send('reregister-crons');
Logger.info('Sent reregister-crons message to HTTP worker');
} catch (error) {
Logger.error(`Failed to send reregister-crons message:\n${errStack(error)}`);
}
}
})
.catch((error) => {
Logger.error(`Failed to restart gRPC worker:\n${errStack(error)}`);
process.exit(1);
});
} else {
// For HTTP worker, just restart it
const newWorker = this.forkWorker(metadata.serviceType);
this.httpWorker = newWorker;
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
}
} }
this.workerMetadataMap.delete(worker.id); this.workerMetadataMap.delete(worker.id);
@@ -77,6 +112,25 @@ class Application {
this.setupMasterShutdown(); this.setupMasterShutdown();
} }
private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const messageHandler = (msg: any) => {
if (msg === 'ready') {
worker.removeListener('message', messageHandler);
clearTimeout(timeoutId);
resolve();
}
};
worker.on('message', messageHandler);
// Timeout after specified milliseconds
const timeoutId = setTimeout(() => {
worker.removeListener('message', messageHandler);
reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`));
}, timeoutMs);
});
}
private forkWorker(serviceType: string): Worker { private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType }); const worker = cluster.fork({ SERVICE_TYPE: serviceType });
@@ -123,10 +177,7 @@ class Application {
try { try {
worker.send('shutdown'); worker.send('shutdown');
} catch (error) { } catch (error) {
Logger.warn( Logger.warn(`Failed to send shutdown to worker ${worker.process.pid}:\n${errStack(error)}`);
`Failed to send shutdown to worker ${worker.process.pid}:`,
error,
);
} }
}); });
@@ -146,7 +197,7 @@ class Application {
]); ]);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error('Error during worker shutdown:', error); Logger.error(`Error during worker shutdown:\n${errStack(error)}`);
process.exit(1); process.exit(1);
} }
}; };
@@ -158,11 +209,11 @@ class Application {
private async startWorkerProcess() { private async startWorkerProcess() {
const serviceType = process.env.SERVICE_TYPE; const serviceType = process.env.SERVICE_TYPE;
if (!serviceType || !['http', 'grpc'].includes(serviceType)) { if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
Logger.error('Invalid SERVICE_TYPE:', serviceType); Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
process.exit(1); process.exit(1);
} }
Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`); Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
try { try {
if (serviceType === 'http') { if (serviceType === 'http') {
@@ -173,12 +224,16 @@ class Application {
process.send?.('ready'); process.send?.('ready');
} catch (error) { } catch (error) {
Logger.error(`${serviceType} worker failed:`, error); Logger.error(`[boot] ${serviceType} worker failed:\n${errStack(error)}`);
process.exit(1); process.exit(1);
} }
} }
private async startHttpService() { private async startHttpService() {
// 在导入任何 gRPC 客户端模块之前初始化 mTLS 证书
const { initGrpcCerts } = await import('./config/grpcCerts');
await initGrpcCerts();
this.setupMiddlewares(); this.setupMiddlewares();
const { HttpServerService } = await import('./services/http'); const { HttpServerService } = await import('./services/http');
@@ -206,9 +261,20 @@ class Application {
} }
private setupWorkerShutdown(serviceType: string) { private setupWorkerShutdown(serviceType: string) {
process.on('message', (msg) => { process.on('message', async (msg) => {
if (msg === 'shutdown') { if (msg === 'shutdown') {
this.gracefulShutdown(serviceType); this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('[boot] Cron jobs re-registered successfully');
} catch (error) {
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
}
} }
}); });
@@ -229,7 +295,7 @@ class Application {
} }
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
Logger.error(`[${serviceType}] Error during shutdown:`, error); Logger.error(`[${serviceType}] Error during shutdown:\n${errStack(error)}`);
process.exit(1); process.exit(1);
} }
} }
@@ -237,6 +303,6 @@ class Application {
const app = new Application(); const app = new Application();
app.start().catch((error) => { app.start().catch((error) => {
Logger.error('Application failed to start:', error); Logger.error(`🙅‍♀️ Application failed to start:\n${errStack(error)}`);
process.exit(1); process.exit(1);
}); });
+38
View File
@@ -1,3 +1,5 @@
import { maybeSudo } from './container';
export const LOG_END_SYMBOL = '     '; export const LOG_END_SYMBOL = '     ';
export const TASK_COMMAND = 'task'; export const TASK_COMMAND = 'task';
@@ -48,4 +50,40 @@ export const NotificationModeStringMap = {
18: 'chronocat', 18: 'chronocat',
19: 'ntfy', 19: 'ntfy',
20: 'wxPusherBot', 20: 'wxPusherBot',
21: 'wxPusherSpt',
} as const; } as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
'Debian' | 'Ubuntu' | 'Alpine',
{
install: string;
uninstall: string;
info: string;
check(info: string): boolean;
}
> = {
Debian: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Ubuntu: {
install: maybeSudo('apt-get install -y'),
uninstall: maybeSudo('apt-get remove -y'),
info: maybeSudo('dpkg-query -s'),
check(info: string) {
return info.includes('install ok installed');
},
},
Alpine: {
install: 'apk add --no-check-certificate',
uninstall: 'apk del',
info: 'apk info -es',
check(info: string) {
return info.includes('installed');
},
},
};
+7
View File
@@ -0,0 +1,7 @@
export function isInContainer(): boolean {
return process.env.QL_CONTAINER === 'true';
}
export function maybeSudo(cmd: string): string {
return isInContainer() ? `sudo ${cmd}` : cmd;
}
+150
View File
@@ -0,0 +1,150 @@
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as os from 'os';
import path from 'path';
import config from './index';
import { fileExist } from './util';
import Logger from '../loaders/logger';
export interface GrpcTlsConfig {
caCert: string;
serverCert: string;
serverKey: string;
clientCert: string;
clientKey: string;
}
const certDir = path.join(config.configPath, 'grpc');
const caKeyPath = path.join(certDir, 'ca.key');
const caCertPath = path.join(certDir, 'ca.crt');
const serverKeyPath = path.join(certDir, 'server.key');
const serverCertPath = path.join(certDir, 'server.crt');
const clientKeyPath = path.join(certDir, 'client.key');
const clientCertPath = path.join(certDir, 'client.crt');
let cachedConfig: GrpcTlsConfig | null = null;
function run(cmd: string, execOpts?: Record<string, unknown>): string {
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
return (execSync(cmd, opts) as string).trim();
}
async function tmpFile(prefix: string): Promise<string> {
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
await fs.mkdir(dir, { recursive: true });
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
}
async function generateAllCerts(): Promise<GrpcTlsConfig> {
Logger.info('[boot] Generating gRPC mTLS certificates...');
const caKeyTmp = await tmpFile('ca_key');
const caCertTmp = await tmpFile('ca_cert');
const serverKeyTmp = await tmpFile('server_key');
const serverCsrTmp = await tmpFile('server_csr');
const serverExtTmp = await tmpFile('server_ext');
const clientKeyTmp = await tmpFile('client_key');
const clientCsrTmp = await tmpFile('client_csr');
const clientExtTmp = await tmpFile('client_ext');
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
const cleanup = async () => {
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
try { await fs.unlink(f); } catch {}
}
};
try {
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
const caCert = await fs.readFile(caCertTmp, 'utf-8');
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
// 2. 服务端
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
const serverCert = run(
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
);
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
// 3. 客户端
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
const clientCert = run(
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
);
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
await cleanup();
Logger.info('[boot] gRPC mTLS certificates generated successfully');
return { caCert, serverCert, serverKey, clientCert, clientKey };
} catch (e) {
await cleanup();
throw e;
}
}
async function saveCerts(tlsConfig: GrpcTlsConfig): Promise<void> {
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caCertPath, tlsConfig.caCert, { mode: 0o644 });
await fs.writeFile(serverCertPath, tlsConfig.serverCert, { mode: 0o644 });
await fs.writeFile(serverKeyPath, tlsConfig.serverKey, { mode: 0o600 });
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
Logger.info(`[boot] gRPC mTLS certificates saved to ${certDir}`);
}
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
const exists = await Promise.all([
fileExist(caCertPath),
fileExist(serverCertPath),
fileExist(serverKeyPath),
fileExist(clientCertPath),
fileExist(clientKeyPath),
]);
if (exists.some((e) => !e)) {
return null;
}
const [caCert, serverCert, serverKey, clientCert, clientKey] = await Promise.all([
fs.readFile(caCertPath, 'utf-8'),
fs.readFile(serverCertPath, 'utf-8'),
fs.readFile(serverKeyPath, 'utf-8'),
fs.readFile(clientCertPath, 'utf-8'),
fs.readFile(clientKeyPath, 'utf-8'),
]);
Logger.info('[boot] Loaded existing gRPC mTLS certificates from disk');
return { caCert, serverCert, serverKey, clientCert, clientKey };
}
export async function initGrpcCerts(): Promise<GrpcTlsConfig> {
if (cachedConfig) {
return cachedConfig;
}
let tlsConfig = await loadExistingCerts();
if (!tlsConfig) {
tlsConfig = await generateAllCerts();
await saveCerts(tlsConfig);
}
cachedConfig = tlsConfig;
return tlsConfig;
}
export function getGrpcCerts(): GrpcTlsConfig | null {
return cachedConfig;
}
+23 -5
View File
@@ -1,6 +1,5 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { createRandomString } from './share';
dotenv.config({ dotenv.config({
path: path.join(__dirname, '../../.env'), path: path.join(__dirname, '../../.env'),
@@ -9,6 +8,8 @@ dotenv.config({
interface Config { interface Config {
port: number; port: number;
grpcPort: number; grpcPort: number;
bindHost: string;
bindHostGrpc: string;
nodeEnv: string; nodeEnv: string;
isDevelopment: boolean; isDevelopment: boolean;
isProduction: boolean; isProduction: boolean;
@@ -31,6 +32,8 @@ interface Config {
const config: Config = { const config: Config = {
port: parseInt(process.env.BACK_PORT || '5700', 10), port: parseInt(process.env.BACK_PORT || '5700', 10),
grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10), grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10),
bindHost: process.env.BIND_HOST || '::',
bindHostGrpc: process.env.BIND_HOST_GRPC || '::',
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development', isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production', isProduction: process.env.NODE_ENV === 'production',
@@ -64,6 +67,19 @@ if (!process.env.QL_DIR) {
const lastVersionFile = `https://qn.whyour.cn/version.yaml`; const lastVersionFile = `https://qn.whyour.cn/version.yaml`;
// Get and normalize QlBaseUrl
let baseUrl = process.env.QlBaseUrl || '';
if (baseUrl) {
// Ensure it starts with /
if (!baseUrl.startsWith('/')) {
baseUrl = `/${baseUrl}`;
}
// Remove trailing slash for consistency in route definitions
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
}
const rootPath = process.env.QL_DIR as string; const 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') });
@@ -93,6 +109,7 @@ const jsEnvFile = path.join(preloadPath, 'env.js');
const pyEnvFile = path.join(preloadPath, 'env.py'); const pyEnvFile = path.join(preloadPath, 'env.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const langEnvFile = path.join(preloadPath, 'lang_env.sh');
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');
@@ -101,8 +118,6 @@ const confBakDir = path.join(dataPath, 'config/bak/');
const sampleFile = path.join(samplePath, 'config.sample.sh'); const sampleFile = path.join(samplePath, 'config.sample.sh');
const sqliteFile = path.join(samplePath, 'database.sqlite'); const sqliteFile = path.join(samplePath, 'database.sqlite');
const authError = '错误的用户名密码,请重试';
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 dataTgzFile = path.join(tmpPath, 'data.tgz');
@@ -116,6 +131,7 @@ if (envFound.error) {
export default { export default {
...config, ...config,
jwt: config.jwt, jwt: config.jwt,
baseUrl,
rootPath, rootPath,
tmpPath, tmpPath,
dataPath, dataPath,
@@ -123,8 +139,6 @@ export default {
shareShellFile, shareShellFile,
dependenceProxyFile, dependenceProxyFile,
configString, configString,
loginFaild,
authError,
logPath, logPath,
extraFile, extraFile,
authConfigFile, authConfigFile,
@@ -137,6 +151,7 @@ export default {
pyEnvFile, pyEnvFile,
jsNotifyFile, jsNotifyFile,
pyNotifyFile, pyNotifyFile,
langEnvFile,
dbPath, dbPath,
uploadPath, uploadPath,
configPath, configPath,
@@ -153,6 +168,8 @@ export default {
'env.js', 'env.js',
'env.py', 'env.py',
'token.json', 'token.json',
'grpc',
'__pycache__',
], ],
writePathList: [configPath, scriptPath], writePathList: [configPath, scriptPath],
bakPath, bakPath,
@@ -176,4 +193,5 @@ export default {
sshdPath, sshdPath,
systemLogPath, systemLogPath,
dependenceCachePath, dependenceCachePath,
maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform
}; };
+220 -11
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { exec } from 'child_process'; import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree'; import psTreeFun from 'ps-tree';
import { promisify } from 'util'; import { promisify } from 'util';
import { load } from 'js-yaml'; import { load } from 'js-yaml';
@@ -10,9 +10,39 @@ import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence'; import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici'; import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
export * from './share'; export * from './share';
let osType: 'Debian' | 'Ubuntu' | 'Alpine' | undefined;
function getOsTypeSync(): 'Debian' | 'Ubuntu' | 'Alpine' | undefined {
// 1. 环境变量覆盖
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') return 'Alpine';
if (envOs === 'debian') return 'Debian';
if (envOs === 'ubuntu') return 'Ubuntu';
// 2. 模块缓存(由 detectOS 设置)
if (osType) return osType;
// 3. 能力检测:检查包管理器二进制
try {
execSync('which apt-get', { stdio: 'ignore' });
return 'Debian';
} catch {
try {
execSync('which apk', { stdio: 'ignore' });
return 'Alpine';
} catch {
// macOS / 未知系统
}
}
return undefined;
}
export async function getFileContentByName(fileName: string) { export async function getFileContentByName(fileName: string) {
const _exsit = await fileExist(fileName); const _exsit = await fileExist(fileName);
if (_exsit) { if (_exsit) {
@@ -233,7 +263,10 @@ export async function readDir(
baseDir: string = '', baseDir: string = '',
blacklist: string[] = [], blacklist: string[] = [],
): Promise<IFile[]> { ): Promise<IFile[]> {
const absoluteDir = path.join(baseDir, dir); const absoluteDir = path.resolve(baseDir, dir);
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
return [];
}
const relativePath = path.relative(baseDir, absoluteDir); const relativePath = path.relative(baseDir, absoluteDir);
try { try {
@@ -405,7 +438,7 @@ export async function killTask(pid: number) {
[pid, ...pids].reverse().forEach((x) => { [pid, ...pids].reverse().forEach((x) => {
process.kill(x, 15); process.kill(x, 15);
}); });
} catch (error) {} } catch (error) { }
} else { } else {
process.kill(pid, 2); process.kill(pid, 2);
} }
@@ -417,6 +450,27 @@ export async function getPid(cmd: string) {
return pid ? Number(pid) : undefined; return pid ? Number(pid) : undefined;
} }
export async function getAllPids(cmd: string): Promise<number[]> {
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`;
const pidsStr = await promiseExec(taskCommand);
if (!pidsStr) return [];
return pidsStr
.split('\n')
.map((p) => Number(p.trim()))
.filter((p) => !isNaN(p) && p > 0);
}
export async function killAllTasks(cmd: string): Promise<void> {
const pids = await getAllPids(cmd);
for (const pid of pids) {
try {
await killTask(pid);
} catch (error) {
// Ignore errors if process already terminated
}
}
}
interface IVersion { interface IVersion {
version: string; version: string;
changeLogLink: string; changeLogLink: string;
@@ -482,11 +536,17 @@ export function safeJSONParse(value?: string) {
try { try {
return JSON.parse(value); return JSON.parse(value);
} catch (error) { } catch (error) {
Logger.error('[safeJSONParse失败]', error); Logger.error('[safeJSONParse error]', error);
return {}; return {};
} }
} }
export function errStack(error: unknown): string {
return error instanceof Error && error.stack
? error.stack
: String(error);
}
export async function rmPath(path: string) { export async function rmPath(path: string) {
try { try {
const _exsit = await fileExist(path); const _exsit = await fileExist(path);
@@ -494,7 +554,7 @@ export async function rmPath(path: string) {
await fs.rm(path, { force: true, recursive: true, maxRetries: 5 }); await fs.rm(path, { force: true, recursive: true, maxRetries: 5 });
} }
} catch (error) { } catch (error) {
Logger.error('[rmPath失败]', error); Logger.error('[rmPath error]', error);
} }
} }
@@ -504,12 +564,12 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
throw new Error('Invalid timezone'); throw new Error('Invalid timezone');
} }
await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`); await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
await promiseExec(`echo "${timezone}" > /etc/timezone`); await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
return true; return true;
} catch (error) { } catch (error) {
Logger.error('[setSystemTimezone失败]', error); Logger.error('[setSystemTimezone error]', error);
return false; return false;
} }
} }
@@ -529,7 +589,9 @@ except:
spec=u.find_spec(name) spec=u.find_spec(name)
print(name if spec else '') print(name if spec else '')
''')"`, ''')"`,
[DependenceTypes.linux]: `apk info -es ${name}`, [DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? `apk info -es ${name}`
: maybeSudo(`dpkg-query -s ${name}`),
}; };
return baseCommands[type]; return baseCommands[type];
@@ -540,7 +602,9 @@ export function getInstallCommand(type: DependenceTypes, name: string): string {
[DependenceTypes.nodejs]: 'pnpm add -g', [DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.python3]: [DependenceTypes.python3]:
'pip3 install --disable-pip-version-check --root-user-action=ignore', 'pip3 install --disable-pip-version-check --root-user-action=ignore',
[DependenceTypes.linux]: 'apk add --no-check-certificate', [DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
}; };
let command = baseCommands[type]; let command = baseCommands[type];
@@ -560,7 +624,9 @@ export function getUninstallCommand(
[DependenceTypes.nodejs]: 'pnpm remove -g', [DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.python3]: [DependenceTypes.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y', 'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: 'apk del', [DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: maybeSudo('apt-get remove -y'),
}; };
return `${baseCommands[type]} ${name.trim()}`; return `${baseCommands[type]} ${name.trim()}`;
@@ -569,3 +635,146 @@ export function getUninstallCommand(
export function isDemoEnv() { export function isDemoEnv() {
return process.env.DeployEnv === 'demo'; return process.env.DeployEnv === 'demo';
} }
async function getOSReleaseInfo(): Promise<string> {
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
return osRelease;
}
function isDebian(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Debian');
}
function isUbuntu(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Ubuntu');
}
function isCentOS(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('CentOS') || osReleaseInfo.includes('Red Hat');
}
function isAlpine(osReleaseInfo: string): boolean {
return osReleaseInfo.includes('Alpine');
}
export async function detectOS(): Promise<
'Debian' | 'Ubuntu' | 'Alpine' | undefined
> {
if (osType) return osType;
const envOs = process.env.QL_OS_TYPE?.toLowerCase();
if (envOs === 'alpine') {
osType = 'Alpine';
return osType;
}
if (envOs === 'debian') {
osType = 'Debian';
return osType;
}
if (envOs === 'ubuntu') {
osType = 'Ubuntu';
return osType;
}
const platform = os.platform();
if (platform === 'linux') {
const osReleaseInfo = await getOSReleaseInfo();
if (isDebian(osReleaseInfo)) {
osType = 'Debian';
} else if (isUbuntu(osReleaseInfo)) {
osType = 'Ubuntu';
} else if (isAlpine(osReleaseInfo)) {
osType = 'Alpine';
} else {
Logger.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
console.error(`Unknown Linux Distribution: ${osReleaseInfo}`);
}
} else if (platform === 'darwin') {
osType = undefined;
} else {
Logger.error(`Unsupported platform: ${platform}`);
console.error(`Unsupported platform: ${platform}`);
}
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
): Promise<string> {
const S = isInContainer() ? 'sudo ' : '';
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
const detectedOS = await detectOS();
if (!detectedOS) {
throw Error(`Unknown Linux Distribution`);
}
return await _updateLinuxMirror(detectedOS, mirror);
}
+12 -3
View File
@@ -21,6 +21,9 @@ export class Crontab {
extra_schedules?: Array<{ schedule: string }>; extra_schedules?: Array<{ schedule: string }>;
task_before?: string; task_before?: string;
task_after?: string; task_after?: string;
log_name?: string;
allow_multiple_instances?: 1 | 0;
work_dir?: string;
constructor(options: Crontab) { constructor(options: Crontab) {
this.name = options.name; this.name = options.name;
@@ -45,17 +48,20 @@ export class Crontab {
this.extra_schedules = options.extra_schedules; this.extra_schedules = options.extra_schedules;
this.task_before = options.task_before; this.task_before = options.task_before;
this.task_after = options.task_after; this.task_after = options.task_after;
this.log_name = options.log_name;
this.allow_multiple_instances = options.allow_multiple_instances || 0;
this.work_dir = options.work_dir;
} }
} }
export enum CrontabStatus { export enum CrontabStatus {
'running' = 0, 'running' = 0,
'queued' = 0.5,
'idle' = 1, 'idle' = 1,
'disabled', 'disabled' = 2,
'queued' = 3,
} }
export 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',
@@ -84,4 +90,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
extra_schedules: DataTypes.JSON, extra_schedules: DataTypes.JSON,
task_before: DataTypes.STRING, task_before: DataTypes.STRING,
task_after: DataTypes.STRING, task_after: DataTypes.STRING,
log_name: DataTypes.STRING,
allow_multiple_instances: DataTypes.NUMBER,
work_dir: DataTypes.STRING,
}); });
+71
View File
@@ -0,0 +1,71 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.';
export class CrontabStat {
id?: number;
ref_id!: number;
date!: string;
run_count?: number;
success_count?: number;
fail_count?: number;
total_time?: number;
max_time?: number;
constructor(options: CrontabStat) {
this.id = options.id;
this.ref_id = options.ref_id;
this.date = options.date;
this.run_count = options.run_count || 0;
this.success_count = options.success_count || 0;
this.fail_count = options.fail_count || 0;
this.total_time = options.total_time || 0;
this.max_time = options.max_time || 0;
}
}
export interface CrontabStatInstance extends Model<CrontabStat, CrontabStat>, CrontabStat {}
export const CrontabStatModel = sequelize.define<CrontabStatInstance>(
'CrontabStat',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
ref_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
date: {
type: DataTypes.STRING,
allowNull: false,
},
run_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
success_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
fail_count: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
total_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
max_time: {
type: DataTypes.NUMBER,
defaultValue: 0,
},
},
{
indexes: [
{ unique: true, fields: ['ref_id', 'date'] },
{ fields: ['date'] },
],
},
);
+7 -1
View File
@@ -1,5 +1,5 @@
import { DataTypes, Model } from 'sequelize';
import { sequelize } from '.'; import { sequelize } from '.';
import { DataTypes, Model, ModelDefined } from 'sequelize';
export class Env { export class Env {
value?: string; value?: string;
@@ -9,6 +9,8 @@ export class Env {
position?: number; position?: number;
name?: string; name?: string;
remarks?: string; remarks?: string;
isPinned?: 1 | 0;
labels?: string[];
constructor(options: Env) { constructor(options: Env) {
this.value = options.value; this.value = options.value;
@@ -21,6 +23,8 @@ export class Env {
this.position = options.position; this.position = options.position;
this.name = options.name; this.name = options.name;
this.remarks = options.remarks || ''; this.remarks = options.remarks || '';
this.isPinned = options.isPinned || 0;
this.labels = options.labels || [];
} }
} }
@@ -42,4 +46,6 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
position: DataTypes.NUMBER, position: DataTypes.NUMBER,
name: { type: DataTypes.STRING, unique: 'compositeIndex' }, name: { type: DataTypes.STRING, unique: 'compositeIndex' },
remarks: DataTypes.STRING, remarks: DataTypes.STRING,
isPinned: DataTypes.NUMBER,
labels: DataTypes.JSON,
}); });
+15 -1
View File
@@ -20,6 +20,7 @@ export enum NotificationMode {
'chronocat' = 'Chronocat', 'chronocat' = 'Chronocat',
'ntfy' = 'ntfy', 'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot', 'wxPusherBot' = 'wxPusherBot',
'openiLink' = 'openiLink',
} }
abstract class NotificationBaseInfo { abstract class NotificationBaseInfo {
@@ -142,6 +143,7 @@ export class WebhookNotification extends NotificationBaseInfo {
export class LarkNotification extends NotificationBaseInfo { export class LarkNotification extends NotificationBaseInfo {
public larkKey = ''; public larkKey = '';
public larkSecret = '';
} }
export class NtfyNotification extends NotificationBaseInfo { export class NtfyNotification extends NotificationBaseInfo {
@@ -160,6 +162,16 @@ export class WxPusherBotNotification extends NotificationBaseInfo {
public wxPusherBotUids = ''; public wxPusherBotUids = '';
} }
export class WxPusherSptNotification extends NotificationBaseInfo {
public wxPusherSptList = '';
}
export class OpeniLinkNotification extends NotificationBaseInfo {
public openiLinkAppToken = '';
public openiLinkHubUrl = '';
public openiLinkContextToken = '';
}
export interface NotificationInfo export interface NotificationInfo
extends GoCqHttpBotNotification, extends GoCqHttpBotNotification,
GotifyNotification, GotifyNotification,
@@ -181,4 +193,6 @@ export interface NotificationInfo
ChronocatNotification, ChronocatNotification,
LarkNotification, LarkNotification,
NtfyNotification, NtfyNotification,
WxPusherBotNotification {} WxPusherBotNotification,
WxPusherSptNotification,
OpeniLinkNotification {}
+1 -1
View File
@@ -24,7 +24,7 @@ export interface AppToken {
expiration: number; expiration: number;
} }
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system'; export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
export interface AppInstance extends Model<App, App>, App {} export interface AppInstance extends Model<App, App>, App {}
export const AppModel = sequelize.define<AppInstance>('App', { export const AppModel = sequelize.define<AppInstance>('App', {
+81
View File
@@ -0,0 +1,81 @@
import { sequelize } from '.';
import { DataTypes, Model } from 'sequelize';
export enum InstanceStatus {
'running' = 0,
'finished' = 1,
'stopped' = 2,
'error' = 3,
}
export interface RunningInstanceAttributes {
id?: number;
cron_id: number;
pid?: number;
log_path?: string;
started_at: number;
finished_at?: number;
status: InstanceStatus;
exit_code?: number;
}
export class RunningInstance {
id?: number;
cron_id!: number;
pid?: number;
log_path?: string;
started_at!: number;
finished_at?: number;
status!: InstanceStatus;
exit_code?: number;
constructor(options: RunningInstanceAttributes) {
this.id = options.id;
this.cron_id = options.cron_id;
this.pid = options.pid;
this.log_path = options.log_path;
this.started_at = options.started_at;
this.finished_at = options.finished_at;
this.status = options.status;
this.exit_code = options.exit_code;
}
}
export interface RunningInstanceModel
extends Model<RunningInstanceAttributes, RunningInstanceAttributes>,
RunningInstanceAttributes {}
export const RunningInstanceModel = sequelize.define<RunningInstanceModel>(
'RunningInstance',
{
cron_id: {
type: DataTypes.NUMBER,
allowNull: false,
},
pid: {
type: DataTypes.NUMBER,
allowNull: true,
},
log_path: {
type: DataTypes.STRING,
allowNull: true,
},
started_at: {
type: DataTypes.NUMBER,
allowNull: false,
},
finished_at: {
type: DataTypes.NUMBER,
allowNull: true,
},
status: {
type: DataTypes.NUMBER,
allowNull: false,
defaultValue: InstanceStatus.running,
},
exit_code: {
type: DataTypes.NUMBER,
allowNull: true,
},
},
);
+2
View File
@@ -2,11 +2,13 @@ export class SockMessage {
message?: string; message?: string;
type?: SockMessageType; type?: SockMessageType;
references?: number[]; references?: number[];
status?: number | string;
constructor(options: SockMessage) { constructor(options: SockMessage) {
this.type = options.type; this.type = options.type;
this.message = options.message; this.message = options.message;
this.references = options.references; this.references = options.references;
this.status = options.status;
} }
} }
+17 -1
View File
@@ -31,6 +31,8 @@ export enum AuthDataType {
} }
export interface SystemConfigInfo { export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
logRemoveFrequency?: number; logRemoveFrequency?: number;
cronConcurrency?: number; cronConcurrency?: number;
dependenceProxy?: string; dependenceProxy?: string;
@@ -38,6 +40,7 @@ export interface SystemConfigInfo {
pythonMirror?: string; pythonMirror?: string;
linuxMirror?: string; linuxMirror?: string;
timezone?: string; timezone?: string;
globalSshKey?: string;
} }
export interface LoginLogInfo { export interface LoginLogInfo {
@@ -48,6 +51,19 @@ export interface LoginLogInfo {
status?: LoginStatus; status?: LoginStatus;
} }
export interface TokenInfo {
value: string;
timestamp: number;
ip: string;
address: string;
platform: string;
/**
* Token expiration time in seconds since Unix epoch.
* If undefined, the token uses JWT's built-in expiration.
*/
expiration?: number;
}
export interface AuthInfo { export interface AuthInfo {
username: string; username: string;
password: string; password: string;
@@ -58,7 +74,7 @@ export interface AuthInfo {
platform: string; platform: string;
isTwoFactorChecking: boolean; isTwoFactorChecking: boolean;
token: string; token: string;
tokens: Record<string, string>; tokens: Record<string, string | TokenInfo[]>;
twoFactorActivated: boolean; twoFactorActivated: boolean;
twoFactorSecret: string; twoFactorSecret: string;
avatar: string; avatar: string;
+7 -7
View File
@@ -9,20 +9,20 @@ import initFile from './initFile';
export default async ({ app }: { app: Application }) => { export default async ({ app }: { app: Application }) => {
depInjectorLoader(); depInjectorLoader();
Logger.info('✌️ Dependency loaded'); Logger.info('[boot] Dependency loaded');
await linkDeps(); await linkDeps();
Logger.info('✌️ Link deps loaded'); Logger.info('[boot] Link deps loaded');
initFile(); await initFile();
Logger.info('✌️ Init file loaded'); Logger.info('[boot] Init file loaded');
await initData(); await initData();
Logger.info('✌️ Init data loaded'); Logger.info('[boot] Init data loaded');
initTask(); initTask();
Logger.info('✌️ Init task loaded'); Logger.info('[boot] Init task loaded');
expressLoader({ app }); expressLoader({ app });
Logger.info('✌️ Express loaded'); Logger.info('[boot] Express loaded');
}; };
+40 -39
View File
@@ -6,6 +6,8 @@ import { AppModel } from '../data/open';
import { SystemModel } from '../data/system'; import { SystemModel } from '../data/system';
import { SubscriptionModel } from '../data/subscription'; import { SubscriptionModel } from '../data/subscription';
import { CrontabViewModel } from '../data/cronView'; import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data'; import { sequelize } from '../data';
export default async () => { export default async () => {
@@ -17,48 +19,47 @@ export default async () => {
await EnvModel.sync(); await EnvModel.sync();
await SubscriptionModel.sync(); await SubscriptionModel.sync();
await CrontabViewModel.sync(); await CrontabViewModel.sync();
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
// 初始化新增字段 // 初始化新增字段
try { const migrations = [
await sequelize.query( {
'alter table CrontabViews add column filterRelation VARCHAR(255)', table: 'CrontabViews',
); column: 'filterRelation',
} catch (error) {} type: 'VARCHAR(255)',
try { },
await sequelize.query( { table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
'alter table Subscriptions add column proxy VARCHAR(255)', { table: 'CrontabViews', column: 'type', type: 'NUMBER' },
); { table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
} catch (error) {} { table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
try { { table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
await sequelize.query('alter table CrontabViews add column type NUMBER'); { table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
} catch (error) {} { table: 'Crontabs', column: 'task_before', type: 'TEXT' },
try { { table: 'Crontabs', column: 'task_after', type: 'TEXT' },
await sequelize.query( { table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
'alter table Subscriptions add column autoAddCron NUMBER', {
); table: 'Crontabs',
} catch (error) {} column: 'allow_multiple_instances',
try { type: 'NUMBER',
await sequelize.query( },
'alter table Subscriptions add column autoDelCron NUMBER', { table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
); { table: 'Envs', column: 'isPinned', type: 'NUMBER' },
} catch (error) {} { table: 'Envs', column: 'labels', type: 'JSON' },
try { ];
await sequelize.query('alter table Crontabs add column sub_id NUMBER');
} catch (error) {}
try {
await sequelize.query(
'alter table Crontabs add column extra_schedules JSON',
);
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_before TEXT');
} catch (error) {}
try {
await sequelize.query('alter table Crontabs add column task_after TEXT');
} catch (error) {}
Logger.info('✌️ DB loaded'); for (const migration of migrations) {
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
Logger.info('[boot] DB loaded');
} catch (error) { } catch (error) {
Logger.error('✌️ DB load failed', error); Logger.error('[boot] DB load failed', error);
} }
}; };
+16 -28
View File
@@ -1,24 +1,22 @@
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import chokidar from 'chokidar'; import os from 'os';
import config from '../config/index'; import config from '../config/index';
import { fileExist, promiseExec, rmPath } from '../config/util'; import Logger from './logger';
async function linkToNodeModule(src: string, dst?: string) {
const target = path.join(config.rootPath, 'node_modules', dst || src);
const source = path.join(config.rootPath, src);
try {
const stats = await fs.lstat(target);
if (!stats) {
await fs.symlink(source, target, 'dir');
}
} catch (error) { }
}
async function linkCommand() { async function linkCommand() {
const commandPath = await promiseExec('which node'); const homeDir = os.homedir();
const commandDir = path.dirname(commandPath); let userBinDir = path.join(homeDir, 'bin');
try {
await fs.mkdir(userBinDir, { recursive: true });
await linkCommandToDir(userBinDir);
} catch (error) {
Logger.error('Linking command failed:', error);
}
}
async function linkCommandToDir(commandDir: string) {
const linkShell = [ const linkShell = [
{ {
src: 'update.sh', src: 'update.sh',
@@ -42,22 +40,12 @@ async function linkCommand() {
await fs.unlink(tmpTarget); await fs.unlink(tmpTarget);
} }
} catch (error) { } } catch (error) { }
await fs.symlink(source, tmpTarget); await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target); await fs.rename(tmpTarget, target);
} }
} }
export default async (src: string = 'deps') => { export default async () => {
await linkCommand(); await linkCommand();
await linkToNodeModule(src);
const source = path.join(config.rootPath, src);
const watcher = chokidar.watch(source, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
});
watcher
.on('add', (path) => linkToNodeModule(src))
.on('change', (path) => linkToNodeModule(src));
}; };
+69 -30
View File
@@ -8,12 +8,43 @@ import { getPlatform, getToken } from '../config/util';
import rewrite from 'express-urlrewrite'; import rewrite from 'express-urlrewrite';
import { errors } from 'celebrate'; import { errors } from 'celebrate';
import { serveEnv } from '../config/serverEnv'; import { serveEnv } from '../config/serverEnv';
import { IKeyvStore, shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
import { AuthInfo } from '../data/system';
import path from 'path'; import path from 'path';
import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
export default ({ app }: { app: Application }) => { export default ({ app }: { app: Application }) => {
// Security: Enable strict routing to prevent case-insensitive path bypass
app.set('case sensitive routing', true);
app.set('strict routing', true);
app.set('trust proxy', 'loopback'); app.set('trust proxy', 'loopback');
app.use(cors()); app.use(cors());
// Security: Path normalization middleware to prevent case variation attacks
app.use((req, res, next) => {
const originalPath = req.path;
const normalizedPath = originalPath.toLowerCase();
// Block requests with case variations on protected paths
if (originalPath !== normalizedPath &&
(normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) {
return res.status(400).json({
code: 400,
message: 'Invalid path format'
});
}
next();
});
// Rewrite URLs to strip baseUrl prefix if configured
// This allows the rest of the app to work without baseUrl awareness
if (config.baseUrl) {
app.use(rewrite(`${config.baseUrl}/*`, '/$1'));
}
app.get(`${config.api.prefix}/env.js`, serveEnv); app.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));
@@ -28,7 +59,7 @@ export default ({ app }: { app: Application }) => {
secret: config.jwt.secret, secret: config.jwt.secret,
algorithms: ['HS384'], algorithms: ['HS384'],
}).unless({ }).unless({
path: [...config.apiWhiteList, /^\/(?!api\/).*/], path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i],
}), }),
); );
@@ -43,31 +74,41 @@ export default ({ app }: { app: Application }) => {
}); });
app.use(async (req: Request, res, next) => { app.use(async (req: Request, res, next) => {
if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) { const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next(); return next();
} }
const headerToken = getToken(req); const headerToken = getToken(req);
if (req.path.startsWith('/open/')) { if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps(); const apps = await shareStore.getApps();
const doc = apps?.filter((x) => const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken), x.tokens?.find((y) => y.value === headerToken),
)?.[0]; )?.[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 = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1]; const key = keyMatch && keyMatch[1];
if (
doc.scopes.includes(key as any) && if (!doc.scopes.includes(key as AppScope)) {
currentToken && const err = new UnauthorizedError('credentials_bad_scheme', {
currentToken.expiration >= Math.round(Date.now() / 1000) message: t('暂无权限'),
) { });
return next(); return next(err);
} }
if (!currentToken || currentToken.expiration < Math.round(Date.now() / 1000)) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
return next(err);
}
return next();
} }
} }
const originPath = `${req.baseUrl}${req.path === '/' ? '' : req.path}`; const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
if ( if (
!headerToken && !headerToken &&
originPath && originPath &&
@@ -77,39 +118,37 @@ export default ({ app }: { app: Application }) => {
} }
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
if (authInfo && headerToken) { if (isValidToken(authInfo, headerToken, req.platform)) {
const { token = '', tokens = {} } = authInfo; return next();
if (headerToken === token || tokens[req.platform] === headerToken) {
return next();
}
} }
const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken const errorMessage = headerToken
? 'jwt malformed' ? t('Token 已失效')
: 'No authorization token was found'; : t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage }); const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err); next(err);
}); });
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) { const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next(); return next();
} }
const authInfo = const authInfo =
(await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']); (await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = true; let isInitialized = !isDefaultAuthInfo(authInfo);
if (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
) {
isInitialized = false;
}
if (isInitialized) { if (isInitialized) {
return res.send({ code: 450, message: '未知错误' }); return res.send({ code: 450, message: t('未知错误') });
} else { } else {
return next(); return next();
} }
+29 -3
View File
@@ -13,11 +13,13 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system'; import SystemService from '../services/system';
import UserService from '../services/user'; import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises'; import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, safeJSONParse } from '../config/util'; import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import OpenService from '../services/open'; import OpenService from '../services/open';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import Logger from './logger'; import Logger from './logger';
import { AppModel } from '../data/open'; import { AppModel } from '../data/open';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { setLang, systemLang } from '../shared/i18n';
export default async () => { export default async () => {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
@@ -36,10 +38,15 @@ export default async () => {
if (!systemApp) { if (!systemApp) {
systemApp = await AppModel.create({ systemApp = await AppModel.create({
name: 'system', name: 'system',
scopes: ['crons', 'system'], scopes: ['crons', 'system', 'dashboard'],
client_id: createRandomString(12, 12), client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24), client_secret: createRandomString(24, 24),
}); });
} else if (!systemApp.scopes.includes('dashboard')) {
await AppModel.update(
{ scopes: [...systemApp.scopes, 'dashboard'] },
{ where: { name: 'system' } },
);
} }
const [systemConfig] = await SystemModel.findOrCreate({ const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig }, where: { type: AuthDataType.systemConfig },
@@ -50,7 +57,7 @@ export default async () => {
const [authConfig] = await SystemModel.findOrCreate({ const [authConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.authConfig }, where: { type: AuthDataType.authConfig },
}); });
if (!authConfig?.info) { if (!authConfig?.info || isDemoEnv()) {
let authInfo = { let authInfo = {
username: 'admin', username: 'admin',
password: 'admin', password: 'admin',
@@ -134,6 +141,12 @@ export default async () => {
// 初始化更新所有任务状态为空闲 // 初始化更新所有任务状态为空闲
await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} }); await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} });
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped },
{ where: { status: InstanceStatus.running } },
);
// 初始化时执行一次所有的 ql repo 任务 // 初始化时执行一次所有的 ql repo 任务
CrontabModel.findAll({ CrontabModel.findAll({
where: { where: {
@@ -210,8 +223,21 @@ export default async () => {
} }
}); });
// 初始化语言(必须在 autosave_crontab 之前)
const lang = systemConfig.info?.lang || systemLang();
setLang(lang);
// 确保 lang_env.sh 存在
try {
const langEnvExist = await fileExist(config.langEnvFile);
if (!langEnvExist) {
await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`);
}
} catch { }
// 初始化保存一次ck和定时任务数据 // 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab(); await cronService.autosave_crontab();
await envService.set_envs(); await envService.set_envs();
const authInfo = await userService.getAuthInfo(); const authInfo = await userService.getAuthInfo();
+3 -1
View File
@@ -20,6 +20,7 @@ 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 tmpPath = path.join(logPath, '.tmp/');
const rootTmpPath = path.join(rootPath, '.tmp/');
const confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
const sampleConfigFile = path.join(samplePath, 'config.sample.sh'); const sampleConfigFile = path.join(samplePath, 'config.sample.sh');
const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh'); const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh');
@@ -44,6 +45,7 @@ const directories = [
preloadPath, preloadPath,
logPath, logPath,
tmpPath, tmpPath,
rootTmpPath,
uploadPath, uploadPath,
sshPath, sshPath,
bakPath, bakPath,
@@ -123,5 +125,5 @@ export default async () => {
} }
} }
Logger.info('✌️ Init file down'); Logger.info('[boot] Init file down');
}; };
+10 -2
View File
@@ -2,14 +2,17 @@ import { Container } from 'typedi';
import SystemService from '../services/system'; import SystemService from '../services/system';
import ScheduleService, { ScheduleTaskType } from '../services/schedule'; import ScheduleService, { ScheduleTaskType } from '../services/schedule';
import SubscriptionService from '../services/subscription'; import SubscriptionService from '../services/subscription';
import SshKeyService from '../services/sshKey';
import config from '../config'; import config from '../config';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
import { join } from 'path'; import { join } from 'path';
import { t } from '../shared/i18n';
export default async () => { export default async () => {
const systemService = Container.get(SystemService); const systemService = Container.get(SystemService);
const scheduleService = Container.get(ScheduleService); const scheduleService = Container.get(ScheduleService);
const subscriptionService = Container.get(SubscriptionService); const subscriptionService = Container.get(SubscriptionService);
const sshKeyService = Container.get(SshKeyService);
// 生成内置token // 生成内置token
let tokenCommand = `ts-node-transpile-only ${join( let tokenCommand = `ts-node-transpile-only ${join(
@@ -23,7 +26,7 @@ export default async () => {
} }
const cron = { const cron = {
id: NaN, id: NaN,
name: '生成token', name: t('生成token'),
command: tokenCommand, command: tokenCommand,
runOrigin: 'system', runOrigin: 'system',
} as ScheduleTaskType; } as ScheduleTaskType;
@@ -42,7 +45,7 @@ export default async () => {
if (data.info.logRemoveFrequency) { if (data.info.logRemoveFrequency) {
const rmlogCron = { const rmlogCron = {
id: data.id as number, id: data.id as number,
name: '删除日志', name: t('删除日志'),
command: `ql rmlog ${data.info.logRemoveFrequency}`, command: `ql rmlog ${data.info.logRemoveFrequency}`,
runOrigin: 'system' as const, runOrigin: 'system' as const,
}; };
@@ -57,6 +60,11 @@ export default async () => {
} }
systemService.updateTimezone(data.info); systemService.updateTimezone(data.info);
// Apply global SSH key if configured
if (data.info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global');
}
} }
await subscriptionService.setSshConfig(); await subscriptionService.setSshConfig();
+1 -1
View File
@@ -4,7 +4,7 @@ import Sock from './sock';
export default async ({ server }: { server: Server }) => { export default async ({ server }: { server: Server }) => {
await Sock({ server }); await Sock({ server });
Logger.info('✌️ Sock loaded'); Logger.info('[boot] Sock loaded');
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
Logger.error('Uncaught exception:', error); Logger.error('Uncaught exception:', error);
+13 -13
View File
@@ -4,9 +4,11 @@ import { Container } from 'typedi';
import SockService from '../services/sock'; import SockService from '../services/sock';
import { getPlatform } from '../config/util'; import { getPlatform } from '../config/util';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { isValidToken } from '../shared/auth';
import config from '../config';
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: `${config.baseUrl}/api/ws`, log: () => { } });
const sockService = Container.get(SockService); const sockService = Container.get(SockService);
echo.on('connection', async (conn) => { echo.on('connection', async (conn) => {
@@ -17,21 +19,19 @@ export default async ({ server }: { server: Server }) => {
const authInfo = await shareStore.getAuthInfo(); const authInfo = await shareStore.getAuthInfo();
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (authInfo) {
const { token = '', tokens = {} } = authInfo;
if (headerToken === token || tokens[platform] === headerToken) {
sockService.addClient(conn);
conn.on('data', (message) => { if (isValidToken(authInfo, headerToken, platform)) {
conn.write(message); sockService.addClient(conn);
});
conn.on('close', function () { conn.on('data', (message) => {
sockService.removeClient(conn); conn.write(message);
}); });
return; conn.on('close', function () {
} sockService.removeClient(conn);
});
return;
} }
conn.close('404'); conn.close('404');
+21
View File
@@ -97,6 +97,18 @@ message UpdateCronRequest {
message DeleteCronsRequest { repeated int32 ids = 1; } message DeleteCronsRequest { repeated int32 ids = 1; }
message GetCronsRequest {
optional string searchValue = 1;
}
message GetCronByIdRequest { int32 id = 1; }
message EnableCronsRequest { repeated int32 ids = 1; }
message DisableCronsRequest { repeated int32 ids = 1; }
message RunCronsRequest { repeated int32 ids = 1; }
message CronsResponse { message CronsResponse {
int32 code = 1; int32 code = 1;
repeated CronItem data = 2; repeated CronItem data = 2;
@@ -139,6 +151,7 @@ enum NotificationMode {
chronocat = 18; chronocat = 18;
ntfy = 19; ntfy = 19;
wxPusherBot = 20; wxPusherBot = 20;
wxPusherSpt = 21;
} }
message NotificationInfo { message NotificationInfo {
@@ -219,6 +232,7 @@ message NotificationInfo {
optional string webhookContentType = 57; optional string webhookContentType = 57;
optional string larkKey = 58; optional string larkKey = 58;
optional string larkSecret = 69;
optional string ntfyUrl = 59; optional string ntfyUrl = 59;
optional string ntfyTopic = 60; optional string ntfyTopic = 60;
@@ -231,6 +245,8 @@ message NotificationInfo {
optional string wxPusherBotAppToken = 66; optional string wxPusherBotAppToken = 66;
optional string wxPusherBotTopicIds = 67; optional string wxPusherBotTopicIds = 67;
optional string wxPusherBotUids = 68; optional string wxPusherBotUids = 68;
optional string wxPusherSptList = 70;
} }
message SystemNotifyRequest { message SystemNotifyRequest {
@@ -254,4 +270,9 @@ service Api {
rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
rpc DeleteCrons(DeleteCronsRequest) returns (Response) {} rpc DeleteCrons(DeleteCronsRequest) returns (Response) {}
rpc GetCrons(GetCronsRequest) returns (CronsResponse) {}
rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {}
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
} }
+514 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v3.21.12
// source: back/protos/api.proto // source: back/protos/api.proto
/* eslint-disable */ /* eslint-disable */
@@ -43,6 +43,7 @@ export enum NotificationMode {
chronocat = 18, chronocat = 18,
ntfy = 19, ntfy = 19,
wxPusherBot = 20, wxPusherBot = 20,
wxPusherSpt = 21,
UNRECOGNIZED = -1, UNRECOGNIZED = -1,
} }
@@ -111,6 +112,9 @@ export function notificationModeFromJSON(object: any): NotificationMode {
case 20: case 20:
case "wxPusherBot": case "wxPusherBot":
return NotificationMode.wxPusherBot; return NotificationMode.wxPusherBot;
case 21:
case "wxPusherSpt":
return NotificationMode.wxPusherSpt;
case -1: case -1:
case "UNRECOGNIZED": case "UNRECOGNIZED":
default: default:
@@ -162,6 +166,8 @@ export function notificationModeToJSON(object: NotificationMode): string {
return "ntfy"; return "ntfy";
case NotificationMode.wxPusherBot: case NotificationMode.wxPusherBot:
return "wxPusherBot"; return "wxPusherBot";
case NotificationMode.wxPusherSpt:
return "wxPusherSpt";
case NotificationMode.UNRECOGNIZED: case NotificationMode.UNRECOGNIZED:
default: default:
return "UNRECOGNIZED"; return "UNRECOGNIZED";
@@ -281,6 +287,26 @@ export interface DeleteCronsRequest {
ids: number[]; ids: number[];
} }
export interface GetCronsRequest {
searchValue?: string | undefined;
}
export interface GetCronByIdRequest {
id: number;
}
export interface EnableCronsRequest {
ids: number[];
}
export interface DisableCronsRequest {
ids: number[];
}
export interface RunCronsRequest {
ids: number[];
}
export interface CronsResponse { export interface CronsResponse {
code: number; code: number;
data: CronItem[]; data: CronItem[];
@@ -362,6 +388,7 @@ export interface NotificationInfo {
webhookMethod?: string | undefined; webhookMethod?: string | undefined;
webhookContentType?: string | undefined; webhookContentType?: string | undefined;
larkKey?: string | undefined; larkKey?: string | undefined;
larkSecret?: string | undefined;
ntfyUrl?: string | undefined; ntfyUrl?: string | undefined;
ntfyTopic?: string | undefined; ntfyTopic?: string | undefined;
ntfyPriority?: string | undefined; ntfyPriority?: string | undefined;
@@ -372,6 +399,7 @@ export interface NotificationInfo {
wxPusherBotAppToken?: string | undefined; wxPusherBotAppToken?: string | undefined;
wxPusherBotTopicIds?: string | undefined; wxPusherBotTopicIds?: string | undefined;
wxPusherBotUids?: string | undefined; wxPusherBotUids?: string | undefined;
wxPusherSptList?: string | undefined;
} }
export interface SystemNotifyRequest { export interface SystemNotifyRequest {
@@ -2207,6 +2235,332 @@ export const DeleteCronsRequest: MessageFns<DeleteCronsRequest> = {
}, },
}; };
function createBaseGetCronsRequest(): GetCronsRequest {
return { searchValue: undefined };
}
export const GetCronsRequest: MessageFns<GetCronsRequest> = {
encode(message: GetCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.searchValue !== undefined) {
writer.uint32(10).string(message.searchValue);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.searchValue = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetCronsRequest {
return { searchValue: isSet(object.searchValue) ? globalThis.String(object.searchValue) : undefined };
},
toJSON(message: GetCronsRequest): unknown {
const obj: any = {};
if (message.searchValue !== undefined) {
obj.searchValue = message.searchValue;
}
return obj;
},
create<I extends Exact<DeepPartial<GetCronsRequest>, I>>(base?: I): GetCronsRequest {
return GetCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetCronsRequest>, I>>(object: I): GetCronsRequest {
const message = createBaseGetCronsRequest();
message.searchValue = object.searchValue ?? undefined;
return message;
},
};
function createBaseGetCronByIdRequest(): GetCronByIdRequest {
return { id: 0 };
}
export const GetCronByIdRequest: MessageFns<GetCronByIdRequest> = {
encode(message: GetCronByIdRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== 0) {
writer.uint32(8).int32(message.id);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetCronByIdRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetCronByIdRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.id = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetCronByIdRequest {
return { id: isSet(object.id) ? globalThis.Number(object.id) : 0 };
},
toJSON(message: GetCronByIdRequest): unknown {
const obj: any = {};
if (message.id !== 0) {
obj.id = Math.round(message.id);
}
return obj;
},
create<I extends Exact<DeepPartial<GetCronByIdRequest>, I>>(base?: I): GetCronByIdRequest {
return GetCronByIdRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetCronByIdRequest>, I>>(object: I): GetCronByIdRequest {
const message = createBaseGetCronByIdRequest();
message.id = object.id ?? 0;
return message;
},
};
function createBaseEnableCronsRequest(): EnableCronsRequest {
return { ids: [] };
}
export const EnableCronsRequest: MessageFns<EnableCronsRequest> = {
encode(message: EnableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): EnableCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseEnableCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): EnableCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: EnableCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<EnableCronsRequest>, I>>(base?: I): EnableCronsRequest {
return EnableCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<EnableCronsRequest>, I>>(object: I): EnableCronsRequest {
const message = createBaseEnableCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseDisableCronsRequest(): DisableCronsRequest {
return { ids: [] };
}
export const DisableCronsRequest: MessageFns<DisableCronsRequest> = {
encode(message: DisableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): DisableCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDisableCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): DisableCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: DisableCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<DisableCronsRequest>, I>>(base?: I): DisableCronsRequest {
return DisableCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DisableCronsRequest>, I>>(object: I): DisableCronsRequest {
const message = createBaseDisableCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseRunCronsRequest(): RunCronsRequest {
return { ids: [] };
}
export const RunCronsRequest: MessageFns<RunCronsRequest> = {
encode(message: RunCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
writer.uint32(10).fork();
for (const v of message.ids) {
writer.int32(v);
}
writer.join();
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RunCronsRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRunCronsRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag === 8) {
message.ids.push(reader.int32());
continue;
}
if (tag === 10) {
const end2 = reader.uint32() + reader.pos;
while (reader.pos < end2) {
message.ids.push(reader.int32());
}
continue;
}
break;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RunCronsRequest {
return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] };
},
toJSON(message: RunCronsRequest): unknown {
const obj: any = {};
if (message.ids?.length) {
obj.ids = message.ids.map((e) => Math.round(e));
}
return obj;
},
create<I extends Exact<DeepPartial<RunCronsRequest>, I>>(base?: I): RunCronsRequest {
return RunCronsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RunCronsRequest>, I>>(object: I): RunCronsRequest {
const message = createBaseRunCronsRequest();
message.ids = object.ids?.map((e) => e) || [];
return message;
},
};
function createBaseCronsResponse(): CronsResponse { function createBaseCronsResponse(): CronsResponse {
return { code: 0, data: [], message: undefined }; return { code: 0, data: [], message: undefined };
} }
@@ -2601,6 +2955,7 @@ function createBaseNotificationInfo(): NotificationInfo {
webhookMethod: undefined, webhookMethod: undefined,
webhookContentType: undefined, webhookContentType: undefined,
larkKey: undefined, larkKey: undefined,
larkSecret: undefined,
ntfyUrl: undefined, ntfyUrl: undefined,
ntfyTopic: undefined, ntfyTopic: undefined,
ntfyPriority: undefined, ntfyPriority: undefined,
@@ -2611,6 +2966,7 @@ function createBaseNotificationInfo(): NotificationInfo {
wxPusherBotAppToken: undefined, wxPusherBotAppToken: undefined,
wxPusherBotTopicIds: undefined, wxPusherBotTopicIds: undefined,
wxPusherBotUids: undefined, wxPusherBotUids: undefined,
wxPusherSptList: undefined,
}; };
} }
@@ -2790,6 +3146,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) { if (message.larkKey !== undefined) {
writer.uint32(466).string(message.larkKey); writer.uint32(466).string(message.larkKey);
} }
if (message.larkSecret !== undefined) {
writer.uint32(554).string(message.larkSecret);
}
if (message.ntfyUrl !== undefined) { if (message.ntfyUrl !== undefined) {
writer.uint32(474).string(message.ntfyUrl); writer.uint32(474).string(message.ntfyUrl);
} }
@@ -2820,6 +3179,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.wxPusherBotUids !== undefined) { if (message.wxPusherBotUids !== undefined) {
writer.uint32(546).string(message.wxPusherBotUids); writer.uint32(546).string(message.wxPusherBotUids);
} }
if (message.wxPusherSptList !== undefined) {
writer.uint32(562).string(message.wxPusherSptList);
}
return writer; return writer;
}, },
@@ -3294,6 +3656,14 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.larkKey = reader.string(); message.larkKey = reader.string();
continue; continue;
} }
case 69: {
if (tag !== 554) {
break;
}
message.larkSecret = reader.string();
continue;
}
case 59: { case 59: {
if (tag !== 474) { if (tag !== 474) {
break; break;
@@ -3374,6 +3744,14 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.wxPusherBotUids = reader.string(); message.wxPusherBotUids = reader.string();
continue; continue;
} }
case 70: {
if (tag !== 562) {
break;
}
message.wxPusherSptList = reader.string();
continue;
}
} }
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
@@ -3451,6 +3829,7 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined, webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined,
webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined, webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined,
larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined, larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined,
larkSecret: isSet(object.larkSecret) ? globalThis.String(object.larkSecret) : undefined,
ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined, ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined,
ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined, ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined,
ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined, ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined,
@@ -3465,6 +3844,7 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
? globalThis.String(object.wxPusherBotTopicIds) ? globalThis.String(object.wxPusherBotTopicIds)
: undefined, : undefined,
wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : undefined, wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : undefined,
wxPusherSptList: isSet(object.wxPusherSptList) ? globalThis.String(object.wxPusherSptList) : undefined,
}; };
}, },
@@ -3644,6 +4024,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.larkKey !== undefined) { if (message.larkKey !== undefined) {
obj.larkKey = message.larkKey; obj.larkKey = message.larkKey;
} }
if (message.larkSecret !== undefined) {
obj.larkSecret = message.larkSecret;
}
if (message.ntfyUrl !== undefined) { if (message.ntfyUrl !== undefined) {
obj.ntfyUrl = message.ntfyUrl; obj.ntfyUrl = message.ntfyUrl;
} }
@@ -3674,6 +4057,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.wxPusherBotUids !== undefined) { if (message.wxPusherBotUids !== undefined) {
obj.wxPusherBotUids = message.wxPusherBotUids; obj.wxPusherBotUids = message.wxPusherBotUids;
} }
if (message.wxPusherSptList !== undefined) {
obj.wxPusherSptList = message.wxPusherSptList;
}
return obj; return obj;
}, },
@@ -3740,6 +4126,7 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.webhookMethod = object.webhookMethod ?? undefined; message.webhookMethod = object.webhookMethod ?? undefined;
message.webhookContentType = object.webhookContentType ?? undefined; message.webhookContentType = object.webhookContentType ?? undefined;
message.larkKey = object.larkKey ?? undefined; message.larkKey = object.larkKey ?? undefined;
message.larkSecret = object.larkSecret ?? undefined;
message.ntfyUrl = object.ntfyUrl ?? undefined; message.ntfyUrl = object.ntfyUrl ?? undefined;
message.ntfyTopic = object.ntfyTopic ?? undefined; message.ntfyTopic = object.ntfyTopic ?? undefined;
message.ntfyPriority = object.ntfyPriority ?? undefined; message.ntfyPriority = object.ntfyPriority ?? undefined;
@@ -3750,6 +4137,7 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.wxPusherBotAppToken = object.wxPusherBotAppToken ?? undefined; message.wxPusherBotAppToken = object.wxPusherBotAppToken ?? undefined;
message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined; message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined;
message.wxPusherBotUids = object.wxPusherBotUids ?? undefined; message.wxPusherBotUids = object.wxPusherBotUids ?? undefined;
message.wxPusherSptList = object.wxPusherSptList ?? undefined;
return message; return message;
}, },
}; };
@@ -3976,6 +4364,51 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value), responseDeserialize: (value: Buffer) => Response.decode(value),
}, },
getCrons: {
path: "/com.ql.api.Api/GetCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: GetCronsRequest) => Buffer.from(GetCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => GetCronsRequest.decode(value),
responseSerialize: (value: CronsResponse) => Buffer.from(CronsResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => CronsResponse.decode(value),
},
getCronById: {
path: "/com.ql.api.Api/GetCronById",
requestStream: false,
responseStream: false,
requestSerialize: (value: GetCronByIdRequest) => Buffer.from(GetCronByIdRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => GetCronByIdRequest.decode(value),
responseSerialize: (value: CronResponse) => Buffer.from(CronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => CronResponse.decode(value),
},
enableCrons: {
path: "/com.ql.api.Api/EnableCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: EnableCronsRequest) => Buffer.from(EnableCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => EnableCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
disableCrons: {
path: "/com.ql.api.Api/DisableCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: DisableCronsRequest) => Buffer.from(DisableCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => DisableCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
runCrons: {
path: "/com.ql.api.Api/RunCrons",
requestStream: false,
responseStream: false,
requestSerialize: (value: RunCronsRequest) => Buffer.from(RunCronsRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RunCronsRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
} as const; } as const;
export interface ApiServer extends UntypedServiceImplementation { export interface ApiServer extends UntypedServiceImplementation {
@@ -3993,6 +4426,11 @@ export interface ApiServer extends UntypedServiceImplementation {
createCron: handleUnaryCall<CreateCronRequest, CronResponse>; createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>; updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
deleteCrons: handleUnaryCall<DeleteCronsRequest, Response>; deleteCrons: handleUnaryCall<DeleteCronsRequest, Response>;
getCrons: handleUnaryCall<GetCronsRequest, CronsResponse>;
getCronById: handleUnaryCall<GetCronByIdRequest, CronResponse>;
enableCrons: handleUnaryCall<EnableCronsRequest, Response>;
disableCrons: handleUnaryCall<DisableCronsRequest, Response>;
runCrons: handleUnaryCall<RunCronsRequest, Response>;
} }
export interface ApiClient extends Client { export interface ApiClient extends Client {
@@ -4206,6 +4644,81 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void, callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall; ): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCrons(
request: GetCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: CronsResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
getCronById(
request: GetCronByIdRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: CronResponse) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
enableCrons(
request: EnableCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
disableCrons(
request: DisableCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
runCrons(
request: RunCronsRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
} }
export const ApiClient = makeGenericClientConstructor(ApiService, "com.ql.api.Api") as unknown as { export const ApiClient = makeGenericClientConstructor(ApiService, "com.ql.api.Api") as unknown as {
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v3.21.12
// source: back/protos/cron.proto // source: back/protos/cron.proto
/* eslint-disable */ /* eslint-disable */
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v3.21.12
// source: back/protos/health.proto // source: back/protos/health.proto
/* eslint-disable */ /* eslint-disable */
+99 -15
View File
@@ -1,16 +1,79 @@
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron'; import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule'; import nodeSchedule from 'node-schedule';
import { scheduleStacks } from './data'; import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron'; import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { tf } from '../shared/i18n';
/**
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
* 提前拦截避免走 scheduleJob 后才发现无效。
*/
const isValidCronField = (cron: string): boolean => {
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
// node-schedule 会对这种字段返回 null
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
return false;
}
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
// cron-parser 接受但 node-schedule 拒绝,提前拦截
if (/\?/.test(cron)) {
return false;
}
return true;
};
const addCron = ( const addCron = (
call: ServerUnaryCall<AddCronRequest, AddCronResponse>, call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
callback: sendUnaryData<AddCronResponse>, callback: sendUnaryData<AddCronResponse>,
) => { ) => {
// ===== 第一遍:预校验所有 cron 表达式 =====
const validationErrors: string[] = [];
for (const item of call.request.crons) {
const { id, schedule, extra_schedules } = item;
if (!isValidCronField(schedule)) {
validationErrors.push(
tf(
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
String(id),
schedule,
),
);
}
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
if (!isValidCronField(x.schedule)) {
validationErrors.push(
tf(
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
String(id),
x.schedule,
),
);
}
});
}
}
if (validationErrors.length > 0) {
const details = validationErrors.join('\n');
const err: any = new Error(details);
err.code = status.INVALID_ARGUMENT;
err.details = details;
callback(err, null);
return;
}
// ===== 第二遍:注册所有任务 =====
for (const item of call.request.crons) { for (const item of call.request.crons) {
const { id, schedule, command, extra_schedules, name } = item; const { id, schedule, command, extra_schedules, name } = item;
// 取消该 id 已有的旧任务
if (scheduleStacks.has(id)) { if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel()); scheduleStacks.get(id)?.forEach((x) => x.cancel());
} }
@@ -35,20 +98,41 @@ const addCron = (
}); });
} }
scheduleStacks.set(id, [ const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
nodeSchedule.scheduleJob(id, schedule, async () => { Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
Logger.info(`[schedule][准备运行任务] 命令: ${command}`); runCron(command, item);
runCron(command, item); });
}),
...(extra_schedules?.length if (!mainJob) {
? extra_schedules.map((x) => Logger.warn(
nodeSchedule.scheduleJob(id, x.schedule, async () => { '[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
Logger.info(`[schedule][准备运行任务] 命令: ${command}`); id,
runCron(command, item); schedule,
}), );
) }
: []),
]); const extraJobs = extra_schedules?.length
? extra_schedules.map((x) => {
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!job) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
x.schedule,
);
}
return job;
})
: [];
// 过滤 null(兜底保护,正常情况下预校验已拦截)
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
if (jobs.length > 0) {
scheduleStacks.set(id, jobs);
}
} }
callback(null, null); callback(null, null);
+120 -1
View File
@@ -30,6 +30,12 @@ import {
UpdateCronRequest, UpdateCronRequest,
DeleteCronsRequest, DeleteCronsRequest,
CronResponse, CronResponse,
GetCronsRequest,
CronsResponse,
GetCronByIdRequest,
EnableCronsRequest,
DisableCronsRequest,
RunCronsRequest,
} from '../protos/api'; } from '../protos/api';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
@@ -244,7 +250,7 @@ const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
return { return {
...data, ...data,
sub_id: data.sub_id ?? undefined, sub_id: data.sub_id ?? undefined,
extra_schedules: data.extra_schedules ?? undefined, extra_schedules: data.extra_schedules ?? [],
pid: data.pid ?? undefined, pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined, task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined, task_after: data.task_after ?? undefined,
@@ -323,3 +329,116 @@ export const deleteCrons = async (
callback(e); callback(e);
} }
}; };
export const getCrons = async (
call: ServerUnaryCall<GetCronsRequest, CronsResponse>,
callback: sendUnaryData<CronsResponse>,
) => {
try {
const cronService = Container.get(CronService);
const result = await cronService.crontabs({
searchValue: call.request.searchValue || '',
page: '0',
size: '0',
sorter: '',
filters: '',
queryString: '',
});
const data = result.data.map((x) => normalizeCronData(x as CronItem));
callback(null, {
code: 200,
data: data.filter((x): x is CronItem => x !== undefined),
});
} catch (e: any) {
callback(null, {
code: 500,
data: [],
message: e.message,
});
}
};
export const getCronById = async (
call: ServerUnaryCall<GetCronByIdRequest, CronResponse>,
callback: sendUnaryData<CronResponse>,
) => {
try {
if (!call.request.id) {
return callback(null, {
code: 400,
data: undefined,
message: 'id parameter is required',
});
}
const cronService = Container.get(CronService);
const data = (await cronService.getDb({ id: call.request.id })) as CronItem;
callback(null, { code: 200, data: normalizeCronData(data) });
} catch (e: any) {
callback(null, {
code: 404,
data: undefined,
message: e.message,
});
}
};
export const enableCrons = async (
call: ServerUnaryCall<EnableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.enabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const disableCrons = async (
call: ServerUnaryCall<DisableCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.disabled(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
export const runCrons = async (
call: ServerUnaryCall<RunCronsRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
if (!call.request.ids || call.request.ids.length === 0) {
return callback(null, {
code: 400,
message: 'ids parameter is required',
});
}
const cronService = Container.get(CronService);
await cronService.run(call.request.ids);
callback(null, { code: 200 });
} catch (e: any) {
callback(e);
}
};
+18 -5
View File
@@ -7,13 +7,26 @@ import {
DeleteCronResponse, DeleteCronResponse,
} from '../protos/cron'; } from '../protos/cron';
import config from '../config'; import config from '../config';
import { getGrpcCerts } from '../config/grpcCerts';
class Client { class Client {
private client = new CronClient( private _client: CronClient | null = null;
`0.0.0.0:${config.grpcPort}`,
credentials.createInsecure(), private get client(): CronClient {
{ 'grpc.enable_http_proxy': 0 }, if (!this._client) {
); const tlsConfig = getGrpcCerts()!;
this._client = new CronClient(
`localhost:${config.grpcPort}`,
credentials.createSsl(
Buffer.from(tlsConfig.caCert),
Buffer.from(tlsConfig.clientKey),
Buffer.from(tlsConfig.clientCert),
),
{ 'grpc.enable_http_proxy': 0 },
);
}
return this._client;
}
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> { addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+14 -1
View File
@@ -13,7 +13,20 @@ const delCron = (
'[schedule][取消定时任务] 任务ID: %s', '[schedule][取消定时任务] 任务ID: %s',
id, id,
); );
scheduleStacks.get(id)?.forEach(x => x.cancel()); // 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
try {
x.cancel();
} catch (error: any) {
Logger.warn(
'[schedule][取消任务失败] 任务ID: %s, 错误: %s',
id,
error?.message || error,
);
}
});
scheduleStacks.delete(id); scheduleStacks.delete(id);
} }
} }
+4 -7
View File
@@ -10,21 +10,18 @@ const check = async (
switch (call.request.service) { switch (call.request.service) {
case 'cron': case 'cron':
const res = await promiseExec( const res = await promiseExec(
`curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`, `curl -s --noproxy '*' http://localhost:${config.port}/api/system`,
); );
if (res.includes('200')) { if (res.includes('200')) {
return callback(null, { status: 1 }); return callback(null, { status: 1 });
} }
const panelErrLog = await promiseExec( const qinglongErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/panel-error.log`, `tail -n 300 ~/.pm2/logs/qinglong-error.log`,
);
const scheduleErrLog = await promiseExec(
`tail -n 300 ~/.pm2/logs/schedule-error.log`,
); );
return callback( return callback(
new Error(`${scheduleErrLog || ''}\n${panelErrLog || ''}\n${res}`.trim()), new Error(`${qinglongErrLog || ''}\n${res}`.trim()),
); );
default: default:
+20 -13
View File
@@ -2,6 +2,7 @@ import { Service, Inject } from 'typedi';
import path, { join } from 'path'; import path, { join } from 'path';
import config from '../config'; import config from '../config';
import { getFileContentByName } from '../config/util'; import { getFileContentByName } from '../config/util';
import { t } from '../shared/i18n';
import { Response } from 'express'; import { Response } from 'express';
import { request } from 'undici'; import { request } from 'undici';
@@ -11,19 +12,25 @@ export default class ConfigService {
public async getFile(filePath: string, res: Response) { public async getFile(filePath: string, res: Response) {
let content = ''; let content = '';
const avaliablePath = [config.rootPath, config.configPath].map((x) => if (!filePath) {
path.resolve(x, filePath), return res.send({ code: 403, message: t('文件无法访问') });
); }
const normalized = path.normalize(filePath);
if ( if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
config.blackFileList.includes(filePath) || return res.send({ code: 403, message: t('文件无法访问') });
avaliablePath.every( }
(x) => const resolvedRoot = path.resolve(config.rootPath, normalized);
!x.startsWith(config.scriptPath) && !x.startsWith(config.configPath), const resolvedConfig = path.resolve(config.configPath, normalized);
) || const isValidPath =
!filePath resolvedRoot.startsWith(config.scriptPath) ||
) { resolvedRoot.startsWith(config.configPath) ||
return res.send({ code: 403, message: '文件无法访问' }); resolvedConfig.startsWith(config.scriptPath) ||
resolvedConfig.startsWith(config.configPath);
if (!isValidPath) {
return res.send({ code: 403, message: t('文件无法访问') });
}
if (config.blackFileList.includes(path.basename(normalized))) {
return res.send({ code: 403, message: t('文件无法访问') });
} }
if (filePath.startsWith('sample/')) { if (filePath.startsWith('sample/')) {
+305 -64
View File
@@ -2,13 +2,18 @@ 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 {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import fs from 'fs/promises'; import fs from 'fs/promises';
import cron_parser from 'cron-parser'; import CronExpressionParser from 'cron-parser';
import { import {
getFileContentByName, getFileContentByName,
fileExist, fileExist,
killTask, killTask,
killAllTasks,
getUniqPath, getUniqPath,
safeJSONParse, safeJSONParse,
isDemoEnv, isDemoEnv,
@@ -23,11 +28,14 @@ import dayjs from 'dayjs';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { t } from '../shared/i18n';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
import { isEmpty } from 'lodash';
@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 isNodeCron(cron: Crontab) { private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron; const { schedule, extra_schedules } = cron;
@@ -37,6 +45,25 @@ export default class CronService {
return false; return false;
} }
private get schedulerMode(): 'system' | 'node' {
const env = process.env.QL_SCHEDULER;
if (env === 'system') return 'system';
if (env === 'node') return 'node';
try {
execSync('which crond', { stdio: 'ignore' });
return 'system';
} catch {
return 'node';
}
}
private shouldUseCronClient(cron: Crontab): boolean {
if (this.schedulerMode === 'node') {
return !this.isSpecialSchedule(cron.schedule);
}
return this.isNodeCron(cron) && !this.isSpecialSchedule(cron.schedule);
}
private isOnceSchedule(schedule?: string) { private isOnceSchedule(schedule?: string) {
return schedule?.startsWith(ScheduleType.ONCE); return schedule?.startsWith(ScheduleType.ONCE);
} }
@@ -49,25 +76,58 @@ export default class CronService {
return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule); return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule);
} }
private async getLogName(cron: Crontab) {
const { log_name, command, id } = cron;
if (log_name === '/dev/null') {
return log_name;
}
let uniqPath = await getUniqPath(command, `${id}`);
if (log_name) {
const normalizedLogName = log_name.startsWith('/')
? log_name
: path.join(config.logPath, log_name);
if (normalizedLogName.startsWith(config.logPath)) {
uniqPath = log_name;
}
}
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
return uniqPath;
}
public async create(payload: Crontab): Promise<Crontab> { public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload); const tab = new Crontab(payload);
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab); const doc = await this.insert(tab);
if (isDemoEnv()) { if (isDemoEnv()) {
return doc; return doc;
} }
if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) { if (this.shouldUseCronClient(doc)) {
await cronClient.addCron([ try {
{ await cronClient.addCron([
name: doc.name || '', {
id: String(doc.id), name: doc.name || '',
schedule: doc.schedule!, id: String(doc.id),
command: this.makeCommand(doc), schedule: doc.schedule!,
extra_schedules: doc.extra_schedules || [], command: this.makeCommand(doc),
}, extra_schedules: doc.extra_schedules || [],
]); },
]);
} catch (error: any) {
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
// DB 和 crontab.list 有记录但调度器永远不会执行)
await CrontabModel.destroy({ where: { id: doc.id } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
} }
await this.setCrontab(); await this.setCrontab();
@@ -82,26 +142,62 @@ export default class CronService {
const doc = await this.getDb({ id: payload.id }); const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload }); const tab = new Crontab({ ...doc, ...payload });
tab.saved = false; tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab); const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) { if (doc.isDisabled === 1 || isDemoEnv()) {
return newDoc; return newDoc;
} }
if (this.isNodeCron(doc)) { try {
await cronClient.delCron([String(doc.id)]); await cronClient.delCron([String(newDoc.id)]);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
} }
if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) { if (this.shouldUseCronClient(newDoc)) {
await cronClient.addCron([ try {
{ await cronClient.addCron([
name: doc.name || '', {
id: String(newDoc.id), name: doc.name || '',
schedule: newDoc.schedule!, id: String(newDoc.id),
command: this.makeCommand(newDoc), schedule: newDoc.schedule!,
extra_schedules: newDoc.extra_schedules || [], command: this.makeCommand(newDoc),
}, extra_schedules: newDoc.extra_schedules || [],
]); },
]);
} catch (error: any) {
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
await CrontabModel.update(doc, { where: { id: doc.id } });
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (_recoveryError: any) {
this.logger.warn(
'[crontab] Failed to restore old cron job in scheduler after rollback:',
_recoveryError?.message || _recoveryError,
);
}
}
this.logger.error(
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
} }
await this.setCrontab(); await this.setCrontab();
@@ -120,6 +216,7 @@ export default class CronService {
log_path, log_path,
last_running_time = 0, last_running_time = 0,
last_execution_time = 0, last_execution_time = 0,
exit_code,
}: { }: {
ids: number[]; ids: number[];
status: CrontabStatus; status: CrontabStatus;
@@ -127,6 +224,7 @@ export default class CronService {
log_path: string; log_path: string;
last_running_time: number; last_running_time: number;
last_execution_time: number; last_execution_time: number;
exit_code?: number;
}) { }) {
let options: any = { let options: any = {
status, status,
@@ -142,13 +240,47 @@ export default class CronService {
let cron; let cron;
try { try {
cron = await this.getDb({ id }); cron = await this.getDb({ id });
} catch (err) {} } catch (err) { }
if (!cron) { if (!cron) {
continue; continue;
} }
if (status === CrontabStatus.idle && log_path !== cron.log_path) { if (status === CrontabStatus.idle && log_path !== cron.log_path) {
options = omit(options, ['status', 'log_path', 'pid']); options = omit(options, ['status', 'log_path', 'pid']);
} }
// Manage RunningInstance records for status transitions from shell scripts
if (status === CrontabStatus.running) {
// Create a new running instance record
await RunningInstanceModel.create({
cron_id: id,
pid: pid || undefined,
log_path: log_path || undefined,
started_at: last_execution_time || dayjs().unix(),
status: InstanceStatus.running,
});
} else if (status === CrontabStatus.idle) {
// Mark the matching running instance as finished
const finishedAt = dayjs().unix();
const instanceStatus =
exit_code !== undefined && exit_code !== null && exit_code !== 0
? InstanceStatus.error
: InstanceStatus.finished;
await RunningInstanceModel.update(
{
finished_at: finishedAt,
status: instanceStatus,
exit_code: exit_code ?? undefined,
},
{
where: {
cron_id: id,
pid: pid || undefined,
status: InstanceStatus.running,
},
},
);
}
await CrontabModel.update( await CrontabModel.update(
{ ...pickBy(options, (v) => v === 0 || !!v) }, { ...pickBy(options, (v) => v === 0 || !!v) },
{ where: { id } }, { where: { id } },
@@ -158,7 +290,14 @@ export default class CronService {
public async remove(ids: number[]) { public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } }); await CrontabModel.destroy({ where: { id: ids } });
await cronClient.delCron(ids.map(String)); try {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab(); await this.setCrontab();
} }
@@ -327,7 +466,7 @@ export default class CronService {
} }
private formatFilterQuery(query: any, filterQuery: any) { private formatFilterQuery(query: any, filterQuery: any) {
if (filterQuery) { if (!isEmpty(filterQuery)) {
if (!query[Op.and]) { if (!query[Op.and]) {
query[Op.and] = []; query[Op.and] = [];
} }
@@ -442,21 +581,70 @@ export default class CronService {
public async stop(ids: number[]) { public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
if (doc.pid) { // Kill all running instances of this task
try { try {
if (doc.pid) {
await killTask(doc.pid); await killTask(doc.pid);
} catch (error) {
this.logger.error(error);
} }
const command = doc.command.replace(/\s+/g, ' ').trim();
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
);
} }
} }
// Mark all running instances as stopped
const finishedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: finishedAt },
{ where: { cron_id: ids, status: InstanceStatus.running } },
);
await CrontabModel.update( await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined }, { status: CrontabStatus.idle, pid: undefined },
{ where: { id: ids } }, { where: { id: ids } },
); );
} }
public async stopInstance(instanceId: number) {
const instance = await RunningInstanceModel.findOne({
where: { id: instanceId, status: InstanceStatus.running },
});
if (!instance) {
return { code: 400, message: t('实例不存在或已停止') };
}
if (instance.pid) {
try {
await killTask(instance.pid);
} catch (error) {
this.logger.error(
`[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`,
);
}
}
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix(), exit_code: 143 },
{ where: { id: instanceId } },
);
// Check if there are still other running instances for this cron
const otherRunning = await RunningInstanceModel.count({
where: { cron_id: instance.cron_id, status: InstanceStatus.running },
});
if (otherRunning === 0) {
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: instance.cron_id } },
);
}
return { code: 200, message: t('实例已停止') };
}
private async runSingle(cronId: number): Promise<number | void> { private async runSingle(cronId: number): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(() => { return taskLimit.manualRunWithCronLimit(() => {
return new Promise(async (resolve: any) => { return new Promise(async (resolve: any) => {
@@ -476,13 +664,15 @@ export default class CronService {
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
); );
let { id, command, log_path } = cron; let { id, command, log_name } = cron;
const uniqPath = await getUniqPath(command, `${id}`);
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`); const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
if (log_path?.split('/')?.every((x) => x !== uniqPath)) { await fs.mkdir(logDirPath, { recursive: true });
await fs.mkdir(logDirPath, { recursive: true });
}
const logPath = `${uniqPath}/${logTime}.log`; const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`); const absolutePath = path.resolve(config.logPath, `${logPath}`);
const cp = spawn( const cp = spawn(
@@ -498,7 +688,7 @@ export default class CronService {
{ where: { id } }, { where: { id } },
); );
cp.stdout.on('data', async (data) => { cp.stdout.on('data', async (data) => {
await fs.appendFile(absolutePath, data.toString()); await logStreamManager.write(absolutePath, data.toString());
}); });
cp.stderr.on('data', async (data) => { cp.stderr.on('data', async (data) => {
this.logger.info( this.logger.info(
@@ -506,7 +696,7 @@ export default class CronService {
command, command,
data.toString(), data.toString(),
); );
await fs.appendFile(absolutePath, data.toString()); await logStreamManager.write(absolutePath, data.toString());
}); });
cp.on('error', async (err) => { cp.on('error', async (err) => {
this.logger.error( this.logger.error(
@@ -514,7 +704,7 @@ export default class CronService {
command, command,
err, err,
); );
await fs.appendFile(absolutePath, JSON.stringify(err)); await logStreamManager.write(absolutePath, JSON.stringify(err));
}); });
cp.on('exit', async (code) => { cp.on('exit', async (code) => {
@@ -523,10 +713,7 @@ export default class CronService {
JSON.stringify(params), JSON.stringify(params),
code, code,
); );
await CrontabModel.update( await logStreamManager.closeStream(absolutePath);
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id } },
);
resolve({ ...params, pid: cp.pid, code }); resolve({ ...params, pid: cp.pid, code });
}); });
}); });
@@ -535,15 +722,22 @@ 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)); try {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab(); await this.setCrontab();
} }
public async enabled(ids: number[]) { public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } });
const sixCron = docs const crons = docs
.filter((x) => this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule)) .filter((x) => this.shouldUseCronClient(x))
.map((doc) => ({ .map((doc) => ({
name: doc.name || '', name: doc.name || '',
id: String(doc.id), id: String(doc.id),
@@ -555,25 +749,44 @@ export default class CronService {
if (isDemoEnv()) { if (isDemoEnv()) {
return; return;
} }
await cronClient.addCron(sixCron);
try {
await cronClient.addCron(crons);
} catch (error: any) {
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
await this.setCrontab(); await this.setCrontab();
} }
public async log(id: number) { public async log(id: number): Promise<{ content: string; status: string }> {
const doc = await this.getDb({ id }); const doc = await this.getDb({ id });
if (!doc) { if (!doc) {
return ''; return { content: '', status: 'empty' };
}
if (doc.log_name === '/dev/null') {
return { content: t('日志设置为忽略'), status: 'ignored' };
} }
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const logFileExist = doc.log_path && (await fileExist(absolutePath)); const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) { if (logFileExist) {
return await getFileContentByName(`${absolutePath}`); const content = await getFileContentByName(`${absolutePath}`);
const isRunning =
typeof doc.status === 'number' &&
[CrontabStatus.running, CrontabStatus.queued].includes(doc.status);
return { content, status: isRunning ? 'running' : 'completed' };
} else { } else {
return typeof doc.status === 'number' && return typeof doc.status === 'number' &&
[CrontabStatus.queued, CrontabStatus.running].includes(doc.status) [CrontabStatus.queued, CrontabStatus.running].includes(doc.status)
? '运行中...' ? { content: t('运行中...'), status: 'running' }
: '日志不存在...'; : { content: t('日志不存在...'), status: 'notFound' };
} }
} }
@@ -607,9 +820,11 @@ export default class CronService {
if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) {
command = `${TASK_PREFIX}${tab.command}`; command = `${TASK_PREFIX}${tab.command}`;
} }
let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${ let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `;
tab.id // Only include log_name if it has a truthy value to avoid passing null/undefined to shell
} `; if (tab.log_name) {
commandVariable += `log_name=${tab.log_name} `;
}
if (tab.task_before) { if (tab.task_before) {
commandVariable += `task_before='${tab.task_before commandVariable += `task_before='${tab.task_before
.replace(/'/g, "'\\''") .replace(/'/g, "'\\''")
@@ -622,6 +837,9 @@ export default class CronService {
.replace(/;? *\n/g, ';') .replace(/;? *\n/g, ';')
.trim()}' `; .trim()}' `;
} }
if (tab.work_dir) {
commandVariable += `work_dir='${tab.work_dir.replace(/'/g, "'\\''")}' `;
}
const crontab_job_string = `${commandVariable}${command}`; const crontab_job_string = `${commandVariable}${command}`;
return crontab_job_string; return crontab_job_string;
@@ -651,12 +869,25 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, crontab_string); await writeFileWithLock(config.crontabFile, crontab_string);
execSync(`crontab ${config.crontabFile}`); if (this.schedulerMode === 'system') {
try {
execSync(`crontab ${config.crontabFile}`);
} catch (error: any) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to update system crontab:', errorMsg);
}
}
await CrontabModel.update({ saved: true }, { where: {} }); await CrontabModel.update({ saved: true }, { where: {} });
} }
public importCrontab() { public importCrontab() {
exec('crontab -l', (error, stdout, stderr) => { exec('crontab -l', (error, stdout) => {
if (error) {
const errorMsg = error.message || String(error);
this.logger.error('[crontab] Failed to read system crontab:', errorMsg);
}
const lines = stdout.split('\n'); const lines = stdout.split('\n');
const namePrefix = new Date().getTime(); const namePrefix = new Date().getTime();
@@ -670,7 +901,7 @@ export default class CronService {
if ( if (
command && command &&
schedule && schedule &&
cron_parser.parseExpression(schedule).hasNext() CronExpressionParser.parse(schedule).hasNext()
) { ) {
const name = namePrefix + '_' + index; const name = namePrefix + '_' + index;
@@ -695,8 +926,7 @@ export default class CronService {
.filter( .filter(
(x) => (x) =>
x.isDisabled !== 1 && x.isDisabled !== 1 &&
this.isNodeCron(x) && this.shouldUseCronClient(x),
!this.isSpecialSchedule(x.schedule),
) )
.map((doc) => ({ .map((doc) => ({
name: doc.name || '', name: doc.name || '',
@@ -710,8 +940,19 @@ export default class CronService {
await writeFileWithLock(config.crontabFile, ''); await writeFileWithLock(config.crontabFile, '');
return; return;
} }
await cronClient.addCron(regularCrons);
this.setCrontab(tabs); // 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
await this.setCrontab(tabs);
try {
await cronClient.addCron(regularCrons);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to register cron job in scheduler:',
error?.message || error,
);
}
} }
public async bootTask() { public async bootTask() {
@@ -725,7 +966,7 @@ export default class CronService {
{ where: { id: bootTasks.map((t) => t.id!) } }, { where: { id: bootTasks.map((t) => t.id!) } },
); );
for (const task of bootTasks) { for (const task of bootTasks) {
await this.runSingle(task.id!); this.runSingle(task.id!);
} }
} }
} }
+101 -39
View File
@@ -22,6 +22,9 @@ import {
} from '../config/util'; } from '../config/util';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
@Service() @Service()
export default class DependenceService { export default class DependenceService {
@@ -107,7 +110,7 @@ export default class DependenceService {
query: any = {}, query: any = {},
): Promise<Dependence[]> { ): Promise<Dependence[]> {
let condition = query; let condition = query;
if (DependenceTypes[type]) { if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type]; condition.type = DependenceTypes[type];
} }
if (status) { if (status) {
@@ -159,8 +162,19 @@ export default class DependenceService {
const docs = await DependenceModel.findAll({ where: { id: ids } }); const docs = await DependenceModel.findAll({ where: { id: ids } });
for (const doc of docs) { for (const doc of docs) {
taskLimit.removeQueuedDependency(doc); taskLimit.removeQueuedDependency(doc);
const depInstallCommand = getInstallCommand(doc.type, doc.name); let depInstallCommand = getInstallCommand(doc.type, doc.name);
const depUnInstallCommand = getUninstallCommand(doc.type, doc.name); let depUnInstallCommand = getUninstallCommand(doc.type, doc.name);
const isLinuxDependence = doc.type === DependenceTypes.linux;
if (isLinuxDependence) {
const osType = await detectOS();
if (!osType) {
continue;
}
const linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
depInstallCommand = `${linuxCommand.install} ${doc.name.trim()}`;
depUnInstallCommand = `${linuxCommand.uninstall} ${doc.name.trim()}`;
}
const pids = await Promise.all([ const pids = await Promise.all([
getPid(depInstallCommand), getPid(depInstallCommand),
getPid(depUnInstallCommand), getPid(depUnInstallCommand),
@@ -217,39 +231,83 @@ export default class DependenceService {
if (taskLimit.firstDependencyId !== dependency.id) { if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null); return resolve(null);
} }
taskLimit.removeQueuedDependency(dependency);
const depIds = [dependency.id!]; const depIds = [dependency.id!];
let depName = dependency.name.trim();
const actionText = isInstall ? t('安装') : t('删除');
const socketMessageType = isInstall
? 'installDependence'
: 'uninstallDependence';
const isNodeDependence = dependency.type === DependenceTypes.nodejs;
const isLinuxDependence = dependency.type === DependenceTypes.linux;
const isPythonDependence = dependency.type === DependenceTypes.python3;
const osType = await detectOS();
let linuxCommand = {} as typeof LINUX_DEPENDENCE_COMMAND.Alpine;
taskLimit.removeQueuedDependency(dependency);
if (isLinuxDependence) {
if (!osType) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: depIds } },
);
const startTime = dayjs();
const message = tf(
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒',
actionText,
depName,
startTime.format('YYYY-MM-DD HH:mm:ss'),
actionText,
startTime.format('YYYY-MM-DD HH:mm:ss'),
String(startTime.diff(startTime, 'second')),
);
this.sockService.sendMessage({
type: socketMessageType,
message,
references: depIds,
status: DependenceStatus.installFailed,
});
this.updateLog(depIds, message);
return resolve(null);
}
linuxCommand = LINUX_DEPENDENCE_COMMAND[osType];
}
const status = isInstall const status = isInstall
? DependenceStatus.installing ? DependenceStatus.installing
: DependenceStatus.removing; : DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } }); await DependenceModel.update({ status }, { where: { id: depIds } });
const socketMessageType = isInstall let command = isInstall
? 'installDependence'
: 'uninstallDependence';
let depName = dependency.name.trim();
const command = isInstall
? getInstallCommand(dependency.type, depName) ? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName); : getUninstallCommand(dependency.type, depName);
const actionText = isInstall ? '安装' : '删除'; if (isLinuxDependence) {
command = isInstall
? `${linuxCommand.install} ${depName.trim()}`
: `${linuxCommand.uninstall} ${depName.trim()}`;
}
const startTime = dayjs(); const startTime = dayjs();
const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( const message = tf(
'YYYY-MM-DD HH:mm:ss', '开始%s依赖 %s,开始时间 %s\n\n',
)}\n\n`; actionText,
depName,
startTime.format('YYYY-MM-DD HH:mm:ss'),
);
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
message, message,
references: depIds, references: depIds,
status,
}); });
this.updateLog(depIds, message); this.updateLog(depIds, message);
// 判断是否已经安装过依赖 // 判断是否已经安装过依赖
if (isInstall && !force) { if (isInstall && !force) {
const getCommand = getGetCommand(dependency.type, depName); let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type]; const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
getCommand = `${linuxCommand.info} ${depName}`;
}
let depVersion = ''; let depVersion = '';
if (depName.includes(depVersionStr)) { if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp( const symbolRegx = new RegExp(
@@ -261,10 +319,6 @@ export default class DependenceService {
depVersion = _depVersion; 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)) const depInfo = (await promiseExecSuccess(getCommand))
.replace(/\s{2,}/, ' ') .replace(/\s{2,}/, ' ')
.replace(/\s+$/, ''); .replace(/\s+$/, '');
@@ -273,18 +327,24 @@ export default class DependenceService {
depInfo && depInfo &&
((isNodeDependence && depInfo.split(' ')?.[0] === depName) || ((isNodeDependence && depInfo.split(' ')?.[0] === depName) ||
(isLinuxDependence && (isLinuxDependence &&
depInfo.toLocaleLowerCase().includes('installed')) || linuxCommand.check(depInfo.toLocaleLowerCase())) ||
isPythonDependence) && isPythonDependence) &&
(!depVersion || depInfo.includes(depVersion)) (!depVersion || depInfo.includes(depVersion))
) { ) {
const endTime = dayjs(); const endTime = dayjs();
const _message = `检测到已经安装 ${depName}\n\n${depInfo}\n\n跳过安装\n\n依赖${actionText}成功,结束时间 ${endTime.format( const _message = tf(
'YYYY-MM-DD HH:mm:ss', '检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒',
)},耗时 ${endTime.diff(startTime, 'second')}`; depName,
depInfo,
actionText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
message: _message, message: _message,
references: depIds, references: depIds,
status: DependenceStatus.installed,
}); });
this.updateLog(depIds, _message); this.updateLog(depIds, _message);
await DependenceModel.update( await DependenceModel.update(
@@ -309,6 +369,7 @@ export default class DependenceService {
type: socketMessageType, type: socketMessageType,
message: data.toString(), message: data.toString(),
references: depIds, references: depIds,
status,
}); });
this.updateLog(depIds, data.toString()); this.updateLog(depIds, data.toString());
}); });
@@ -318,6 +379,7 @@ export default class DependenceService {
type: socketMessageType, type: socketMessageType,
message: data.toString(), message: data.toString(),
references: depIds, references: depIds,
status,
}); });
this.updateLog(depIds, data.toString()); this.updateLog(depIds, data.toString());
}); });
@@ -327,6 +389,7 @@ export default class DependenceService {
type: socketMessageType, type: socketMessageType,
message: JSON.stringify(err), message: JSON.stringify(err),
references: depIds, references: depIds,
status,
}); });
this.updateLog(depIds, JSON.stringify(err)); this.updateLog(depIds, JSON.stringify(err));
}); });
@@ -334,28 +397,27 @@ export default class DependenceService {
cp.on('exit', async (code) => { cp.on('exit', async (code) => {
const endTime = dayjs(); const endTime = dayjs();
const isSucceed = code === 0; const isSucceed = code === 0;
const resultText = isSucceed ? '成功' : '失败'; const resultText = isSucceed ? t('成功') : t('失败');
const message = `\n依赖${actionText}${resultText},结束时间 ${endTime.format( const message =
'YYYY-MM-DD HH:mm:ss', '\n' +
)},耗时 ${endTime.diff(startTime, 'second')}`; tf('依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
const exitStatus = isSucceed
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed)
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed);
this.sockService.sendMessage({ this.sockService.sendMessage({
type: socketMessageType, type: socketMessageType,
message, message,
references: depIds, references: depIds,
status: exitStatus,
}); });
this.updateLog(depIds, message); this.updateLog(depIds, message);
let status: number;
if (isSucceed) {
status = isInstall
? DependenceStatus.installed
: DependenceStatus.removed;
} else {
status = isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed;
}
const docs = await DependenceModel.findAll({ where: { id: depIds } }); const docs = await DependenceModel.findAll({ where: { id: depIds } });
const _docIds = docs const _docIds = docs
.filter((x) => x.status !== DependenceStatus.cancelled) .filter((x) => x.status !== DependenceStatus.cancelled)
@@ -363,7 +425,7 @@ export default class DependenceService {
if (_docIds.length > 0) { if (_docIds.length > 0) {
await DependenceModel.update( await DependenceModel.update(
{ status }, { status: exitStatus },
{ where: { id: _docIds } }, { where: { id: _docIds } },
); );
} }
+55 -8
View File
@@ -1,7 +1,8 @@
import { Service, Inject } from 'typedi'; import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { Inject, Service } from 'typedi';
import winston from 'winston'; import winston from 'winston';
import config from '../config'; import config from '../config';
import * as fs from 'fs/promises';
import { import {
Env, Env,
EnvModel, EnvModel,
@@ -11,13 +12,12 @@ import {
minPosition, minPosition,
stepPosition, stepPosition,
} from '../data/env'; } from '../data/env';
import groupBy from 'lodash/groupBy';
import { FindOptions, Op } from 'sequelize';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
@Service() @Service()
export default class EnvService { export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) {} constructor(@Inject('logger') private logger: winston.Logger) { }
public async create(payloads: Env[]): Promise<Env[]> { public async create(payloads: Env[]): Promise<Env[]> {
const envs = await this.envs(); const envs = await this.envs();
@@ -27,7 +27,7 @@ export default class EnvService {
envs.length > 0 && envs.length > 0 &&
typeof envs[envs.length - 1].position === 'number' typeof envs[envs.length - 1].position === 'number'
) { ) {
position = envs[envs.length - 1].position!; position = this.getPrecisionPosition(envs[envs.length - 1].position!);
} }
const tabs = payloads.map((x) => { const tabs = payloads.map((x) => {
position = position - stepPosition; position = position - stepPosition;
@@ -100,7 +100,7 @@ export default class EnvService {
} }
private async checkPosition(position: number, edge: number = 0) { private async checkPosition(position: number, edge: number = 0) {
const precisionPosition = parseFloat(position.toPrecision(16)); const precisionPosition = this.getPrecisionPosition(position);
if ( if (
precisionPosition < minPosition || precisionPosition < minPosition ||
precisionPosition > maxPosition || precisionPosition > maxPosition ||
@@ -116,7 +116,7 @@ export default class EnvService {
} }
private getPrecisionPosition(position: number): number { private getPrecisionPosition(position: number): number {
return parseFloat(position.toPrecision(16)); return Math.trunc(parseFloat(position.toPrecision(16)));
} }
public async envs(searchText: string = '', query: any = {}): Promise<Env[]> { public async envs(searchText: string = '', query: any = {}): Promise<Env[]> {
@@ -147,6 +147,7 @@ export default class EnvService {
} }
try { try {
const result = await this.find(condition, [ const result = await this.find(condition, [
[sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'],
['position', 'DESC'], ['position', 'DESC'],
['createdAt', 'ASC'], ['createdAt', 'ASC'],
]); ]);
@@ -190,6 +191,52 @@ export default class EnvService {
await this.set_envs(); await this.set_envs();
} }
public async pin(ids: number[]) {
await EnvModel.update({ isPinned: 1 }, { where: { id: ids } });
}
public async unPin(ids: number[]) {
await EnvModel.update({ isPinned: 0 }, { where: { id: ids } });
}
public async addLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{ labels: Array.from(new Set([...(env.labels || []), ...labels])) },
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async removeLabels(ids: number[], labels: string[]) {
await sequelize.transaction(async (transaction) => {
const docs = await EnvModel.findAll({
where: { id: ids },
transaction,
});
for (const doc of docs) {
const env = doc.get({ plain: true });
await EnvModel.update(
{
labels: (env.labels || []).filter(
(label: string) => !labels.includes(label),
),
},
{ where: { id: env.id }, transaction },
);
}
});
return await this.find({ id: ids });
}
public async set_envs() { public async set_envs() {
const envs = await this.envs('', { const envs = await this.envs('', {
name: { [Op.not]: null }, name: { [Op.not]: null },
+39 -10
View File
@@ -11,30 +11,59 @@ import { promisify } from 'util';
import config from '../config'; import config from '../config';
import { metricsService } from './metrics'; import { metricsService } from './metrics';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { initGrpcCerts } from '../config/grpcCerts';
@Service() @Service()
export class GrpcServerService { export class GrpcServerService {
private server: Server = new Server({ 'grpc.enable_http_proxy': 0 }); private server: Server = new Server({ 'grpc.enable_http_proxy': 0 });
private formatGrpcAddress(host: string, port: number): string {
if (host === '::') {
return `[::]:${port}`;
}
return `${host}:${port}`;
}
async initialize() { async initialize() {
try { try {
this.server.addService(HealthService, { check }); this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron }); this.server.addService(CronService, { addCron, delCron });
this.server.addService(ApiService, Api); this.server.addService(ApiService, Api);
const grpcPort = config.grpcPort; const tlsConfig = await initGrpcCerts();
const bindAsync = promisify(this.server.bindAsync).bind(this.server); const credentials = ServerCredentials.createSsl(
await bindAsync( Buffer.from(tlsConfig.caCert),
`0.0.0.0:${grpcPort}`, [{ cert_chain: Buffer.from(tlsConfig.serverCert), private_key: Buffer.from(tlsConfig.serverKey) }],
ServerCredentials.createInsecure(), true,
); );
Logger.debug(`✌️ gRPC service started successfully`);
metricsService.record('grpc_service_start', 1, { const grpcPort = config.grpcPort;
port: grpcPort.toString(), const hostsToTry = [
}); config.bindHostGrpc,
...(config.bindHostGrpc !== '0.0.0.0' ? ['0.0.0.0'] : [])
];
const bindAsync = promisify(this.server.bindAsync).bind(this.server);
return grpcPort; let lastError: Error | null = null;
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return grpcPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
}
}
Logger.error('Failed to start gRPC service on all hosts');
throw lastError || new Error('Failed to start gRPC service');
} catch (err) { } catch (err) {
Logger.error('Failed to start gRPC service:', err); Logger.error('Failed to start gRPC service:', err);
throw err; throw err;
+36 -16
View File
@@ -3,31 +3,51 @@ import Logger from '../loaders/logger';
import { metricsService } from './metrics'; import { metricsService } from './metrics';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { Server } from 'http'; import { Server } from 'http';
import config from '../config';
@Service() @Service()
export class HttpServerService { export class HttpServerService {
private server?: Server = undefined; private server?: Server = undefined;
async initialize(expressApp: express.Application, port: number) { async initialize(expressApp: express.Application, port: number) {
try { const hostsToTry = [
return new Promise((resolve, reject) => { config.bindHost,
this.server = expressApp.listen(port, '0.0.0.0', () => { ...(config.bindHost !== '0.0.0.0' ? ['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) => { let lastError: Error | null = null;
Logger.error('Failed to start HTTP service:', err);
reject(err); for (const host of hostsToTry) {
try {
const server = await this.tryListen(expressApp, port, host);
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
metricsService.record('http_service_start', 1, {
port: port.toString(),
host
}); });
}); this.server = server;
} catch (err) { return server;
Logger.error('Failed to start HTTP service:', err); } catch (err) {
throw err; lastError = err as Error;
Logger.warn(`Failed to bind HTTP on ${host}:${port}, trying next...`, err);
}
} }
Logger.error('Failed to start HTTP service on all hosts');
throw lastError || new Error('Failed to start HTTP service');
}
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
return new Promise((resolve, reject) => {
const server = expressApp.listen(port, host, () => {
resolve(server);
});
server.on('error', (err: Error) => {
server.close();
reject(err);
});
});
} }
async shutdown() { async shutdown() {
+116 -13
View File
@@ -3,6 +3,7 @@ import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { parseBody, parseHeaders } from '../config/util'; import { parseBody, parseHeaders } from '../config/util';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import { t } from '../shared/i18n';
import UserService from './user'; import UserService from './user';
import { httpClient } from '../config/http'; import { httpClient } from '../config/http';
import { ProxyAgent } from 'undici'; import { ProxyAgent } from 'undici';
@@ -34,6 +35,8 @@ export default class NotificationService {
['chronocat', this.chronocat], ['chronocat', this.chronocat],
['ntfy', this.ntfy], ['ntfy', this.ntfy],
['wxPusherBot', this.wxPusherBot], ['wxPusherBot', this.wxPusherBot],
['wxPusherSpt', this.wxPusherSpt],
['openiLink', this.openiLink],
]); ]);
private title = ''; private title = '';
@@ -90,6 +93,14 @@ export default class NotificationService {
return true; return true;
} }
private parseMailRecipients(value?: string) {
const recipients = (value || '')
.split(/[;]/)
.map((item) => item.trim())
.filter(Boolean);
return recipients.length > 0 ? recipients : undefined;
}
private async gotify() { private async gotify() {
const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params; const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params;
try { try {
@@ -355,7 +366,7 @@ export default class NotificationService {
{ {
title: `${this.title}`, title: `${this.title}`,
thumb_media_id, thumb_media_id,
author: `智能助手`, author: t('智能助手'),
content_source_url: ``, content_source_url: ``,
content: `${this.content.replace(/\n/g, '<br/>')}`, content: `${this.content.replace(/\n/g, '<br/>')}`,
digest: `${this.content}`, digest: `${this.content}`,
@@ -371,7 +382,7 @@ export default class NotificationService {
title: `${this.title}`, title: `${this.title}`,
description: `${this.content}`, description: `${this.content}`,
url: 'https://github.com/whyour/qinglong', url: 'https://github.com/whyour/qinglong',
btntxt: '更多', btntxt: t('更多'),
}, },
}; };
break; break;
@@ -422,7 +433,7 @@ export default class NotificationService {
roomName: `${aibotkName}`, roomName: `${aibotkName}`,
message: { message: {
type: 1, type: 1,
content: `青龙快讯\n\n${this.title}\n${this.content}`, content: `${t('青龙快讯')}\n\n${this.title}\n${this.content}`,
}, },
}; };
break; break;
@@ -433,7 +444,7 @@ export default class NotificationService {
name: `${aibotkName}`, name: `${aibotkName}`,
message: { message: {
type: 1, type: 1,
content: `青龙快讯\n\n${this.title}\n${this.content}`, content: `${t('青龙快讯')}\n\n${this.title}\n${this.content}`,
}, },
}; };
break; break;
@@ -550,19 +561,33 @@ export default class NotificationService {
} }
private async lark() { private async lark() {
let { larkKey } = this.params; let { larkKey, larkSecret } = this.params;
if (!larkKey.startsWith('http')) { if (!larkKey.startsWith('http')) {
larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`; larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`;
} }
const body: Record<string, any> = {
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (larkSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${larkSecret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
try { try {
const res = await httpClient.post(larkKey, { const res = await httpClient.post(larkKey, {
...this.gotOption, ...this.gotOption,
json: { json: body,
msg_type: 'text',
content: { text: `${this.title}\n\n${this.content}` },
},
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
if (res.StatusCode === 0 || res.code === 0) { if (res.StatusCode === 0 || res.code === 0) {
@@ -577,6 +602,7 @@ export default class NotificationService {
private async email() { private async email() {
const { emailPass, emailService, emailUser, emailTo } = this.params; const { emailPass, emailService, emailUser, emailTo } = this.params;
const recipients = this.parseMailRecipients(emailTo) || emailUser;
try { try {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
@@ -588,8 +614,8 @@ export default class NotificationService {
}); });
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: `"青龙快讯" <${emailUser}>`, from: `"${t('青龙快讯')}" <${emailUser}>`,
to: emailTo ? emailTo.split(';') : emailUser, to: recipients,
subject: `${this.title}`, subject: `${this.title}`,
html: `${this.content.replace(/\n/g, '<br/>')}`, html: `${this.content.replace(/\n/g, '<br/>')}`,
}); });
@@ -703,7 +729,7 @@ export default class NotificationService {
// topic_ids 和 uids 至少要有一个 // topic_ids 和 uids 至少要有一个
if (!topicIds.length && !uids.length) { if (!topicIds.length && !uids.length) {
throw new Error('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'); throw new Error(t('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'));
} }
const url = `https://wxpusher.zjiecode.com/api/send/message`; const url = `https://wxpusher.zjiecode.com/api/send/message`;
@@ -731,6 +757,52 @@ export default class NotificationService {
} }
} }
private async wxPusherSpt() {
const { wxPusherSptList } = this.params;
// 处理 SPT,将逗号分隔的字符串转为数组
const spts = wxPusherSptList
? wxPusherSptList
.split(',')
.map((spt) => spt.trim())
.filter((spt) => spt)
: [];
if (!spts.length) {
throw new Error(t('wxPusher SPT 不能为空'));
}
if (spts.length > 10) {
throw new Error(t('wxPusher SPT 最多支持 10 个'));
}
const url = `https://wxpusher.zjiecode.com/api/send/message/simple-push`;
const json: any = {
content: `<h1>${this.title}</h1><br/><div style='white-space: pre-wrap;'>${this.content}</div>`,
summary: this.title,
contentType: 2,
};
// 单个 SPT 用 spt,多个用 sptList
if (spts.length === 1) {
json.spt = spts[0];
} else {
json.sptList = spts;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json,
});
if (res.code === 1000) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
private async chronocat() { private async chronocat() {
const { chronocatURL, chronocatQQ, chronocatToken } = this.params; const { chronocatURL, chronocatQQ, chronocatToken } = this.params;
try { try {
@@ -799,7 +871,7 @@ export default class NotificationService {
} = this.params; } = this.params;
if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) { if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) {
throw new Error('Url 或者 Body 中必须包含 $title'); throw new Error(t('Url 或者 Body 中必须包含 $title'));
} }
const headers = parseHeaders(webhookHeaders); const headers = parseHeaders(webhookHeaders);
@@ -844,4 +916,35 @@ export default class NotificationService {
} }
return {}; return {};
} }
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
content: `${this.title}\n\n${this.content}`,
};
if (openiLinkContextToken) {
body.context_token = openiLinkContextToken;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json: body,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${openiLinkAppToken}`,
},
});
if (res.ok) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
} }
+2 -1
View File
@@ -5,6 +5,7 @@ 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'; import { shareStore } from '../shared/store';
import { t } from '../shared/i18n';
@Service() @Service()
export default class OpenService { export default class OpenService {
@@ -151,7 +152,7 @@ export default class OpenService {
}, },
}; };
} else { } else {
return { code: 400, message: 'client_id 或 client_seret 有误' }; return { code: 400, message: t('client_id 或 client_seret 有误') };
} }
} }
+33 -7
View File
@@ -42,13 +42,11 @@ export default class SshKeyService {
key: string, key: string,
): Promise<void> { ): Promise<void> {
try { try {
await writeFileWithLock( const filePath = path.join(this.sshPath, alias);
path.join(this.sshPath, alias), try {
`${key}${os.EOL}`, await rmPath(filePath);
{ } catch { }
mode: '400', await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' });
},
);
} catch (error) { } catch (error) {
this.logger.error('生成私钥文件失败', error); this.logger.error('生成私钥文件失败', error);
} }
@@ -131,4 +129,32 @@ export default class SshKeyService {
} }
} }
} }
public async addGlobalSSHKey(key: string, alias: string): Promise<void> {
await this.generatePrivateKeyFile(`~global_${alias}`, key);
// Create a global SSH config entry that matches all hosts
// This allows the key to be used for any Git repository
await this.generateGlobalSshConfig(`~global_${alias}`);
}
public async removeGlobalSSHKey(alias: string): Promise<void> {
await this.removePrivateKeyFile(`~global_${alias}`);
await this.removeSshConfig(`~global_${alias}`);
}
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
config,
{
encoding: 'utf8',
mode: '600',
},
);
}
} }
+21 -12
View File
@@ -24,6 +24,7 @@ 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';
import { t, tf } from '../shared/i18n';
import SshKeyService from './sshKey'; 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';
@@ -31,6 +32,7 @@ import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron'; import { CrontabModel } from '../data/cron';
import CrontabService from './cron'; import CrontabService from './cron';
import taskLimit from '../shared/pLimit'; import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
@@ -129,14 +131,14 @@ export default class SubscriptionService {
); );
const absolutePath = await handleLogPath( const absolutePath = await handleLogPath(
logPath as string, logPath as string,
`## 开始执行... ${startTime.format('YYYY-MM-DD HH:mm:ss')}\n`, tf('## 开始执行... %s\n', startTime.format('YYYY-MM-DD HH:mm:ss')),
); );
// 执行sub_before // 执行sub_before
let beforeStr = ''; let beforeStr = '';
try { try {
if (doc.sub_before) { if (doc.sub_before) {
await fs.appendFile(absolutePath, `\n## 执行before命令...\n\n`); await logStreamManager.write(absolutePath, `\n## ${t('执行before命令...')}\n\n`);
beforeStr = await promiseExec(doc.sub_before); beforeStr = await promiseExec(doc.sub_before);
} }
} catch (error: any) { } catch (error: any) {
@@ -144,7 +146,7 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (beforeStr) { if (beforeStr) {
await fs.appendFile(absolutePath, `${beforeStr}\n`); await logStreamManager.write(absolutePath, `${beforeStr}\n`);
} }
}, },
onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => { onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => {
@@ -163,7 +165,7 @@ export default class SubscriptionService {
let afterStr = ''; let afterStr = '';
try { try {
if (sub.sub_after) { if (sub.sub_after) {
await fs.appendFile(absolutePath, `\n\n## 执行after命令...\n\n`); await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`);
afterStr = await promiseExec(sub.sub_after); afterStr = await promiseExec(sub.sub_after);
} }
} catch (error: any) { } catch (error: any) {
@@ -171,16 +173,23 @@ export default class SubscriptionService {
(error.stderr && error.stderr.toString()) || JSON.stringify(error); (error.stderr && error.stderr.toString()) || JSON.stringify(error);
} }
if (afterStr) { if (afterStr) {
await fs.appendFile(absolutePath, `${afterStr}\n`); await logStreamManager.write(absolutePath, `${afterStr}\n`);
} }
await fs.appendFile( await logStreamManager.write(
absolutePath, absolutePath,
`\n## 执行结束... ${endTime.format( '\n' +
'YYYY-MM-DD HH:mm:ss', tf(
)} ${diff} ${LOG_END_SYMBOL}`, '## 执行结束... %s 耗时 %s 秒',
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(diff),
) +
LOG_END_SYMBOL,
); );
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update( await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined }, { status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } }, { where: { id: sub.id } },
@@ -188,19 +197,19 @@ export default class SubscriptionService {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'runSubscriptionEnd', type: 'runSubscriptionEnd',
message: '订阅执行完成', message: t('订阅执行完成'),
references: [doc.id as number], references: [doc.id as number],
}); });
}, },
onError: async (message: string) => { onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
await fs.appendFile(absolutePath, `\n${message}`); await logStreamManager.write(absolutePath, `\n${message}`);
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
const sub = await this.getDb({ id: doc.id }); const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string); const absolutePath = await handleLogPath(sub.log_path as string);
await fs.appendFile(absolutePath, `\n${message}`); await logStreamManager.write(absolutePath, `\n${message}`);
}, },
}; };
} }
+83 -34
View File
@@ -37,6 +37,8 @@ import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock'; import SockService from './sock';
import os from 'os'; import os from 'os';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { t, setLang } from '../shared/i18n';
import { updateLinuxMirrorFile } from '../config/util';
@Service() @Service()
export default class SystemService { export default class SystemService {
@@ -76,8 +78,8 @@ export default class SystemService {
const code = Math.random().toString().slice(-6); const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify( const isSuccess = await this.notificationService.testNotify(
notificationInfo, notificationInfo,
'青龙', t('青龙'),
`【蛟龙】测试通知 https://t.me/jiao_long`, t('【蛟龙】测试通知 https://t.me/jiao_long'),
); );
if (isSuccess) { if (isSuccess) {
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
@@ -86,7 +88,7 @@ export default class SystemService {
}); });
return { code: 200, data: { ...result, code } }; return { code: 200, data: { ...result, code } };
} else { } else {
return { code: 400, message: '通知发送失败,请检查参数' }; return { code: 400, message: t('通知发送失败,请检查参数') };
} }
} }
@@ -98,7 +100,7 @@ export default class SystemService {
}); });
const cron = { const cron = {
id: result.id as number, id: result.id as number,
name: '删除日志', name: t('删除日志'),
command: `ql rmlog ${info.logRemoveFrequency}`, command: `ql rmlog ${info.logRemoveFrequency}`,
runOrigin: 'system' as const, runOrigin: 'system' as const,
}; };
@@ -177,6 +179,7 @@ export default class SystemService {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'updateNodeMirror', type: 'updateNodeMirror',
message: 'update node mirror end', message: 'update node mirror end',
status: 'completed',
}); });
}, },
onError: async (message: string) => { onError: async (message: string) => {
@@ -214,33 +217,11 @@ export default class SystemService {
onEnd?: () => void, onEnd?: () => void,
) { ) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
let defaultDomain = 'https://dl-cdn.alpinelinux.org';
let targetDomain = 'https://dl-cdn.alpinelinux.org';
if (os.platform() !== 'linux') { if (os.platform() !== 'linux') {
return; return;
} }
const content = await fs.promises.readFile('/etc/apk/repositories', { const command = await updateLinuxMirrorFile(info.linuxMirror || '');
encoding: 'utf-8', let hasError = false;
});
const domainMatch = content.match(/(http.*)\/alpine\/.*/);
if (domainMatch) {
defaultDomain = domainMatch[1];
}
if (info.linuxMirror) {
targetDomain = info.linuxMirror;
}
const command = `sed -i 's/${defaultDomain.replace(
/\//g,
'\\/',
)}/${targetDomain.replace(
/\//g,
'\\/',
)}/g' /etc/apk/repositories && apk update -f`;
this.scheduleService.runTask( this.scheduleService.runTask(
command, command,
{ {
@@ -252,10 +233,18 @@ export default class SystemService {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'updateLinuxMirror', type: 'updateLinuxMirror',
message: 'update linux mirror end', message: 'update linux mirror end',
status: 'completed',
}); });
onEnd?.(); onEnd?.();
if (!hasError) {
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
}
}, },
onError: async (message: string) => { onError: async (message: string) => {
hasError = true;
this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
}, },
onLog: async (message: string) => { onLog: async (message: string) => {
@@ -353,6 +342,15 @@ export default class SystemService {
this.sockService.sendMessage({ this.sockService.sendMessage({
type: 'updateSystemVersion', type: 'updateSystemVersion',
message: JSON.stringify(err), message: JSON.stringify(err),
status: 'failed',
});
});
cp.on('exit', (code) => {
this.sockService.sendMessage({
type: 'updateSystemVersion',
message: '',
status: code === 0 ? 'success' : 'failed',
}); });
}); });
@@ -395,9 +393,9 @@ export default class SystemService {
notificationInfo, notificationInfo,
); );
if (isSuccess) { if (isSuccess) {
return { code: 200, message: '通知发送成功' }; return { code: 200, message: t('通知发送成功') };
} else { } else {
return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' }; return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') };
} }
} }
@@ -415,7 +413,7 @@ export default class SystemService {
public async stop({ command, pid }: { command: string; pid: number }) { public async stop({ command, pid }: { command: string; pid: number }) {
if (!pid && !command) { if (!pid && !command) {
return { code: 400, message: '参数错误' }; return { code: 400, message: t('参数错误') };
} }
if (pid) { if (pid) {
@@ -431,7 +429,7 @@ export default class SystemService {
await killTask(_pid); await killTask(_pid);
return { code: 200 }; return { code: 200 };
} else { } else {
return { code: 400, message: '任务未找到' }; return { code: 400, message: t('任务未找到') };
} }
} }
@@ -526,13 +524,64 @@ export default class SystemService {
if (success) { if (success) {
return { code: 200, data: info }; return { code: 200, data: info };
} else { } else {
return { code: 400, message: '设置时区失败' }; return { code: 400, message: t('设置时区失败') };
} }
} }
public async updateLanguage(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const lang = info.lang || 'zh';
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, lang },
});
// Write to standalone lang_env.sh, sourced by shell scripts
try {
await fs.promises.writeFile(
config.langEnvFile,
`export QL_LANG='${lang}'\n`,
);
} catch (error) {
this.logger.error(`Failed to write lang_env.sh: ${error}`);
}
setLang(lang);
return { code: 200, data: { lang } };
}
public async updatePanelTitle(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const panelTitle = info.panelTitle?.trim() || '';
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, panelTitle },
});
return { code: 200, data: { panelTitle } };
}
public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
// Apply the global SSH key
const SshKeyService = require('./sshKey').default;
const Container = require('typedi').Container;
const sshKeyService = Container.get(SshKeyService);
if (info.globalSshKey) {
await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global');
} else {
await sshKeyService.removeGlobalSSHKey('global');
}
return { code: 200, data: result };
}
public async cleanDependence(type: 'node' | 'python3') { public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' }; return { code: 400, message: t('参数错误') };
} }
try { try {
const finalPath = path.join(config.dependenceCachePath, type); const finalPath = path.join(config.dependenceCachePath, type);
+181 -27
View File
@@ -11,6 +11,7 @@ import {
SystemModelInfo, SystemModelInfo,
LoginStatus, LoginStatus,
AuthInfo, AuthInfo,
TokenInfo,
} from '../data/system'; } from '../data/system';
import { NotificationInfo } from '../data/notify'; import { NotificationInfo } from '../data/notify';
import NotificationService from './notify'; import NotificationService from './notify';
@@ -24,6 +25,7 @@ import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { t, tf } from '../shared/i18n';
@Service() @Service()
export default class UserService { export default class UserService {
@@ -65,7 +67,7 @@ export default class UserService {
); );
return { return {
code: 410, code: 410,
message: `失败次数过多,请${waitTime}秒后重试`, message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime, data: waitTime,
}; };
} }
@@ -101,12 +103,23 @@ export default class UserService {
algorithm: 'HS384', algorithm: 'HS384',
}); });
const tokenInfo: TokenInfo = {
value: token,
timestamp,
ip,
address,
platform: req.platform,
};
const updatedTokens = this.addTokenToList(
tokens,
req.platform,
tokenInfo,
);
await this.updateAuthInfo(content, { await this.updateAuthInfo(content, {
token, token,
tokens: { tokens: updatedTokens,
...tokens,
[req.platform]: token,
},
lastlogon: timestamp, lastlogon: timestamp,
retries: 0, retries: 0,
lastip: ip, lastip: ip,
@@ -115,10 +128,19 @@ export default class UserService {
isTwoFactorChecking: false, isTwoFactorChecking: false,
}); });
this.notificationService.notify( this.notificationService.notify(
'登录通知', t('登录通知'),
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${ t('你于') +
req.platform dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
} ip地址 ${ip}`, t('在') +
address +
' ' +
req.platform +
t('端') +
' ' +
t('登录成功') +
t('ip地址') +
' ' +
ip,
); );
await this.insertDb({ await this.insertDb({
type: AuthDataType.loginLog, type: AuthDataType.loginLog,
@@ -151,10 +173,19 @@ export default class UserService {
platform: req.platform, platform: req.platform,
}); });
this.notificationService.notify( this.notificationService.notify(
'登录通知', t('登录通知'),
`你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}${address} ${ t('你于') +
req.platform dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') +
} ip地址 ${ip}`, t('在') +
address +
' ' +
req.platform +
t('端') +
' ' +
t('登录失败') +
t('ip地址') +
' ' +
ip,
); );
await this.insertDb({ await this.insertDb({
type: AuthDataType.loginLog, type: AuthDataType.loginLog,
@@ -171,20 +202,46 @@ export default class UserService {
const waitTime = Math.round(Math.pow(3, retries + 1)); const waitTime = Math.round(Math.pow(3, retries + 1));
return { return {
code: 410, code: 410,
message: `失败次数过多,请${waitTime}秒后重试`, message: tf('失败次数过多,请%s秒后重试', waitTime),
data: waitTime, data: waitTime,
}; };
} else { } else {
return { code: 400, message: config.authError }; return { code: 400, message: t('错误的用户名密码,请重试') };
} }
} }
} }
public async logout(platform: string): Promise<any> { public async logout(platform: string, tokenValue: string): Promise<any> {
if (!platform || !tokenValue) {
this.logger.warn('Invalid logout parameters - empty platform or token');
return;
}
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
// Verify the token exists before attempting to remove it
const tokenExists = this.findTokenInList(
authInfo.tokens,
platform,
tokenValue,
);
if (!tokenExists && authInfo.token !== tokenValue) {
// Token not found, but don't throw error - user may have already logged out
this.logger.info(
`Logout attempted for non-existent token on platform: ${platform}`,
);
return;
}
const updatedTokens = this.removeTokenFromList(
authInfo.tokens,
platform,
tokenValue,
);
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
token: '', token: authInfo.token === tokenValue ? '' : authInfo.token,
tokens: { ...authInfo.tokens, [platform]: '' }, tokens: updatedTokens,
}); });
} }
@@ -220,17 +277,17 @@ export default class UserService {
password: string; password: string;
}) { }) {
if (password === 'admin') { if (password === 'admin') {
return { code: 400, message: '密码不能设置为admin' }; return { code: 400, message: t('密码不能设置为admin') };
} }
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { username, password }); await this.updateAuthInfo(authInfo, { username, password });
return { code: 200, message: '更新成功' }; return { code: 200, message: t('更新成功') };
} }
public async updateAvatar(avatar: string) { public async updateAvatar(avatar: string) {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar }); await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: '更新成功' }; return { code: 200, data: avatar, message: t('更新成功') };
} }
public async initTwoFactor() { public async initTwoFactor() {
@@ -264,7 +321,7 @@ export default class UserService {
const authInfo = await 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: t('未知错误') };
} }
const isValid = authenticator.verify({ const isValid = authenticator.verify({
token: code, token: code,
@@ -288,11 +345,11 @@ export default class UserService {
lastaddr: address, lastaddr: address,
platform: req.platform, platform: req.platform,
}); });
return { code: 430, message: '验证失败' }; return { code: 430, message: t('验证失败') };
} }
} }
public async deactiveTwoFactor() { public async deactivateTwoFactor() {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { await this.updateAuthInfo(authInfo, {
twoFactorActivated: false, twoFactorActivated: false,
@@ -350,8 +407,8 @@ export default class UserService {
const code = Math.random().toString().slice(-6); const code = Math.random().toString().slice(-6);
const isSuccess = await this.notificationService.testNotify( const isSuccess = await this.notificationService.testNotify(
notificationInfo, notificationInfo,
'青龙', t('青龙'),
`【蛟龙】测试通知 https://t.me/jiao_long`, t('【蛟龙】测试通知 https://t.me/jiao_long'),
); );
if (isSuccess) { if (isSuccess) {
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
@@ -360,12 +417,109 @@ export default class UserService {
}); });
return { code: 200, data: { ...result, code } }; return { code: 200, data: { ...result, code } };
} else { } else {
return { code: 400, message: '通知发送失败,请检查参数' }; return { code: 400, message: t('通知发送失败,请检查参数') };
} }
} }
private normalizeTokens(
tokens: Record<string, string | TokenInfo[]>,
): Record<string, TokenInfo[]> {
const normalized: Record<string, TokenInfo[]> = {};
for (const [platform, value] of Object.entries(tokens)) {
if (typeof value === 'string') {
// Legacy format: convert string token to TokenInfo array
if (value) {
normalized[platform] = [
{
value,
timestamp: Date.now(),
ip: '',
address: '',
platform,
},
];
} else {
normalized[platform] = [];
}
} else {
// Already in new format
normalized[platform] = value || [];
}
}
return normalized;
}
private addTokenToList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenInfo: TokenInfo,
maxTokensPerPlatform: number = config.maxTokensPerPlatform,
): Record<string, TokenInfo[]> {
// Validate maxTokensPerPlatform parameter
if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) {
this.logger.warn(
`Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`,
);
maxTokensPerPlatform = config.maxTokensPerPlatform;
}
const normalized = this.normalizeTokens(tokens);
if (!normalized[platform]) {
normalized[platform] = [];
}
// Add new token
normalized[platform].unshift(tokenInfo);
// Limit the number of active tokens per platform
if (normalized[platform].length > maxTokensPerPlatform) {
normalized[platform] = normalized[platform].slice(
0,
maxTokensPerPlatform,
);
}
return normalized;
}
private removeTokenFromList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): Record<string, TokenInfo[]> {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
normalized[platform] = normalized[platform].filter(
(t) => t.value !== tokenValue,
);
}
return normalized;
}
private findTokenInList(
tokens: Record<string, string | TokenInfo[]>,
platform: string,
tokenValue: string,
): TokenInfo | undefined {
const normalized = this.normalizeTokens(tokens);
if (normalized[platform]) {
return normalized[platform].find((t) => t.value === tokenValue);
}
return undefined;
}
public async resetAuthInfo(info: Partial<AuthInfo>) { public async resetAuthInfo(info: Partial<AuthInfo>) {
const { retries, twoFactorActivated, password, username } = info; const { retries, twoFactorActivated, password, username } = info;
if (password === 'admin') {
return { code: 400, message: t('密码不能设置为admin') };
}
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const payload = pickBy( const payload = pickBy(
{ {
+54
View File
@@ -0,0 +1,54 @@
import { AuthInfo, TokenInfo } from '../data/system';
export function isDefaultAuthInfo(authInfo: AuthInfo): boolean {
return (
Object.keys(authInfo).length === 2 &&
authInfo.username === 'admin' &&
authInfo.password === 'admin'
);
}
/**
* Validates if a token exists in the authentication info.
* Supports both legacy string tokens and new TokenInfo array format.
*
* @param authInfo - The authentication information
* @param headerToken - The token to validate
* @param platform - The platform (desktop, mobile)
* @returns true if the token is valid, false otherwise
*/
export function isValidToken(
authInfo: AuthInfo | null | undefined,
headerToken: string,
platform: string,
): boolean {
if (!authInfo || !headerToken) {
return false;
}
const { token = '', tokens = {} } = authInfo;
// Check legacy token field
if (headerToken === token) {
return true;
}
// Check platform-specific tokens (support both legacy string and new TokenInfo[] format)
const platformTokens = tokens[platform];
// Handle null/undefined platformTokens
if (platformTokens === null || platformTokens === undefined) {
return false;
}
if (typeof platformTokens === 'string') {
// Legacy format: single string token
return headerToken === platformTokens;
} else if (Array.isArray(platformTokens)) {
// New format: array of TokenInfo objects
return platformTokens.some((t: TokenInfo) => t && t.value === headerToken);
}
// Unexpected type - log warning and reject
return false;
}
+175
View File
@@ -0,0 +1,175 @@
import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'暂无权限': 'Access denied',
'参数错误': 'Invalid parameter',
'参数不正确': 'Invalid parameter',
'文件无法访问': 'File not accessible',
'文件不存在': 'File not found',
'路径不存在': 'Path does not exist',
'路径不正确': 'Invalid path',
'通知发送失败,请检查参数': 'Notification failed, check parameters',
'通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings',
'通知发送成功': 'Notification sent successfully',
'设置时区失败': 'Failed to set timezone',
'任务未找到': 'Task not found',
'密码不能设置为admin': 'Password cannot be admin',
'更新成功': 'Update successful',
'未知错误': 'Unknown error',
'验证失败': 'Verification failed',
'实例不存在或已停止': 'Instance does not exist or stopped',
'实例已停止': 'Instance stopped',
'确认停止实例': 'Confirm to stop instance',
'确认停止运行实例': 'Confirm to stop running instance',
'确认停止': 'Confirm to stop',
'确认停止定时任务': 'Confirm to stop scheduled task',
'确认删除': 'Confirm to delete',
'确认删除定时任务': 'Confirm to delete scheduled task',
'确认删除选中的定时任务吗': 'Confirm to delete selected tasks?',
'确认运行': 'Confirm to run',
'确认运行定时任务': 'Confirm to run scheduled task',
'确认保存': 'Confirm to save',
'确认保存文件': 'Confirm to save file',
'确认重启': 'Confirm restart',
'确认启用': 'Confirm to enable',
'确认禁用': 'Confirm to disable',
'确认': 'Confirm',
'删除成功': 'Deleted successfully',
'操作成功': 'Operation successful',
'参数不完整': 'Incomplete parameters',
'默认路径不支持删除': 'Default path cannot be deleted',
'必须在日志目录下': 'Must be within log directory',
'备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite',
'如果恢复失败,可进入容器执行': 'If restore fails, run in container:',
'系统将在': 'System will',
'秒后自动刷新': 'refresh in seconds',
'生成数据中...': 'Generating data...',
'每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format',
'不支持当前依赖类型': 'Unsupported dependency type',
'依赖已存在': 'Dependency already exists',
'依赖不存在': 'Dependency does not exist',
'该脚本正在运行中': 'Script is running',
'该脚本未在运行中': 'Script is not running',
'文件内容为空': 'File content is empty',
'文件名不能为空': 'File name cannot be empty',
'标签不能为空': 'Label cannot be empty',
'名称不能为空': 'Name cannot be empty',
'名称不能为保留关键字': 'Name cannot be reserved keyword',
'名称已存在': 'Name already exists',
'密码错误': 'Incorrect password',
'用户不存在': 'User does not exist',
'请输入用户名和密码': 'Please enter username and password',
'无权访问': 'Access denied',
'登录成功': 'Login successful',
'退出成功': 'Logout successful',
'Token 已失效': 'Token expired',
'Token 无效': 'Invalid token',
'两步骤验证已开启': '2FA enabled',
'两步骤验证已关闭': '2FA disabled',
'验证码错误': 'Invalid verification code',
'验证码已过期': 'Verification code expired',
'请先开启两步骤验证': 'Please enable 2FA first',
'两步骤验证密钥不能为空': '2FA secret cannot be empty',
'用户已存在': 'User already exists',
'用户名不能为admin': 'Username cannot be admin',
'不能删除自己': 'Cannot delete yourself',
'不能禁用自己': 'Cannot disable yourself',
'文件路径无效': 'Invalid file path',
'保存成功': 'Saved successfully',
'client_id 或 client_seret 有误': 'Invalid client_id or client_secret',
'订阅执行完成': 'Subscription completed',
'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids',
'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty',
'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens',
'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title',
'绝对路径必须在日志目录内或使用 /dev/null':
'Absolute path must be within log directory or use /dev/null',
'请先登录': 'Please login first',
'运行中...': 'Running...',
'日志不存在...': 'Log does not exist...',
'未分类': 'Uncategorized',
'任务重复运行': 'Duplicate task execution',
'日志设置为忽略': 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule',
'日志名称只能包含字母、数字、下划线和连字符':
'Log name can only contain letters, numbers, underscores, and hyphens',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
'青龙快讯': 'QingLong',
'登录通知': 'Login Notification',
'你于': 'You at ',
'在': ' in ',
: '',
: 'login failed',
'ip地址': ', IP: ',
'任务#%s': 'Task#%s',
: 'Install',
: 'Uninstall',
: 'Succeeded',
: 'Failed',
'失败次数过多,请%s秒后重试':
'Too many failed attempts, please retry in %s seconds',
: 'Smart Assistant',
: 'More',
'开始%s依赖 %s,开始时间 %s\n\n当前系统不支持\n\n依赖%s失败,结束时间 %s,耗时 %s 秒':
'Start %s dependency %s, start time %s\n\nCurrent system not supported\n\nDependency %s failed, end time %s, elapsed %s seconds',
'检测到已经安装 %s\n\n%s\n\n跳过安装\n\n依赖%s成功,结束时间 %s,耗时 %s 秒':
'Already installed %s\n\n%s\n\nSkipping install\n\nDependency %s succeeded, end time %s, elapsed %s seconds',
'开始%s依赖 %s,开始时间 %s\n\n':
'Start %s dependency %s, start time %s\n\n',
'依赖%s%s,结束时间 %s,耗时 %s 秒':
'Dependency %s%s, end time %s, elapsed %s seconds',
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置':
'Task: %s, command: %s, schedule: %s, more than %d instances running, please check schedule settings',
: 'QingLong',
'【蛟龙】测试通知 https://t.me/jiao_long':
'[JiaoLong] Test notification https://t.me/jiao_long',
'生成token': 'Generate token',
'删除日志': 'Delete logs',
'## 开始执行... %s\n': '## Start executing... %s\n',
'执行before命令...': 'Execute before command...',
'执行after命令...': 'Execute after command...',
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
'调度器注册失败,任务创建已回滚':
'Scheduler registration failed, task creation rolled back',
'调度器注册失败,任务更新已回滚':
'Scheduler registration failed, task update rolled back',
'调度器注册失败,任务启用已回滚':
'Scheduler registration failed, task enable rolled back',
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
},
};
let currentLang: string = 'zh';
export function setLang(lang: string) {
currentLang = lang || 'zh';
shareStore.setLang(currentLang);
}
/** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en */
export function systemLang(): string {
const prefix = Intl.DateTimeFormat().resolvedOptions().locale.split('-')[0];
return prefix === 'en' ? 'en' : 'zh';
}
export function t(key: string, lang?: string): string {
const effectiveLang = lang || currentLang;
if (effectiveLang === 'en' && messages.en[key]) {
return messages.en[key];
}
return key;
}
export function tf(key: string, ...args: (string | number)[]): string {
return args.reduce<string>(
(str, arg) => str.replace(/%s|%d/, String(arg)),
t(key),
);
}
+110
View File
@@ -0,0 +1,110 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
*/
export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
}
}
/**
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);
}
/**
* Get the number of open streams
*/
getOpenStreamCount(): number {
return this.streams.size;
}
}
// Export a singleton instance for shared use
export const logStreamManager = new LogStreamManager();
+30 -7
View File
@@ -4,6 +4,7 @@ import { AuthDataType, SystemModel } from '../data/system';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { Dependence } from '../data/dependence'; import { Dependence } from '../data/dependence';
import NotificationService from '../services/notify'; import NotificationService from '../services/notify';
import { t, tf } from '../shared/i18n';
import { import {
ICronFn, ICronFn,
IDependencyFn, IDependencyFn,
@@ -14,6 +15,7 @@ import {
import config from '../config'; import config from '../config';
import { credentials } from '@grpc/grpc-js'; import { credentials } from '@grpc/grpc-js';
import { ApiClient } from '../protos/api'; import { ApiClient } from '../protos/api';
import { getGrpcCerts } from '../config/grpcCerts';
class TaskLimit { class TaskLimit {
private dependenyLimit = new PQueue({ concurrency: 1 }); private dependenyLimit = new PQueue({ concurrency: 1 });
@@ -36,11 +38,26 @@ class TaskLimit {
private systemLimit = new PQueue({ private systemLimit = new PQueue({
concurrency: Math.max(os.cpus().length, 4), concurrency: Math.max(os.cpus().length, 4),
}); });
private client = new ApiClient( private _client: ApiClient | null = null;
`0.0.0.0:${config.grpcPort}`,
credentials.createInsecure(), private get client(): ApiClient {
{ 'grpc.enable_http_proxy': 0 }, if (!this._client) {
); const tlsConfig = getGrpcCerts();
const creds = tlsConfig
? credentials.createSsl(
Buffer.from(tlsConfig.caCert),
Buffer.from(tlsConfig.clientKey),
Buffer.from(tlsConfig.clientCert),
)
: credentials.createInsecure();
this._client = new ApiClient(
`localhost:${config.grpcPort}`,
creds,
{ 'grpc.enable_http_proxy': 0 },
);
}
return this._client;
}
get cronLimitActiveCount() { get cronLimitActiveCount() {
return this.cronLimit.pending; return this.cronLimit.pending;
@@ -136,8 +153,14 @@ class TaskLimit {
this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1); this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1);
this.client.systemNotify( this.client.systemNotify(
{ {
title: '任务重复运行', title: t('任务重复运行'),
content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`, content: tf(
'任务:%s,命令:%s,定时:%s,处于运行中的超过 %d 个,请检查定时设置',
cron.name || '',
cron.command || '',
cron.schedule || '',
5,
),
}, },
(err, res) => { (err, res) => {
if (err) { if (err) {
+46
View File
@@ -2,10 +2,56 @@ import { spawn } from 'cross-spawn';
import taskLimit from './pLimit'; import taskLimit from './pLimit';
import Logger from '../loaders/logger'; import Logger from '../loaders/logger';
import { ICron } from '../protos/cron'; import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import dayjs from 'dayjs';
export function runCron(cmd: string, cron: ICron): Promise<number | void> { export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => { return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => { return new Promise(async (resolve: any) => {
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{ where: { cron_id: Number(cron.id), status: InstanceStatus.running } },
);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
}
Logger.info( Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({ `[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron, ...cron,
+8
View File
@@ -8,11 +8,13 @@ import path from 'path';
export enum EKeyv { export enum EKeyv {
'apps' = 'apps', 'apps' = 'apps',
'authInfo' = 'authInfo', 'authInfo' = 'authInfo',
'lang' = 'lang',
} }
export interface IKeyvStore { export interface IKeyvStore {
apps: App[]; apps: App[];
authInfo: AuthInfo; authInfo: AuthInfo;
lang: string;
} }
const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite')); const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite'));
@@ -31,4 +33,10 @@ export const shareStore = {
updateApps(apps: App[]) { updateApps(apps: App[]) {
return keyvStore.set<IKeyvStore['apps']>(EKeyv.apps, apps); return keyvStore.set<IKeyvStore['apps']>(EKeyv.apps, apps);
}, },
getLang() {
return keyvStore.get<IKeyvStore['lang']>(EKeyv.lang);
},
setLang(value: IKeyvStore['lang']) {
return keyvStore.set<IKeyvStore['lang']>(EKeyv.lang, value);
},
}; };
+58 -2
View File
@@ -1,6 +1,8 @@
import { Joi } from 'celebrate'; import { Joi } from 'celebrate';
import cron_parser from 'cron-parser'; import CronExpressionParser from 'cron-parser';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
const validateSchedule = (value: string, helpers: any) => { const validateSchedule = (value: string, helpers: any) => {
if ( if (
@@ -10,8 +12,18 @@ const validateSchedule = (value: string, helpers: any) => {
return value; return value;
} }
// 检测裸 /N 模式:cron-parser 会接受,但 node-schedule 会返回 null
// 提前拦截,避免任务入库后调度器注册失败
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
return helpers.error('any.invalid');
}
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
if (/\?/.test(value)) {
return helpers.error('any.invalid');
}
try { try {
if (cron_parser.parseExpression(value).hasNext()) { if (CronExpressionParser.parse(value).hasNext()) {
return value; return value;
} }
} catch (e) { } catch (e) {
@@ -37,4 +49,48 @@ export const commonCronSchema = {
extra_schedules: Joi.array().optional().allow(null), extra_schedules: Joi.array().optional().allow(null),
task_before: Joi.string().optional().allow('').allow(null), task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null), task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
.optional()
.allow('')
.allow(null)
.custom((value, helpers) => {
if (!value) return value;
// Check if it's an absolute path
if (value.startsWith('/')) {
// Allow /dev/null as special case
if (value === '/dev/null') {
return value;
}
// For other absolute paths, ensure they are within the safe log directory
const normalizedValue = path.normalize(value);
const normalizedLogPath = path.normalize(config.logPath);
if (!normalizedValue.startsWith(normalizedLogPath)) {
return helpers.error('string.unsafePath');
}
return value;
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
value,
)
) {
return helpers.error('string.pattern.base');
}
if (value.length > 100) {
return helpers.error('string.max');
}
return value;
})
.messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null),
work_dir: Joi.string().optional().allow('').allow(null),
}; };
+81
View File
@@ -0,0 +1,81 @@
# Kubernetes deployment
This deploys Qinglong as a single-replica `StatefulSet` with persistent data at `/ql/data`.
```bash
kubectl apply -k deploy/kubernetes/overlays/local
kubectl -n qinglong rollout status statefulset/qinglong
```
Open the panel locally:
```bash
kubectl -n qinglong port-forward svc/qinglong 5700:5700
```
Then visit <http://127.0.0.1:5700>.
## Image registry overlays
Use `overlays/example` as the committed template for registry customization:
```yaml
whyour/qinglong:debian -> registry.example.com/whyour/qinglong:debian
```
Create `overlays/local/kustomization.yaml` for the actual cluster image. The `local` overlay is ignored by git so private registry names, digests, and credentials-related references stay local.
## Storage
The manifest creates a 5 GiB `ReadWriteOnce` PVC from the cluster's default `StorageClass`.
If your cluster has no default storage class, add `storageClassName` under:
```yaml
volumeClaimTemplates:
- metadata:
name: data
spec:
storageClassName: your-storage-class
```
Keep `replicas: 1`. Qinglong stores state in the persistent data directory, including SQLite files, so multiple replicas should not share the same data volume.
## Ingress example
If you expose Qinglong through an Ingress path other than `/`, set `QlBaseUrl` to the same path with leading and trailing slashes.
```yaml
env:
- name: QlBaseUrl
value: "/qinglong/"
```
Example Ingress:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: qinglong
namespace: qinglong
spec:
rules:
- host: qinglong.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: qinglong
port:
number: 5700
```
## Maintenance commands
```bash
kubectl -n qinglong logs -f statefulset/qinglong
kubectl -n qinglong exec -it statefulset/qinglong -- ql check
kubectl -n qinglong exec -it statefulset/qinglong -- ql update
```
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- qinglong.yaml
+95
View File
@@ -0,0 +1,95 @@
apiVersion: v1
kind: Namespace
metadata:
name: qinglong
---
apiVersion: v1
kind: Service
metadata:
name: qinglong
namespace: qinglong
labels:
app.kubernetes.io/name: qinglong
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: qinglong
ports:
- name: http
port: 5700
targetPort: http
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: qinglong
namespace: qinglong
labels:
app.kubernetes.io/name: qinglong
spec:
serviceName: qinglong
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: qinglong
template:
metadata:
labels:
app.kubernetes.io/name: qinglong
spec:
securityContext:
fsGroup: 5432
fsGroupChangePolicy: OnRootMismatch
containers:
- name: qinglong
image: whyour/qinglong:debian
imagePullPolicy: IfNotPresent
env:
- name: QlBaseUrl
value: "/"
- name: TZ
value: Asia/Shanghai
ports:
- name: http
containerPort: 5700
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 60
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 6
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
volumeMounts:
- name: data
mountPath: /ql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: whyour/qinglong
newName: registry.example.com/whyour/qinglong
newTag: debian
+33 -12
View File
@@ -1,11 +1,26 @@
FROM python:3.11-alpine3.18 AS builder # Run Node package installation natively on the builder. Node/npm can spin at
# 100% CPU when Alpine s390x is emulated through QEMU.
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
ARG TARGETARCH
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
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 add --no-cache git \
&& apk add nodejs npm git \ && npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
&& npm i -g pnpm@8.3.1 pm2 ts-node \
&& cd /tmp/build \ && cd /tmp/build \
&& pnpm install --prod && case "${TARGETARCH}" in \
amd64) NODE_ARCH=x64 ;; \
386) NODE_ARCH=ia32 ;; \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
esac \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \
pnpm install --prod
FROM python:3.11-alpine FROM python:3.11-alpine
@@ -17,16 +32,17 @@ ARG PYTHON_SHORT_VERSION=3.11
ENV QL_DIR=/ql \ ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \ QL_BRANCH=${QL_BRANCH} \
QL_CONTAINER=true \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
SHELL=/bin/bash \ SHELL=/bin/bash \
PS1="\u@\h:\w \$ " PS1="\u@\h:\w \$ "
VOLUME /ql/data VOLUME /ql/data
EXPOSE 5700 EXPOSE 5700
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /usr/local/bin/. /usr/local/bin/ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN set -x \ RUN set -x \
&& apk update -f \ && apk update -f \
@@ -69,10 +85,11 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \ 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 PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -80,9 +97,13 @@ 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/
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1 CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"] ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+33 -12
View File
@@ -1,11 +1,26 @@
FROM python:3.10-alpine3.18 AS builder # Run Node package installation natively on the builder. Node/npm can spin at
# 100% CPU when Alpine s390x is emulated through QEMU.
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
ARG TARGETARCH
ENV NPM_CONFIG_PREFIX=/opt/node-global
ENV PATH=/opt/node-global/bin:${PATH}
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 add --no-cache git \
&& apk add nodejs npm git \ && npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
&& npm i -g pnpm@8.3.1 pm2 ts-node \
&& cd /tmp/build \ && cd /tmp/build \
&& pnpm install --prod && case "${TARGETARCH}" in \
amd64) NODE_ARCH=x64 ;; \
386) NODE_ARCH=ia32 ;; \
ppc64le) NODE_ARCH=ppc64 ;; \
*) NODE_ARCH="${TARGETARCH}" ;; \
esac \
&& npm_config_target_platform=linux \
npm_config_target_arch="${NODE_ARCH}" \
npm_config_target_libc=musl \
pnpm install --prod
FROM python:3.10-alpine FROM python:3.10-alpine
@@ -17,16 +32,17 @@ ARG PYTHON_SHORT_VERSION=3.10
ENV QL_DIR=/ql \ ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \ QL_BRANCH=${QL_BRANCH} \
QL_CONTAINER=true \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
SHELL=/bin/bash \ SHELL=/bin/bash \
PS1="\u@\h:\w \$ " PS1="\u@\h:\w \$ "
VOLUME /ql/data VOLUME /ql/data
EXPOSE 5700 EXPOSE 5700
COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
COPY --from=builder /usr/local/bin/. /usr/local/bin/ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
RUN set -x \ RUN set -x \
&& apk update -f \ && apk update -f \
@@ -69,10 +85,11 @@ RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \
HOME=/root
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin \ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \ 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 PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
@@ -80,9 +97,13 @@ 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/
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR} WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://127.0.0.1:5700/api/health || exit 1 CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"] ENTRYPOINT ["./docker/docker-entrypoint.sh"]
+122
View File
@@ -0,0 +1,122 @@
# Node 20 Bookworm is the latest official Node image variant that covers the
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
FROM node:20-bookworm-slim AS nodebuilder
FROM python:3.11.14-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.11.14-slim-bookworm
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.11
ENV QL_DIR=/ql \
QL_BRANCH=${QL_BRANCH} \
QL_CONTAINER=true \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
ARG QL_UID=5432
ARG QL_GID=5432
RUN groupadd -g ${QL_GID} qinglong && \
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
chmod 700 /home/qinglong/.ssh && \
chown -R ${QL_UID}:${QL_GID} /home/qinglong && \
mkdir -p /etc/sudoers.d && \
echo 'qinglong ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qinglong
ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
apt-get update && \
apt-get upgrade -y && \
apt-get install --no-install-recommends -y git \
curl \
wget \
tzdata \
perl \
openssl \
openssh-client \
jq \
procps \
netcat-openbsd \
sudo \
unzip \
libatomic1 && \
apt-get clean && \
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 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
rm -rf /etc/apt/apt.conf.d/docker-clean && \
ulimit -c 0
RUN mkdir -p ${QL_DIR} && \
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
USER qinglong
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 /tmp/static && \
mkdir -p ${QL_DIR}/static && \
cp -rf /tmp/static/* ${QL_DIR}/static && \
rm -rf /tmp/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 \
HOME=/home/qinglong
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
USER root
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
VOLUME /ql/data
EXPOSE 5700
+122
View File
@@ -0,0 +1,122 @@
# Node 20 Bookworm is the latest official Node image variant that covers the
# full Debian build matrix, including arm/v7, ppc64le, and s390x.
FROM node:20-bookworm-slim AS nodebuilder
FROM python:3.10-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get install --no-install-recommends -y libatomic1 && \
npm i -g pnpm@8.3.1 && \
cd /tmp/build && \
pnpm install --prod
FROM python:3.10-slim-bookworm
ARG QL_MAINTAINER="whyour"
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} \
QL_CONTAINER=true \
LANG=C.UTF-8 \
SHELL=/bin/bash \
PS1="\u@\h:\w \$ "
ARG QL_UID=5432
ARG QL_GID=5432
RUN groupadd -g ${QL_GID} qinglong && \
useradd -m -u ${QL_UID} -g ${QL_GID} -s /bin/bash qinglong && \
mkdir -p /home/qinglong/bin /home/qinglong/.ssh && \
chmod 700 /home/qinglong/.ssh && \
chown -R ${QL_UID}:${QL_GID} /home/qinglong && \
mkdir -p /etc/sudoers.d && \
echo 'qinglong ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qinglong
ENV QL_USER=qinglong
ENV QL_HOME=/home/$QL_USER
COPY --from=nodebuilder /usr/local/bin/node /usr/local/bin/
COPY --from=nodebuilder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
RUN set -x && \
ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
apt-get update && \
apt-get upgrade -y && \
apt-get install --no-install-recommends -y git \
curl \
wget \
tzdata \
perl \
openssl \
openssh-client \
jq \
procps \
netcat-openbsd \
sudo \
unzip \
libatomic1 && \
apt-get clean && \
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 && \
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \
rm -rf /root/.cache && \
rm -rf /root/.npm && \
rm -rf /etc/apt/apt.conf.d/docker-clean && \
ulimit -c 0
RUN mkdir -p ${QL_DIR} && \
chown -R ${QL_UID}:${QL_GID} ${QL_DIR}
USER qinglong
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 /tmp/static && \
mkdir -p ${QL_DIR}/static && \
cp -rf /tmp/static/* ${QL_DIR}/static && \
rm -rf /tmp/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 \
HOME=/home/qinglong
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
USER root
RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \
&& ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \
&& chmod +x /usr/local/bin/task /usr/local/bin/ql
WORKDIR ${QL_DIR}
HEALTHCHECK --interval=5s --timeout=2s --retries=20 \
CMD curl -sf --noproxy '*' http://localhost:${QlPort:-5700}/api/health || exit 1
ENTRYPOINT ["./docker/docker-entrypoint.sh"]
VOLUME /ql/data
EXPOSE 5700
+117 -8
View File
@@ -2,37 +2,146 @@
dir_shell=/ql/shell dir_shell=/ql/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/env.sh
export_ql_envs() {
export BACK_PORT="${ql_port}"
export GRPC_PORT="${ql_grpc_port}"
}
log_with_style() { log_with_style() {
local level="$1" local level="$1"
local message="$2" local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}" printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
} }
log_with_style "INFO" "🚀 1. 检测配置文件..." # ============================================
# 确保当前用户对 /ql 和 /ql/data 目录有写入权限
# /ql/data 是 Docker Volume 挂载点,权限可能与 /ql 不同,需单独检测
# ============================================
ensure_ql_permissions() {
local current_uid
local current_gid
current_uid=$(id -u)
current_gid=$(id -g)
if [ "$current_uid" -eq 0 ]; then
return 0
fi
# ---- 检查 /ql 目录 ----
if ! mkdir -p "$QL_DIR/.tmp" 2>/dev/null; then
if chown -R "$current_uid:$current_gid" "$QL_DIR" 2>/dev/null; then
log_with_style "INFO" "已修正 /ql 目录权限: UID=$current_uid GID=$current_gid"
else
local ql_owner
ql_owner=$(stat -c '%u' "$QL_DIR" 2>/dev/null || stat -f '%u' "$QL_DIR" 2>/dev/null)
log_with_style "ERROR" "============================================="
log_with_style "ERROR" " 权限错误:无法写入 /ql 目录"
log_with_style "ERROR" " 当前用户 UID: $current_uid"
log_with_style "ERROR" " /ql 目录所有者 UID: ${ql_owner:-未知}"
log_with_style "ERROR" ""
log_with_style "ERROR" " 解决方案:"
log_with_style "ERROR" " 1. 使用镜像内置用户: docker run --user ${ql_owner:-5432}:${ql_owner:-5432} ..."
log_with_style "ERROR" " 2. 使用 root 运行: 移除 --user 参数"
log_with_style "ERROR" " 3. 修正宿主机数据目录: chown -R $current_uid:$current_gid /path/to/ql/data"
log_with_style "ERROR" "============================================="
exit 1
fi
fi
rmdir "$QL_DIR/.tmp" 2>/dev/null || true
# ---- 检查 /ql/data 目录(Volume 挂载点,不在用户数据卷内创建临时文件) ----
if [ ! -w "$QL_DIR/data" ] || [ ! -x "$QL_DIR/data" ]; then
if chown "$current_uid:$current_gid" "$QL_DIR/data" 2>/dev/null; then
log_with_style "INFO" "已修正 /ql/data 目录权限: UID=$current_uid GID=$current_gid"
if [ ! -w "$QL_DIR/data" ] || [ ! -x "$QL_DIR/data" ]; then
log_with_style "ERROR" "修正后仍无法写入 /ql/data,请检查挂载的数据卷权限"
log_with_style "ERROR" "确保宿主机目录: chown -R $current_uid:$current_gid /your/data"
exit 1
fi
else
local data_owner
data_owner=$(stat -c '%u' "$QL_DIR/data" 2>/dev/null || stat -f '%u' "$QL_DIR/data" 2>/dev/null)
log_with_style "ERROR" "============================================="
log_with_style "ERROR" " 权限错误:无法写入 /ql/data (Volume 挂载点)"
log_with_style "ERROR" " 当前用户 UID: $current_uid"
log_with_style "ERROR" " /ql/data 所有者 UID: ${data_owner:-未知}"
log_with_style "ERROR" ""
log_with_style "ERROR" " 请修正宿主机数据目录权限:"
log_with_style "ERROR" " chown -R $current_uid:$current_gid /your/ql/data"
log_with_style "ERROR" "============================================="
exit 1
fi
fi
}
# Fix DNS resolution issues in Alpine Linux
if [ -f /etc/alpine-release ]; then
if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then
echo "options ndots:0" >> /etc/resolv.conf
log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)"
fi
fi
# 确保 /etc/hosts 包含 localhost 解析(应对精简镜像或仅 IPv4/IPv6 环境)
if ! grep -qE '^127\.0\.0\.1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then
echo "127.0.0.1 localhost" >> /etc/hosts
log_with_style "INFO" "🔧 0. 已添加 IPv4 localhost 解析"
fi
if ! grep -qE '^::1[[:space:]]+.*localhost' /etc/hosts 2>/dev/null; then
echo "::1 localhost ip6-localhost ip6-loopback" >> /etc/hosts
log_with_style "INFO" "🔧 0. 已添加 IPv6 localhost 解析"
fi
# 自定义用户(非 qinglong/root)可能 HOME 为空或不可写
# 修正 HOME 确保 npm/pip/pm2 等工具有可用的缓存目录
if [ ! -w "$HOME" ]; then
mkdir -p "$QL_DIR/.tmp"
export HOME="$QL_DIR/.tmp"
fi
# 在一切操作之前检查目录权限
ensure_ql_permissions
log_with_style "INFO" "🚀 1. 检测配置文件..."
load_ql_envs
export_ql_envs
. $dir_shell/env.sh
import_config "$@" import_config "$@"
fix_config fix_config
pm2 l &>/dev/null # Try to initialize PM2, but don't fail if it doesn't work
pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案"
log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..." log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..."
reload_pm2 reload_pm2
if [[ $AutoStartBot == true ]]; then if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..." log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 & nohup ql bot >$dir_log/bot.log 2>&1 &
fi fi
if [[ $EnableExtraShell == true ]]; then if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 & nohup ql extra >$dir_log/extra.log 2>&1 &
fi fi
log_with_style "SUCCESS" "🎉 容器启动成功!" log_with_style "SUCCESS" "🎉 容器启动成功!"
crond -f >/dev/null # 自动检测调度模式:有 crond 二进制 → system 模式,否则 node 模式
if [ -z "$QL_SCHEDULER" ]; then
if command -v crond &>/dev/null; then
export QL_SCHEDULER="system"
else
export QL_SCHEDULER="node"
fi
fi
if [ "$QL_SCHEDULER" = "system" ]; then
crond -f > /dev/null
else
tail -f /dev/null
fi
exec "$@" exec "$@"
+550
View File
@@ -0,0 +1,550 @@
# Project Architecture Guide
This document is written for AI coding agents and maintainers who need to understand and modify this project safely. It focuses on where behavior lives, how the application starts, and which files are usually involved for common changes.
## Project Summary
Qinglong is a timed task management platform. It provides a web admin panel for managing cron jobs, scripts, environment variables, subscriptions, dependencies, logs, configuration files, and system settings.
The repository is organized as a full-stack TypeScript application:
- `src/`: frontend admin panel, built with Umi Max, React, Ant Design, and Ant Design Pro Layout.
- `back/`: backend application, built with Express, TypeScript, typedi, Sequelize, SQLite, gRPC, and worker processes.
- `shell/`: runtime shell scripts used to execute tasks and preload task environments.
- `data/`: local runtime data, including scripts, logs, configs, SQLite database, uploaded files, and cloned repositories.
- `static/`: built frontend and backend artifacts.
- `docker/`: Docker images, compose file, and entrypoint.
- `sample/`: sample scripts and default config templates.
## High-Level Runtime Flow
```text
Browser
-> src/pages/*
-> src/utils/http.tsx
-> /api/*
-> back/api/*
-> back/services/*
-> back/data/* Sequelize models
-> data/db/database.sqlite
Cron/task execution
-> back/services/cron.ts
-> shell/task.sh or shell/otask.sh
-> data/scripts/*
-> data/log/*
Frontend assets in production
-> static/dist/*
-> served by back/loaders/express.ts
```
## Main Startup Path
Development starts from `package.json`:
```bash
pnpm start
```
This runs:
- `start:back`: `nodemon ./back/app.ts`
- `start:front`: `max dev`
Backend startup begins in `back/app.ts`.
Important details:
- The backend uses Node `cluster`.
- The primary process initializes the database first.
- A gRPC worker starts before the HTTP worker.
- The HTTP worker starts Express and serves API routes plus frontend static files.
- If the gRPC worker restarts, the HTTP worker is asked to re-register cron jobs.
Production-style backend output is generated by:
```bash
pnpm run build:back
```
The compiled backend is placed under `static/build`.
Frontend output is generated by:
```bash
pnpm run build:front
```
The compiled frontend is placed under `static/dist`.
## Backend Architecture
### Entry Point
- `back/app.ts`
Responsibilities:
- Creates the Express application.
- Starts primary/worker process logic.
- Initializes database in the primary process.
- Starts gRPC and HTTP workers.
- Handles graceful shutdown.
- Re-registers cron jobs after gRPC worker recovery.
### Loaders
- `back/loaders/app.ts`
- `back/loaders/express.ts`
- `back/loaders/db.ts`
- `back/loaders/depInjector.ts`
- `back/loaders/initData.ts`
- `back/loaders/initFile.ts`
- `back/loaders/initTask.ts`
- `back/loaders/server.ts`
- `back/loaders/sock.ts`
Loader responsibilities:
- Register dependency injection bindings.
- Sync Sequelize models.
- Initialize files and default data.
- Initialize scheduled tasks.
- Configure Express middleware.
- Register routes.
- Attach socket/server behavior.
`back/loaders/express.ts` is the main HTTP middleware and routing setup. It handles:
- CORS.
- Helmet.
- body parser.
- static frontend serving.
- JWT validation.
- token validation against shared auth state.
- `/open/*` rewrite to `/api/*`.
- route mounting through `back/api/index.ts`.
- frontend fallback to `static/dist/index.html`.
- API error handling.
### API Routes
- `back/api/index.ts`
This file registers all API modules:
- `user.ts`: login, initialization, authentication-related user endpoints.
- `env.ts`: environment variable endpoints.
- `config.ts`: config file endpoints.
- `log.ts`: log endpoints.
- `cron.ts`: cron/task endpoints.
- `script.ts`: script file endpoints.
- `open.ts`: open API/app token endpoints.
- `dependence.ts`: dependency management endpoints.
- `system.ts`: system information/settings endpoints.
- `subscription.ts`: subscription endpoints.
- `update.ts`: update/check endpoints.
- `health.ts`: health check endpoints.
Route files should stay thin. They should validate input, get a service from `typedi`'s `Container`, call the service, and return `{ code, data, message }` style responses.
### Services
- `back/services/*`
Services contain most business logic. Common examples:
- `cron.ts`: create/update/delete/run cron jobs, generate crontab data, manage logs, call scheduler client.
- `env.ts`: manage environment variables.
- `config.ts`: read/write config files.
- `script.ts`: manage script files.
- `subscription.ts`: manage script subscriptions and repository pulls.
- `dependence.ts`: install/manage runtime dependencies.
- `system.ts`: system info and settings.
- `notify.ts`: notification behavior.
- `sock.ts`: socket/log stream behavior.
- `grpc.ts`: gRPC server lifecycle.
- `http.ts`: HTTP server lifecycle.
When changing backend behavior, first find the API route, then follow it into the matching service. In most cases, the service is the right place for behavioral changes.
### Data Models
- `back/data/index.ts`
- `back/data/*.ts`
The backend uses Sequelize with SQLite. Database storage is configured in `back/data/index.ts`:
```text
data/db/database.sqlite
```
Common model files:
- `cron.ts`: cron job model.
- `cronView.ts`: saved cron table views.
- `env.ts`: environment variable model.
- `dependence.ts`: dependency model.
- `open.ts`: open API app/token model.
- `subscription.ts`: subscription model.
- `system.ts`: system settings model.
- `notify.ts`: notification-related data.
Model sync and simple column migrations are currently handled in `back/loaders/db.ts`.
### Configuration
- `back/config/index.ts`
This is the central runtime config file. It reads `.env`, establishes `QL_DIR`, and defines important paths:
- `dataPath`: runtime data root.
- `configPath`: config files.
- `scriptPath`: user scripts.
- `repoPath`: subscription repositories.
- `logPath`: task logs.
- `dbPath`: SQLite database location.
- `uploadPath`: uploaded files.
- `shellPath`: shell runtime scripts.
- `preloadPath`: JS/Python/Shell preload files.
Before hardcoding paths, check `back/config/index.ts`.
### Scheduling And gRPC
- `back/schedule/*`
- `back/protos/*`
- `back/services/grpc.ts`
The project has two scheduling paths:
- Standard crontab-style tasks are persisted and written through backend cron logic.
- Node/gRPC scheduler logic handles cases such as second-level cron expressions or additional schedules.
`back/services/cron.ts` decides whether a task needs the Node scheduler using schedule shape and `extra_schedules`.
### Shared Backend Utilities
- `back/shared/*`
- `back/config/util.ts`
- `back/config/share.ts`
- `back/config/http.ts`
Use these before adding new global helpers. Existing shared code includes:
- auth helpers.
- shared store.
- log stream manager.
- task runner helpers.
- concurrency limits.
- file locking utilities.
- HTTP/proxy helpers.
## Frontend Architecture
### Umi Config
- `.umirc.ts`
Important behavior:
- Dev server proxies API requests to `http://127.0.0.1:5700/`.
- Frontend build output is `static/dist`.
- Runtime env script is loaded from `./api/env.js`.
- `QlBaseUrl` affects frontend public path and routing base.
### App Initialization
- `src/app.ts`
Responsibilities:
- Load Chinese and English locale JSON.
- Determine locale from URL/cookie/localStorage.
- Set Umi locale.
- Apply `QlBaseUrl` as public path and router basename.
### Layout And Routes
- `src/layouts/defaultProps.tsx`
- `src/layouts/index.tsx`
`defaultProps.tsx` defines the main route/menu list. If adding a new page visible in the sidebar, update this file.
Current major pages:
- `src/pages/crontab`: timed task management.
- `src/pages/subscription`: subscription management.
- `src/pages/env`: environment variables.
- `src/pages/config`: config files.
- `src/pages/script`: script management.
- `src/pages/dependence`: dependency management.
- `src/pages/log`: log management.
- `src/pages/diff`: diff tool.
- `src/pages/setting`: system settings.
- `src/pages/login`: login.
- `src/pages/initialization`: first-run initialization.
- `src/pages/error`: error page.
### Frontend Utilities
- `src/utils/http.tsx`: API request helper.
- `src/utils/websocket.ts`: socket connection behavior.
- `src/utils/config.ts`: frontend config helpers.
- `src/utils/const.ts`: constants.
- `src/utils/date.ts`: date formatting helpers.
- `src/utils/init.ts`: initialization helpers.
- `src/utils/codemirror/*`: CodeMirror integration.
- `src/utils/monaco/*`: Monaco integration.
When changing a page's API behavior, inspect both the page file and `src/utils/http.tsx`.
### Components And Styling
- `src/components/*`: reusable UI components.
- `src/pages/**/index.less`: page-level styles.
- `src/pages/script/index.module.less` and `src/pages/log/index.module.less`: CSS module styles.
- `src/assets/fonts/*`: bundled fonts.
- `src/locales/*.json`: i18n text.
Follow the existing Ant Design and Ant Design Pro patterns when modifying UI.
## Shell Runtime
- `shell/task.sh`: task execution path.
- `shell/otask.sh`: alternate/manual task execution path.
- `shell/api.sh`: shell-side API helpers.
- `shell/env.sh`: environment setup.
- `shell/check.sh`: runtime check helpers.
- `shell/update.sh`: update helpers.
- `shell/rmlog.sh`: log cleanup.
- `shell/share.sh`: shared shell helpers.
- `shell/preload/*`: preload files injected into JS/Python/Shell task environments.
The backend often coordinates task execution, but the actual user script process environment is shaped by files in `shell/`.
## Runtime Data Directory
- `data/`
This directory is runtime state, not just source code. Be careful when modifying or deleting files here.
Important subdirectories:
- `data/db`: SQLite database.
- `data/config`: generated and user-edited config files.
- `data/scripts`: user scripts.
- `data/repo`: cloned subscription repositories.
- `data/log`: task logs.
- `data/upload`: uploaded files.
- `data/syslog`: system logs.
- `data/ssh.d`: SSH-related runtime data.
- `data/dep_cache`: dependency cache, when present.
Many bugs that appear as "backend logic" may involve state stored under `data/`.
## Docker And Release Files
- `docker/Dockerfile`
- `docker/310.Dockerfile`
- `docker/docker-compose.yml`
- `docker/docker-entrypoint.sh`
- `ecosystem.config.js`
- `version.yaml`
Use these when changing deployment, container startup, PM2 behavior, or release metadata.
## Common Modification Map
### Add Or Modify A Backend API
Typical files:
1. Add or update route in `back/api/<module>.ts`.
2. Add or update service logic in `back/services/<module>.ts`.
3. Add or update model in `back/data/<module>.ts` if persistence changes.
4. Add validation with `celebrate`/`Joi` near the route.
5. Update frontend caller in `src/pages/**` or `src/utils/**`.
### Add A New Frontend Page
Typical files:
1. Create `src/pages/<page>/index.tsx`.
2. Add styles in `src/pages/<page>/index.less` if needed.
3. Register route/menu in `src/layouts/defaultProps.tsx`.
4. Add locale strings in `src/locales/zh-CN.json` and `src/locales/en-US.json`.
5. Add API calls through the existing request helper.
### Change Cron/Task Behavior
Start with:
- `back/api/cron.ts`
- `back/services/cron.ts`
- `back/schedule/*`
- `shell/task.sh`
- `shell/otask.sh`
- `shell/preload/*`
Also inspect:
- `back/data/cron.ts`
- `back/validation/schedule.ts`
- `data/config/crontab.list`
- `data/log/*`
### Change Environment Variable Behavior
Start with:
- `back/api/env.ts`
- `back/services/env.ts`
- `back/data/env.ts`
- `src/pages/env/index.tsx`
Also inspect:
- `shell/preload/env.sh`
- `shell/preload/env.js`
- `shell/preload/env.py`
### Change Script Management
Start with:
- `back/api/script.ts`
- `back/services/script.ts`
- `src/pages/script/index.tsx`
- `data/scripts/*`
### Change Login/Auth/Security
Start with:
- `back/api/user.ts`
- `back/services/user.ts`
- `back/shared/auth.ts`
- `back/shared/store.ts`
- `back/loaders/express.ts`
- `back/token.ts`
- `src/pages/login/index.tsx`
- `src/pages/initialization/index.tsx`
Be careful with:
- JWT behavior.
- open API token behavior.
- first-run initialization.
- platform-specific session limits.
### Change Subscription Behavior
Start with:
- `back/api/subscription.ts`
- `back/services/subscription.ts`
- `back/data/subscription.ts`
- `src/pages/subscription/index.tsx`
- `data/repo/*`
### Change Dependency Management
Start with:
- `back/api/dependence.ts`
- `back/services/dependence.ts`
- `back/data/dependence.ts`
- `src/pages/dependence/index.tsx`
- `data/deps`
- `data/dep_cache`
### Change Logs Or Live Log Streaming
Start with:
- `back/api/log.ts`
- `back/services/log.ts`
- `back/services/sock.ts`
- `back/shared/logStreamManager.ts`
- `src/pages/log/index.tsx`
- `src/components/terminal.tsx`
- `data/log/*`
## Coding Conventions
Backend:
- Prefer adding business logic to services, not route files.
- Use `typedi` services consistently.
- Use existing config paths from `back/config/index.ts`.
- Return API responses in the existing `{ code, data, message }` shape.
- Use existing utilities before adding new helpers.
- Preserve current SQLite/Sequelize style unless doing a larger data-layer refactor.
Frontend:
- Follow existing Umi/React/Ant Design patterns.
- Keep route/menu changes in `src/layouts/defaultProps.tsx`.
- Use existing request/WebSocket helpers.
- Add or update locale strings for visible UI text.
- Keep page-specific styles near the page.
Shell/runtime:
- Treat `shell/` as part of production behavior.
- Test task execution changes with realistic scripts when possible.
- Be careful with path quoting and environment variable propagation.
Data:
- Treat `data/` as mutable runtime state.
- Do not delete runtime state unless explicitly requested.
- Schema changes should account for existing SQLite databases.
## Suggested First Steps For AI Agents
When asked to modify behavior:
1. Identify whether the change is frontend, backend, shell runtime, data model, or deployment.
2. Search by feature name in `src/pages`, `back/api`, and `back/services`.
3. Read the route file and matching service before editing.
4. If persistence is involved, read the matching `back/data` model and `back/loaders/db.ts`.
5. If task execution is involved, inspect `shell/` and `back/services/cron.ts`.
6. Make the smallest scoped change that matches existing patterns.
7. Run the most relevant check:
- `pnpm run build:back` for backend TypeScript changes.
- `pnpm run build:front` for frontend build changes.
- targeted manual task/API checks for shell and scheduler changes.
## Quick Directory Reference
```text
.
├── back/ Backend TypeScript application
│ ├── api/ Express route modules
│ ├── config/ Runtime config, paths, constants, helpers
│ ├── data/ Sequelize models and SQLite connection
│ ├── loaders/ Startup initialization and Express setup
│ ├── middlewares/ Express middlewares
│ ├── protos/ gRPC proto files and generated TS
│ ├── schedule/ Scheduler/gRPC client helpers
│ ├── services/ Business logic services
│ ├── shared/ Shared backend utilities
│ └── validation/ Joi validation schemas
├── src/ Frontend Umi/React application
│ ├── assets/ Fonts and static frontend assets
│ ├── components/ Shared UI components
│ ├── hooks/ Frontend hooks
│ ├── layouts/ Main layout and menu route config
│ ├── locales/ i18n JSON
│ ├── pages/ Feature pages
│ └── utils/ HTTP, WebSocket, editor, date, and config utilities
├── shell/ Task runtime shell scripts and preload files
├── data/ Runtime state: db, logs, scripts, repos, configs
├── docker/ Docker build and compose files
├── sample/ Sample scripts and default config templates
├── static/ Built frontend/backend artifacts
└── docs/ Project documentation
```
+48 -30
View File
@@ -1,5 +1,17 @@
{ {
"private": true, "name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.21.0-16",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
"url": "https://github.com/whyour/qinglong.git"
},
"author": "whyour",
"license": "Apache License 2.0",
"bugs": {
"url": "https://github.com/whyour/qinglong/issues"
},
"scripts": { "scripts": {
"start": "concurrently -n w: npm:start:*", "start": "concurrently -n w: npm:start:*",
"start:back": "nodemon ./back/app.ts", "start:back": "nodemon ./back/app.ts",
@@ -9,9 +21,7 @@
"panel": "npm run build:back && node static/build/app.js", "panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false", "gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true", "postinstall": "max setup 2>/dev/null || true"
"test": "umi-test",
"test:coverage": "umi-test --coverage"
}, },
"gitHooks": { "gitHooks": {
"pre-commit": "lint-staged" "pre-commit": "lint-staged"
@@ -24,6 +34,11 @@
"prettier --parser=typescript --write" "prettier --parser=typescript --write"
] ]
}, },
"bin": {
"ql": "shell/update.sh",
"task": "shell/task.sh",
"qinglong": "shell/start.sh"
},
"pnpm": { "pnpm": {
"peerDependencyRules": { "peerDependencyRules": {
"ignoreMissing": [ "ignoreMissing": [
@@ -50,18 +65,24 @@
} }
}, },
"overrides": { "overrides": {
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3" "sqlite3": "npm:@whyour/sqlite3@1.1.0",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16"
} }
}, },
"dependencies": { "dependencies": {
"@ant-design/plots": "^2.6.8",
"@bufbuild/protobuf": "^2.10.0",
"@grpc/grpc-js": "^1.14.0", "@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0", "@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1", "@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"celebrate": "^15.0.3", "celebrate": "^15.0.3",
"chokidar": "^4.0.1", "chokidar": "^4.0.1",
"compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.9.0", "cron-parser": "^5.4.0",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.6", "dotenv": "^16.4.6",
@@ -69,51 +90,48 @@
"express-jwt": "^8.4.1", "express-jwt": "^8.4.1",
"express-rate-limit": "^7.4.1", "express-rate-limit": "^7.4.1",
"express-urlrewrite": "^2.0.3", "express-urlrewrite": "^2.0.3",
"undici": "^7.9.0", "helmet": "^8.1.0",
"hpagent": "^1.2.0", "hpagent": "^1.2.0",
"http-proxy-middleware": "^3.0.3", "http-proxy-middleware": "^3.0.3",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"ip2region": "2.3.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"multer": "1.4.5-lts.1", "multer": "2.1.1",
"node-schedule": "^2.1.0", "node-schedule": "^2.1.0",
"nodemailer": "^6.9.16", "nodemailer": "^8.0.1",
"p-queue-cjs": "7.3.4", "p-queue-cjs": "7.3.4",
"@bufbuild/protobuf": "^2.10.0", "proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0", "ps-tree": "^1.2.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"request-ip": "3.3.0",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"sockjs": "^0.3.24", "sockjs": "^0.3.24",
"sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", "sqlite3": "npm:@whyour/sqlite3@1.1.0",
"toad-scheduler": "^3.0.1", "toad-scheduler": "^3.0.1",
"typedi": "^0.10.0", "typedi": "^0.10.0",
"undici": "^7.9.0",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0"
"request-ip": "3.3.0",
"ip2region": "2.3.0",
"keyv": "^5.2.3",
"@keyv/sqlite": "^4.0.1",
"proper-lockfile": "^4.1.2",
"compression": "^1.7.4",
"helmet": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"moment": "2.30.1",
"@ant-design/icons": "^5.0.1", "@ant-design/icons": "^5.0.1",
"@ant-design/pro-layout": "6.38.22", "@ant-design/pro-layout": "6.38.22",
"@codemirror/view": "^6.34.1", "@codemirror/state": "6.5.4",
"@codemirror/state": "^6.4.1", "@codemirror/view": "6.39.16",
"@monaco-editor/react": "4.2.1", "@monaco-editor/react": "4.2.1",
"@react-hook/resize-observer": "^2.0.2", "@react-hook/resize-observer": "^2.0.2",
"react-router-dom": "6.26.1",
"@types/body-parser": "^1.19.2", "@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/cross-spawn": "^6.0.2", "@types/cross-spawn": "^6.0.2",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/express-jwt": "^6.0.4", "@types/express-jwt": "^6.0.4",
"@types/file-saver": "2.0.2", "@types/file-saver": "2.0.2",
"@types/helmet": "^4.0.0",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^8.5.8", "@types/jsonwebtoken": "^8.5.8",
"@types/lodash": "^4.14.185", "@types/lodash": "^4.14.185",
@@ -121,17 +139,17 @@
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/node-schedule": "^1.3.2", "@types/node-schedule": "^1.3.2",
"@types/nodemailer": "^6.4.4", "@types/nodemailer": "^6.4.4",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@types/qrcode.react": "^1.0.2", "@types/qrcode.react": "^1.0.2",
"@types/react": "^18.0.20", "@types/react": "^18.0.20",
"@types/react-copy-to-clipboard": "^5.0.4", "@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/request-ip": "0.0.41",
"@types/serve-handler": "^6.1.1", "@types/serve-handler": "^6.1.1",
"@types/sockjs": "^0.3.33", "@types/sockjs": "^0.3.33",
"@types/sockjs-client": "^1.5.1", "@types/sockjs-client": "^1.5.1",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@types/request-ip": "0.0.41",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@uiw/codemirror-extensions-langs": "^4.21.9", "@uiw/codemirror-extensions-langs": "^4.21.9",
"@uiw/react-codemirror": "^4.21.9", "@uiw/react-codemirror": "^4.21.9",
"@umijs/max": "^4.4.4", "@umijs/max": "^4.4.4",
@@ -143,9 +161,9 @@
"axios": "^1.4.0", "axios": "^1.4.0",
"compression-webpack-plugin": "9.2.0", "compression-webpack-plugin": "9.2.0",
"concurrently": "^7.0.0", "concurrently": "^7.0.0",
"react-hotkeys-hook": "^4.6.1",
"file-saver": "2.0.2", "file-saver": "2.0.2",
"lint-staged": "^13.0.3", "lint-staged": "^13.0.3",
"moment": "2.30.1",
"monaco-editor": "0.33.0", "monaco-editor": "0.33.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^2.5.1", "prettier": "^2.5.1",
@@ -161,7 +179,9 @@
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-hotkeys-hook": "^4.6.1",
"react-intl-universal": "^2.12.0", "react-intl-universal": "^2.12.0",
"react-router-dom": "6.26.1",
"react-split-pane": "^0.1.92", "react-split-pane": "^0.1.92",
"sockjs-client": "^1.6.0", "sockjs-client": "^1.6.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -169,8 +189,6 @@
"tslib": "^2.4.0", "tslib": "^2.4.0",
"typescript": "5.2.2", "typescript": "5.2.2",
"vh-check": "^2.0.5", "vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0", "virtualizedtableforantd4": "1.3.0"
"@types/compression": "^1.7.2",
"@types/helmet": "^4.0.0"
} }
} }
+10548 -13055
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -195,12 +195,14 @@ export SMTP_SERVER=""
## SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false ## SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false
export SMTP_SSL="" export SMTP_SSL=""
## smtp_email 填写 SMTP 发件邮箱,通知将会由自己发给自己 ## smtp_email 填写 SMTP 发件邮箱
export SMTP_EMAIL="" export SMTP_EMAIL=""
## smtp_password 填写 SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 ## smtp_password 填写 SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
export SMTP_PASSWORD="" export SMTP_PASSWORD=""
## smtp_name 填写 SMTP 收发件人姓名,可随意填写 ## smtp_name 填写 SMTP 收发件人姓名,可随意填写
export SMTP_NAME="" export SMTP_NAME=""
## smtp_email_to 填写 SMTP 收件邮箱,多个用英文;分隔,不填默认发给发件邮箱
export SMTP_EMAIL_TO=""
## 17. PushMe ## 17. PushMe
## 官方说明文档:https://push.i-i.me/ ## 官方说明文档:https://push.i-i.me/
@@ -239,14 +241,19 @@ export NTFY_PASSWORD=""
export NTFY_ACTIONS="" export NTFY_ACTIONS=""
## 21. wxPusher ## 21. wxPusher
## 官方文档: https://wxpusher.zjiecode.com/docs/ ### 方式一:标准发送(更强大)
## 管理后台: https://wxpusher.zjiecode.com/admin/ #### 官方文档: https://wxpusher.zjiecode.com/docs/
#### 管理后台: https://wxpusher.zjiecode.com/admin/
## wxPusher 的 appToken ## wxPusher 的 appToken
export WXPUSHER_APP_TOKEN="" export WXPUSHER_APP_TOKEN=""
## wxPusher 的 topicIds,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 ## wxPusher 的 topicIds,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
export WXPUSHER_TOPIC_IDS="" export WXPUSHER_TOPIC_IDS=""
## wxPusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 ## wxPusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
export WXPUSHER_UIDS="" export WXPUSHER_UIDS=""
### 方式二:极简发送(最简单,简单好用,一键配置,更推荐)
#### wxPusher 的 SPT(极简推送),扫码获取 https://wxpusher.zjiecode.com/docs/#/?id=spt
#### 多个用英文逗号,分隔,最多10个;与上面的 appToken 方式二选一即可
export WXPUSHER_SPT_LIST=""
## 22. 自定义通知 ## 22. 自定义通知
## 自定义通知 接收回调的URL ## 自定义通知 接收回调的URL
@@ -259,4 +266,13 @@ export WEBHOOK_METHOD=""
## 支持 text/plain、application/json、multipart/form-data、application/x-www-form-urlencoded ## 支持 text/plain、application/json、multipart/form-data、application/x-www-form-urlencoded
export WEBHOOK_CONTENT_TYPE="" export WEBHOOK_CONTENT_TYPE=""
## 23. OpeniLink
## 官方文档: https://openilink.com/docs/hub/apps
## 在 OpeniLink Hub 后台安装 App 后获取 app_token
export OPENILINK_APP_TOKEN=""
## OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址
export OPENILINK_HUB_URL=""
## OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取
export OPENILINK_CONTEXT_TOKEN=""
## 其他需要的变量,脚本中需要的变量使用 export 变量名= 声明即可 ## 其他需要的变量,脚本中需要的变量使用 export 变量名= 声明即可
+182 -14
View File
@@ -52,6 +52,7 @@ const push_config = {
DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN
FSKEY: '', // 飞书机器人的 FSKEY FSKEY: '', // 飞书机器人的 FSKEY
FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
// 推送到个人QQhttp://127.0.0.1/send_private_msg // 推送到个人QQhttp://127.0.0.1/send_private_msg
// 群:http://127.0.0.1/send_group_msg // 群:http://127.0.0.1/send_group_msg
@@ -107,8 +108,8 @@ const push_config = {
QYWX_KEY: '', // 企业微信机器人的 webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa QYWX_KEY: '', // 企业微信机器人的 webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa
TG_BOT_TOKEN: '', // tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ TG_BOT_TOKEN: '', // tg 机器人的 TG_BOT_TOKEN,例:1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
TG_USER_ID: '', // tg 机器人的 TG_USER_ID,例:1434078534 TG_USER_ID: '', // tg 机器人的 TG_USER_ID,例:1234567890
TG_API_HOST: 'https://api.telegram.org', // tg 代理 api TG_API_HOST: 'https://api.telegram.org', // tg 代理 api
TG_PROXY_AUTH: '', // tg 代理认证参数 TG_PROXY_AUTH: '', // tg 代理认证参数
TG_PROXY_HOST: '', // tg 机器人的 TG_PROXY_HOST TG_PROXY_HOST: '', // tg 机器人的 TG_PROXY_HOST
@@ -120,7 +121,8 @@ const push_config = {
SMTP_SERVICE: '', // 邮箱服务名称,比如 126、163、Gmail、QQ 等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json SMTP_SERVICE: '', // 邮箱服务名称,比如 126、163、Gmail、QQ 等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json
SMTP_EMAIL: '', // SMTP 发件邮箱 SMTP_EMAIL: '', // SMTP 发件邮箱
SMTP_TO: '', // SMTP 收件邮箱,默认通知将会发给发件邮箱 SMTP_TO: '', // SMTP 收件邮箱,兼容旧参数名,默认通知将会发给发件邮箱
SMTP_EMAIL_TO: '', // SMTP 收件邮箱,多个分号分隔,默认发给发件邮箱
SMTP_PASSWORD: '', // SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 SMTP_PASSWORD: '', // SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
SMTP_NAME: '', // SMTP 收发件人姓名,可随意填写 SMTP_NAME: '', // SMTP 收发件人姓名,可随意填写
@@ -145,11 +147,21 @@ const push_config = {
NTFY_PASSWORD: '', // 推送用户密码,可选 NTFY_PASSWORD: '', // 推送用户密码,可选
NTFY_ACTIONS: '', // 推送用户动作,可选 NTFY_ACTIONS: '', // 推送用户动作,可选
// 方式一:标准发送(更强大)
// 官方文档: https://wxpusher.zjiecode.com/docs/ // 官方文档: https://wxpusher.zjiecode.com/docs/
// 管理后台: https://wxpusher.zjiecode.com/admin/ // 管理后台: https://wxpusher.zjiecode.com/admin/
WXPUSHER_APP_TOKEN: '', // wxpusher 的 appToken WXPUSHER_APP_TOKEN: '', // wxpusher 的 appToken
WXPUSHER_TOPIC_IDS: '', // wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 WXPUSHER_TOPIC_IDS: '', // wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
WXPUSHER_UIDS: '', // wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 WXPUSHER_UIDS: '', // wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
// 方式二:极简发送(最简单,简单好用,一键配置,更推荐)
// wxPusher 的 SPT(极简推送),扫码获取 https://wxpusher.zjiecode.com/docs/#/?id=spt
// 多个用英文逗号,分隔,最多10个;与上面的 appToken 方式二选一即可
WXPUSHER_SPT_LIST: '', // wxpusher 的 SPT(极简推送),多个用英文逗号,分隔,最多10个
// 官方文档: https://openilink.com/docs/hub/apps
OPENILINK_APP_TOKEN: '', // OpeniLink 的 app_token,在 OpeniLink Hub 后台安装 App 后获取
OPENILINK_HUB_URL: '', // OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址
OPENILINK_CONTEXT_TOKEN: '', // OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取
}; };
for (const key in push_config) { for (const key in push_config) {
@@ -300,7 +312,7 @@ function serverNotify(text, desp) {
console.log('Server 酱发送通知调用API失败😞\n', err); console.log('Server 酱发送通知调用API失败😞\n', err);
} else { } else {
// server酱和Server酱·Turbo版的返回json格式不太一样 // server酱和Server酱·Turbo版的返回json格式不太一样
if (data.errno === 0 || data.data.errno === 0) { if (data.errno === 0 || data.code === 0) {
console.log('Server 酱发送通知消息成功🎉\n'); console.log('Server 酱发送通知消息成功🎉\n');
} else if (data.errno === 1024) { } else if (data.errno === 1024) {
// 一分钟内发送相同的内容会触发 // 一分钟内发送相同的内容会触发
@@ -481,9 +493,13 @@ function tgBotNotify(text, desp) {
timeout, timeout,
}; };
if (TG_PROXY_HOST && TG_PROXY_PORT) { if (TG_PROXY_HOST && TG_PROXY_PORT) {
let proxyHost = TG_PROXY_HOST;
if (TG_PROXY_AUTH && !TG_PROXY_HOST.includes('@')) {
proxyHost = `${TG_PROXY_AUTH}@${TG_PROXY_HOST}`;
}
let agent; let agent;
agent = new ProxyAgent({ agent = new ProxyAgent({
uri: `http://${TG_PROXY_AUTH}${TG_PROXY_HOST}:${TG_PROXY_PORT}`, uri: `http://${proxyHost}:${TG_PROXY_PORT}`,
}); });
options.dispatcher = agent; options.dispatcher = agent;
} }
@@ -989,11 +1005,29 @@ function aibotkNotify(text, desp) {
function fsBotNotify(text, desp) { function fsBotNotify(text, desp) {
return new Promise((resolve) => { return new Promise((resolve) => {
const { FSKEY } = push_config; const { FSKEY, FSSECRET } = push_config;
if (FSKEY) { if (FSKEY) {
const body = {
msg_type: 'text',
content: { text: `${text}\n\n${desp}` },
};
// Add signature if secret is provided
// Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
// and signs an empty message, which differs from typical HMAC usage
if (FSSECRET) {
const crypto = require('crypto');
const timestamp = Math.floor(Date.now() / 1000).toString();
const stringToSign = `${timestamp}\n${FSSECRET}`;
const hmac = crypto.createHmac('sha256', stringToSign);
const sign = hmac.digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
const options = { const options = {
url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`, url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`,
json: { msg_type: 'text', content: { text: `${text}\n\n${desp}` } }, json: body,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -1023,8 +1057,14 @@ function fsBotNotify(text, desp) {
} }
async function smtpNotify(text, desp) { async function smtpNotify(text, desp) {
const { SMTP_EMAIL, SMTP_TO, SMTP_PASSWORD, SMTP_SERVICE, SMTP_NAME } = const {
push_config; SMTP_EMAIL,
SMTP_TO,
SMTP_EMAIL_TO,
SMTP_PASSWORD,
SMTP_SERVICE,
SMTP_NAME,
} = push_config;
if (![SMTP_EMAIL, SMTP_PASSWORD].every(Boolean) || !SMTP_SERVICE) { if (![SMTP_EMAIL, SMTP_PASSWORD].every(Boolean) || !SMTP_SERVICE) {
return; return;
} }
@@ -1040,9 +1080,20 @@ async function smtpNotify(text, desp) {
}); });
const addr = SMTP_NAME ? `"${SMTP_NAME}" <${SMTP_EMAIL}>` : SMTP_EMAIL; const addr = SMTP_NAME ? `"${SMTP_NAME}" <${SMTP_EMAIL}>` : SMTP_EMAIL;
const recipients = [SMTP_EMAIL_TO, SMTP_TO].reduce((list, value) => {
if (!value) {
return list;
}
return list.concat(
value
.split(/[;]/)
.map((item) => item.trim())
.filter(Boolean),
);
}, []);
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: addr, from: addr,
to: SMTP_TO ? SMTP_TO.split(';') : addr, to: recipients.length ? recipients : SMTP_EMAIL,
subject: text, subject: text,
html: `${desp.replace(/\n/g, '<br/>')}`, html: `${desp.replace(/\n/g, '<br/>')}`,
}); });
@@ -1262,7 +1313,15 @@ function ntfyNotify(text, desp) {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS } = push_config; const {
NTFY_URL,
NTFY_TOPIC,
NTFY_PRIORITY,
NTFY_TOKEN,
NTFY_USERNAME,
NTFY_PASSWORD,
NTFY_ACTIONS,
} = push_config;
if (NTFY_TOPIC) { if (NTFY_TOPIC) {
const options = { const options = {
url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`, url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`,
@@ -1277,7 +1336,8 @@ function ntfyNotify(text, desp) {
if (NTFY_TOKEN) { if (NTFY_TOKEN) {
options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`; options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`;
} else if (NTFY_USERNAME && NTFY_PASSWORD) { } else if (NTFY_USERNAME && NTFY_PASSWORD) {
options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`; options.headers['Authorization'] =
`Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`;
} }
if (NTFY_ACTIONS) { if (NTFY_ACTIONS) {
options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS); options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS);
@@ -1376,6 +1436,112 @@ function wxPusherNotify(text, desp) {
}); });
} }
function wxPusherSptNotify(text, desp) {
return new Promise((resolve) => {
const { WXPUSHER_SPT_LIST } = push_config;
if (WXPUSHER_SPT_LIST) {
// 处理 SPT,将逗号分隔的字符串转为数组
const spts = WXPUSHER_SPT_LIST.split(',')
.map((spt) => spt.trim())
.filter((spt) => spt);
if (!spts.length) {
console.log('wxpusher SPT 不能为空!!');
return resolve();
}
if (spts.length > 10) {
console.log('wxpusher SPT 最多支持 10 个!!');
return resolve();
}
const body = {
content: `<h1>${text}</h1><br/><div style='white-space: pre-wrap;'>${desp}</div>`,
summary: text,
contentType: 2,
// 单个 SPT 用 spt,多个用 sptList
...(spts.length === 1 ? { spt: spts[0] } : { sptList: spts }),
};
const options = {
url: 'https://wxpusher.zjiecode.com/api/send/message/simple-push',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
timeout,
};
$.post(options, (err, resp, data) => {
try {
if (err) {
console.log('wxpusher SPT 发送通知消息失败!\n', err);
} else {
if (data.code === 1000) {
console.log('wxpusher SPT 发送通知消息完成!');
} else {
console.log(`wxpusher SPT 发送通知消息异常:${data.msg}`);
}
}
} catch (e) {
$.logErr(e, resp);
} finally {
resolve(data);
}
});
} else {
resolve();
}
});
}
function openiLinkNotify(text, desp) {
return new Promise((resolve) => {
const { OPENILINK_APP_TOKEN, OPENILINK_HUB_URL, OPENILINK_CONTEXT_TOKEN } =
push_config;
if (OPENILINK_APP_TOKEN) {
const baseUrl = OPENILINK_HUB_URL
? OPENILINK_HUB_URL.replace(/\/$/, '')
: 'https://hub.openilink.com';
const body = {
type: 'text',
content: `${text}\n\n${desp}`,
};
if (OPENILINK_CONTEXT_TOKEN) {
body.context_token = OPENILINK_CONTEXT_TOKEN;
}
const options = {
url: `${baseUrl}/bot/v1/message/send`,
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENILINK_APP_TOKEN}`,
},
timeout,
};
$.post(options, (err, resp, data) => {
try {
if (err) {
console.log('OpeniLink 发送通知消息失败!\n', err);
} else {
if (data.ok) {
console.log('OpeniLink 发送通知消息成功!');
} else {
console.log(`OpeniLink 发送通知消息异常:${data.error}`);
}
}
} catch (e) {
$.logErr(e, resp);
} finally {
resolve(data);
}
});
} else {
resolve();
}
});
}
function parseString(input, valueFormatFn) { function parseString(input, valueFormatFn) {
const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g; const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g;
const matches = {}; const matches = {};
@@ -1479,7 +1645,7 @@ async function sendNotify(text, desp, params = {}) {
} }
} }
if (push_config.HITOKOTO !== 'false') { if (![false, 'false'].includes(push_config.HITOKOTO)) {
desp += '\n\n' + (await one()); desp += '\n\n' + (await one());
} }
@@ -1495,7 +1661,7 @@ async function sendNotify(text, desp, params = {}) {
iGotNotify(text, desp, params), // iGot iGotNotify(text, desp, params), // iGot
gobotNotify(text, desp), // go-cqhttp gobotNotify(text, desp), // go-cqhttp
gotifyNotify(text, desp), // gotify gotifyNotify(text, desp), // gotify
chatNotify(text, desp), // synolog chat chatNotify(text, desp), // synology chat
pushDeerNotify(text, desp), // PushDeer pushDeerNotify(text, desp), // PushDeer
aibotkNotify(text, desp), // 智能微秘书 aibotkNotify(text, desp), // 智能微秘书
fsBotNotify(text, desp), // 飞书机器人 fsBotNotify(text, desp), // 飞书机器人
@@ -1506,6 +1672,8 @@ async function sendNotify(text, desp, params = {}) {
qmsgNotify(text, desp), // 自定义通知 qmsgNotify(text, desp), // 自定义通知
ntfyNotify(text, desp), // Ntfy ntfyNotify(text, desp), // Ntfy
wxPusherNotify(text, desp), // wxpusher wxPusherNotify(text, desp), // wxpusher
wxPusherSptNotify(text, desp), // wxpusher SPT
openiLinkNotify(text, desp), // OpeniLink
]); ]);
} }
+125 -11
View File
@@ -49,6 +49,7 @@ push_config = {
'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN
'FSKEY': '', # 飞书机器人的 FSKEY 'FSKEY': '', # 飞书机器人的 FSKEY
'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥
'GOBOT_URL': '', # go-cqhttp 'GOBOT_URL': '', # go-cqhttp
# 推送到个人QQhttp://127.0.0.1/send_private_msg # 推送到个人QQhttp://127.0.0.1/send_private_msg
@@ -93,8 +94,8 @@ push_config = {
'QYWX_KEY': '', # 企业微信机器人 'QYWX_KEY': '', # 企业微信机器人
'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ 'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1434078534 'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1234567890
'TG_API_HOST': '', # tg 代理 api 'TG_API_HOST': '', # tg 代理 api
'TG_PROXY_AUTH': '', # tg 代理认证参数 'TG_PROXY_AUTH': '', # tg 代理认证参数
'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST 'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST
@@ -106,7 +107,8 @@ push_config = {
'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465 'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465
'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false 'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false
'SMTP_EMAIL': '', # SMTP 发件邮箱,通知将会由自己发给自己 'SMTP_EMAIL': '', # SMTP 发件邮箱
'SMTP_EMAIL_TO': '', # SMTP 收件邮箱,多个分号分隔,默认发给发件邮箱
'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定
'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写 'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写
@@ -131,9 +133,20 @@ push_config = {
'NTFY_PASSWORD': '', # 推送用户密码,可选 'NTFY_PASSWORD': '', # 推送用户密码,可选
'NTFY_ACTIONS': '', # 推送用户动作,可选 'NTFY_ACTIONS': '', # 推送用户动作,可选
### 方式一:标准发送(更强大)
#### 官方文档: https://wxpusher.zjiecode.com/docs/
#### 管理后台: https://wxpusher.zjiecode.com/admin/
'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/ 'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/
'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
'WXPUSHER_UIDS': '', # wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 'WXPUSHER_UIDS': '', # wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行
### 方式二:极简发送(最简单,简单好用,一键配置,更推荐)
#### wxPusher 的 SPT(极简推送),扫码获取 https://wxpusher.zjiecode.com/docs/#/?id=spt
#### 多个用英文逗号,分隔,最多10个;与上面的 appToken 方式二选一即可
'WXPUSHER_SPT_LIST': '', # wxpusher 的 SPT(极简推送),多个用英文逗号,分隔,最多10个 官方文档: https://wxpusher.zjiecode.com/docs/#/?id=spt
'OPENILINK_APP_TOKEN': '', # OpeniLink 的 app_token,在 OpeniLink Hub 后台安装 App 后获取 官方文档: https://openilink.com/docs/hub/apps
'OPENILINK_HUB_URL': '', # OpeniLink Hub 地址,默认为 https://hub.openilink.com,自建 Hub 时填写自己的地址
'OPENILINK_CONTEXT_TOKEN': '', # OpeniLink 的 context_token,用于标识消息会话上下文,可从消息事件中获取
} }
# fmt: on # fmt: on
@@ -233,6 +246,20 @@ def feishu_bot(title: str, content: str) -> None:
url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}' url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}'
data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}} data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}}
# Add signature if secret is provided
# Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key
# and signs an empty message, which differs from typical HMAC usage
if push_config.get("FSSECRET"):
timestamp = str(int(time.time()))
string_to_sign = f'{timestamp}\n{push_config.get("FSSECRET")}'
hmac_code = hmac.new(
string_to_sign.encode("utf-8"), digestmod=hashlib.sha256
).digest()
sign = base64.b64encode(hmac_code).decode("utf-8")
data["timestamp"] = timestamp
data["sign"] = sign
response = requests.post(url, data=json.dumps(data)).json() response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0: if response.get("StatusCode") == 0 or response.get("code") == 0:
@@ -675,6 +702,10 @@ def smtp(title: str, content: str) -> None:
return return
print("SMTP 邮件 服务启动") print("SMTP 邮件 服务启动")
email_to = push_config.get("SMTP_EMAIL_TO") or push_config.get("SMTP_EMAIL")
email_to_list = [
item.strip() for item in re.split(r"[;]", email_to) if item.strip()
]
message = MIMEText(content, "plain", "utf-8") message = MIMEText(content, "plain", "utf-8")
message["From"] = formataddr( message["From"] = formataddr(
( (
@@ -682,12 +713,7 @@ def smtp(title: str, content: str) -> None:
push_config.get("SMTP_EMAIL"), push_config.get("SMTP_EMAIL"),
) )
) )
message["To"] = formataddr( message["To"] = ",".join(email_to_list)
(
Header(push_config.get("SMTP_NAME"), "utf-8").encode(),
push_config.get("SMTP_EMAIL"),
)
)
message["Subject"] = Header(title, "utf-8") message["Subject"] = Header(title, "utf-8")
try: try:
@@ -701,7 +727,7 @@ def smtp(title: str, content: str) -> None:
) )
smtp_server.sendmail( smtp_server.sendmail(
push_config.get("SMTP_EMAIL"), push_config.get("SMTP_EMAIL"),
push_config.get("SMTP_EMAIL"), email_to_list,
message.as_bytes(), message.as_bytes(),
) )
smtp_server.close() smtp_server.close()
@@ -883,6 +909,90 @@ def wxpusher_bot(title: str, content: str) -> None:
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}") print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
def wxpusher_spt(title: str, content: str) -> None:
"""
通过 wxpusher 极简推送SPT推送消息
支持的环境变量:
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
"""
if not push_config.get("WXPUSHER_SPT_LIST"):
return
# 处理 SPT,将逗号分隔的字符串转为数组
spts = [
spt.strip()
for spt in push_config.get("WXPUSHER_SPT_LIST").split(",")
if spt.strip()
]
if not spts:
print("wxpusher 服务的 WXPUSHER_SPT_LIST 不能为空!!")
return
if len(spts) > 10:
print("wxpusher 服务的 WXPUSHER_SPT_LIST 最多支持 10 个!!")
return
print("wxpusher SPT 服务启动")
url = "https://wxpusher.zjiecode.com/api/send/message/simple-push"
data = {
"content": f"<h1>{title}</h1><br/><div style='white-space: pre-wrap;'>{content}</div>",
"summary": title,
"contentType": 2,
}
# 单个 SPT 用 spt,多个用 sptList
if len(spts) == 1:
data["spt"] = spts[0]
else:
data["sptList"] = spts
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("code") == 1000:
print("wxpusher SPT 推送成功!")
else:
print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}")
def openilink(title: str, content: str) -> None:
"""
通过 OpeniLink 推送消息
支持的环境变量:
- OPENILINK_APP_TOKEN: OpeniLink Hub 后台安装 App 后获取的 app_token
- OPENILINK_HUB_URL: OpeniLink Hub 地址默认为 https://hub.openilink.com
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token可从消息事件中获取
"""
if not push_config.get("OPENILINK_APP_TOKEN"):
return
print("OpeniLink 服务启动")
base_url = (
push_config.get("OPENILINK_HUB_URL", "").rstrip("/")
or "https://hub.openilink.com"
)
url = f"{base_url}/bot/v1/message/send"
headers = {
"Content-Type": "application/json",
"Authorization": f'Bearer {push_config.get("OPENILINK_APP_TOKEN")}',
}
data = {
"type": "text",
"content": f"{title}\n\n{content}",
}
if push_config.get("OPENILINK_CONTEXT_TOKEN"):
data["context_token"] = push_config.get("OPENILINK_CONTEXT_TOKEN")
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("ok"):
print("OpeniLink 推送成功!")
else:
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
def parse_headers(headers): def parse_headers(headers):
if not headers: if not headers:
return {} return {}
@@ -1048,6 +1158,10 @@ def add_notify_function():
push_config.get("WXPUSHER_TOPIC_IDS") or push_config.get("WXPUSHER_UIDS") push_config.get("WXPUSHER_TOPIC_IDS") or push_config.get("WXPUSHER_UIDS")
): ):
notify_function.append(wxpusher_bot) notify_function.append(wxpusher_bot)
if push_config.get("WXPUSHER_SPT_LIST"):
notify_function.append(wxpusher_spt)
if push_config.get("OPENILINK_APP_TOKEN"):
notify_function.append(openilink)
if not notify_function: if not notify_function:
print(f"无推送渠道,请检查通知变量是否正确") print(f"无推送渠道,请检查通知变量是否正确")
return notify_function return notify_function
@@ -1073,7 +1187,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
return return
hitokoto = push_config.get("HITOKOTO") hitokoto = push_config.get("HITOKOTO")
content += "\n\n" + one() if hitokoto != "false" else "" content += "\n\n" + one() if hitokoto not in [False, "false"] else ""
notify_function = add_notify_function() notify_function = add_notify_function()
ts = [ ts = [
File diff suppressed because it is too large Load Diff
+28
View File
@@ -12,4 +12,32 @@ QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => {
QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => { QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => {
console.log('systemNotify', x); console.log('systemNotify', x);
}); });
// 查询定时任务 (Query cron tasks)
QLAPI.getCrons({ searchValue: 'test' }).then((x) => {
console.log('getCrons', x);
});
// 通过ID查询定时任务 (Get cron by ID)
QLAPI.getCronById({ id: 1 }).then((x) => {
console.log('getCronById', x);
}).catch((err) => {
console.log('getCronById error', err);
});
// 启用定时任务 (Enable cron tasks)
QLAPI.enableCrons({ ids: [1, 2] }).then((x) => {
console.log('enableCrons', x);
});
// 禁用定时任务 (Disable cron tasks)
QLAPI.disableCrons({ ids: [1, 2] }).then((x) => {
console.log('disableCrons', x);
});
// 手动执行定时任务 (Run cron tasks manually)
QLAPI.runCrons({ ids: [1] }).then((x) => {
console.log('runCrons', x);
});
console.log('test desc'); console.log('test desc');
+51 -21
View File
@@ -41,7 +41,7 @@ add_cron_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
@@ -50,9 +50,9 @@ add_cron_api() {
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then if [[ $code == 200 ]]; then
echo -e "$name -> 添加成功" t '%s -> 添加成功' "$name"
else else
echo -e "$name -> 添加失败(${message})" t '%s -> 添加失败(%s)' "$name" "$message"
fi fi
} }
@@ -71,7 +71,7 @@ update_cron_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -81,9 +81,9 @@ update_cron_api() {
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then if [[ $code == 200 ]]; then
echo -e "$name -> 更新成功" t '%s -> 更新成功' "$name"
else else
echo -e "$name -> 更新失败(${message})" t '%s -> 更新失败(%s)' "$name" "$message"
fi fi
} }
@@ -98,7 +98,7 @@ update_cron_command_api() {
fi fi
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -108,9 +108,9 @@ update_cron_command_api() {
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then if [[ $code == 200 ]]; then
echo -e "$command -> 更新成功" t '%s -> 更新成功' "$command"
else else
echo -e "$command -> 更新失败(${message})" t '%s -> 更新失败(%s)' "$command" "$message"
fi fi
} }
@@ -118,7 +118,7 @@ del_cron_api() {
local ids="$1" local ids="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X 'DELETE' \ -X 'DELETE' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -128,9 +128,9 @@ del_cron_api() {
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then if [[ $code == 200 ]]; then
echo -e "成功" t '成功'
else else
echo -e "失败(${message})" t '失败(%s)' "$message"
fi fi
} }
@@ -141,13 +141,19 @@ update_cron() {
local logPath="$4" local logPath="$4"
local lastExecutingTime="${5:-0}" local lastExecutingTime="${5:-0}"
local runningTime="${6:-0}" local runningTime="${6:-0}"
local exitCode="${7:-}"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local dataRaw="{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime"
if [[ -n $exitCode ]]; then
dataRaw="${dataRaw},\"exit_code\":$exitCode"
fi
dataRaw="${dataRaw}}"
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/status?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/status?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--data-raw "{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime}" \ --data-raw "$dataRaw" \
--compressed --compressed
) )
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
@@ -165,7 +171,7 @@ notify_api() {
local content="$2" local content="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/notify?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/open/system/notify?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -175,9 +181,9 @@ notify_api() {
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then if [[ $code == 200 ]]; then
echo -e "通知发送成功🎉" t '通知发送成功🎉'
else else
echo -e "通知失败(${message})" t '通知失败(%s)' "$message"
fi fi
} }
@@ -185,7 +191,7 @@ find_cron_api() {
local params="$1" local params="$1"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/crons/detail?$params&t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
--compressed --compressed
@@ -204,7 +210,7 @@ update_auth_config() {
local tip="$2" local tip="$2"
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/open/system/auth/reset?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \
-X 'PUT' \ -X 'PUT' \
-H "Authorization: Bearer ${__ql_token__}" \ -H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \ -H "Content-Type: application/json;charset=UTF-8" \
@@ -214,9 +220,33 @@ update_auth_config() {
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then if [[ $code == 200 ]]; then
echo -e "${tip}成功🎉" t '%s成功🎉' "$tip"
else else
echo -e "${tip}失败(${message})" t '%s失败(%s)' "$tip" "$message"
fi
}
record_cron_stat() {
local ref_id="$1"
local exit_code="${2:-0}"
local elapsed="${3:-0}"
[[ $ref_id ]] && [[ $ref_id -gt 0 ]] 2>/dev/null || return
local api=$(
curl -s --noproxy "*" "http://localhost:${ql_port:-5700}/open/dashboard/record" \
-X POST \
-H "Authorization: Bearer ${__ql_token__}" \
-H "Content-Type: application/json;charset=UTF-8" \
--data-raw "{\"ref_id\":$ref_id,\"code\":$exit_code,\"elapsed\":$elapsed}" \
--compressed
)
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
if [[ $code != 200 ]]; then
if [[ ! $message ]]; then
message="$api"
fi
echo -e "${message}"
fi fi
} }
+31 -9
View File
@@ -8,11 +8,33 @@ else
repo_path="${dir_repo}/diybot" repo_path="${dir_repo}/diybot"
fi fi
echo -e "\n1、安装bot依赖...\n" t '\n1、安装bot依赖...\n'
apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev os_name="${QL_OS_TYPE:-}"
echo -e "\nbot依赖安装成功...\n" if [ -z "$os_name" ]; then
os_name=$(source /etc/os-release && echo "$ID")
fi
echo -e "2、下载bot所需文件...\n" # 非 root 用户使用 sudo
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
case "$os_name" in
alpine)
$SUDO apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev
;;
debian|ubuntu)
$SUDO apt-get install -y gcc python3-dev musl-dev zlib1g-dev libjpeg-dev libfreetype-dev
;;
*)
t '暂不支持此系统 %s' "$os_name"
exit 1
;;
esac
t '\nbot依赖安装成功...\n'
t '2、下载bot所需文件...\n'
if [[ ! -d ${repo_path}/.git ]]; then if [[ ! -d ${repo_path}/.git ]]; then
rm -rf ${repo_path} rm -rf ${repo_path}
git_clone_scripts ${url} ${repo_path} "main" git_clone_scripts ${url} ${repo_path} "main"
@@ -22,9 +44,9 @@ cp -rf "$repo_path/jbot" $dir_data
if [[ ! -f "$dir_config/bot.json" ]]; then if [[ ! -f "$dir_config/bot.json" ]]; then
cp -f "$repo_path/config/bot.json" "$dir_config" cp -f "$repo_path/config/bot.json" "$dir_config"
fi fi
echo -e "\nbot文件下载成功...\n" t '\nbot文件下载成功...\n'
echo -e "3、安装python3依赖...\n" t '3、安装python3依赖...\n'
cp -f "$repo_path/jbot/requirements.txt" "$dir_data" cp -f "$repo_path/jbot/requirements.txt" "$dir_data"
cd $dir_data cd $dir_data
@@ -34,11 +56,11 @@ cat requirements.txt | while read LREAD; do
fi fi
done done
echo -e "\npython3依赖安装成功...\n" t '\npython3依赖安装成功...\n'
echo -e "4、启动bot程序...\n" t '4、启动bot程序...\n'
make_dir $dir_log/bot make_dir $dir_log/bot
cd $dir_data cd $dir_data
ps -eo pid,command | grep "python3 -m jbot" | grep -v grep | awk '{print $1}' | xargs kill -9 2>/dev/null ps -eo pid,command | grep "python3 -m jbot" | grep -v grep | awk '{print $1}' | xargs kill -9 2>/dev/null
nohup python3 -m jbot >$dir_log/bot/nohup.log 2>&1 & nohup python3 -m jbot >$dir_log/bot/nohup.log 2>&1 &
echo -e "bot启动成功...\n" t 'bot启动成功...\n'
+24 -22
View File
@@ -1,40 +1,41 @@
#!/usr/bin/env bash #!/usr/bin/env bash
reset_env() { reset_env() {
echo -e "---> 1. 开始检测配置文件\n" t '---> 1. 开始检测配置文件\n'
fix_config fix_config
echo -e "---> 配置文件检测完成\n" t '---> 配置文件检测完成\n'
echo -e "---> 2. 开始安装青龙依赖\n" t '---> 2. 开始安装青龙依赖\n'
npm_install_2 $dir_root npm_install_2 $dir_root
echo -e "---> 青龙依赖安装完成\n" t '---> 青龙依赖安装完成\n'
echo -e "---> 脚本依赖安装完成\n" t '---> 脚本依赖安装完成\n'
} }
copy_dep() { copy_dep() {
echo -e "---> 1. 复制通知文件\n" t '---> 1. 复制通知文件\n'
echo -e "---> 复制一份 $file_notify_py_sample$file_notify_py\n" t '---> 复制一份 %s 为 %s\n' "$file_notify_py_sample" "$file_notify_py"
cp -fv $file_notify_py_sample $file_notify_py cp -fv $file_notify_py_sample $file_notify_py
echo echo
echo -e "---> 复制一份 $file_notify_js_sample$file_notify_js\n" t '---> 复制一份 %s 为 %s\n' "$file_notify_js_sample" "$file_notify_js"
cp -fv $file_notify_js_sample $file_notify_js cp -fv $file_notify_js_sample $file_notify_js
echo -e "---> 通知文件复制完成\n" t '---> 通知文件复制完成\n'
} }
pm2_log() { pm2_log() {
echo -e "---> pm2日志" t '---> pm2日志'
local panelOut="/root/.pm2/logs/panel-out.log" local panelOut="/root/.pm2/logs/qinglong-out.log"
local panelError="/root/.pm2/logs/panel-error.log" local panelError="/root/.pm2/logs/qinglong-error.log"
tail -n 300 "$panelOut" tail -n 300 "$panelOut"
tail -n 300 "$panelError" tail -n 300 "$panelError"
} }
check_ql() { check_ql() {
local api=$(curl -s --noproxy "*" "http://0.0.0.0:5700") local api=$(curl -s --noproxy "*" "http://localhost:${ql_port}")
echo -e "\n=====> 检测面板\n\n$api\n" t '\n=====> 检测面板'
echo -e "\n\n$api\n"
if [[ $api =~ "<div id=\"root\"></div>" ]]; then if [[ $api =~ "<div id=\"root\"></div>" ]]; then
echo -e "=====> 面板服务启动正常\n" t '=====> 面板服务启动正常\n'
fi fi
} }
@@ -42,29 +43,30 @@ check_pm2() {
pm2_log pm2_log
local currentTimeStamp=$(date +%s) local currentTimeStamp=$(date +%s)
local api=$( local api=$(
curl -s --noproxy "*" "http://0.0.0.0:5700/api/system?t=$currentTimeStamp" \ curl -s --noproxy "*" "http://localhost:${ql_port}/api/system?t=$currentTimeStamp" \
-H 'Accept: */*' \ -H 'Accept: */*' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \
-H 'Referer: http://0.0.0.0:5700/crontab' \ -H "Referer: http://localhost:${ql_port}/crontab" \
-H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \
--compressed --compressed
) )
echo -e "\n=====> 检测后台\n\n$api\n" t '\n=====> 检测后台'
echo -e "\n\n$api\n"
if [[ $api =~ "{\"code\"" ]]; then if [[ $api =~ "{\"code\"" ]]; then
echo -e "=====> 后台服务启动正常\n" t '=====> 后台服务启动正常\n'
fi fi
} }
main() { main() {
echo -e "=====> 开始检测" t '=====> 开始检测'
npm i -g pnpm@8.3.1 pm2 ts-node npm i -g pnpm@8.3.1 pm2 ts-node typescript@5
reset_env reset_env
copy_dep copy_dep
check_ql check_ql
check_pm2 check_pm2
reload_pm2 reload_pm2
echo -e "\n=====> 检测结束\n" t '\n=====> 检测结束\n'
} }
main main
+105
View File
@@ -0,0 +1,105 @@
# English language pack
declare -gA LANG_MESSAGES=(
['任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n']='Task delayed %s seconds, will start at %s. Set RandomDelay to empty to cancel delay\n'
['开始执行...\n']='Starting execution...\n'
['已停止']='Stopped'
['完成']='Completed'
['失败(退出码 %s)']='Failed (exit code %s)'
['安装 %s 依赖包...\n']='Installing %s dependencies...\n'
['开始拉取仓库 %s 到 %s\n']='Cloning repository %s to %s\n'
['添加成功']='Added successfully'
['添加失败(%s)']='Add failed (%s)'
['更新成功']='Updated successfully'
['更新失败(%s)']='Update failed (%s)'
['成功']='Success'
['失败(%s)']='Failed (%s)'
['通知发送成功🎉']='Notification sent successfully 🎉'
['通知失败(%s)']='Notification failed (%s)'
['当前有以下脚本可以运行:']='Available scripts:'
['暂无脚本可以执行']='No scripts available'
['警告:工作目录不存在 %s']='Warning: working directory does not exist: %s'
['\n缺少并发运行的环境变量参数']='\nMissing concurrency environment variable'
['\n缺少单独运行的参数 task xxx.js desi Test']='\nMissing parameter: task xxx.js desi Test'
['暂不支持此系统 %s']='Unsupported system: %s'
['检测到 pm2 服务正在运行']='pm2 service is running'
['npm 模块位置: %s']='npm module location: %s'
['导入数据成功 %s']='Data imported successfully: %s'
['导出数据成功 %s']='Data exported successfully: %s'
['当前版本: %s / 最新版本: %s']='Current: %s / Latest: %s'
['已是最新版本']='Already up to date'
['%s 个定时任务正在运行']='%s scheduled tasks running'
['执行前置命令\n']='Running pre-command\n'
['\n执行前置命令结束\n']='\nPre-command finished\n'
['\n执行后置命令\n']='\nRunning post-command\n'
['\n执行后置命令结束']='\nPost-command finished'
['警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容']='Warning: PM2 start failed (exit code: %s), possibly due to hardware incompatibility'
['正在尝试直接使用 Node.js 启动服务...']='Attempting to start service with Node.js directly...'
['已使用 Node.js 直接启动服务 (PID: %s)']='Service started with Node.js directly (PID: %s)'
['注意: 使用此模式时,部分 PM2 管理功能将不可用']='Note: some PM2 management features are unavailable in this mode'
['## 开始执行... %s\n']='## Starting... %s\n'
['\n## 已停止 🛑... %s 耗时 %s 秒%s']='\n## Stopped 🛑... %s took %s seconds%s'
['\n## 完成 ✅... %s 耗时 %s 秒%s']='\n## Completed ✅... %s took %s seconds%s'
['\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s']='\n## Failed ❌(exit code %s)... %s took %s seconds%s'
['%s -> 添加成功']='%s -> Added successfully'
['%s -> 添加失败(%s)']='%s -> Add failed (%s)'
['%s -> 更新成功']='%s -> Updated successfully'
['%s -> 更新失败(%s)']='%s -> Update failed (%s)'
['%s成功🎉']='%s succeeded 🎉'
['%s失败(%s)']='%s failed (%s)'
['检测到有%s的定时任务:']='Found %s scheduled tasks:'
['\n开始尝试自动删除失效的定时任务...']='\nAttempting to remove invalid scheduled tasks...'
['\n开始尝试自动添加定时任务...']='\nAttempting to add scheduled tasks...'
['拉取 %s 成功...\n']='Pull %s succeeded...\n'
['拉取 %s 失败,请检查日志...\n']='Pull %s failed, check logs...\n'
['开始下载:%s 保存路径:%s\n']='Downloading: %s to: %s\n'
['下载 %s 成功...\n']='Download %s succeeded...\n'
['下载 %s 失败,保留之前正常下载的版本...\n']='Download %s failed, keeping previous version...\n'
['%s文件不存在,跳过执行...\n']='%s does not exist, skipping...\n'
['使用 %s 源更新...\n']='Updating using %s mirror...\n'
['更新青龙源文件成功...\n']='Qinglong source updated successfully\n'
['更新青龙源文件失败,请检查网络...\n']='Qinglong source update failed, check network\n'
['更新青龙静态资源成功...\n']='Static assets updated successfully\n'
['更新青龙静态资源失败,请检查网络...\n']='Static assets update failed, check network\n'
['\n开始检测依赖...\n']='\nChecking dependencies...\n'
['\n依赖检测安装成功...\n']='\nDependencies installed successfully\n'
['更新包下载成功...\n']='Package download succeeded\n'
['\n依赖检测安装失败,请检查网络...\n']='\nDependency installation failed, check network\n'
['\n1、安装bot依赖...\n']='\n1. Installing bot dependencies...\n'
['\nbot依赖安装成功...\n']='\nBot dependencies installed\n'
['2、下载bot所需文件...\n']='2. Downloading bot files...\n'
['\nbot文件下载成功...\n']='\nBot files downloaded\n'
['3、安装python3依赖...\n']='3. Installing python3 dependencies...\n'
['\npython3依赖安装成功...\n']='\nPython3 dependencies installed\n'
['4、启动bot程序...\n']='4. Starting bot...\n'
['bot启动成功...\n']='Bot started successfully\n'
# check.sh
['---> 1. 开始检测配置文件\n']='---> 1. Checking config...\n'
['---> 配置文件检测完成\n']='---> Config check complete\n'
['---> 2. 开始安装青龙依赖\n']='---> 2. Installing qinglong dependencies...\n'
['---> 青龙依赖安装完成\n']='---> Dependencies installed\n'
['---> 脚本依赖安装完成\n']='---> Script dependencies installed\n'
['---> 1. 复制通知文件\n']='---> 1. Copying notification files...\n'
['---> 复制一份 %s 为 %s\n']='---> Copying %s to %s\n'
['---> 通知文件复制完成\n']='---> Notification files copied\n'
['---> pm2日志']='---> pm2 log'
['\n=====> 检测面板']='\n=====> Checking panel'
['=====> 面板服务启动正常\n']='=====> Panel service running normally\n'
['\n=====> 检测后台']='\n=====> Checking backend'
['=====> 后台服务启动正常\n']='=====> Backend service running normally\n'
['=====> 开始检测']='=====> Starting check'
['\n=====> 检测结束\n']='\n=====> Check complete\n'
# rmlog.sh
['查询文件 %s']='Checking file: %s'
['删除中~']='Deleting...'
['正在被 %s 使用,跳过~']='In use by %s, skipping...'
['查找旧日志文件中...\n']='Looking for old log files...\n'
['删除旧日志执行完毕\n']='Old log cleanup complete\n'
# start.sh
['未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装']='Module not found. Run: npm i -g @whyour/qinglong'
['请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务']='Set env: export QL_DIR=%s, then run qinglong to start'
['请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量']='Set QL_DATA_DIR (absolute path ending with /data, e.g. /ql/data)'
['QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中']='QL_DATA_DIR must end with /data, e.g. /ql/data'
['暂不支持此系统部署 %s']='Unsupported system for deployment: %s'
# update.sh
['命令输入错误...\n']='Invalid command...\n'
)
+105
View File
@@ -0,0 +1,105 @@
# 中文语言包(zh key → zh value 恒等,en 包做实际翻译)
declare -gA LANG_MESSAGES=(
['任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n']='任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n'
['开始执行...\n']='开始执行...\n'
['已停止']='已停止'
['完成']='完成'
['失败(退出码 %s)']='失败(退出码 %s)'
['安装 %s 依赖包...\n']='安装 %s 依赖包...\n'
['开始拉取仓库 %s 到 %s\n']='开始拉取仓库 %s 到 %s\n'
['添加成功']='添加成功'
['添加失败(%s)']='添加失败(%s)'
['更新成功']='更新成功'
['更新失败(%s)']='更新失败(%s)'
['成功']='成功'
['失败(%s)']='失败(%s)'
['通知发送成功🎉']='通知发送成功🎉'
['通知失败(%s)']='通知失败(%s)'
['当前有以下脚本可以运行:']='当前有以下脚本可以运行:'
['暂无脚本可以执行']='暂无脚本可以执行'
['警告:工作目录不存在 %s']='警告:工作目录不存在 %s'
['\n缺少并发运行的环境变量参数']='\n缺少并发运行的环境变量参数'
['\n缺少单独运行的参数 task xxx.js desi Test']='\n缺少单独运行的参数 task xxx.js desi Test'
['暂不支持此系统 %s']='暂不支持此系统 %s'
['检测到 pm2 服务正在运行']='检测到 pm2 服务正在运行'
['npm 模块位置: %s']='npm 模块位置: %s'
['导入数据成功 %s']='导入数据成功 %s'
['导出数据成功 %s']='导出数据成功 %s'
['当前版本: %s / 最新版本: %s']='当前版本: %s / 最新版本: %s'
['已是最新版本']='已是最新版本'
['%s 个定时任务正在运行']='%s 个定时任务正在运行'
['执行前置命令\n']='执行前置命令\n'
['\n执行前置命令结束\n']='\n执行前置命令结束\n'
['\n执行后置命令\n']='\n执行后置命令\n'
['\n执行后置命令结束']='\n执行后置命令结束'
['警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容']='警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容'
['正在尝试直接使用 Node.js 启动服务...']='正在尝试直接使用 Node.js 启动服务...'
['已使用 Node.js 直接启动服务 (PID: %s)']='已使用 Node.js 直接启动服务 (PID: %s)'
['注意: 使用此模式时,部分 PM2 管理功能将不可用']='注意: 使用此模式时,部分 PM2 管理功能将不可用'
['## 开始执行... %s\n']='## 开始执行... %s\n'
['\n## 已停止 🛑... %s 耗时 %s 秒%s']='\n## 已停止 🛑... %s 耗时 %s 秒%s'
['\n## 完成 ✅... %s 耗时 %s 秒%s']='\n## 完成 ✅... %s 耗时 %s 秒%s'
['\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s']='\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s'
['%s -> 添加成功']='%s -> 添加成功'
['%s -> 添加失败(%s)']='%s -> 添加失败(%s)'
['%s -> 更新成功']='%s -> 更新成功'
['%s -> 更新失败(%s)']='%s -> 更新失败(%s)'
['%s成功🎉']='%s成功🎉'
['%s失败(%s)']='%s失败(%s)'
['检测到有%s的定时任务:']='检测到有%s的定时任务:'
['\n开始尝试自动删除失效的定时任务...']='\n开始尝试自动删除失效的定时任务...'
['\n开始尝试自动添加定时任务...']='\n开始尝试自动添加定时任务...'
['拉取 %s 成功...\n']='拉取 %s 成功...\n'
['拉取 %s 失败,请检查日志...\n']='拉取 %s 失败,请检查日志...\n'
['开始下载:%s 保存路径:%s\n']='开始下载:%s 保存路径:%s\n'
['下载 %s 成功...\n']='下载 %s 成功...\n'
['下载 %s 失败,保留之前正常下载的版本...\n']='下载 %s 失败,保留之前正常下载的版本...\n'
['%s文件不存在,跳过执行...\n']='%s文件不存在,跳过执行...\n'
['使用 %s 源更新...\n']='使用 %s 源更新...\n'
['更新青龙源文件成功...\n']='更新青龙源文件成功...\n'
['更新青龙源文件失败,请检查网络...\n']='更新青龙源文件失败,请检查网络...\n'
['更新青龙静态资源成功...\n']='更新青龙静态资源成功...\n'
['更新青龙静态资源失败,请检查网络...\n']='更新青龙静态资源失败,请检查网络...\n'
['\n开始检测依赖...\n']='\n开始检测依赖...\n'
['\n依赖检测安装成功...\n']='\n依赖检测安装成功...\n'
['更新包下载成功...\n']='更新包下载成功...\n'
['\n依赖检测安装失败,请检查网络...\n']='\n依赖检测安装失败,请检查网络...\n'
['\n1、安装bot依赖...\n']='\n1、安装bot依赖...\n'
['\nbot依赖安装成功...\n']='\nbot依赖安装成功...\n'
['2、下载bot所需文件...\n']='2、下载bot所需文件...\n'
['\nbot文件下载成功...\n']='\nbot文件下载成功...\n'
['3、安装python3依赖...\n']='3、安装python3依赖...\n'
['\npython3依赖安装成功...\n']='\npython3依赖安装成功...\n'
['4、启动bot程序...\n']='4、启动bot程序...\n'
['bot启动成功...\n']='bot启动成功...\n'
# check.sh
['---> 1. 开始检测配置文件\n']='---> 1. 开始检测配置文件\n'
['---> 配置文件检测完成\n']='---> 配置文件检测完成\n'
['---> 2. 开始安装青龙依赖\n']='---> 2. 开始安装青龙依赖\n'
['---> 青龙依赖安装完成\n']='---> 青龙依赖安装完成\n'
['---> 脚本依赖安装完成\n']='---> 脚本依赖安装完成\n'
['---> 1. 复制通知文件\n']='---> 1. 复制通知文件\n'
['---> 复制一份 %s 为 %s\n']='---> 复制一份 %s 为 %s\n'
['---> 通知文件复制完成\n']='---> 通知文件复制完成\n'
['---> pm2日志']='---> pm2日志'
['\n=====> 检测面板']='\n=====> 检测面板'
['=====> 面板服务启动正常\n']='=====> 面板服务启动正常\n'
['\n=====> 检测后台']='\n=====> 检测后台'
['=====> 后台服务启动正常\n']='=====> 后台服务启动正常\n'
['=====> 开始检测']='=====> 开始检测'
['\n=====> 检测结束\n']='\n=====> 检测结束\n'
# rmlog.sh
['查询文件 %s']='查询文件 %s'
['删除中~']='删除中~'
['正在被 %s 使用,跳过~']='正在被 %s 使用,跳过~'
['查找旧日志文件中...\n']='查找旧日志文件中...\n'
['删除旧日志执行完毕\n']='删除旧日志执行完毕\n'
# start.sh
['未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装']='未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
['请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务']='请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务'
['请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量']='请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
['QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中']='QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
['暂不支持此系统部署 %s']='暂不支持此系统部署 %s'
# update.sh
['命令输入错误...\n']='命令输入错误...\n'
)
+117 -28
View File
@@ -24,7 +24,13 @@ random_delay() {
done done
local delay_second=$(($(gen_random_num "$random_delay_max") + 1)) local delay_second=$(($(gen_random_num "$random_delay_max") + 1))
echo -e "任务随机延迟 $delay_second 秒,配置文件参数 RandomDelay 置空可取消延迟 \n" local start_time
if [[ $is_macos -eq 1 ]]; then
start_time=$(date -v "+${delay_second}S" "+%Y-%m-%d %H:%M:%S")
else
start_time=$(date -d "+${delay_second} seconds" "+%Y-%m-%d %H:%M:%S")
fi
t '任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n' "$delay_second" "$start_time"
sleep $delay_second sleep $delay_second
fi fi
} }
@@ -83,6 +89,61 @@ clear_non_sh_env() {
fi fi
} }
append_node_dependency_path() {
export PREV_NODE_PATH="${NODE_PATH:=}"
# 用户依赖目录加入 NODE_PATH,替代 symlink 到 node_modules 的方式
export NODE_PATH="${NODE_PATH:+${NODE_PATH}:}${dir_dep}"
local pnpm_global_path=$(pnpm root -g 2>/dev/null)
if [[ -n "$pnpm_global_path" ]]; then
export QL_NODE_GLOBAL_PATH="$pnpm_global_path"
export NODE_PATH="${NODE_PATH:+${NODE_PATH}:}${pnpm_global_path}"
fi
}
enter_script_workdir() {
local use_dot_prefix="$1"
# 如果定时任务显式指定了工作目录,优先使用
if [[ -n "${work_dir:=}" ]]; then
local _target_dir
if [[ "${work_dir}" == /* ]]; then
_target_dir="${work_dir}"
else
_target_dir="${dir_scripts}/${work_dir}"
fi
if [[ -d "${_target_dir}" ]]; then
cd "${_target_dir}"
if [[ ${file_param} =~ "/" ]]; then
local script_name="${file_param##*/}"
if [[ "${use_dot_prefix}" == "true" ]]; then
file_param="./${script_name}"
else
file_param="${script_name}"
fi
fi
return
fi
t '警告:工作目录不存在 %s' "${_target_dir}"
fi
cd $dir_scripts
if [[ ${file_param} =~ "/" ]]; then
local script_dir="${file_param%/*}"
local script_name="${file_param##*/}"
if [[ -d ${script_dir} ]]; then
cd ${script_dir}
if [[ "${use_dot_prefix}" == "true" ]]; then
file_param="./${script_name}"
else
file_param="${script_name}"
fi
fi
fi
}
## 正常运行单个脚本,$1:传入参数 ## 正常运行单个脚本,$1:传入参数
run_normal() { run_normal() {
local file_param=$1 local file_param=$1
@@ -90,12 +151,7 @@ run_normal() {
random_delay "$file_param" random_delay "$file_param"
fi fi
cd $dir_scripts enter_script_workdir
local relative_path="${file_param%/*}"
if [[ ${file_param} != /* ]] && [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then
cd ${relative_path}
file_param=${file_param/$relative_path\//}
fi
if [[ $isJsOrPythonFile == 'false' ]]; then if [[ $isJsOrPythonFile == 'false' ]]; then
clear_non_sh_env clear_non_sh_env
@@ -120,7 +176,7 @@ run_concurrent() {
local env_param="$2" local env_param="$2"
local num_param=$(echo "$3" | perl -pe "s|.*$2(.*)|\1|" | awk '{$1=$1};1') local num_param=$(echo "$3" | perl -pe "s|.*$2(.*)|\1|" | awk '{$1=$1};1')
if [[ ! $env_param ]]; then if [[ ! $env_param ]]; then
echo -e "\n 缺少并发运行的环境变量参数" t '\n缺少并发运行的环境变量参数'
exit 1 exit 1
fi fi
@@ -128,12 +184,7 @@ run_concurrent() {
time=$(date "+$mtime_format") time=$(date "+$mtime_format")
single_log_time=$(format_log_time "$mtime_format" "$time") single_log_time=$(format_log_time "$mtime_format" "$time")
cd $dir_scripts enter_script_workdir
local relative_path="${file_param%/*}"
if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then
cd ${relative_path}
file_param=${file_param/$relative_path\//}
fi
local j=0 local j=0
for i in ${array_run[@]}; do for i in ${array_run[@]}; do
@@ -162,7 +213,7 @@ run_designated() {
local env_param="$2" local env_param="$2"
local num_param=$(echo "$3" | perl -pe "s|.*$2(.*)|\1|" | awk '{$1=$1};1') local num_param=$(echo "$3" | perl -pe "s|.*$2(.*)|\1|" | awk '{$1=$1};1')
if [[ ! $env_param ]]; then if [[ ! $env_param ]]; then
echo -e "\n 缺少单独运行的参数 task xxx.js desi Test" t '\n缺少单独运行的参数 task xxx.js desi Test'
exit 1 exit 1
fi fi
@@ -182,12 +233,7 @@ run_designated() {
clear_non_sh_env clear_non_sh_env
fi fi
cd $dir_scripts enter_script_workdir
local relative_path="${file_param%/*}"
if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then
cd ${relative_path}
file_param=${file_param/$relative_path\//}
fi
envParam="${env_param}" numParam="${num_param}" $timeoutCmd $which_program $file_param "${script_params[@]}" envParam="${env_param}" numParam="${num_param}" $timeoutCmd $which_program $file_param "${script_params[@]}"
} }
@@ -196,14 +242,53 @@ run_designated() {
run_else() { run_else() {
local file_param="$1" local file_param="$1"
cd $dir_scripts # 判断 file_param 本身是否是脚本文件
local relative_path="${file_param%/*}" local is_file_script="false"
if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then if [[ "$file_param" == *.js || "$file_param" == *.mjs ||
cd ${relative_path} "$file_param" == *.py || "$file_param" == *.pyc ||
file_param=${file_param/$relative_path\//.\/} "$file_param" == *.sh || "$file_param" == *.ts ]]; then
is_file_script="true"
fi fi
shift if [[ "$is_file_script" != "true" ]]; then
# file_param 不是脚本,从后续参数中查找脚本路径来确定工作目录
local script_for_dir=""
for arg in "$@"; do
if [[ "$arg" == *.js || "$arg" == *.mjs ||
"$arg" == *.py || "$arg" == *.pyc ||
"$arg" == *.sh || "$arg" == *.ts ]]; then
script_for_dir="$arg"
break
fi
done
if [[ -n "$script_for_dir" ]]; then
local saved_file_param="$file_param"
file_param="$script_for_dir"
enter_script_workdir true
local adjusted_script="$file_param"
file_param="$saved_file_param"
shift
local new_args=()
for arg in "$@"; do
if [[ "$arg" == "$script_for_dir" ]]; then
new_args+=("$adjusted_script")
else
new_args+=("$arg")
fi
done
set -- "${new_args[@]}"
else
# 没有找到脚本参数,只 cd 到 scripts 目录
enter_script_workdir true
shift
fi
else
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
enter_script_workdir true
shift
fi
clear_non_sh_env clear_non_sh_env
$timeoutCmd $which_program $file_param "$@" $timeoutCmd $which_program $file_param "$@"
@@ -242,7 +327,7 @@ check_nounset() {
} }
main() { main() {
if [[ $1 == *.js ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.sh ]] || [[ $1 == *.ts ]]; then if [[ $1 == *.js ]] || [[ $1 == *.mjs ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.sh ]] || [[ $1 == *.ts ]]; then
if [[ $1 == *.sh ]]; then if [[ $1 == *.sh ]]; then
timeoutCmd="" timeoutCmd=""
fi fi
@@ -278,15 +363,19 @@ main() {
handle_task_start "${task_shell_params[@]}" handle_task_start "${task_shell_params[@]}"
check_file "${task_shell_params[@]}" check_file "${task_shell_params[@]}"
append_node_dependency_path
if [[ $isJsOrPythonFile == 'false' ]]; then if [[ $isJsOrPythonFile == 'false' ]]; then
run_task_before "${task_shell_params[@]}" run_task_before "${task_shell_params[@]}"
fi fi
set_u_on="false" set_u_on="false"
check_nounset check_nounset
main "${task_shell_params[@]}" main "${task_shell_params[@]}"
_task_exit_code=$?
if [[ "$set_u_on" == 'true' ]]; then if [[ "$set_u_on" == 'true' ]]; then
set -u set -u
fi fi
export NODE_PATH="${PREV_NODE_PATH}"
unset QL_NODE_GLOBAL_PATH
if [[ $isJsOrPythonFile == 'true' ]]; then if [[ $isJsOrPythonFile == 'true' ]]; then
export NODE_OPTIONS="${PREV_NODE_OPTIONS}" export NODE_OPTIONS="${PREV_NODE_OPTIONS}"
export PYTHONPATH="${PREV_PYTHONPATH}" export PYTHONPATH="${PREV_PYTHONPATH}"
+25 -2
View File
@@ -1,11 +1,29 @@
const grpc = require('@grpc/grpc-js'); const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader'); const protoLoader = require('@grpc/proto-loader');
const { readFileSync } = require('fs');
const { join } = require('path'); const { join } = require('path');
class GrpcClient { class GrpcClient {
static #certDir = join(
process.env.QL_DATA_DIR || join(process.env.QL_DIR, 'data'),
'config/grpc',
);
static #loadTlsCredentials() {
try {
return grpc.credentials.createSsl(
readFileSync(join(GrpcClient.#certDir, 'ca.crt')),
readFileSync(join(GrpcClient.#certDir, 'client.key')),
readFileSync(join(GrpcClient.#certDir, 'client.crt')),
);
} catch {
return grpc.credentials.createInsecure();
}
}
static #config = { static #config = {
protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'), protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'),
serverAddress: '0.0.0.0:5500', serverAddress: `localhost:${process.env.GRPC_PORT || '5500'}`,
protoOptions: { protoOptions: {
keepCase: true, keepCase: true,
longs: String, longs: String,
@@ -33,6 +51,11 @@ class GrpcClient {
'createCron', 'createCron',
'updateCron', 'updateCron',
'deleteCrons', 'deleteCrons',
'getCrons',
'getCronById',
'enableCrons',
'disableCrons',
'runCrons',
]; ];
#client; #client;
@@ -53,7 +76,7 @@ class GrpcClient {
this.#client = new apiProto.Api( this.#client = new apiProto.Api(
serverAddress, serverAddress,
grpc.credentials.createInsecure(), GrpcClient.#loadTlsCredentials(),
grpcOptions, grpcOptions,
); );
} catch (error) { } catch (error) {
+41
View File
@@ -0,0 +1,41 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
const builtinModules = [
'assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns',
'events', 'fs', 'http', 'https', 'net', 'os', 'path', 'process', 'querystring',
'readline', 'stream', 'string_decoder', 'timers', 'tls', 'tty', 'url', 'util',
'v8', 'zlib',
];
function isBareSpecifier(specifier) {
return !specifier.startsWith('.') &&
!specifier.startsWith('/') &&
!specifier.startsWith('file:') &&
!specifier.startsWith('node:') &&
!builtinModules.includes(specifier) &&
!builtinModules.includes(specifier.split('/')[0]);
}
export function resolve(specifier, context, nextResolve) {
if (!isBareSpecifier(specifier)) {
return nextResolve(specifier, context);
}
// 解析优先级:全局 pnpm > 系统全局
const bases = [
process.env.QL_NODE_GLOBAL_PATH,
'/usr/local/lib/node_modules',
].filter(Boolean);
for (const base of bases) {
if (existsSync(join(base, specifier))) {
return nextResolve(specifier, {
...context,
parentURL: new URL(`${join(base, specifier)}/`, 'file://').href,
});
}
}
return nextResolve(specifier, context);
}
+41 -1
View File
@@ -1,7 +1,41 @@
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const Module = require('module');
const path = require('path');
const client = require('./client.js'); const client = require('./client.js');
require(`./env.js`); require(`./env.js`);
// 注册 ESM loader,使全局安装的包也可通过 import 导入
try {
Module.register(new URL('esm-loader.mjs', `file://${__dirname}/`).href);
} catch (_) {}
function preferGlobalNodeModules() {
const { QL_NODE_GLOBAL_PATH } = process.env;
if (!QL_NODE_GLOBAL_PATH || Module._qlGlobalPathPatched) {
return;
}
const originalResolveFilename = Module._resolveFilename;
Module._resolveFilename = function (request, parent, isMain, options) {
if (
!Module.builtinModules.includes(request) &&
!request.startsWith('node:') &&
!request.startsWith('.') &&
!path.isAbsolute(request)
) {
try {
return originalResolveFilename.call(this, request, parent, isMain, {
...options,
paths: [QL_NODE_GLOBAL_PATH],
});
} catch (error) {}
}
return originalResolveFilename.call(this, request, parent, isMain, options);
};
Module._qlGlobalPathPatched = true;
}
function expandRange(rangeStr, max) { function expandRange(rangeStr, max) {
const tempRangeStr = rangeStr const tempRangeStr = rangeStr
.trim() .trim()
@@ -64,7 +98,11 @@ function run() {
const newEnvObject = JSON.parse(envStr); const newEnvObject = JSON.parse(envStr);
if (typeof newEnvObject === 'object' && newEnvObject !== null) { if (typeof newEnvObject === 'object' && newEnvObject !== null) {
for (const key in newEnvObject) { for (const key in newEnvObject) {
if (Object.prototype.hasOwnProperty.call(newEnvObject, key)) { if (
Object.prototype.hasOwnProperty.call(newEnvObject, key) &&
key !== 'NODE_PATH' &&
key !== 'QL_NODE_GLOBAL_PATH'
) {
process.env[key] = newEnvObject[key]; process.env[key] = newEnvObject[key];
} }
} }
@@ -113,6 +151,8 @@ try {
return; return;
} }
preferGlobalNodeModules();
process.on('SIGTERM', (code) => { process.on('SIGTERM', (code) => {
process.exit(15); process.exit(15);
}); });
+5 -5
View File
@@ -22,12 +22,12 @@ remove_js_log() {
if [[ $diff_time -gt $((${days} * 86400)) ]]; then if [[ $diff_time -gt $((${days} * 86400)) ]]; then
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g") local log_path=$(echo "$log" | sed "s,${dir_log}/,,g")
local result=$(find_cron_api "log_path=$log_path") local result=$(find_cron_api "log_path=$log_path")
echo -e "查询文件 $log_path" t '查询文件 %s' "$log_path"
if [[ -z $result ]]; then if [[ -z $result ]]; then
echo -e "删除中~" t '删除中~'
rm -vf $log rm -vf $log
else else
echo -e "正在被 $result 使用,跳过~" t '正在被 %s 使用,跳过~' "$result"
fi fi
fi fi
done done
@@ -43,8 +43,8 @@ remove_empty_dir() {
} }
if [[ ${days} ]]; then if [[ ${days} ]]; then
echo -e "查找旧日志文件中...\n" t '查找旧日志文件中...\n'
remove_js_log remove_js_log
remove_empty_dir remove_empty_dir
echo -e "删除旧日志执行完毕\n" t '删除旧日志执行完毕\n'
fi fi
+75 -35
View File
@@ -59,40 +59,40 @@ list_own_user=$dir_list_tmp/own_user.list
list_own_add=$dir_list_tmp/own_add.list list_own_add=$dir_list_tmp/own_add.list
list_own_drop=$dir_list_tmp/own_drop.list list_own_drop=$dir_list_tmp/own_drop.list
## 软连接及其原始文件对应关系
link_name=( link_name=(
task task
ql ql
) )
original_name=(
task.sh
update.sh
)
init_env() { init_env() {
local pnpm_global_path=$(pnpm root -g 2>/dev/null) export NODE_PATH="/usr/local/bin:/usr/local/lib/node_modules"
export NODE_PATH="/usr/local/bin:/usr/local/lib/node_modules${pnpm_global_path:+:${pnpm_global_path}}"
# 如果存在 pnpm 全局路径,创建软链接
if [[ -n "$pnpm_global_path" ]]; then
# 确保目标目录存在
mkdir -p "${dir_root}/node_modules"
# 链接全局模块到项目的 node_modules
ln -sf "${pnpm_global_path}/"* "${dir_root}/node_modules/" 2>/dev/null || true
fi
export PYTHONUNBUFFERED=1 export PYTHONUNBUFFERED=1
} }
load_ql_envs() {
ql_base_url=${QlBaseUrl:-"/"}
ql_port=${QlPort:-"5700"}
ql_grpc_port=${QlGrpcPort:-"5500"}
current_branch=${QL_BRANCH:-""}
}
import_config() { import_config() {
[[ -f $file_config_user ]] && . $file_config_user [[ -f $file_config_user ]] && . $file_config_user
[[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh
ql_base_url=${QlBaseUrl:-"/"} # 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key)
ql_port=${QlPort:-"5700"} local lang=${QL_LANG:-zh}
local lang_file="$dir_shell/lang/${lang}.sh"
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then
. $lang_file
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then
. "$dir_shell/lang/zh.sh"
fi
load_ql_envs
command_timeout_time=${CommandTimeoutTime:-""} command_timeout_time=${CommandTimeoutTime:-""}
file_extensions=${RepoFileExtensions:-"js py"} file_extensions=${RepoFileExtensions:-"js py"}
proxy_url=${ProxyUrl:-""} proxy_url=${ProxyUrl:-""}
current_branch=${QL_BRANCH:-""}
if [[ -n "${DefaultCronRule}" ]]; then if [[ -n "${DefaultCronRule}" ]]; then
default_cron="${DefaultCronRule}" default_cron="${DefaultCronRule}"
@@ -101,6 +101,18 @@ import_config() {
fi fi
} }
t() {
local key="$1"
shift
local msg
if declare -p LANG_MESSAGES &>/dev/null; then
msg="${LANG_MESSAGES["$key"]}"
fi
[[ -z $msg ]] && msg="$key"
# shellcheck disable=SC2059
printf "$msg\n" "$@"
}
set_proxy() { set_proxy() {
local proxy="$1" local proxy="$1"
if [[ $proxy ]]; then if [[ $proxy ]]; then
@@ -235,7 +247,7 @@ npm_install_2() {
local dir_work=$1 local dir_work=$1
cd $dir_work cd $dir_work
echo -e "安装 $dir_work 依赖包...\n" t '安装 %s 依赖包...\n' "$dir_work"
npm_install_sub npm_install_sub
cd $dir_current cd $dir_current
} }
@@ -254,7 +266,7 @@ git_clone_scripts() {
local branch="$3" local branch="$3"
local proxy="$4" local proxy="$4"
[[ $branch ]] && local part_cmd="-b $branch " [[ $branch ]] && local part_cmd="-b $branch "
echo -e "开始拉取仓库 ${uniq_path}$dir\n" t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
set_proxy "$proxy" set_proxy "$proxy"
@@ -272,14 +284,35 @@ random_range() {
delete_pm2() { delete_pm2() {
cd $dir_root cd $dir_root
pm2 delete ecosystem.config.js # Try to delete PM2 processes, but don't fail if PM2 is not available
pm2 delete ecosystem.config.js 2>/dev/null || true
# Also try to kill any directly spawned node processes
pkill -f "node.*static/build/app.js" 2>/dev/null || true
} }
reload_pm2() { reload_pm2() {
cd $dir_root cd $dir_root
restore_env_vars restore_env_vars
pm2 flush &>/dev/null
pm2 startOrGracefulReload ecosystem.config.js --update-env # Try to start PM2, but handle failures gracefully
if pm2 flush &>/dev/null && pm2 startOrGracefulReload ecosystem.config.js --update-env; then
return 0
else
local exit_code=$?
t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code"
t '正在尝试直接使用 Node.js 启动服务...'
# Kill any existing node processes for qinglong
pkill -f "node.*static/build/app.js" 2>/dev/null || true
# Start node directly in the background
nohup node static/build/app.js > $dir_log/qinglong.log 2>&1 &
local node_pid=$!
t '已使用 Node.js 直接启动服务 (PID: %s)' "$node_pid"
t '注意: 使用此模式时,部分 PM2 管理功能将不可用'
return 0
fi
} }
diff_time() { diff_time() {
@@ -349,16 +382,16 @@ handle_task_start() {
error_message=", 任务状态更新失败(${error})" error_message=", 任务状态更新失败(${error})"
fi fi
fi fi
echo -e "## 开始执行... ${begin_time}${error_message}\n" t '## 开始执行... %s\n' "${begin_time}${error_message}"
} }
run_task_before() { run_task_before() {
. $file_task_before "$@" . $file_task_before "$@"
if [[ ${task_before:=} ]]; then if [[ ${task_before:=} ]]; then
echo -e "执行前置命令\n" t '执行前置命令\n'
eval "${task_before%;}" eval "${task_before%;}"
echo -e "\n执行前置命令结束\n" t '\n执行前置命令结束\n'
fi fi
} }
@@ -366,9 +399,9 @@ run_task_after() {
. $file_task_after "$@" . $file_task_after "$@"
if [[ ${task_after:=} ]]; then if [[ ${task_after:=} ]]; then
echo -e "\n执行后置命令\n" t '\n执行后置命令\n'
eval "${task_after%;}" eval "${task_after%;}"
echo -e "\n执行后置命令结束" t '\n执行后置命令结束'
fi fi
} }
@@ -377,18 +410,25 @@ handle_task_end() {
local end_time=$(format_time "$time_format" "$etime") local end_time=$(format_time "$time_format" "$etime")
local end_timestamp=$(format_timestamp "$time_format" "$etime") local end_timestamp=$(format_timestamp "$time_format" "$etime")
local diff_time=$(($end_timestamp - $begin_timestamp)) local diff_time=$(($end_timestamp - $begin_timestamp))
local suffix="" local exit_code="${_task_exit_code:-0}"
[[ "${MANUAL:=}" == "true" ]] && suffix="(手动停止)"
[[ "$diff_time" == 0 ]] && diff_time=1 [[ "$diff_time" == 0 ]] && diff_time=1
if [[ $ID ]]; then if [[ $ID ]]; then
local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time") local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" "$exit_code")
if [[ $error ]]; then if [[ $error ]]; then
error_message=", 任务状态更新失败(${error})" error_message=", 状态更新失败(${error})"
fi fi
fi fi
echo -e "\n## 执行结束$suffix... $end_time 耗时 $diff_time${error_message:=}     "
record_cron_stat "$ID" "${exit_code:-0}" "$diff_time"
if [[ "${MANUAL:=}" == "true" ]]; then
t '\n## 已停止 🛑... %s 耗时 %s 秒%s' "$end_time" "$diff_time" "${error_message:=}     "
elif [[ $exit_code -eq 0 ]]; then
t '\n## 完成 ✅... %s 耗时 %s 秒%s' "$end_time" "$diff_time" "${error_message:=}     "
else
t '\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s' "$exit_code" "$end_time" "$diff_time" "${error_message:=}     "
fi
} }
init_env init_env
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# 前置依赖 nodejs、npm、python3
set -e
set -x
if [[ ! $QL_DIR ]]; then
npm_dir=$(npm root -g)
pnpm_dir=$(pnpm root -g)
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then
QL_DIR="$npm_dir/@whyour/qinglong"
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then
QL_DIR="$pnpm_dir/@whyour/qinglong"
else
t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
fi
if [[ $QL_DIR ]]; then
t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
fi
exit 1
fi
if [[ ! $QL_DATA_DIR ]]; then
t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
exit 1
fi
if [[ $QL_DATA_DIR != */data ]]; then
t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
exit 1
fi
command="$1"
if [[ $command != "reload" ]]; then
# 安装依赖
os_name="${QL_OS_TYPE:-}"
if [ -z "$os_name" ]; then
os_name=$(source /etc/os-release && echo "$ID")
fi
# 非 root 用户使用 sudo
SUDO=""
if [ "$(id -u)" -ne 0 ]; then
SUDO="sudo"
fi
case "$os_name" in
alpine)
$SUDO apk update
$SUDO apk add -f bash \
coreutils \
git \
curl \
wget \
tzdata \
perl \
openssl \
jq \
nginx \
openssh \
procps \
netcat-openbsd
;;
debian|ubuntu)
$SUDO apt-get update
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
;;
*)
t '暂不支持此系统部署 %s' "$os_name"
exit 1
;;
esac
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5
fi
export PYTHON_SHORT_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
export PNPM_HOME=${QL_DIR}/data/dep_cache/node
export PYTHON_HOME=${QL_DIR}/data/dep_cache/python3
export PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin
export NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules
export PIP_CACHE_DIR=${PYTHON_HOME}/pip
export PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
if [[ $command != "reload" ]]; then
pip3 install --prefix ${PYTHON_HOME} requests
fi
cd ${QL_DIR}
cp -f .env.example .env
chmod 777 ${QL_DIR}/shell/*.sh
. ${QL_DIR}/shell/share.sh
. ${QL_DIR}/shell/env.sh
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
fix_config
pm2 l &>/dev/null
log_with_style "INFO" "🔄 2. 启动 nginx..."
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
reload_pm2
if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
pm2 startup
pm2 save
fi
log_with_style "SUCCESS" "🎉 启动成功!"
+29 -12
View File
@@ -4,9 +4,17 @@ dir_shell=$QL_DIR/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/api.sh . $dir_shell/api.sh
trap "single_hanle" 2 3 20 15 14 19 1 trap 'single_hanle SIGINT' INT
trap 'single_hanle SIGTERM' TERM
trap 'single_hanle SIGHUP' HUP
trap 'single_hanle SIGALRM' ALRM
trap 'single_hanle SIGTSTP' TSTP
trap 'single_hanle SIGQUIT' QUIT
single_hanle() { single_hanle() {
eval MANUAL=true handle_task_end "$@" "$cmd" _task_exit_code="${_task_exit_code:-$?}"
[[ "$_task_exit_code" == "0" ]] && _task_exit_code="1"
eval MANUAL=true handle_task_end "$@"
exit 1 exit 1
} }
@@ -46,18 +54,22 @@ handle_log_path() {
time=$(date "+$mtime_format") time=$(date "+$mtime_format")
log_time=$(format_log_time "$mtime_format" "$time") log_time=$(format_log_time "$mtime_format" "$time")
log_dir_tmp="${file_param##*/}" if [[ -z $log_name ]]; then
if [[ $file_param =~ "/" ]]; then log_dir_tmp="${file_param##*/}"
if [[ $file_param == /* ]]; then if [[ $file_param =~ "/" ]]; then
log_dir_tmp_path="${file_param:1}" if [[ $file_param == /* ]]; then
else log_dir_tmp_path="${file_param:1}"
log_dir_tmp_path="${file_param}" else
log_dir_tmp_path="${file_param}"
fi
fi fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
else
log_dir="$log_name"
fi fi
log_dir_tmp_path="${log_dir_tmp_path%/*}"
log_dir_tmp_path="${log_dir_tmp_path##*/}"
[[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}"
log_dir="${log_dir_tmp%.*}${suffix}"
log_path="$log_dir/$log_time.log" log_path="$log_dir/$log_time.log"
if [[ ${real_log_path:=} ]]; then if [[ ${real_log_path:=} ]]; then
@@ -73,6 +85,11 @@ handle_log_path() {
if [[ "${real_time:=}" == "true" ]]; then if [[ "${real_time:=}" == "true" ]]; then
cmd="" cmd=""
fi fi
if [[ "${log_dir:=}" == "/dev/null" ]]; then
cmd=">> /dev/null"
log_path="/dev/null"
fi
} }
format_params() { format_params() {
+23 -22
View File
@@ -3,6 +3,7 @@
dir_shell=$QL_DIR/shell dir_shell=$QL_DIR/shell
. $dir_shell/share.sh . $dir_shell/share.sh
. $dir_shell/api.sh . $dir_shell/api.sh
load_ql_envs
. $dir_shell/env.sh . $dir_shell/env.sh
send_mark=$dir_shell/send_mark send_mark=$dir_shell/send_mark
@@ -32,7 +33,7 @@ output_list_add_drop() {
local list=$1 local list=$1
local type=$2 local type=$2
if [[ -s $list ]]; then if [[ -s $list ]]; then
echo -e "检测到有${type}的定时任务:" t '检测到有%s的定时任务:' "$type"
cat $list cat $list
fi fi
} }
@@ -44,7 +45,7 @@ del_cron() {
local path=$2 local path=$2
local detail="" local detail=""
local ids="" local ids=""
echo -e "\n开始尝试自动删除失效的定时任务..." t '\n开始尝试自动删除失效的定时任务...'
for cron in $(cat $list_drop); do for cron in $(cat $list_drop); do
local id=$(cat $list_crontab_user | grep -E "$cmd_task.* $cron" | perl -pe "s|.*ID=(.*) $cmd_task.* $cron\.*|\1|" | head -1 | awk -F " " '{print $1}') local id=$(cat $list_crontab_user | grep -E "$cmd_task.* $cron" | perl -pe "s|.*ID=(.*) $cmd_task.* $cron\.*|\1|" | head -1 | awk -F " " '{print $1}')
if [[ $ids ]]; then if [[ $ids ]]; then
@@ -75,7 +76,7 @@ del_cron() {
add_cron() { add_cron() {
local list_add=$1 local list_add=$1
local path=$2 local path=$2
echo -e "\n开始尝试自动添加定时任务..." t '\n开始尝试自动添加定时任务...'
local detail="" local detail=""
cd $dir_scripts cd $dir_scripts
for file in $(cat $list_add); do for file in $(cat $list_add); do
@@ -135,10 +136,10 @@ update_repo() {
git_clone_scripts "${formatUrl}" ${repo_path} "${branch}" "${proxy}" git_clone_scripts "${formatUrl}" ${repo_path} "${branch}" "${proxy}"
if [[ $exit_status -eq 0 ]]; then if [[ $exit_status -eq 0 ]]; then
echo -e "拉取 ${uniq_path} 成功...\n" t '拉取 %s 成功...\n' "${uniq_path}"
diff_scripts "$repo_path" "$author" "$path" "$blackword" "$dependence" "$extensions" "$autoAddCron" "$autoDelCron" diff_scripts "$repo_path" "$author" "$path" "$blackword" "$dependence" "$extensions" "$autoAddCron" "$autoDelCron"
else else
echo -e "拉取 ${uniq_path} 失败,请检查日志...\n" t '拉取 %s 失败,请检查日志...\n' "${uniq_path}"
fi fi
} }
@@ -159,7 +160,7 @@ update_raw() {
local raw_url="$url" local raw_url="$url"
local suffix="${raw_url##*.}" local suffix="${raw_url##*.}"
local raw_file_name="${uniq_path}.${suffix}" local raw_file_name="${uniq_path}.${suffix}"
echo -e "开始下载:${raw_url} \n\n保存路径:$dir_raw/${raw_file_name}\n" t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
set_proxy "$proxy" set_proxy "$proxy"
wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url} wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url}
@@ -168,7 +169,7 @@ update_raw() {
if [[ $? -eq 0 ]]; then if [[ $? -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}"
echo -e "下载 ${raw_file_name} 成功...\n" t '下载 %s 成功...\n' "${raw_file_name}"
cd $dir_raw cd $dir_raw
local filename="raw_${raw_file_name}" local filename="raw_${raw_file_name}"
local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}') local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
@@ -195,7 +196,7 @@ update_raw() {
# update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id" # update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id"
fi fi
else else
echo -e "下载 ${raw_file_name} 失败,保留之前正常下载的版本...\n" t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}"
[[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new" [[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new"
fi fi
@@ -206,13 +207,13 @@ run_extra_shell() {
if [[ -f $file_extra_shell ]]; then if [[ -f $file_extra_shell ]]; then
. $file_extra_shell . $file_extra_shell
else else
echo -e "$file_extra_shell文件不存在,跳过执行...\n" t '%s文件不存在,跳过执行...\n' "$file_extra_shell"
fi fi
} }
## 脚本用法 ## 脚本用法
usage() { usage() {
echo -e "$cmd_update 命令使用方法:" t "$cmd_update 命令使用方法:"
echo -e "1. $cmd_update update # 更新并重启青龙" echo -e "1. $cmd_update update # 更新并重启青龙"
echo -e "2. $cmd_update extra # 运行自定义脚本" echo -e "2. $cmd_update extra # 运行自定义脚本"
echo -e "3. $cmd_update raw <fileurl> # 更新单个脚本文件" echo -e "3. $cmd_update raw <fileurl> # 更新单个脚本文件"
@@ -267,7 +268,7 @@ update_qinglong() {
downloadQLUrl="https://github.com/whyour/qinglong/archive/refs/heads" downloadQLUrl="https://github.com/whyour/qinglong/archive/refs/heads"
downloadStaticUrl="https://github.com/whyour/qinglong-static/archive/refs/heads" downloadStaticUrl="https://github.com/whyour/qinglong-static/archive/refs/heads"
fi fi
echo -e "使用 ${mirror} 源更新...\n" t '使用 %s 源更新...\n' "${mirror}"
local primary_branch="master" local primary_branch="master"
if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then
@@ -278,13 +279,13 @@ update_qinglong() {
exit_status=$? exit_status=$?
if [[ $exit_status -eq 0 ]]; then if [[ $exit_status -eq 0 ]]; then
echo -e "更新青龙源文件成功...\n" t '更新青龙源文件成功...\n'
unzip -oq ${dir_tmp}/ql.zip -d ${dir_tmp} unzip -oq ${dir_tmp}/ql.zip -d ${dir_tmp}
update_qinglong_static update_qinglong_static
else else
echo -e "更新青龙源文件失败,请检查网络...\n" t '更新青龙源文件失败,请检查网络...\n'
fi fi
} }
@@ -293,30 +294,30 @@ update_qinglong_static() {
exit_status=$? exit_status=$?
if [[ $exit_status -eq 0 ]]; then if [[ $exit_status -eq 0 ]]; then
echo -e "更新青龙静态资源成功...\n" t '更新青龙静态资源成功...\n'
unzip -oq ${dir_tmp}/static.zip -d ${dir_tmp} unzip -oq ${dir_tmp}/static.zip -d ${dir_tmp}
check_update_dep check_update_dep
else else
echo -e "更新青龙静态资源失败,请检查网络...\n" t '更新青龙静态资源失败,请检查网络...\n'
fi fi
} }
check_update_dep() { check_update_dep() {
echo -e "\n开始检测依赖...\n" t '\n开始检测依赖...\n'
if [[ $(diff $dir_root/package.json ${dir_tmp}/qinglong-${primary_branch}/package.json) ]]; then if [[ $(diff $dir_root/package.json ${dir_tmp}/qinglong-${primary_branch}/package.json) ]]; then
npm_install_2 "${dir_tmp}/qinglong-${primary_branch}" npm_install_2 "${dir_tmp}/qinglong-${primary_branch}"
fi fi
if [[ $exit_status -eq 0 ]]; then if [[ $exit_status -eq 0 ]]; then
echo -e "\n依赖检测安装成功...\n" t '\n依赖检测安装成功...\n'
echo -e "更新包下载成功..." t '更新包下载成功...\n'
if [[ "$needRestart" == 'true' ]]; then if [[ "$needRestart" == 'true' ]]; then
reload_qinglong "system" reload_qinglong "system"
fi fi
else else
echo -e "\n依赖检测安装失败,请检查网络...\n" t '\n依赖检测安装失败,请检查网络...\n'
fi fi
} }
@@ -513,7 +514,7 @@ main() {
if [[ -n $p2 ]]; then if [[ -n $p2 ]]; then
update_repo "$p2" "$p3" "$p4" "$p5" "$p6" "$p7" "$p8" "$p9" "$p10" update_repo "$p2" "$p3" "$p4" "$p5" "$p6" "$p7" "$p8" "$p9" "$p10"
else else
eval echo -e "命令输入错误...\\\n" $cmd t '命令输入错误...\n'
eval usage $cmd eval usage $cmd
fi fi
;; ;;
@@ -522,7 +523,7 @@ main() {
if [[ -n $p2 ]]; then if [[ -n $p2 ]]; then
update_raw "$p2" "$p3" "$p4" "$p5" update_raw "$p2" "$p3" "$p4" "$p5"
else else
eval echo -e "命令输入错误...\\\n" $cmd t '命令输入错误...\n'
eval usage $cmd eval usage $cmd
fi fi
;; ;;
@@ -548,7 +549,7 @@ main() {
eval update_auth_config "\\\"username\\\":\\\"$p2\\\"" "重置用户名" $cmd eval update_auth_config "\\\"username\\\":\\\"$p2\\\"" "重置用户名" $cmd
;; ;;
*) *)
eval echo -e "命令输入错误...\\\n" $cmd t '命令输入错误...\n'
eval usage $cmd eval usage $cmd
;; ;;
esac esac

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