Compare commits

..
20 changed files with 739 additions and 511 deletions
-1
View File
@@ -20,7 +20,6 @@ export default (app: Router) => {
res.status(500).send({
code: 500,
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(
'/avatar',
upload.single('avatar'),
-2
View File
@@ -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
View File
@@ -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,
});
},
);
+4 -4
View File
@@ -15,7 +15,7 @@
"@types/node": "22.19.0",
"commander": "15.0.0",
"esbuild": "0.28.2",
"tar": "7.5.21",
"tar": "7.5.20",
"typescript": "5.2.2"
},
"engines": {
@@ -573,9 +573,9 @@
}
},
"node_modules/tar": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz",
"integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==",
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
+1 -1
View File
@@ -27,7 +27,7 @@
"esbuild": "0.28.2",
"typescript": "5.2.2",
"@types/node": "22.19.0",
"tar": "7.5.21"
"tar": "7.5.20"
},
"license": "Apache-2.0",
"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-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 |
-7
View File
@@ -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',
+6 -10
View File
@@ -116,10 +116,6 @@
"@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8",
"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@2": "2.1.7"
}
@@ -131,7 +127,7 @@
"@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.9",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"chokidar": "^4.0.1",
"compression": "1.8.2",
@@ -152,10 +148,10 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"lodash": "^4.18.1",
"multer": "2.5.0",
"lodash": "^4.17.21",
"multer": "2.4.0",
"node-cron": "4.6.0",
"nodemailer": "^10.1.4",
"nodemailer": "^10.0.9",
"p-queue-cjs": "7.3.4",
"proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0",
@@ -166,11 +162,11 @@
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
"toad-scheduler": "^3.0.1",
"typedi": "^0.10.0",
"undici": "^7.30.1",
"undici": "^7.9.0",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"tar": "7.5.21"
"tar": "7.5.20"
},
"devDependencies": {
"@ant-design/icons": "^5.0.1",
+542 -345
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -508,6 +508,7 @@
"脚本(只读)": "Scripts (read only)",
"自动删除任务": "Automatically Delete Tasks",
"自动拆分": "Auto Split",
"自动换行": "Word wrap",
"自动添加任务": "Automatically Add Tasks",
"自动生成": "Auto-generated",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "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,
useRef,
} from 'react';
import { Button, message, Modal, TreeSelect } from 'antd';
import { Button, message, Modal, Space, Switch, TreeSelect } from 'antd';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http';
@@ -15,6 +15,7 @@ import CodeMirror from '@uiw/react-codemirror';
import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts';
import { langs } from '@uiw/codemirror-extensions-langs';
import { EditorView } from '@codemirror/view';
import { useHotkeys } from 'react-hotkeys-hook';
import { getEditorMode } from '@/utils';
@@ -28,6 +29,11 @@ const Config = () => {
const editorRef = useRef<any>(null);
const [confirmLoading, setConfirmLoading] = useState(false);
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) => {
request
@@ -104,6 +110,20 @@ const Config = () => {
defaultValue="config.sh"
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
key="1"
loading={confirmLoading}
@@ -121,7 +141,10 @@ const Config = () => {
<CodeMirror
value={value}
theme={theme.includes('dark') ? 'dark' : 'light'}
extensions={[langs.shell()]}
extensions={[
langs.shell(),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
onChange={(value) => {
setValue(value);
}}
@@ -136,7 +159,8 @@ const Config = () => {
lineNumbersMinChars: 3,
folding: false,
glyphMargin: false,
accessibilitySupport: 'off'
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
+2 -83
View File
@@ -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: (
+25 -1
View File
@@ -6,7 +6,16 @@ import React, {
useCallback,
useReducer,
} 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 config from '@/utils/config';
import SplitPane from 'react-split-pane';
@@ -24,11 +33,15 @@ const EditModal = ({
treeData,
currentNode,
content,
wordWrap,
onWordWrapChange,
handleCancel,
}: {
treeData?: any;
content?: string;
currentNode: any;
wordWrap: boolean;
onWordWrapChange: (checked: boolean) => void;
handleCancel: () => void;
}) => {
const [value, setValue] = useState('');
@@ -219,6 +232,16 @@ const EditModal = ({
</>
}
width={'100%'}
extra={
<Space size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={onWordWrapChange}
/>
</Space>
}
headerStyle={{ padding: '11px 24px' }}
onClose={cancel}
open={true}
@@ -241,6 +264,7 @@ const EditModal = ({
lineNumbersMinChars: 3,
glyphMargin: false,
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
+28 -1
View File
@@ -15,6 +15,7 @@ import {
PlusOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { EditorView } from '@codemirror/view';
import Editor from '@monaco-editor/react';
import CodeMirror from '@uiw/react-codemirror';
import { history, useOutletContext } from '@umijs/max';
@@ -26,6 +27,8 @@ import {
MenuProps,
message,
Modal,
Space,
Switch,
Tooltip,
Tree,
TreeSelect,
@@ -70,6 +73,11 @@ const Script = () => {
const [showMonaco, setShowMonaco] = useState(true);
const [historyVisible, setHistoryVisible] = 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 savedContentRef = useRef('');
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 (
<PageContainer
className="ql-container-wrapper log-wrapper"
@@ -567,6 +580,14 @@ const Script = () => {
}
loading={loading}
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
? [
<Button
@@ -734,6 +755,7 @@ const Script = () => {
lineNumbersMinChars: 3,
glyphMargin: false,
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
@@ -747,7 +769,10 @@ const Script = () => {
{isPhone && (
<CodeMirror
value={value}
extensions={getCodeMirrorExtensions(mode)}
extensions={[
...getCodeMirrorExtensions(mode),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing || saving}
onChange={(value) => {
@@ -760,6 +785,8 @@ const Script = () => {
treeData={data}
currentNode={currentNode}
content={value}
wordWrap={wordWrap}
onWordWrapChange={changeWordWrap}
handleCancel={() => {
setIsLogModalVisible(false);
}}
-1
View File
@@ -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) => {
+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();
load('back/loaders/express.ts', {
'../api': () => express.Router(),
'./logger': { error() {} },
'../config': {
rootPath: dir,
uploadPath: dir,
+52
View File
@@ -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',
+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);
}
});
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;