Compare commits

..
Author SHA1 Message Date
whyour 2ddb592800 test(ci): isolate npm publication fixtures from dispatch event 2026-11-10 02:23:09 +08:00
whyour b838d4bb42 fix(ci): create develop RC publish job explicitly 2026-11-10 02:16:01 +08:00
whyour 73ad00ba32 fix(ci): require explicit RC input in npm publish script 2026-11-10 01:39:23 +08:00
whyour 27b322a10d chore(release): prepare 2.25.0-rc.0 candidate 2026-11-10 01:20:58 +08:00
whyour 1cfc0c8e2e fix(security): protect first-run notifications and server errors (#3126)
* fix(security): close anonymous notification setup and mask server errors

* fix: hide internal errors in default deployment

* fix: route HTTP errors through application logger

* test: mock logger in API fallback regression

* fix: avoid sensitive SQL details in HTTP error logs
2026-11-10 01:11:54 +08:00
whyour cae580e02f fix(docker): retain Python notification dependency with mounted data 2026-11-09 23:54:31 +08:00
whyour 76b285c3db fix(db): repair invalid environment labels before startup (#3130)
Normalize malformed Envs.labels values to JSON arrays during schema migration so ORM reads cannot abort HTTP worker initialization. Preserve valid label arrays and cover corrupt legacy rows. Fixes #3128.
2026-11-09 01:18:05 +08:00
Henglie 13e0ff55d4 fix(notify): bound notification requests and thread waits (#3129)
Add a 10s timeout to every requests call in sample/notify.py so a
non-responsive push service cannot block a task indefinitely. Mark
notification threads as daemon and join them with a bounded timeout,
recording channels that do not finish as failures.

Add a regression test covering missing timeouts and send() returning
after a hung channel.
2026-11-09 01:11:32 +08:00
whyour 2bf814e562 feat(editor): add word wrap toggle for config and scripts 2026-11-08 03:04:17 +08:00
30 changed files with 983 additions and 560 deletions
+18 -2
View File
@@ -33,6 +33,10 @@ on:
type: choice type: choice
options: [branch, tag] options: [branch, tag]
default: branch default: branch
npm_rc_version:
description: Publish the verified main npm RC from develop, for example 2.25.0-rc.0
type: string
default: ''
permissions: permissions:
contents: read contents: read
@@ -470,7 +474,7 @@ jobs:
retention-days: 14 retention-days: 14
publish: publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }} if: ${{ github.repository == 'whyour/qinglong' && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop')) }}
needs: [build-alpine, build-debian, verify-npm-package] needs: [build-alpine, build-debian, verify-npm-package]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -491,6 +495,8 @@ jobs:
- name: Publish npm package with OIDC - name: Publish npm package with OIDC
shell: bash shell: bash
env:
NPM_RC_VERSION: ${{ inputs.npm_rc_version }}
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
@@ -503,11 +509,21 @@ jobs:
metadata=$(tar -xOf "$archive" package/package.json) metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata") name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata") version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if [[ "${GITHUB_EVENT_NAME:-}" == workflow_dispatch && "${GITHUB_REF:-}" == refs/heads/develop ]]; then
if [[ ! "${NPM_RC_VERSION:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ || "$version" != "${NPM_RC_VERSION:-}" ]]; then
echo '::error::Develop npm publication requires the exact requested RC archive version.'
exit 1
fi
fi
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild." echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org if [[ "${GITHUB_EVENT_NAME:-}" == workflow_dispatch && "${GITHUB_REF:-}" == refs/heads/develop ]]; then
npm publish "./$archive" --ignore-scripts --access public --tag rc --registry=https://registry.npmjs.org
else
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
fi
else else
echo '::error::Could not check the published npm version; refusing to publish.' echo '::error::Could not check the published npm version; refusing to publish.'
exit 1 exit 1
-1
View File
@@ -20,7 +20,6 @@ export default (app: Router) => {
res.status(500).send({ res.status(500).send({
code: 500, code: 500,
message: 'Health check failed', message: 'Health check failed',
error: err.message,
}); });
} }
}); });
-14
View File
@@ -310,20 +310,6 @@ export default (app: Router) => {
}, },
); );
route.put(
'/notification/init',
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
const userService = Container.get(UserService);
const result = await userService.updateNotificationMode(req.body);
res.send(result);
} catch (e) {
return next(e);
}
},
);
route.put( route.put(
'/avatar', '/avatar',
upload.single('avatar'), upload.single('avatar'),
-2
View File
@@ -180,12 +180,10 @@ export default {
'/api/user/two-factor/login', '/api/user/two-factor/login',
'/api/system', '/api/system',
'/api/user/init', '/api/user/init',
'/api/user/notification/init',
'/open/user/login', '/open/user/login',
'/open/user/two-factor/login', '/open/user/two-factor/login',
'/open/system', '/open/system',
'/open/user/init', '/open/user/init',
'/open/user/notification/init',
], ],
versionFile, versionFile,
lastVersionFile, lastVersionFile,
+36 -37
View File
@@ -16,6 +16,7 @@ import { t } from '../shared/i18n';
import { AppScope } from '../data/open'; import { AppScope } from '../data/open';
import uniqueConstraintError from '../middlewares/uniqueConstraintError'; import uniqueConstraintError from '../middlewares/uniqueConstraintError';
import protectedPathCase from '../middlewares/protectedPathCase'; import protectedPathCase from '../middlewares/protectedPathCase';
import Logger from './logger';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) { function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
const setting = value?.trim(); const setting = value?.trim();
@@ -191,14 +192,7 @@ export default ({ app }: { app: Application }) => {
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
const pathLower = req.path.toLowerCase(); const pathLower = req.path.toLowerCase();
if ( if (!['/api/user/init', '/open/user/init'].includes(pathLower)) {
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next(); return next();
} }
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo); const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
@@ -238,37 +232,23 @@ export default ({ app }: { app: Application }) => {
app.use( app.use(
( (
err: Error & { status: number }, err: Error & { status: number; code?: string },
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
if (err.name === 'UnauthorizedError') { if (err.name === 'UnauthorizedError') {
return res let message = err.message;
.status(err.status) if (!config.isDevelopment) {
.send({ code: 401, message: err.message }) if (err.code === 'credentials_bad_scheme') {
.end(); message = t('暂无权限');
} } else if (err.code === 'credentials_required') {
return next(err); message = t('请先登录');
}, } else {
); message = t('Token 已失效');
}
app.use( }
( return res.status(401).send({ code: 401, message }).end();
err: Error & { errors: any[] },
req: Request,
res: Response,
next: NextFunction,
) => {
if (err.name.includes('Sequelize')) {
return res
.status(500)
.send({
code: 400,
message: `${err.message}`,
errors: err.errors,
})
.end();
} }
return next(err); return next(err);
}, },
@@ -281,10 +261,29 @@ export default ({ app }: { app: Application }) => {
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
res.status(err.status || 500); const status =
Number.isInteger(err.status) && err.status >= 400 && err.status < 600
? err.status
: 500;
if (status >= 500) {
const route =
typeof req.route?.path === 'string' ? req.route.path : '?';
const errorName = /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(err.name)
? err.name
: 'Error';
Logger.error(
'HTTP request failed: status=%d method=%s route=%s type=%s',
status,
req.method,
route,
errorName,
);
}
res.status(status);
res.json({ res.json({
code: err.status || 500, code: status,
message: err.message, message:
!config.isDevelopment && status >= 500 ? t('未知错误') : err.message,
}); });
}, },
); );
+11
View File
@@ -66,5 +66,16 @@ export async function migrateSchema(database: Sequelize): Promise<void> {
); );
} }
} }
// Older databases can contain non-JSON values in Envs.labels. Sequelize
// parses this column on every environment read, including HTTP startup.
await database.query(
`UPDATE "Envs" SET "labels" = '[]'
WHERE CASE WHEN json_valid("labels")
THEN json_type("labels") <> 'array'
ELSE 1
END`,
{ transaction },
);
}); });
} }
+4 -4
View File
@@ -15,7 +15,7 @@
"@types/node": "22.19.0", "@types/node": "22.19.0",
"commander": "15.0.0", "commander": "15.0.0",
"esbuild": "0.28.2", "esbuild": "0.28.2",
"tar": "7.5.21", "tar": "7.5.20",
"typescript": "5.2.2" "typescript": "5.2.2"
}, },
"engines": { "engines": {
@@ -573,9 +573,9 @@
} }
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "7.5.21", "version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==", "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -27,7 +27,7 @@
"esbuild": "0.28.2", "esbuild": "0.28.2",
"typescript": "5.2.2", "typescript": "5.2.2",
"@types/node": "22.19.0", "@types/node": "22.19.0",
"tar": "7.5.21" "tar": "7.5.20"
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -224,7 +224,6 @@ System/user operations below execute remotely through the panel API. They do not
| `ql user notification-get ` | GET | `user/notification` | --query | | `ql user notification-get ` | GET | `user/notification` | --query |
| `ql user notification-set ` | PUT | `user/notification` | --data, --query | | `ql user notification-set ` | PUT | `user/notification` | --data, --query |
| `ql user init ` | PUT | `user/init` | --data, --query | | `ql user init ` | PUT | `user/init` | --data, --query |
| `ql user notification-init ` | PUT | `user/notification/init` | --data, --query |
| `ql user avatar ` | PUT | `user/avatar` | --data, --file (avatar), --query | | `ql user avatar ` | PUT | `user/avatar` | --data, --file (avatar), --query |
| `ql system apply-reload ` | PUT | `update/reload` | --query | | `ql system apply-reload ` | PUT | `update/reload` | --query |
| `ql system apply-system ` | PUT | `update/system` | --query | | `ql system apply-system ` | PUT | `update/system` | --query |
-7
View File
@@ -608,13 +608,6 @@ export const openOperations: readonly OpenOperation[] = [
body: 'json', body: 'json',
anonymous: true, anonymous: true,
}, },
{
name: 'user notification-init',
method: 'PUT',
path: 'user/notification/init',
body: 'json',
anonymous: true,
},
{ {
name: 'user avatar', name: 'user avatar',
method: 'PUT', method: 'PUT',
+2 -2
View File
@@ -118,9 +118,9 @@ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${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 \ 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:${QL_DIR}/python-base/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests RUN pip3 install --ignore-installed --prefix ${QL_DIR}/python-base requests
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
+2 -2
View File
@@ -112,9 +112,9 @@ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${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 \ 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:${QL_DIR}/python-base/lib/python${PYTHON_SHORT_VERSION}/site-packages
RUN pip3 install --prefix ${PYTHON_HOME} requests RUN pip3 install --ignore-installed --prefix ${QL_DIR}/python-base requests
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/ COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
+17
View File
@@ -0,0 +1,17 @@
# 青龙 2.25.0-rc.0 开发候选
本候选包含 2.24.0 之后的安全、依赖、编辑器、通知及环境变量修复。主 npm 包使用 `rc` 标签发布,正式 `latest` 保持 2.24.0。
## 变更
- 应用权限、首次通知配置与任务环境变量的控制边界更严格;旧数据库中的不安全环境变量标签会在启动前修复,受保护名称不会写入任务环境,服务端错误不会向客户端泄露内部细节。
- 依赖管理页面显示已保存状态,用户可以手动复查;配置与脚本编辑器可切换自动换行。
- 通知请求和线程等待设置上限,避免无响应的通知渠道拖住任务。
- Alpine 和 Debian 镜像的内置 Python 通知依赖放在数据卷之外,挂载空 `/ql/data` 后仍能发送通知。
- 独立远程 CLI 候选 `@whyour/qinglong-cli@0.1.5-rc.1` 改善跨平台配置和 OpenAPI 操作,并移除不安全的首次通知命令。
## 测试与安装
开发镜像 `whyour/qinglong:develop`、`whyour/qinglong:debian-dev` 与主包 `@whyour/qinglong@2.25.0-rc.0` 应对应同一候选源码。主包可用 `npm install @whyour/qinglong@rc` 安装;CLI 可用 `npm install @whyour/qinglong-cli@rc` 安装。
升级前备份 `/ql/data`,在目标设备验证任务、订阅、环境变量、依赖与通知。开发候选不建议覆盖正式环境。
+7 -11
View File
@@ -1,7 +1,7 @@
{ {
"name": "@whyour/qinglong", "name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1", "packageManager": "pnpm@8.3.1",
"version": "2.24.0", "version": "2.25.0-rc.0",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript", "description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -116,10 +116,6 @@
"@codemirror/view": "6.39.16", "@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8", "proxy-addr": "2.0.8",
"websocket-driver": "0.7.5", "websocket-driver": "0.7.5",
"body-parser": "1.20.9",
"lodash": "4.18.1",
"protobufjs": "7.6.1",
"undici@6": "6.29.1",
"brace-expansion@1": "1.1.21", "brace-expansion@1": "1.1.21",
"brace-expansion@2": "2.1.7" "brace-expansion@2": "2.1.7"
} }
@@ -131,7 +127,7 @@
"@grpc/proto-loader": "^0.8.0", "@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1", "@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1", "@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.9", "body-parser": "^1.20.3",
"celebrate": "^15.0.3", "celebrate": "^15.0.3",
"chokidar": "^4.0.1", "chokidar": "^4.0.1",
"compression": "1.8.2", "compression": "1.8.2",
@@ -152,10 +148,10 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3", "keyv": "^5.2.3",
"lodash": "^4.18.1", "lodash": "^4.17.21",
"multer": "2.5.0", "multer": "2.4.0",
"node-cron": "4.6.0", "node-cron": "4.6.0",
"nodemailer": "^10.1.4", "nodemailer": "^10.0.9",
"p-queue-cjs": "7.3.4", "p-queue-cjs": "7.3.4",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0", "ps-tree": "^1.2.0",
@@ -166,11 +162,11 @@
"sqlite3": "npm:@whyour/sqlite3@1.1.4", "sqlite3": "npm:@whyour/sqlite3@1.1.4",
"toad-scheduler": "^3.0.1", "toad-scheduler": "^3.0.1",
"typedi": "^0.10.0", "typedi": "^0.10.0",
"undici": "^7.30.1", "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",
"tar": "7.5.21" "tar": "7.5.20"
}, },
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^5.0.1", "@ant-design/icons": "^5.0.1",
+542 -345
View File
File diff suppressed because it is too large Load Diff
+78 -30
View File
@@ -18,6 +18,11 @@ from email.utils import formataddr
import requests import requests
# 单次 HTTP 请求的默认超时(秒),避免推送服务无响应时永久阻塞
REQUEST_TIMEOUT = 10
# 等待推送线程结束的最长时间(秒),超时后跳过并继续退出
NOTIFY_JOIN_TIMEOUT = 30
# 原先的 print 函数和主线程的锁 # 原先的 print 函数和主线程的锁
_print = print _print = print
mutex = threading.Lock() mutex = threading.Lock()
@@ -199,7 +204,7 @@ def bark(title: str, content: str) -> Optional[bool]:
data[bark_params.get(pair[0])] = pair[1] data[bark_params.get(pair[0])] = pair[1]
headers = {"Content-Type": "application/json;charset=utf-8"} headers = {"Content-Type": "application/json;charset=utf-8"}
response = requests.post( response = requests.post(
url=url, data=json.dumps(data), headers=headers, timeout=15 url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
).json() ).json()
if response["code"] == 200: if response["code"] == 200:
@@ -241,7 +246,7 @@ def dingding_bot(title: str, content: str) -> Optional[bool]:
headers = {"Content-Type": "application/json;charset=utf-8"} headers = {"Content-Type": "application/json;charset=utf-8"}
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
response = requests.post( response = requests.post(
url=url, data=json.dumps(data), headers=headers, timeout=15 url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
).json() ).json()
if not response["errcode"]: if not response["errcode"]:
@@ -278,7 +283,9 @@ def feishu_bot(title: str, content: str) -> Optional[bool]:
data["timestamp"] = timestamp data["timestamp"] = timestamp
data["sign"] = sign data["sign"] = sign
response = requests.post(url, data=json.dumps(data)).json() response = requests.post(
url, data=json.dumps(data), timeout=REQUEST_TIMEOUT
).json()
if response.get("StatusCode") == 0 or response.get("code") == 0: if response.get("StatusCode") == 0 or response.get("code") == 0:
audit_results.append(True) audit_results.append(True)
@@ -299,7 +306,7 @@ def go_cqhttp(title: str, content: str) -> Optional[bool]:
print("go-cqhttp 服务启动") print("go-cqhttp 服务启动")
url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}' url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}'
response = requests.get(url).json() response = requests.get(url, timeout=REQUEST_TIMEOUT).json()
if response["status"] == "ok": if response["status"] == "ok":
audit_results.append(True) audit_results.append(True)
@@ -325,7 +332,7 @@ def gotify(title: str, content: str) -> Optional[bool]:
"message": content, "message": content,
"priority": push_config.get("GOTIFY_PRIORITY"), "priority": push_config.get("GOTIFY_PRIORITY"),
} }
response = requests.post(url, data=data).json() response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
if response.get("id"): if response.get("id"):
audit_results.append(True) audit_results.append(True)
@@ -348,7 +355,9 @@ def iGot(title: str, content: str) -> Optional[bool]:
url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}' url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}'
data = {"title": title, "content": content} data = {"title": title, "content": content}
headers = {"Content-Type": "application/x-www-form-urlencoded"} headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(url, data=data, headers=headers).json() response = requests.post(
url, data=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response["ret"] == 0: if response["ret"] == 0:
audit_results.append(True) audit_results.append(True)
@@ -377,7 +386,7 @@ def serverJ(title: str, content: str) -> Optional[bool]:
else: else:
url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send' url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send'
response = requests.post(url, data=data).json() response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
if response.get("errno") == 0 or response.get("code") == 0: if response.get("errno") == 0 or response.get("code") == 0:
audit_results.append(True) audit_results.append(True)
@@ -406,7 +415,7 @@ def pushdeer(title: str, content: str) -> Optional[bool]:
if push_config.get("DEER_URL"): if push_config.get("DEER_URL"):
url = push_config.get("DEER_URL") url = push_config.get("DEER_URL")
response = requests.post(url, data=data).json() response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
if len(response.get("content").get("result")) > 0: if len(response.get("content").get("result")) > 0:
audit_results.append(True) audit_results.append(True)
@@ -427,7 +436,7 @@ def chat(title: str, content: str) -> Optional[bool]:
print("chat 服务启动") print("chat 服务启动")
data = "payload=" + json.dumps({"text": title + "\n" + content}) data = "payload=" + json.dumps({"text": title + "\n" + content})
url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN") url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN")
response = requests.post(url, data=data) response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT)
if response.status_code == 200: if response.status_code == 200:
audit_results.append(True) audit_results.append(True)
@@ -461,7 +470,9 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
} }
body = json.dumps(data).encode(encoding="utf-8") body = json.dumps(data).encode(encoding="utf-8")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
response = requests.post(url=url, data=body, headers=headers).json() response = requests.post(
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
code = response["code"] code = response["code"]
if code == 200: if code == 200:
@@ -477,7 +488,9 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
else: else:
url_old = "http://pushplus.hxtrip.com/send" url_old = "http://pushplus.hxtrip.com/send"
headers["Accept"] = "application/json" headers["Accept"] = "application/json"
response = requests.post(url=url_old, data=body, headers=headers).json() response = requests.post(
url=url_old, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response["code"] == 200: if response["code"] == 200:
audit_results.append(True) audit_results.append(True)
@@ -513,7 +526,9 @@ def weplus_bot(title: str, content: str) -> Optional[bool]:
} }
body = json.dumps(data).encode(encoding="utf-8") body = json.dumps(data).encode(encoding="utf-8")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
response = requests.post(url=url, data=body, headers=headers).json() response = requests.post(
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response["code"] == 200: if response["code"] == 200:
audit_results.append(True) audit_results.append(True)
@@ -535,7 +550,9 @@ def qmsg_bot(title: str, content: str) -> Optional[bool]:
url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}' url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}'
payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")} payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")}
response = requests.post(url=url, params=payload).json() response = requests.post(
url=url, params=payload, timeout=REQUEST_TIMEOUT
).json()
if response["code"] == 0: if response["code"] == 0:
audit_results.append(True) audit_results.append(True)
@@ -599,7 +616,7 @@ class WeCom:
"corpid": self.CORPID, "corpid": self.CORPID,
"corpsecret": self.CORPSECRET, "corpsecret": self.CORPSECRET,
} }
req = requests.post(url, params=values) req = requests.post(url, params=values, timeout=REQUEST_TIMEOUT)
data = json.loads(req.text) data = json.loads(req.text)
return data["access_token"] return data["access_token"]
@@ -615,7 +632,9 @@ class WeCom:
"safe": "0", "safe": "0",
} }
send_msges = bytes(json.dumps(send_values), "utf-8") send_msges = bytes(json.dumps(send_values), "utf-8")
respone = requests.post(send_url, send_msges) respone = requests.post(
send_url, send_msges, timeout=REQUEST_TIMEOUT
)
respone = respone.json() respone = respone.json()
return respone["errmsg"] return respone["errmsg"]
@@ -641,7 +660,9 @@ class WeCom:
}, },
} }
send_msges = bytes(json.dumps(send_values), "utf-8") send_msges = bytes(json.dumps(send_values), "utf-8")
respone = requests.post(send_url, send_msges) respone = requests.post(
send_url, send_msges, timeout=REQUEST_TIMEOUT
)
respone = respone.json() respone = respone.json()
return respone["errmsg"] return respone["errmsg"]
@@ -663,7 +684,7 @@ def wecom_bot(title: str, content: str) -> Optional[bool]:
headers = {"Content-Type": "application/json;charset=utf-8"} headers = {"Content-Type": "application/json;charset=utf-8"}
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
response = requests.post( response = requests.post(
url=url, data=json.dumps(data), headers=headers, timeout=15 url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
).json() ).json()
if response["errcode"] == 0: if response["errcode"] == 0:
@@ -711,7 +732,11 @@ def telegram_bot(title: str, content: str) -> Optional[bool]:
) )
proxies = {"http": proxyStr, "https": proxyStr} proxies = {"http": proxyStr, "https": proxyStr}
response = requests.post( response = requests.post(
url=url, headers=headers, params=payload, proxies=proxies url=url,
headers=headers,
params=payload,
proxies=proxies,
timeout=REQUEST_TIMEOUT,
).json() ).json()
if response["ok"]: if response["ok"]:
@@ -752,7 +777,9 @@ def aibotk(title: str, content: str) -> Optional[bool]:
} }
body = json.dumps(data).encode(encoding="utf-8") body = json.dumps(data).encode(encoding="utf-8")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
response = requests.post(url=url, data=body, headers=headers).json() response = requests.post(
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
print(response) print(response)
if response["code"] == 0: if response["code"] == 0:
audit_results.append(True) audit_results.append(True)
@@ -836,7 +863,7 @@ def pushme(title: str, content: str) -> Optional[bool]:
"date": push_config.get("date") if push_config.get("date") else "", "date": push_config.get("date") if push_config.get("date") else "",
"type": push_config.get("type") if push_config.get("type") else "", "type": push_config.get("type") if push_config.get("type") else "",
} }
response = requests.post(url, data=data) response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT)
if response.status_code == 200 and response.text == "success": if response.status_code == 200 and response.text == "success":
audit_results.append(True) audit_results.append(True)
@@ -883,7 +910,9 @@ def chronocat(title: str, content: str) -> Optional[bool]:
} }
], ],
} }
response = requests.post(url, headers=headers, data=json.dumps(data)) response = requests.post(
url, headers=headers, data=json.dumps(data), timeout=REQUEST_TIMEOUT
)
if response.status_code == 200: if response.status_code == 200:
if chat_type == 1: if chat_type == 1:
audit_results.append(True) audit_results.append(True)
@@ -936,7 +965,9 @@ def ntfy(title: str, content: str) -> Optional[bool]:
headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS")) headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS"))
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC") url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers) response = requests.post(
url, data=data, headers=headers, timeout=REQUEST_TIMEOUT
)
if response.status_code == 200: # 使用 response.status_code 进行检查 if response.status_code == 200: # 使用 response.status_code 进行检查
audit_results.append(True) audit_results.append(True)
print("Ntfy 推送成功!") print("Ntfy 推送成功!")
@@ -995,7 +1026,9 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
} }
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
response = requests.post(url=url, json=data, headers=headers).json() response = requests.post(
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response.get("code") == 1000: if response.get("code") == 1000:
audit_results.append(True) audit_results.append(True)
@@ -1046,7 +1079,9 @@ def wxpusher_spt(title: str, content: str) -> Optional[bool]:
data["sptList"] = spts data["sptList"] = spts
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
response = requests.post(url=url, json=data, headers=headers).json() response = requests.post(
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response.get("code") == 1000: if response.get("code") == 1000:
audit_results.append(True) audit_results.append(True)
@@ -1080,7 +1115,9 @@ def wpush(title: str, content: str) -> Optional[bool]:
data["topic_code"] = push_config.get("WPUSH_TOPIC_CODE") data["topic_code"] = push_config.get("WPUSH_TOPIC_CODE")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
response = requests.post(url=url, json=data, headers=headers, timeout=15).json() response = requests.post(
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response.get("code") == 0: if response.get("code") == 0:
audit_results.append(True) audit_results.append(True)
@@ -1121,7 +1158,9 @@ def openilink(title: str, content: str) -> Optional[bool]:
if push_config.get("OPENILINK_CONTEXT_TOKEN"): if push_config.get("OPENILINK_CONTEXT_TOKEN"):
data["context_token"] = 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() response = requests.post(
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response.get("ok"): if response.get("ok"):
audit_results.append(True) audit_results.append(True)
@@ -1215,7 +1254,7 @@ def custom_notify(title: str, content: str) -> Optional[bool]:
"$title", urllib.parse.quote_plus(title) "$title", urllib.parse.quote_plus(title)
).replace("$content", urllib.parse.quote_plus(content)) ).replace("$content", urllib.parse.quote_plus(content))
response = requests.request( response = requests.request(
method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=REQUEST_TIMEOUT, data=body
) )
if response.status_code == 200: if response.status_code == 200:
@@ -1233,7 +1272,7 @@ def one() -> str:
:return: :return:
""" """
url = "https://v1.hitokoto.cn/" url = "https://v1.hitokoto.cn/"
res = requests.get(url).json() res = requests.get(url, timeout=REQUEST_TIMEOUT).json()
return res["hitokoto"] + " ----" + res["from"] return res["hitokoto"] + " ----" + res["from"]
@@ -1374,11 +1413,20 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
notify_function = add_notify_function() notify_function = add_notify_function()
results = [] results = []
ts = [ ts = [
threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__) threading.Thread(
target=audited_notification,
args=(mode, title, content, results),
name=mode.__name__,
daemon=True,
)
for mode in notify_function for mode in notify_function
] ]
[t.start() for t in ts] [t.start() for t in ts]
[t.join() for t in ts] for t in ts:
t.join(timeout=NOTIFY_JOIN_TIMEOUT)
if t.is_alive():
results.append((t.name, False))
print(f"{t.name} 推送超时(>{NOTIFY_JOIN_TIMEOUT}s),已跳过,任务继续结束")
report_notifications(results, title, content) report_notifications(results, title, content)
+1
View File
@@ -508,6 +508,7 @@
"脚本(只读)": "Scripts (read only)", "脚本(只读)": "Scripts (read only)",
"自动删除任务": "Automatically Delete Tasks", "自动删除任务": "Automatically Delete Tasks",
"自动拆分": "Auto Split", "自动拆分": "Auto Split",
"自动换行": "Word wrap",
"自动添加任务": "Automatically Add Tasks", "自动添加任务": "Automatically Add Tasks",
"自动生成": "Auto-generated", "自动生成": "Auto-generated",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate",
+1
View File
@@ -508,6 +508,7 @@
"脚本(只读)": "脚本(只读)", "脚本(只读)": "脚本(只读)",
"自动删除任务": "自动删除任务", "自动删除任务": "自动删除任务",
"自动拆分": "自动拆分", "自动拆分": "自动拆分",
"自动换行": "自动换行",
"自动添加任务": "自动添加任务", "自动添加任务": "自动添加任务",
"自动生成": "自动生成", "自动生成": "自动生成",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成",
+27 -3
View File
@@ -6,7 +6,7 @@ import React, {
useEffect, useEffect,
useRef, useRef,
} from 'react'; } from 'react';
import { Button, message, Modal, TreeSelect } from 'antd'; import { Button, message, Modal, Space, Switch, TreeSelect } from 'antd';
import config from '@/utils/config'; import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout'; import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
@@ -15,6 +15,7 @@ import CodeMirror from '@uiw/react-codemirror';
import { useOutletContext } from '@umijs/max'; import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts'; import { SharedContext } from '@/layouts';
import { langs } from '@uiw/codemirror-extensions-langs'; import { langs } from '@uiw/codemirror-extensions-langs';
import { EditorView } from '@codemirror/view';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { getEditorMode } from '@/utils'; import { getEditorMode } from '@/utils';
@@ -28,6 +29,11 @@ const Config = () => {
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const [confirmLoading, setConfirmLoading] = useState(false); const [confirmLoading, setConfirmLoading] = useState(false);
const [language, setLanguage] = useState<string>('shell'); const [language, setLanguage] = useState<string>('shell');
const [wordWrap, setWordWrap] = useState(
() =>
(localStorage.getItem('qinglong_editor_word_wrap') ??
localStorage.getItem('qinglong_config_word_wrap')) === 'true',
);
const getConfig = (name: string) => { const getConfig = (name: string) => {
request request
@@ -104,6 +110,20 @@ const Config = () => {
defaultValue="config.sh" defaultValue="config.sh"
onSelect={onSelect} onSelect={onSelect}
/>, />,
<Space key="word-wrap" size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={(checked) => {
setWordWrap(checked);
localStorage.setItem(
'qinglong_editor_word_wrap',
String(checked),
);
}}
/>
</Space>,
<Button <Button
key="1" key="1"
loading={confirmLoading} loading={confirmLoading}
@@ -121,7 +141,10 @@ const Config = () => {
<CodeMirror <CodeMirror
value={value} value={value}
theme={theme.includes('dark') ? 'dark' : 'light'} theme={theme.includes('dark') ? 'dark' : 'light'}
extensions={[langs.shell()]} extensions={[
langs.shell(),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
onChange={(value) => { onChange={(value) => {
setValue(value); setValue(value);
}} }}
@@ -136,7 +159,8 @@ const Config = () => {
lineNumbersMinChars: 3, lineNumbersMinChars: 3,
folding: false, folding: false,
glyphMargin: false, glyphMargin: false,
accessibilitySupport: 'off' accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}} }}
onMount={(editor) => { onMount={(editor) => {
editorRef.current = editor; editorRef.current = editor;
+2 -83
View File
@@ -1,38 +1,21 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import React, { Fragment, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import { Button, Input, Form, Typography, Steps } from 'antd';
Button,
Row,
Input,
Form,
message,
Typography,
Steps,
Select,
} from 'antd';
import config from '@/utils/config'; import config from '@/utils/config';
import { history } from '@umijs/max';
import styles from './index.less'; import styles from './index.less';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
const FormItem = Form.Item;
const { Step } = Steps; const { Step } = Steps;
const { Option } = Select;
const { Link } = Typography; const { Link } = Typography;
const Initialization = () => { const Initialization = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [current, setCurrent] = React.useState(0); const [current, setCurrent] = React.useState(0);
const [fields, setFields] = useState<any[]>([]);
const next = () => { const next = () => {
setCurrent(current + 1); setCurrent(current + 1);
}; };
const prev = () => {
setCurrent(current - 1);
};
const submitAccountSetting = (values: any) => { const submitAccountSetting = (values: any) => {
setLoading(true); setLoading(true);
request request
@@ -48,23 +31,6 @@ const Initialization = () => {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
const submitNotification = (values: any) => {
setLoading(true);
request
.put(`${config.apiPrefix}user/notification/init`, values)
.then(({ code, data }) => {
if (code === 200) {
next();
}
})
.finally(() => setLoading(false));
};
const notificationModeChange = (value: string) => {
const _fields = (config.notificationModeMap as any)[value];
setFields(_fields || []);
};
useEffect(() => { useEffect(() => {
localStorage.removeItem(config.authKey); localStorage.removeItem(config.authKey);
}, []); }, []);
@@ -95,53 +61,6 @@ const Initialization = () => {
</div> </div>
), ),
}, },
{
title: intl.get('通知设置'),
content: (
<Form onFinish={submitNotification} layout="vertical">
<Form.Item
label={intl.get('通知方式')}
name="type"
rules={[{ required: true, message: intl.get('请选择通知方式') }]}
style={{ maxWidth: 350 }}
>
<Select
onChange={notificationModeChange}
placeholder={intl.get('请选择通知方式')}
>
{config.notificationModes
.filter((x) => x.value !== 'closed')
.map((x) => (
<Option key={x.value} value={x.value}>
{x.label}
</Option>
))}
</Select>
</Form.Item>
{fields.map((x) => (
<Form.Item
key={x.label}
label={x.label}
name={x.label}
extra={x.tip}
rules={[{ required: x.required }]}
style={{ maxWidth: 400 }}
>
<Input.TextArea
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={`${intl.get('请输入')} ${x.label}`}
/>
</Form.Item>
))}
<Button type="primary" htmlType="submit" loading={loading}>
{intl.get('保存')}
</Button>
<Button type="link" htmlType="button" onClick={() => next()}>
{intl.get('跳过')}
</Button>
</Form>
),
},
{ {
title: intl.get('账户设置'), title: intl.get('账户设置'),
content: ( content: (
+25 -1
View File
@@ -6,7 +6,16 @@ import React, {
useCallback, useCallback,
useReducer, useReducer,
} from 'react'; } from 'react';
import { Drawer, Button, Tabs, Badge, Select, TreeSelect } from 'antd'; import {
Drawer,
Button,
Tabs,
Badge,
Select,
Space,
Switch,
TreeSelect,
} from 'antd';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import SplitPane from 'react-split-pane'; import SplitPane from 'react-split-pane';
@@ -24,11 +33,15 @@ const EditModal = ({
treeData, treeData,
currentNode, currentNode,
content, content,
wordWrap,
onWordWrapChange,
handleCancel, handleCancel,
}: { }: {
treeData?: any; treeData?: any;
content?: string; content?: string;
currentNode: any; currentNode: any;
wordWrap: boolean;
onWordWrapChange: (checked: boolean) => void;
handleCancel: () => void; handleCancel: () => void;
}) => { }) => {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
@@ -219,6 +232,16 @@ const EditModal = ({
</> </>
} }
width={'100%'} width={'100%'}
extra={
<Space size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={onWordWrapChange}
/>
</Space>
}
headerStyle={{ padding: '11px 24px' }} headerStyle={{ padding: '11px 24px' }}
onClose={cancel} onClose={cancel}
open={true} open={true}
@@ -241,6 +264,7 @@ const EditModal = ({
lineNumbersMinChars: 3, lineNumbersMinChars: 3,
glyphMargin: false, glyphMargin: false,
accessibilitySupport: 'off', accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}} }}
onMount={(editor) => { onMount={(editor) => {
editorRef.current = editor; editorRef.current = editor;
+28 -1
View File
@@ -15,6 +15,7 @@ import {
PlusOutlined, PlusOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout'; import { PageContainer } from '@ant-design/pro-layout';
import { EditorView } from '@codemirror/view';
import Editor from '@monaco-editor/react'; import Editor from '@monaco-editor/react';
import CodeMirror from '@uiw/react-codemirror'; import CodeMirror from '@uiw/react-codemirror';
import { history, useOutletContext } from '@umijs/max'; import { history, useOutletContext } from '@umijs/max';
@@ -26,6 +27,8 @@ import {
MenuProps, MenuProps,
message, message,
Modal, Modal,
Space,
Switch,
Tooltip, Tooltip,
Tree, Tree,
TreeSelect, TreeSelect,
@@ -70,6 +73,11 @@ const Script = () => {
const [showMonaco, setShowMonaco] = useState(true); const [showMonaco, setShowMonaco] = useState(true);
const [historyVisible, setHistoryVisible] = useState(false); const [historyVisible, setHistoryVisible] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [wordWrap, setWordWrap] = useState(
() =>
(localStorage.getItem('qinglong_editor_word_wrap') ??
localStorage.getItem('qinglong_config_word_wrap')) === 'true',
);
const savingRef = useRef(false); const savingRef = useRef(false);
const savedContentRef = useRef(''); const savedContentRef = useRef('');
const currentKeyRef = useRef(currentNode?.key); const currentKeyRef = useRef(currentNode?.key);
@@ -544,6 +552,11 @@ const Script = () => {
}); });
}; };
const changeWordWrap = (checked: boolean) => {
setWordWrap(checked);
localStorage.setItem('qinglong_editor_word_wrap', String(checked));
};
return ( return (
<PageContainer <PageContainer
className="ql-container-wrapper log-wrapper" className="ql-container-wrapper log-wrapper"
@@ -567,6 +580,14 @@ const Script = () => {
} }
loading={loading} loading={loading}
extra={[ extra={[
<Space key="word-wrap" size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={changeWordWrap}
/>
</Space>,
...(currentNode?.type === 'file' && showMonaco ...(currentNode?.type === 'file' && showMonaco
? [ ? [
<Button <Button
@@ -734,6 +755,7 @@ const Script = () => {
lineNumbersMinChars: 3, lineNumbersMinChars: 3,
glyphMargin: false, glyphMargin: false,
accessibilitySupport: 'off', accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}} }}
onMount={(editor) => { onMount={(editor) => {
editorRef.current = editor; editorRef.current = editor;
@@ -747,7 +769,10 @@ const Script = () => {
{isPhone && ( {isPhone && (
<CodeMirror <CodeMirror
value={value} value={value}
extensions={getCodeMirrorExtensions(mode)} extensions={[
...getCodeMirrorExtensions(mode),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
theme={theme.includes('dark') ? 'dark' : 'light'} theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing || saving} readOnly={!isEditing || saving}
onChange={(value) => { onChange={(value) => {
@@ -760,6 +785,8 @@ const Script = () => {
treeData={data} treeData={data}
currentNode={currentNode} currentNode={currentNode}
content={value} content={value}
wordWrap={wordWrap}
onWordWrapChange={changeWordWrap}
handleCancel={() => { handleCancel={() => {
setIsLogModalVisible(false); setIsLogModalVisible(false);
}} }}
-1
View File
@@ -90,7 +90,6 @@ const apiWhiteList = [
`${config.baseUrl}api/user/two-factor/login`, `${config.baseUrl}api/user/two-factor/login`,
`${config.baseUrl}api/system`, `${config.baseUrl}api/system`,
`${config.baseUrl}api/user/init`, `${config.baseUrl}api/user/init`,
`${config.baseUrl}api/user/notification/init`,
]; ];
_request.interceptors.request.use((_config) => { _request.interceptors.request.use((_config) => {
+1
View File
@@ -13,6 +13,7 @@ test('unknown API requests return JSON 404 while env.js and SPA deep links work'
const app = express(); const app = express();
load('back/loaders/express.ts', { load('back/loaders/express.ts', {
'../api': () => express.Router(), '../api': () => express.Router(),
'./logger': { error() {} },
'../config': { '../config': {
rootPath: dir, rootPath: dir,
uploadPath: dir, uploadPath: dir,
+52
View File
@@ -3,6 +3,7 @@ const test = require('node:test');
const fs = require('node:fs'); const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const util = require('node:util');
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const load = require('../helpers/load-security-module.cjs'); const load = require('../helpers/load-security-module.cjs');
@@ -105,6 +106,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
let envWriteCalls = 0; let envWriteCalls = 0;
let scopedMutationCalls = 0; let scopedMutationCalls = 0;
let initialized = 0; let initialized = 0;
let notificationCalls = 0;
const loggedErrors = [];
const User = class {}; const User = class {};
const Script = class { const Script = class {
getFile(folder, filename) { getFile(folder, filename) {
@@ -121,6 +124,10 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
return { code: 200 }; return { code: 200 };
}, },
getAuthInfo: async () => auth, getAuthInfo: async () => auth,
updateNotificationMode: async () => {
notificationCalls++;
return { code: 200 };
},
}; };
const mocks = { const mocks = {
'../config': config, '../config': config,
@@ -136,6 +143,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
shareStore: { getAuthInfo: async () => auth, getApps: async () => apps }, shareStore: { getAuthInfo: async () => auth, getApps: async () => apps },
}, },
'../config/serverEnv': { serveEnv: (_req, res) => res.end() }, '../config/serverEnv': { serveEnv: (_req, res) => res.end() },
'./logger': { error: (...args) => loggedErrors.push(util.format(...args)) },
'../services/user': User, '../services/user': User,
'../services/script': Script, '../services/script': Script,
'../data/open': {}, '../data/open': {},
@@ -209,6 +217,17 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
commandCalls++; commandCalls++;
res.json({ code: 200 }); res.json({ code: 200 });
}); });
router.get('/security-test/error', (_req, _res, next) =>
next(new Error('sensitive server detail')),
);
router.get('/security-test/db-error', (_req, _res, next) => {
const error = new Error('sensitive database detail');
error.name = 'SequelizeDatabaseError';
error.errors = [{ message: 'sensitive row value' }];
error.sql = "SELECT * FROM users WHERE password = 'sensitive password'";
error.parameters = ['sensitive parameter'];
next(error);
});
return router; return router;
}; };
const app = express(); const app = express();
@@ -238,6 +257,39 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
); );
return { status: response.status, body: await response.json() }; return { status: response.status, body: await response.json() };
}; };
auth.username = 'admin';
auth.password = 'admin';
for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) {
const response = await request(
`${prefix}/user/notification/init`,
undefined,
'PUT',
{ type: 'webhook', webhookUrl: 'http://127.0.0.1/?title=$title' },
);
assert.equal(response.status, 401, prefix);
}
assert.equal(notificationCalls, 0);
auth.username = 'owner';
auth.password = 'configured';
assert.equal(
(await request('/api/user/notification', valid, 'PUT', {})).body.code,
200,
);
assert.equal(notificationCalls, 1);
for (const route of ['/security-test/error', '/security-test/db-error']) {
const response = await request(`/api${route}`, valid);
assert.equal(response.status, 500);
assert.deepEqual(response.body, { code: 500, message: '未知错误' });
}
assert.equal(loggedErrors.length, 2);
assert.match(loggedErrors[1], /type=SequelizeDatabaseError/);
assert.ok(loggedErrors.every((line) => !line.includes('sensitive')));
const malformedToken = await request('/api/envs', 'not-a-jwt');
assert.equal(malformedToken.status, 401);
assert.deepEqual(malformedToken.body, {
code: 401,
message: 'Token 已失效',
});
for (const url of [ for (const url of [
'/api/user/init', '/api/user/init',
'/open/user/init', '/open/user/init',
+69
View File
@@ -0,0 +1,69 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
fs = require('node:fs'),
path = require('node:path'),
{ spawnSync } = require('node:child_process');
const notifyFile = path.resolve(
process.env.NOTIFY_PY_PATH || 'sample/notify.py',
);
const source = fs.readFileSync(notifyFile, 'utf8');
function httpCalls(text) {
const calls = [];
const pattern = /requests\.(?:get|post|request)\s*\(/g;
let match;
while ((match = pattern.exec(text))) {
let depth = 0;
let end = match.index + match[0].length - 1;
for (; end < text.length; end++) {
if (text[end] === '(') depth++;
else if (text[end] === ')' && --depth === 0) break;
}
calls.push(text.slice(match.index, end + 1));
}
return calls;
}
test('sample/notify.py sends every HTTP request with a timeout', () => {
const calls = httpCalls(source);
assert.ok(
calls.length >= 27,
`expected at least 27 HTTP calls, got ${calls.length}`,
);
const missing = calls.filter((call) => !/\btimeout\s*=/.test(call));
assert.deepEqual(missing, []);
});
test('notify.send returns after a bounded wait when a channel hangs', () => {
const script = `
import os, sys, time, types
requests = types.ModuleType("requests")
requests.get = requests.post = requests.request = lambda *a, **k: None
sys.modules["requests"] = requests
sys.path.insert(0, ${JSON.stringify(path.dirname(notifyFile))})
os.environ.pop("QL_DIR", None)
import notify
notify.NOTIFY_JOIN_TIMEOUT = 1
notify.push_config = {key: "" for key in notify.push_config}
notify.push_config["HITOKOTO"] = False
notify.push_config["CONSOLE"] = True
def hang(*args, **kwargs):
time.sleep(60)
notify.console = hang
start = time.monotonic()
notify.send("title", "content")
elapsed = time.monotonic() - start
assert elapsed < 5, elapsed
print("elapsed=%.2f" % elapsed)
`;
const python = process.platform === 'win32' ? 'python' : 'python3';
const result = spawnSync(python, ['-'], {
input: script,
encoding: 'utf8',
timeout: 15000,
env: { ...process.env, QL_DIR: '' },
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /elapsed=/);
});
+3
View File
@@ -66,6 +66,9 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
encoding: 'utf8', encoding: 'utf8',
env: { env: {
...process.env, ...process.env,
GITHUB_EVENT_NAME: 'push',
GITHUB_REF: 'refs/heads/master',
NPM_RC_VERSION: '',
PATH: `${bin}${path.delimiter}${process.env.PATH}`, PATH: `${bin}${path.delimiter}${process.env.PATH}`,
RUNNER_TEMP: root, RUNNER_TEMP: root,
QL_PUBLISH_SCENARIO: scenario, QL_PUBLISH_SCENARIO: scenario,
+13
View File
@@ -52,6 +52,19 @@ test('health uses actual readiness and returns HTTP 503 until recovery',async(t)
assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready); assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready);
} }
}); });
test('health failure does not reveal internal error details',async(t)=>{
const express=require('express');
const HealthService=class{};
const app=express();load('back/api/health.ts',{
typedi:{get:()=>({check:async()=>{throw Error('sensitive health detail');}})},
'../services/health':{HealthService},'../loaders/logger':{error(){}},
}).default(app);
const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
t.after(()=>new Promise(r=>{server.close(r);server.closeAllConnections();}));
const response=await fetch(`http://127.0.0.1:${server.address().port}/health`);
assert.equal(response.status,500);
assert.deepEqual(await response.json(),{code:500,message:'Health check failed'});
});
test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{ test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{
const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a); const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a);
const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText; const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText;
+34 -1
View File
@@ -3,7 +3,7 @@ const test = require('node:test');
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const { Sequelize, QueryTypes } = require('sequelize'); const { Sequelize, DataTypes, QueryTypes } = require('sequelize');
const { migrateSchema } = require('../../back/shared/schemaMigrations'); const { migrateSchema } = require('../../back/shared/schemaMigrations');
const load = require('../helpers/load-security-module.cjs'); const load = require('../helpers/load-security-module.cjs');
@@ -46,6 +46,39 @@ test('legacy database upgrades without losing rows and repeated migration is ide
assert.equal(applied.length, 15); assert.equal(applied.length, 15);
}); });
test('migration repairs malformed environment labels before ORM reads', async (t) => {
const database = await createLegacy();
t.after(() => database.close());
await migrateSchema(database);
for (const [id, labels] of [
[1, ''],
[2, null],
[3, '{invalid'],
[4, '{}'],
[5, '["keep"]'],
]) {
await database.query('INSERT INTO "Envs" (id, name, labels) VALUES (?, ?, ?)', {
replacements: [id, `env-${id}`, labels],
});
}
const EnvModel = database.define('Env', {
name: DataTypes.STRING,
labels: DataTypes.JSON,
}, { timestamps: false });
await assert.rejects(EnvModel.findAll(), /Unexpected end of JSON input/);
await migrateSchema(database);
const rows = await EnvModel.findAll({ order: [['id', 'ASC']] });
assert.deepEqual(rows.map((row) => row.labels), [[], [], [], [], ['keep']]);
await migrateSchema(database);
const repaired = await database.query('SELECT id, labels FROM "Envs" ORDER BY id', {
type: QueryTypes.SELECT,
});
assert.deepEqual(repaired.map(({ labels }) => labels), [
'[]', '[]', '[]', '[]', '["keep"]',
]);
});
test('migration failure rolls back added columns and can be retried after repair', async (t) => { test('migration failure rolls back added columns and can be retried after repair', async (t) => {
const database = await createLegacy(':memory:', true); const database = await createLegacy(':memory:', true);
t.after(() => database.close()); t.after(() => database.close());
+9 -11
View File
@@ -1,12 +1,10 @@
version: 2.24.0 version: 2.25.0-rc.0
changeLogLink: https://github.com/whyour/qinglong/blob/v2.24.0/docs/releases/2.24.0.md changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.25.0-rc.0.md
publishTime: "2026-10-29 02:14:58" publishTime: "2026-11-10 01:16:12"
changeLog: | changeLog: |
1. Alpine 与 Debian 默认镜像统一使用 Node.js 24,SQLite 依赖升级至 1.1.4 1. 收紧应用权限、首次通知配置与任务环境变量边界;启动前修复不安全的环境变量标签
2. 新增 Node.js 原生部署的 Python venv 隔离支持,以及 Python Git URL、requirements、pyproject 依赖源 2. 依赖管理改为按需复查状态,配置与脚本编辑器新增自动换行开关
3. 新增通知审计日志,完善通知失败记录与配置提示 3. 通知请求及线程等待增加超时,避免无响应渠道阻塞任务
4. 升级包按平台构建并校验;支持在线暂存、离线安装、失败回退及保留数据迁移 4. 修复挂载空数据卷后 Python 通知缺少 requests 依赖的问题
5. 修复任务调度、额外规则、依赖安装、脚本发现和订阅同步等问题 5. 独立远程 CLI 候选版本为 0.1.5-rc.1,改善跨平台配置与 OpenAPI 命令
6. 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0 6. 此版本为开发候选;升级前备份数据并在目标设备验证任务、依赖、通知和应用权限
7. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7,Python 3.10 兼容镜像停止更新
8. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知