mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-10 03:33:28 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ddb592800 | ||
|
|
b838d4bb42 | ||
|
|
73ad00ba32 | ||
|
|
27b322a10d | ||
|
|
1cfc0c8e2e | ||
|
|
cae580e02f | ||
|
|
76b285c3db |
@@ -33,6 +33,10 @@ on:
|
||||
type: choice
|
||||
options: [branch, tag]
|
||||
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:
|
||||
contents: read
|
||||
@@ -470,7 +474,7 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
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]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -491,6 +495,8 @@ jobs:
|
||||
|
||||
- name: Publish npm package with OIDC
|
||||
shell: bash
|
||||
env:
|
||||
NPM_RC_VERSION: ${{ inputs.npm_rc_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
@@ -503,11 +509,21 @@ jobs:
|
||||
metadata=$(tar -xOf "$archive" package/package.json)
|
||||
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$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
|
||||
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."
|
||||
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
|
||||
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||
exit 1
|
||||
|
||||
@@ -20,7 +20,6 @@ export default (app: Router) => {
|
||||
res.status(500).send({
|
||||
code: 500,
|
||||
message: 'Health check failed',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
'/avatar',
|
||||
upload.single('avatar'),
|
||||
|
||||
@@ -180,12 +180,10 @@ export default {
|
||||
'/api/user/two-factor/login',
|
||||
'/api/system',
|
||||
'/api/user/init',
|
||||
'/api/user/notification/init',
|
||||
'/open/user/login',
|
||||
'/open/user/two-factor/login',
|
||||
'/open/system',
|
||||
'/open/user/init',
|
||||
'/open/user/notification/init',
|
||||
],
|
||||
versionFile,
|
||||
lastVersionFile,
|
||||
|
||||
+36
-37
@@ -16,6 +16,7 @@ import { t } from '../shared/i18n';
|
||||
import { AppScope } from '../data/open';
|
||||
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||
import Logger from './logger';
|
||||
|
||||
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
||||
const setting = value?.trim();
|
||||
@@ -191,14 +192,7 @@ export default ({ app }: { app: Application }) => {
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
const pathLower = req.path.toLowerCase();
|
||||
if (
|
||||
![
|
||||
'/api/user/init',
|
||||
'/api/user/notification/init',
|
||||
'/open/user/init',
|
||||
'/open/user/notification/init',
|
||||
].includes(pathLower)
|
||||
) {
|
||||
if (!['/api/user/init', '/open/user/init'].includes(pathLower)) {
|
||||
return next();
|
||||
}
|
||||
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||
@@ -238,37 +232,23 @@ export default ({ app }: { app: Application }) => {
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: Error & { status: number },
|
||||
err: Error & { status: number; code?: string },
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (err.name === 'UnauthorizedError') {
|
||||
return res
|
||||
.status(err.status)
|
||||
.send({ code: 401, message: err.message })
|
||||
.end();
|
||||
}
|
||||
return next(err);
|
||||
},
|
||||
);
|
||||
|
||||
app.use(
|
||||
(
|
||||
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();
|
||||
let message = err.message;
|
||||
if (!config.isDevelopment) {
|
||||
if (err.code === 'credentials_bad_scheme') {
|
||||
message = t('暂无权限');
|
||||
} else if (err.code === 'credentials_required') {
|
||||
message = t('请先登录');
|
||||
} else {
|
||||
message = t('Token 已失效');
|
||||
}
|
||||
}
|
||||
return res.status(401).send({ code: 401, message }).end();
|
||||
}
|
||||
return next(err);
|
||||
},
|
||||
@@ -281,10 +261,29 @@ export default ({ app }: { app: Application }) => {
|
||||
res: Response,
|
||||
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({
|
||||
code: err.status || 500,
|
||||
message: err.message,
|
||||
code: status,
|
||||
message:
|
||||
!config.isDevelopment && status >= 500 ? t('未知错误') : err.message,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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-set ` | PUT | `user/notification` | --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 system apply-reload ` | PUT | `update/reload` | --query |
|
||||
| `ql system apply-system ` | PUT | `update/system` | --query |
|
||||
|
||||
@@ -608,13 +608,6 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
body: 'json',
|
||||
anonymous: true,
|
||||
},
|
||||
{
|
||||
name: 'user notification-init',
|
||||
method: 'PUT',
|
||||
path: 'user/notification/init',
|
||||
body: 'json',
|
||||
anonymous: true,
|
||||
},
|
||||
{
|
||||
name: 'user avatar',
|
||||
method: 'PUT',
|
||||
|
||||
+2
-2
@@ -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 \
|
||||
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
|
||||
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
|
||||
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
||||
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/
|
||||
|
||||
|
||||
@@ -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 \
|
||||
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
|
||||
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
|
||||
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
||||
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/
|
||||
|
||||
|
||||
@@ -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`,在目标设备验证任务、订阅、环境变量、依赖与通知。开发候选不建议覆盖正式环境。
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@whyour/qinglong",
|
||||
"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",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,38 +1,21 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import React, { Fragment, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Row,
|
||||
Input,
|
||||
Form,
|
||||
message,
|
||||
Typography,
|
||||
Steps,
|
||||
Select,
|
||||
} from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Form, Typography, Steps } from 'antd';
|
||||
import config from '@/utils/config';
|
||||
import { history } from '@umijs/max';
|
||||
import styles from './index.less';
|
||||
import { request } from '@/utils/http';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
const { Link } = Typography;
|
||||
|
||||
const Initialization = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [current, setCurrent] = React.useState(0);
|
||||
const [fields, setFields] = useState<any[]>([]);
|
||||
|
||||
const next = () => {
|
||||
setCurrent(current + 1);
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
setCurrent(current - 1);
|
||||
};
|
||||
|
||||
const submitAccountSetting = (values: any) => {
|
||||
setLoading(true);
|
||||
request
|
||||
@@ -48,23 +31,6 @@ const Initialization = () => {
|
||||
.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(() => {
|
||||
localStorage.removeItem(config.authKey);
|
||||
}, []);
|
||||
@@ -95,53 +61,6 @@ const Initialization = () => {
|
||||
</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('账户设置'),
|
||||
content: (
|
||||
|
||||
@@ -90,7 +90,6 @@ const apiWhiteList = [
|
||||
`${config.baseUrl}api/user/two-factor/login`,
|
||||
`${config.baseUrl}api/system`,
|
||||
`${config.baseUrl}api/user/init`,
|
||||
`${config.baseUrl}api/user/notification/init`,
|
||||
];
|
||||
|
||||
_request.interceptors.request.use((_config) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ test('unknown API requests return JSON 404 while env.js and SPA deep links work'
|
||||
const app = express();
|
||||
load('back/loaders/express.ts', {
|
||||
'../api': () => express.Router(),
|
||||
'./logger': { error() {} },
|
||||
'../config': {
|
||||
rootPath: dir,
|
||||
uploadPath: dir,
|
||||
|
||||
@@ -3,6 +3,7 @@ const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const util = require('node:util');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
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 scopedMutationCalls = 0;
|
||||
let initialized = 0;
|
||||
let notificationCalls = 0;
|
||||
const loggedErrors = [];
|
||||
const User = class {};
|
||||
const Script = class {
|
||||
getFile(folder, filename) {
|
||||
@@ -121,6 +124,10 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
return { code: 200 };
|
||||
},
|
||||
getAuthInfo: async () => auth,
|
||||
updateNotificationMode: async () => {
|
||||
notificationCalls++;
|
||||
return { code: 200 };
|
||||
},
|
||||
};
|
||||
const mocks = {
|
||||
'../config': config,
|
||||
@@ -136,6 +143,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
shareStore: { getAuthInfo: async () => auth, getApps: async () => apps },
|
||||
},
|
||||
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
|
||||
'./logger': { error: (...args) => loggedErrors.push(util.format(...args)) },
|
||||
'../services/user': User,
|
||||
'../services/script': Script,
|
||||
'../data/open': {},
|
||||
@@ -209,6 +217,17 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
commandCalls++;
|
||||
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;
|
||||
};
|
||||
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() };
|
||||
};
|
||||
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 [
|
||||
'/api/user/init',
|
||||
'/open/user/init',
|
||||
|
||||
@@ -66,6 +66,9 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_EVENT_NAME: 'push',
|
||||
GITHUB_REF: 'refs/heads/master',
|
||||
NPM_RC_VERSION: '',
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||
RUNNER_TEMP: root,
|
||||
QL_PUBLISH_SCENARIO: scenario,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
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()=>{
|
||||
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;
|
||||
|
||||
@@ -3,7 +3,7 @@ const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { Sequelize, QueryTypes } = require('sequelize');
|
||||
const { Sequelize, DataTypes, QueryTypes } = require('sequelize');
|
||||
const { migrateSchema } = require('../../back/shared/schemaMigrations');
|
||||
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);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const database = await createLegacy(':memory:', true);
|
||||
t.after(() => database.close());
|
||||
|
||||
+9
-11
@@ -1,12 +1,10 @@
|
||||
version: 2.24.0
|
||||
changeLogLink: https://github.com/whyour/qinglong/blob/v2.24.0/docs/releases/2.24.0.md
|
||||
publishTime: "2026-10-29 02:14:58"
|
||||
version: 2.25.0-rc.0
|
||||
changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.25.0-rc.0.md
|
||||
publishTime: "2026-11-10 01:16:12"
|
||||
changeLog: |
|
||||
1. Alpine 与 Debian 默认镜像统一使用 Node.js 24,SQLite 依赖升级至 1.1.4
|
||||
2. 新增 Node.js 原生部署的 Python venv 隔离支持,以及 Python Git URL、requirements、pyproject 依赖源
|
||||
3. 新增通知审计日志,完善通知失败记录与配置提示
|
||||
4. 升级包按平台构建并校验;支持在线暂存、离线安装、失败回退及保留数据迁移
|
||||
5. 修复任务调度、额外规则、依赖安装、脚本发现和订阅同步等问题
|
||||
6. 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0
|
||||
7. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7,Python 3.10 兼容镜像停止更新
|
||||
8. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知
|
||||
1. 收紧应用权限、首次通知配置与任务环境变量边界;启动前修复不安全的环境变量标签
|
||||
2. 依赖管理改为按需复查状态,配置与脚本编辑器新增自动换行开关
|
||||
3. 通知请求及线程等待增加超时,避免无响应渠道阻塞任务
|
||||
4. 修复挂载空数据卷后 Python 通知缺少 requests 依赖的问题
|
||||
5. 独立远程 CLI 候选版本为 0.1.5-rc.1,改善跨平台配置与 OpenAPI 命令
|
||||
6. 此版本为开发候选;升级前备份数据并在目标设备验证任务、依赖、通知和应用权限
|
||||
|
||||
Reference in New Issue
Block a user