Compare commits

..
Author SHA1 Message Date
whyour 7ebbe17aa8 fix(security): refresh vulnerable runtime dependencies 2026-11-08 02:49:12 +08:00
30 changed files with 560 additions and 983 deletions
+2 -18
View File
@@ -33,10 +33,6 @@ 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
@@ -474,7 +470,7 @@ jobs:
retention-days: 14 retention-days: 14
publish: publish:
if: ${{ github.repository == 'whyour/qinglong' && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop')) }} if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
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:
@@ -495,8 +491,6 @@ 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
@@ -509,21 +503,11 @@ 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
if [[ "${GITHUB_EVENT_NAME:-}" == workflow_dispatch && "${GITHUB_REF:-}" == refs/heads/develop ]]; then npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
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,6 +20,7 @@ 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,6 +310,20 @@ 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,10 +180,12 @@ 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,
+37 -36
View File
@@ -16,7 +16,6 @@ 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();
@@ -192,7 +191,14 @@ 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 (!['/api/user/init', '/open/user/init'].includes(pathLower)) { if (
![
'/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);
@@ -232,23 +238,37 @@ export default ({ app }: { app: Application }) => {
app.use( app.use(
( (
err: Error & { status: number; code?: string }, err: Error & { status: number },
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
if (err.name === 'UnauthorizedError') { if (err.name === 'UnauthorizedError') {
let message = err.message; return res
if (!config.isDevelopment) { .status(err.status)
if (err.code === 'credentials_bad_scheme') { .send({ code: 401, message: err.message })
message = t('暂无权限'); .end();
} else if (err.code === 'credentials_required') { }
message = t('请先登录'); return next(err);
} 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);
}, },
@@ -261,29 +281,10 @@ export default ({ app }: { app: Application }) => {
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
const status = res.status(err.status || 500);
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: status, code: err.status || 500,
message: message: err.message,
!config.isDevelopment && status >= 500 ? t('未知错误') : err.message,
}); });
}, },
); );
-11
View File
@@ -66,16 +66,5 @@ 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.20", "tar": "7.5.21",
"typescript": "5.2.2" "typescript": "5.2.2"
}, },
"engines": { "engines": {
@@ -573,9 +573,9 @@
} }
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "7.5.20", "version": "7.5.21",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", "integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==",
"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.20" "tar": "7.5.21"
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -224,6 +224,7 @@ 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,6 +608,13 @@ 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:${QL_DIR}/python-base/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
RUN pip3 install --ignore-installed --prefix ${QL_DIR}/python-base requests 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/
+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:${QL_DIR}/python-base/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
RUN pip3 install --ignore-installed --prefix ${QL_DIR}/python-base requests RUN pip3 install --prefix ${PYTHON_HOME} 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
@@ -1,17 +0,0 @@
# 青龙 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`,在目标设备验证任务、订阅、环境变量、依赖与通知。开发候选不建议覆盖正式环境。
+11 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "@whyour/qinglong", "name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1", "packageManager": "pnpm@8.3.1",
"version": "2.25.0-rc.0", "version": "2.24.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,6 +116,10 @@
"@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"
} }
@@ -127,7 +131,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.3", "body-parser": "^1.20.9",
"celebrate": "^15.0.3", "celebrate": "^15.0.3",
"chokidar": "^4.0.1", "chokidar": "^4.0.1",
"compression": "1.8.2", "compression": "1.8.2",
@@ -148,10 +152,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.17.21", "lodash": "^4.18.1",
"multer": "2.4.0", "multer": "2.5.0",
"node-cron": "4.6.0", "node-cron": "4.6.0",
"nodemailer": "^10.0.9", "nodemailer": "^10.1.4",
"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",
@@ -162,11 +166,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.9.0", "undici": "^7.30.1",
"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.20" "tar": "7.5.21"
}, },
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^5.0.1", "@ant-design/icons": "^5.0.1",
+345 -542
View File
File diff suppressed because it is too large Load Diff
+30 -78
View File
@@ -18,11 +18,6 @@ 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()
@@ -204,7 +199,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=REQUEST_TIMEOUT url=url, data=json.dumps(data), headers=headers, timeout=15
).json() ).json()
if response["code"] == 200: if response["code"] == 200:
@@ -246,7 +241,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=REQUEST_TIMEOUT url=url, data=json.dumps(data), headers=headers, timeout=15
).json() ).json()
if not response["errcode"]: if not response["errcode"]:
@@ -283,9 +278,7 @@ def feishu_bot(title: str, content: str) -> Optional[bool]:
data["timestamp"] = timestamp data["timestamp"] = timestamp
data["sign"] = sign data["sign"] = sign
response = requests.post( response = requests.post(url, data=json.dumps(data)).json()
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)
@@ -306,7 +299,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, timeout=REQUEST_TIMEOUT).json() response = requests.get(url).json()
if response["status"] == "ok": if response["status"] == "ok":
audit_results.append(True) audit_results.append(True)
@@ -332,7 +325,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, timeout=REQUEST_TIMEOUT).json() response = requests.post(url, data=data).json()
if response.get("id"): if response.get("id"):
audit_results.append(True) audit_results.append(True)
@@ -355,9 +348,7 @@ 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( response = requests.post(url, data=data, headers=headers).json()
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)
@@ -386,7 +377,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, timeout=REQUEST_TIMEOUT).json() response = requests.post(url, data=data).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)
@@ -415,7 +406,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, timeout=REQUEST_TIMEOUT).json() response = requests.post(url, data=data).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)
@@ -436,7 +427,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, timeout=REQUEST_TIMEOUT) response = requests.post(url, data=data)
if response.status_code == 200: if response.status_code == 200:
audit_results.append(True) audit_results.append(True)
@@ -470,9 +461,7 @@ 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( response = requests.post(url=url, data=body, headers=headers).json()
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
code = response["code"] code = response["code"]
if code == 200: if code == 200:
@@ -488,9 +477,7 @@ 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( response = requests.post(url=url_old, data=body, headers=headers).json()
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)
@@ -526,9 +513,7 @@ 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( response = requests.post(url=url, data=body, headers=headers).json()
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)
@@ -550,9 +535,7 @@ 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( response = requests.post(url=url, params=payload).json()
url=url, params=payload, timeout=REQUEST_TIMEOUT
).json()
if response["code"] == 0: if response["code"] == 0:
audit_results.append(True) audit_results.append(True)
@@ -616,7 +599,7 @@ class WeCom:
"corpid": self.CORPID, "corpid": self.CORPID,
"corpsecret": self.CORPSECRET, "corpsecret": self.CORPSECRET,
} }
req = requests.post(url, params=values, timeout=REQUEST_TIMEOUT) req = requests.post(url, params=values)
data = json.loads(req.text) data = json.loads(req.text)
return data["access_token"] return data["access_token"]
@@ -632,9 +615,7 @@ 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( respone = requests.post(send_url, send_msges)
send_url, send_msges, timeout=REQUEST_TIMEOUT
)
respone = respone.json() respone = respone.json()
return respone["errmsg"] return respone["errmsg"]
@@ -660,9 +641,7 @@ class WeCom:
}, },
} }
send_msges = bytes(json.dumps(send_values), "utf-8") send_msges = bytes(json.dumps(send_values), "utf-8")
respone = requests.post( respone = requests.post(send_url, send_msges)
send_url, send_msges, timeout=REQUEST_TIMEOUT
)
respone = respone.json() respone = respone.json()
return respone["errmsg"] return respone["errmsg"]
@@ -684,7 +663,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=REQUEST_TIMEOUT url=url, data=json.dumps(data), headers=headers, timeout=15
).json() ).json()
if response["errcode"] == 0: if response["errcode"] == 0:
@@ -732,11 +711,7 @@ 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, url=url, headers=headers, params=payload, proxies=proxies
headers=headers,
params=payload,
proxies=proxies,
timeout=REQUEST_TIMEOUT,
).json() ).json()
if response["ok"]: if response["ok"]:
@@ -777,9 +752,7 @@ 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( response = requests.post(url=url, data=body, headers=headers).json()
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)
@@ -863,7 +836,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, timeout=REQUEST_TIMEOUT) response = requests.post(url, data=data)
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)
@@ -910,9 +883,7 @@ def chronocat(title: str, content: str) -> Optional[bool]:
} }
], ],
} }
response = requests.post( response = requests.post(url, headers=headers, data=json.dumps(data))
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)
@@ -965,9 +936,7 @@ 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( response = requests.post(url, data=data, headers=headers)
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 推送成功!")
@@ -1026,9 +995,7 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
} }
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
response = requests.post( response = requests.post(url=url, json=data, headers=headers).json()
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)
@@ -1079,9 +1046,7 @@ 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( response = requests.post(url=url, json=data, headers=headers).json()
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)
@@ -1115,9 +1080,7 @@ 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( response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
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)
@@ -1158,9 +1121,7 @@ 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( response = requests.post(url=url, json=data, headers=headers).json()
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)
@@ -1254,7 +1215,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=REQUEST_TIMEOUT, data=body method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body
) )
if response.status_code == 200: if response.status_code == 200:
@@ -1272,7 +1233,7 @@ def one() -> str:
:return: :return:
""" """
url = "https://v1.hitokoto.cn/" url = "https://v1.hitokoto.cn/"
res = requests.get(url, timeout=REQUEST_TIMEOUT).json() res = requests.get(url).json()
return res["hitokoto"] + " ----" + res["from"] return res["hitokoto"] + " ----" + res["from"]
@@ -1413,20 +1374,11 @@ 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( threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__)
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]
for t in ts: [t.join() 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,7 +508,6 @@
"脚本(只读)": "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,7 +508,6 @@
"脚本(只读)": "脚本(只读)", "脚本(只读)": "脚本(只读)",
"自动删除任务": "自动删除任务", "自动删除任务": "自动删除任务",
"自动拆分": "自动拆分", "自动拆分": "自动拆分",
"自动换行": "自动换行",
"自动添加任务": "自动添加任务", "自动添加任务": "自动添加任务",
"自动生成": "自动生成", "自动生成": "自动生成",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成",
+3 -27
View File
@@ -6,7 +6,7 @@ import React, {
useEffect, useEffect,
useRef, useRef,
} from 'react'; } from 'react';
import { Button, message, Modal, Space, Switch, TreeSelect } from 'antd'; import { Button, message, Modal, 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,7 +15,6 @@ 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';
@@ -29,11 +28,6 @@ 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
@@ -110,20 +104,6 @@ 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}
@@ -141,10 +121,7 @@ const Config = () => {
<CodeMirror <CodeMirror
value={value} value={value}
theme={theme.includes('dark') ? 'dark' : 'light'} theme={theme.includes('dark') ? 'dark' : 'light'}
extensions={[ extensions={[langs.shell()]}
langs.shell(),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
onChange={(value) => { onChange={(value) => {
setValue(value); setValue(value);
}} }}
@@ -159,8 +136,7 @@ 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;
+83 -2
View File
@@ -1,21 +1,38 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import React, { useEffect, useState } from 'react'; import React, { Fragment, useEffect, useState } from 'react';
import { Button, Input, Form, Typography, Steps } from 'antd'; import {
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
@@ -31,6 +48,23 @@ 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);
}, []); }, []);
@@ -61,6 +95,53 @@ 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: (
+1 -25
View File
@@ -6,16 +6,7 @@ import React, {
useCallback, useCallback,
useReducer, useReducer,
} from 'react'; } from 'react';
import { import { Drawer, Button, Tabs, Badge, Select, TreeSelect } from 'antd';
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';
@@ -33,15 +24,11 @@ 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('');
@@ -232,16 +219,6 @@ 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}
@@ -264,7 +241,6 @@ 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;
+1 -28
View File
@@ -15,7 +15,6 @@ 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';
@@ -27,8 +26,6 @@ import {
MenuProps, MenuProps,
message, message,
Modal, Modal,
Space,
Switch,
Tooltip, Tooltip,
Tree, Tree,
TreeSelect, TreeSelect,
@@ -73,11 +70,6 @@ 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);
@@ -552,11 +544,6 @@ 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"
@@ -580,14 +567,6 @@ 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
@@ -755,7 +734,6 @@ 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;
@@ -769,10 +747,7 @@ const Script = () => {
{isPhone && ( {isPhone && (
<CodeMirror <CodeMirror
value={value} value={value}
extensions={[ extensions={getCodeMirrorExtensions(mode)}
...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) => {
@@ -785,8 +760,6 @@ 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,6 +90,7 @@ 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,7 +13,6 @@ 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,7 +3,6 @@ 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');
@@ -106,8 +105,6 @@ 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) {
@@ -124,10 +121,6 @@ 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,
@@ -143,7 +136,6 @@ 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': {},
@@ -217,17 +209,6 @@ 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();
@@ -257,39 +238,6 @@ 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
@@ -1,69 +0,0 @@
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,9 +66,6 @@ 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,19 +52,6 @@ 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;
+1 -34
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, DataTypes, QueryTypes } = require('sequelize'); const { Sequelize, 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,39 +46,6 @@ 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());
+11 -9
View File
@@ -1,10 +1,12 @@
version: 2.25.0-rc.0 version: 2.24.0
changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.25.0-rc.0.md changeLogLink: https://github.com/whyour/qinglong/blob/v2.24.0/docs/releases/2.24.0.md
publishTime: "2026-11-10 01:16:12" publishTime: "2026-10-29 02:14:58"
changeLog: | changeLog: |
1. 收紧应用权限、首次通知配置与任务环境变量边界;启动前修复不安全的环境变量标签 1. Alpine 与 Debian 默认镜像统一使用 Node.js 24,SQLite 依赖升级至 1.1.4
2. 依赖管理改为按需复查状态,配置与脚本编辑器新增自动换行开关 2. 新增 Node.js 原生部署的 Python venv 隔离支持,以及 Python Git URL、requirements、pyproject 依赖源
3. 通知请求及线程等待增加超时,避免无响应渠道阻塞任务 3. 新增通知审计日志,完善通知失败记录与配置提示
4. 修复挂载空数据卷后 Python 通知缺少 requests 依赖的问题 4. 升级包按平台构建并校验;支持在线暂存、离线安装、失败回退及保留数据迁移
5. 独立远程 CLI 候选版本为 0.1.5-rc.1,改善跨平台配置与 OpenAPI 命令 5. 修复任务调度、额外规则、依赖安装、脚本发现和订阅同步等问题
6. 此版本为开发候选;升级前备份数据并在目标设备验证任务、依赖、通知和应用权限 6. 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0
7. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7,Python 3.10 兼容镜像停止更新
8. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知