mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-10 03:33:28 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ddb592800 | ||
|
|
b838d4bb42 | ||
|
|
73ad00ba32 | ||
|
|
27b322a10d | ||
|
|
1cfc0c8e2e | ||
|
|
cae580e02f | ||
|
|
76b285c3db | ||
|
|
13e0ff55d4 | ||
|
|
2bf814e562 | ||
|
|
41910b5236 | ||
|
|
442ab2c616 |
@@ -33,6 +33,10 @@ on:
|
|||||||
type: choice
|
type: choice
|
||||||
options: [branch, tag]
|
options: [branch, tag]
|
||||||
default: branch
|
default: branch
|
||||||
|
npm_rc_version:
|
||||||
|
description: Publish the verified main npm RC from develop, for example 2.25.0-rc.0
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -470,7 +474,7 @@ jobs:
|
|||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
if: ${{ github.repository == 'whyour/qinglong' && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop')) }}
|
||||||
needs: [build-alpine, build-debian, verify-npm-package]
|
needs: [build-alpine, build-debian, verify-npm-package]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -491,6 +495,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish npm package with OIDC
|
- name: Publish npm package with OIDC
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
NPM_RC_VERSION: ${{ inputs.npm_rc_version }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -503,11 +509,21 @@ jobs:
|
|||||||
metadata=$(tar -xOf "$archive" package/package.json)
|
metadata=$(tar -xOf "$archive" package/package.json)
|
||||||
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
|
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
|
||||||
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
|
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
|
||||||
|
if [[ "${GITHUB_EVENT_NAME:-}" == workflow_dispatch && "${GITHUB_REF:-}" == refs/heads/develop ]]; then
|
||||||
|
if [[ ! "${NPM_RC_VERSION:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ || "$version" != "${NPM_RC_VERSION:-}" ]]; then
|
||||||
|
echo '::error::Develop npm publication requires the exact requested RC archive version.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
|
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
|
||||||
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
||||||
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
||||||
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
||||||
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
if [[ "${GITHUB_EVENT_NAME:-}" == workflow_dispatch && "${GITHUB_REF:-}" == refs/heads/develop ]]; then
|
||||||
|
npm publish "./$archive" --ignore-scripts --access public --tag rc --registry=https://registry.npmjs.org
|
||||||
|
else
|
||||||
|
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo '::error::Could not check the published npm version; refusing to publish.'
|
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export default (app: Router) => {
|
|||||||
res.status(500).send({
|
res.status(500).send({
|
||||||
code: 500,
|
code: 500,
|
||||||
message: 'Health check failed',
|
message: 'Health check failed',
|
||||||
error: err.message,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -27,7 +27,7 @@ export default (app: Router) => {
|
|||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
name: Joi.string().optional().allow('').disallow('system'),
|
name: Joi.string().optional().allow('').disallow('system'),
|
||||||
scopes: Joi.array().items(Joi.string().required()),
|
scopes: Joi.array().items(Joi.string().required().invalid('apps')),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -47,7 +47,7 @@ export default (app: Router) => {
|
|||||||
celebrate({
|
celebrate({
|
||||||
body: Joi.object({
|
body: Joi.object({
|
||||||
name: Joi.string().optional().allow(''),
|
name: Joi.string().optional().allow(''),
|
||||||
scopes: Joi.array().items(Joi.string()),
|
scopes: Joi.array().items(Joi.string().invalid('apps')),
|
||||||
id: Joi.number().required(),
|
id: Joi.number().required(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -310,20 +310,6 @@ export default (app: Router) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
route.put(
|
|
||||||
'/notification/init',
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const logger: Logger = Container.get('logger');
|
|
||||||
try {
|
|
||||||
const userService = Container.get(UserService);
|
|
||||||
const result = await userService.updateNotificationMode(req.body);
|
|
||||||
res.send(result);
|
|
||||||
} catch (e) {
|
|
||||||
return next(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
route.put(
|
route.put(
|
||||||
'/avatar',
|
'/avatar',
|
||||||
upload.single('avatar'),
|
upload.single('avatar'),
|
||||||
|
|||||||
@@ -180,12 +180,10 @@ export default {
|
|||||||
'/api/user/two-factor/login',
|
'/api/user/two-factor/login',
|
||||||
'/api/system',
|
'/api/system',
|
||||||
'/api/user/init',
|
'/api/user/init',
|
||||||
'/api/user/notification/init',
|
|
||||||
'/open/user/login',
|
'/open/user/login',
|
||||||
'/open/user/two-factor/login',
|
'/open/user/two-factor/login',
|
||||||
'/open/system',
|
'/open/system',
|
||||||
'/open/user/init',
|
'/open/user/init',
|
||||||
'/open/user/notification/init',
|
|
||||||
],
|
],
|
||||||
versionFile,
|
versionFile,
|
||||||
lastVersionFile,
|
lastVersionFile,
|
||||||
|
|||||||
+19
-1
@@ -24,7 +24,25 @@ export interface AppToken {
|
|||||||
expiration: number;
|
expiration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system' | 'dashboard';
|
export type AppScope =
|
||||||
|
| 'crons'
|
||||||
|
| 'crons:read'
|
||||||
|
| 'subscriptions'
|
||||||
|
| 'subscriptions:read'
|
||||||
|
| 'envs'
|
||||||
|
| 'envs:read'
|
||||||
|
| 'configs'
|
||||||
|
| 'configs:read'
|
||||||
|
| 'scripts'
|
||||||
|
| 'scripts:read'
|
||||||
|
| 'logs'
|
||||||
|
| 'logs:read'
|
||||||
|
| 'dependencies'
|
||||||
|
| 'dependencies:read'
|
||||||
|
| 'dashboard'
|
||||||
|
| 'dashboard:read'
|
||||||
|
| 'system'
|
||||||
|
| 'apps';
|
||||||
|
|
||||||
export interface AppInstance extends Model<App, App>, App {}
|
export interface AppInstance extends Model<App, App>, App {}
|
||||||
export const AppModel = sequelize.define<AppInstance>('App', {
|
export const AppModel = sequelize.define<AppInstance>('App', {
|
||||||
|
|||||||
+73
-38
@@ -16,6 +16,7 @@ import { t } from '../shared/i18n';
|
|||||||
import { AppScope } from '../data/open';
|
import { AppScope } from '../data/open';
|
||||||
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||||
|
import Logger from './logger';
|
||||||
|
|
||||||
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
|
||||||
const setting = value?.trim();
|
const setting = value?.trim();
|
||||||
@@ -112,7 +113,43 @@ export default ({ app }: { app: Application }) => {
|
|||||||
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
|
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
|
||||||
const key = keyMatch && keyMatch[1];
|
const key = keyMatch && keyMatch[1];
|
||||||
|
|
||||||
if (!doc.scopes.includes(key as AppScope)) {
|
const isReadRequest =
|
||||||
|
(['GET', 'HEAD'].includes(req.method) &&
|
||||||
|
!(
|
||||||
|
key === 'crons' && /^\/open\/crons\/import\/?$/.test(pathLower)
|
||||||
|
)) ||
|
||||||
|
(['scripts', 'logs'].includes(key || '') &&
|
||||||
|
req.method === 'POST' &&
|
||||||
|
/^\/open\/(?:scripts|logs)\/download\/?$/.test(pathLower));
|
||||||
|
const hasNamespaceScope =
|
||||||
|
doc.scopes.includes(key as AppScope) ||
|
||||||
|
([
|
||||||
|
'crons',
|
||||||
|
'subscriptions',
|
||||||
|
'envs',
|
||||||
|
'configs',
|
||||||
|
'scripts',
|
||||||
|
'logs',
|
||||||
|
'dependencies',
|
||||||
|
'dashboard',
|
||||||
|
].includes(key || '') &&
|
||||||
|
doc.scopes.includes(`${key}:read` as AppScope) &&
|
||||||
|
isReadRequest);
|
||||||
|
if (!hasNamespaceScope) {
|
||||||
|
const err = new UnauthorizedError('credentials_bad_scheme', {
|
||||||
|
message: t('暂无权限'),
|
||||||
|
});
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The built-in system app still needs its local CLI maintenance API.
|
||||||
|
const appRouteAllowed =
|
||||||
|
key !== 'system' ||
|
||||||
|
doc.name === 'system' ||
|
||||||
|
((req.method === 'GET' || req.method === 'HEAD') &&
|
||||||
|
(pathLower === '/open/system' || pathLower === '/open/system/')) ||
|
||||||
|
(req.method === 'PUT' && pathLower === '/open/system/notify');
|
||||||
|
if (!appRouteAllowed) {
|
||||||
const err = new UnauthorizedError('credentials_bad_scheme', {
|
const err = new UnauthorizedError('credentials_bad_scheme', {
|
||||||
message: t('暂无权限'),
|
message: t('暂无权限'),
|
||||||
});
|
});
|
||||||
@@ -155,14 +192,7 @@ export default ({ app }: { app: Application }) => {
|
|||||||
|
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
const pathLower = req.path.toLowerCase();
|
const pathLower = req.path.toLowerCase();
|
||||||
if (
|
if (!['/api/user/init', '/open/user/init'].includes(pathLower)) {
|
||||||
![
|
|
||||||
'/api/user/init',
|
|
||||||
'/api/user/notification/init',
|
|
||||||
'/open/user/init',
|
|
||||||
'/open/user/notification/init',
|
|
||||||
].includes(pathLower)
|
|
||||||
) {
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
|
||||||
@@ -202,37 +232,23 @@ export default ({ app }: { app: Application }) => {
|
|||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
err: Error & { status: number },
|
err: Error & { status: number; code?: string },
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) => {
|
) => {
|
||||||
if (err.name === 'UnauthorizedError') {
|
if (err.name === 'UnauthorizedError') {
|
||||||
return res
|
let message = err.message;
|
||||||
.status(err.status)
|
if (!config.isDevelopment) {
|
||||||
.send({ code: 401, message: err.message })
|
if (err.code === 'credentials_bad_scheme') {
|
||||||
.end();
|
message = t('暂无权限');
|
||||||
}
|
} else if (err.code === 'credentials_required') {
|
||||||
return next(err);
|
message = t('请先登录');
|
||||||
},
|
} else {
|
||||||
);
|
message = t('Token 已失效');
|
||||||
|
}
|
||||||
app.use(
|
}
|
||||||
(
|
return res.status(401).send({ code: 401, message }).end();
|
||||||
err: Error & { errors: any[] },
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
) => {
|
|
||||||
if (err.name.includes('Sequelize')) {
|
|
||||||
return res
|
|
||||||
.status(500)
|
|
||||||
.send({
|
|
||||||
code: 400,
|
|
||||||
message: `${err.message}`,
|
|
||||||
errors: err.errors,
|
|
||||||
})
|
|
||||||
.end();
|
|
||||||
}
|
}
|
||||||
return next(err);
|
return next(err);
|
||||||
},
|
},
|
||||||
@@ -245,10 +261,29 @@ export default ({ app }: { app: Application }) => {
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) => {
|
) => {
|
||||||
res.status(err.status || 500);
|
const status =
|
||||||
|
Number.isInteger(err.status) && err.status >= 400 && err.status < 600
|
||||||
|
? err.status
|
||||||
|
: 500;
|
||||||
|
if (status >= 500) {
|
||||||
|
const route =
|
||||||
|
typeof req.route?.path === 'string' ? req.route.path : '?';
|
||||||
|
const errorName = /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(err.name)
|
||||||
|
? err.name
|
||||||
|
: 'Error';
|
||||||
|
Logger.error(
|
||||||
|
'HTTP request failed: status=%d method=%s route=%s type=%s',
|
||||||
|
status,
|
||||||
|
req.method,
|
||||||
|
route,
|
||||||
|
errorName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.status(status);
|
||||||
res.json({
|
res.json({
|
||||||
code: err.status || 500,
|
code: status,
|
||||||
message: err.message,
|
message:
|
||||||
|
!config.isDevelopment && status >= 500 ? t('未知错误') : err.message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -222,6 +222,9 @@ export default async () => {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
|
// Replace legacy task environment files before recovered tasks can run.
|
||||||
|
await envService.set_envs();
|
||||||
|
|
||||||
// 初始化保存一次ck和定时任务数据
|
// 初始化保存一次ck和定时任务数据
|
||||||
cronClient.readiness.configure(async () => {
|
cronClient.readiness.configure(async () => {
|
||||||
await cronService.autosave_crontab(true);
|
await cronService.autosave_crontab(true);
|
||||||
@@ -229,8 +232,6 @@ export default async () => {
|
|||||||
});
|
});
|
||||||
await cronClient.readiness.recover();
|
await cronClient.readiness.recover();
|
||||||
|
|
||||||
await envService.set_envs();
|
|
||||||
|
|
||||||
const authInfo = await userService.getAuthInfo();
|
const authInfo = await userService.getAuthInfo();
|
||||||
await shareStore.updateAuthInfo(authInfo);
|
await shareStore.updateAuthInfo(authInfo);
|
||||||
await openService.refreshApps();
|
await openService.refreshApps();
|
||||||
|
|||||||
+22
-3
@@ -14,12 +14,17 @@ import {
|
|||||||
} from '../data/env';
|
} from '../data/env';
|
||||||
import { writeFileWithLock } from '../shared/utils';
|
import { writeFileWithLock } from '../shared/utils';
|
||||||
import { sequelize } from '../data';
|
import { sequelize } from '../data';
|
||||||
|
import {
|
||||||
|
assertAllowedTaskEnvName,
|
||||||
|
isAllowedTaskEnvName,
|
||||||
|
} from '../shared/taskEnvName';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class EnvService {
|
export default class EnvService {
|
||||||
constructor(@Inject('logger') private logger: winston.Logger) { }
|
constructor(@Inject('logger') private logger: winston.Logger) { }
|
||||||
|
|
||||||
public async create(payloads: Env[]): Promise<Env[]> {
|
public async create(payloads: Env[]): Promise<Env[]> {
|
||||||
|
payloads.forEach((payload) => assertAllowedTaskEnvName(payload.name));
|
||||||
const envs = await this.envs();
|
const envs = await this.envs();
|
||||||
let position = initPosition;
|
let position = initPosition;
|
||||||
if (
|
if (
|
||||||
@@ -52,6 +57,7 @@ export default class EnvService {
|
|||||||
public async update(payload: Env): Promise<Env> {
|
public async update(payload: Env): Promise<Env> {
|
||||||
const doc = await this.getDb({ id: payload.id });
|
const doc = await this.getDb({ id: payload.id });
|
||||||
const tab = new Env({ ...doc, ...payload });
|
const tab = new Env({ ...doc, ...payload });
|
||||||
|
assertAllowedTaskEnvName(tab.name);
|
||||||
const newDoc = await this.updateDb(tab);
|
const newDoc = await this.updateDb(tab);
|
||||||
await this.set_envs();
|
await this.set_envs();
|
||||||
return newDoc;
|
return newDoc;
|
||||||
@@ -151,7 +157,10 @@ export default class EnvService {
|
|||||||
['position', 'DESC'],
|
['position', 'DESC'],
|
||||||
['createdAt', 'ASC'],
|
['createdAt', 'ASC'],
|
||||||
]);
|
]);
|
||||||
return result;
|
return result.map((env) => ({
|
||||||
|
...env,
|
||||||
|
securityBlocked: !isAllowedTaskEnvName(env.name),
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -182,11 +191,14 @@ export default class EnvService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async enabled(ids: number[]) {
|
public async enabled(ids: number[]) {
|
||||||
|
const docs = await EnvModel.findAll({ where: { id: ids } });
|
||||||
|
docs.forEach((doc) => assertAllowedTaskEnvName(doc.name));
|
||||||
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
|
await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } });
|
||||||
await this.set_envs();
|
await this.set_envs();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
|
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
|
||||||
|
assertAllowedTaskEnvName(name);
|
||||||
await EnvModel.update({ name }, { where: { id: ids } });
|
await EnvModel.update({ name }, { where: { id: ids } });
|
||||||
await this.set_envs();
|
await this.set_envs();
|
||||||
}
|
}
|
||||||
@@ -242,6 +254,13 @@ export default class EnvService {
|
|||||||
name: { [Op.not]: null },
|
name: { [Op.not]: null },
|
||||||
status: EnvStatus.normal,
|
status: EnvStatus.normal,
|
||||||
});
|
});
|
||||||
|
const invalid = envs.filter((env) => !isAllowedTaskEnvName(env.name));
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Skipped reserved environment variables in task files: %s',
|
||||||
|
JSON.stringify(invalid.map((env) => ({ id: env.id, name: env.name }))),
|
||||||
|
);
|
||||||
|
}
|
||||||
const groups = groupBy(envs, 'name');
|
const groups = groupBy(envs, 'name');
|
||||||
let env_string = '';
|
let env_string = '';
|
||||||
let js_env_string = '';
|
let js_env_string = '';
|
||||||
@@ -250,8 +269,8 @@ export default class EnvService {
|
|||||||
if (Object.prototype.hasOwnProperty.call(groups, key)) {
|
if (Object.prototype.hasOwnProperty.call(groups, key)) {
|
||||||
const group = groups[key];
|
const group = groups[key];
|
||||||
|
|
||||||
// 忽略不符合bash要求的环境变量名称
|
// Ignore invalid and reserved names, including legacy database rows.
|
||||||
if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) {
|
if (isAllowedTaskEnvName(key)) {
|
||||||
let value = group
|
let value = group
|
||||||
.map((x) => x.value)
|
.map((x) => x.value)
|
||||||
.join('&')
|
.join('&')
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ export default class LogService {
|
|||||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||||
|
|
||||||
public checkFilePath(filePath: string, fileName: string) {
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
|
return resolveFileAccess(config.logPath, [filePath || '', fileName], ['.tmp']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ export default class OpenService {
|
|||||||
|
|
||||||
private async updateDb(payload: Partial<App>): Promise<App> {
|
private async updateDb(payload: Partial<App>): Promise<App> {
|
||||||
return this.withAppsLock(async () => {
|
return this.withAppsLock(async () => {
|
||||||
|
const target = await AppModel.findOne({ where: { id: payload.id } });
|
||||||
|
if (target?.name === 'system' || payload.name === 'system') {
|
||||||
|
throw Object.assign(new Error(t('暂无权限')), { status: 403 });
|
||||||
|
}
|
||||||
await AppModel.update(payload, { where: { id: payload.id } });
|
await AppModel.update(payload, { where: { id: payload.id } });
|
||||||
const apps = await this.find({});
|
const apps = await this.find({});
|
||||||
await shareStore.updateApps(apps);
|
await shareStore.updateApps(apps);
|
||||||
@@ -109,6 +113,12 @@ export default class OpenService {
|
|||||||
|
|
||||||
public async remove(ids: number[]) {
|
public async remove(ids: number[]) {
|
||||||
return this.withAppsLock(async () => {
|
return this.withAppsLock(async () => {
|
||||||
|
const systemApp = await AppModel.findOne({
|
||||||
|
where: { id: ids, name: 'system' },
|
||||||
|
});
|
||||||
|
if (systemApp) {
|
||||||
|
throw Object.assign(new Error(t('暂无权限')), { status: 403 });
|
||||||
|
}
|
||||||
await AppModel.destroy({ where: { id: ids } });
|
await AppModel.destroy({ where: { id: ids } });
|
||||||
const apps = await this.find({});
|
const apps = await this.find({});
|
||||||
await shareStore.updateApps(apps);
|
await shareStore.updateApps(apps);
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ export function resolveFileAccess(
|
|||||||
const resolvedRoot = path.resolve(root);
|
const resolvedRoot = path.resolve(root);
|
||||||
const target = path.resolve(resolvedRoot, ...parts);
|
const target = path.resolve(resolvedRoot, ...parts);
|
||||||
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
|
if (target === resolvedRoot || !isWithin(resolvedRoot, target)) return '';
|
||||||
|
// File systems such as default APFS can resolve a differently cased spelling
|
||||||
|
// of the same entry. Check every protected name without case sensitivity.
|
||||||
|
const blockedNames = new Set(blacklist.map((part) => part.toLowerCase()));
|
||||||
const isBlocked = (relative: string) =>
|
const isBlocked = (relative: string) =>
|
||||||
relative.split(path.sep).some((part) => blacklist.includes(part));
|
relative.split(path.sep).some((part) => blockedNames.has(part.toLowerCase()));
|
||||||
if (isBlocked(path.relative(resolvedRoot, target))) return '';
|
if (isBlocked(path.relative(resolvedRoot, target))) return '';
|
||||||
try {
|
try {
|
||||||
const realRoot = fs.realpathSync(resolvedRoot);
|
const realRoot = fs.realpathSync(resolvedRoot);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const messages: Record<string, Record<string, string>> = {
|
|||||||
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
|
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
|
||||||
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
|
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
|
||||||
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
|
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
|
||||||
|
环境变量名称不可使用: 'Environment variable name is reserved or invalid',
|
||||||
系统配置不存在: 'System configuration does not exist',
|
系统配置不存在: 'System configuration does not exist',
|
||||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
|
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
|
||||||
'Version history only supports UTF-8 text scripts up to 1 MiB',
|
'Version history only supports UTF-8 text scripts up to 1 MiB',
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { t } from './i18n';
|
||||||
|
|
||||||
|
// These names control the task runner or the interpreters it starts. Values
|
||||||
|
// managed through the environment API must remain task data, not executable
|
||||||
|
// configuration.
|
||||||
|
const reservedNames = new Set([
|
||||||
|
'TASK_BEFORE',
|
||||||
|
'TASK_AFTER',
|
||||||
|
'WORK_DIR',
|
||||||
|
'TASK_SHELL_PARAMS',
|
||||||
|
'SCRIPT_PARAMS',
|
||||||
|
'WHICH_PROGRAM',
|
||||||
|
'ISJSORPYTHONFILE',
|
||||||
|
'EXPORTED_VARIABLES',
|
||||||
|
'FOUND_DOUBLE_DASH',
|
||||||
|
'LOG_DIR',
|
||||||
|
'LOG_PATH',
|
||||||
|
'ID',
|
||||||
|
'BEGIN_TIMESTAMP',
|
||||||
|
'TIME_FORMAT',
|
||||||
|
'MTIME_FORMAT',
|
||||||
|
'IS_MACOS',
|
||||||
|
'RANDOMDELAY',
|
||||||
|
'RANDOMDELAYFILEEXTENSIONS',
|
||||||
|
'RANDOMDELAYIGNOREDMINUTES',
|
||||||
|
'COMMAND_TIMEOUT_TIME',
|
||||||
|
'SET_U_ON',
|
||||||
|
'_TASK_EXIT_CODE',
|
||||||
|
'PATH',
|
||||||
|
'HOME',
|
||||||
|
'SHELL',
|
||||||
|
'ENV',
|
||||||
|
'BASH_ENV',
|
||||||
|
'BASHOPTS',
|
||||||
|
'SHELLOPTS',
|
||||||
|
'IFS',
|
||||||
|
'CDPATH',
|
||||||
|
'GLOBIGNORE',
|
||||||
|
'PROMPT_COMMAND',
|
||||||
|
'PS4',
|
||||||
|
'NODE_OPTIONS',
|
||||||
|
'NODE_PATH',
|
||||||
|
'PREV_NODE_OPTIONS',
|
||||||
|
'PREV_NODE_PATH',
|
||||||
|
'PREV_PYTHONPATH',
|
||||||
|
'GIT_SSH',
|
||||||
|
'GIT_SSH_COMMAND',
|
||||||
|
'GIT_ASKPASS',
|
||||||
|
'GIT_EXEC_PATH',
|
||||||
|
'GIT_TEMPLATE_DIR',
|
||||||
|
'SSH_ASKPASS',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isAllowedTaskEnvName(name: unknown): name is string {
|
||||||
|
if (typeof name !== 'string' || !/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upper = name.toUpperCase();
|
||||||
|
return (
|
||||||
|
!reservedNames.has(upper) &&
|
||||||
|
!upper.startsWith('BASH_') &&
|
||||||
|
!upper.startsWith('QL') &&
|
||||||
|
!upper.startsWith('FILE_') &&
|
||||||
|
!upper.startsWith('DIR_') &&
|
||||||
|
!upper.startsWith('LD_') &&
|
||||||
|
!upper.startsWith('DYLD_') &&
|
||||||
|
!upper.startsWith('PYTHON') &&
|
||||||
|
!upper.startsWith('GIT_CONFIG_')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertAllowedTaskEnvName(name: unknown): asserts name is string {
|
||||||
|
if (!isAllowedTaskEnvName(name)) {
|
||||||
|
throw Object.assign(new Error(t('环境变量名称不可使用')), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -48,7 +48,7 @@ The CLI also supports task/subscription CRUD, application and secret management,
|
|||||||
```sh
|
```sh
|
||||||
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
|
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
|
||||||
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
|
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
|
||||||
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
|
ql app create --name agent --scopes crons,subscriptions,envs --show-secrets --json
|
||||||
ql env create --data @envs.json --json
|
ql env create --data @envs.json --json
|
||||||
ql api request PUT /open/crons/run --data '[12,13]' --json
|
ql api request PUT /open/crons/run --data '[12,13]' --json
|
||||||
```
|
```
|
||||||
@@ -68,7 +68,7 @@ Protected requests use `Authorization: Bearer <token>`. Two credential sources a
|
|||||||
|
|
||||||
### Application login
|
### Application login
|
||||||
|
|
||||||
Create a dedicated panel application with the required scopes, such as crons, subscriptions or envs. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
|
Create a dedicated panel application with the required scopes. Existing scopes retain their original permissions. For read-only access, use `crons:read`, `subscriptions:read`, `envs:read`, `configs:read`, `scripts:read`, `dependencies:read`, `logs:read`, or `dashboard:read`. Script and log downloads use POST but are available to their read-only scopes. `GET /crons/import` writes tasks and is excluded from `crons:read`. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ql login --url https://ql.example.com
|
ql login --url https://ql.example.com
|
||||||
|
|||||||
+2
-2
@@ -48,7 +48,7 @@ ql auth logout --json
|
|||||||
```sh
|
```sh
|
||||||
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
|
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
|
||||||
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
|
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
|
||||||
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
|
ql app create --name agent --scopes crons,subscriptions,envs --show-secrets --json
|
||||||
ql env create --data @envs.json --json
|
ql env create --data @envs.json --json
|
||||||
ql api request PUT /open/crons/run --data '[12,13]' --json
|
ql api request PUT /open/crons/run --data '[12,13]' --json
|
||||||
```
|
```
|
||||||
@@ -68,7 +68,7 @@ ql api request PUT /open/crons/run --data '[12,13]' --json
|
|||||||
|
|
||||||
### 应用凭据登录
|
### 应用凭据登录
|
||||||
|
|
||||||
在面板创建专用应用,按需授予 crons、subscriptions、envs 等权限。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
|
在面板创建专用应用,按需授予 crons、subscriptions 等权限。旧 scope 保留原有权限;仅读取时可选用 `crons:read`、`subscriptions:read`、`envs:read`、`configs:read`、`scripts:read`、`dependencies:read`、`logs:read`、`dashboard:read`。脚本和日志下载虽使用 POST,也允许对应只读权限;`GET /crons/import` 会写入任务,不属于只读操作。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ql login --url https://ql.example.com
|
ql login --url https://ql.example.com
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ The catalogue covers 151 active routes in the current backend. `ql api routes --
|
|||||||
|
|
||||||
## 认证与权限 / Authentication and permissions
|
## 认证与权限 / Authentication and permissions
|
||||||
|
|
||||||
常规使用 `ql login` 的应用凭据。scope 是 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。应用管理需要 apps;user、health、update 等路由同样接受服务端权限校验。面板 UI 当前没有列出全部这些 scope,不能把路由存在理解成现有应用已获授权。
|
常规使用 `ql login` 的应用凭据。scope 通常对应 /open 后首个路径段:crons、subscriptions、envs、configs、scripts、logs、dependencies、system、dashboard 等。旧 scope 保留原有权限;上述资源除 system 外可选用对应的 `:read` scope。`crons:read` 不包含会写入任务的 `GET /crons/import`;`scripts:read`、`logs:read` 包含使用 POST 的下载,不包含写入、删除或执行。`dashboard:read` 不允许 POST 统计上报。外部应用的 `system` scope 仅允许读取系统状态和发送通知。应用管理需要 apps;`GET /apps` 会返回 Client Secret,因此不提供 `apps:read`。user、health、update 等路由同样接受服务端权限校验。路由存在不代表现有应用已获授权。
|
||||||
|
|
||||||
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用 apps 权限的应用或授权面板会话;QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
|
认证步骤、环境变量优先级、应用登录、面板会话与双因素验证、退出行为,统一见 [认证参考](panel.md#authentication)。应用管理可用已有 apps 权限的应用或授权面板会话;后端创建和更新应用时拒绝授予 apps 权限。QL_URL/QL_ACCESS_TOKEN 不保存、不刷新,不自动退回应用配置。
|
||||||
|
|
||||||
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts apps-scoped credentials or an authorized owner session. Direct tokens are not saved/refreshed and never fall back to application configuration.
|
See the [authentication reference](panel.md#authentication) for application login, direct-token precedence, owner sessions/2FA and logout. App management accepts existing apps-scoped credentials or an authorized owner session; the backend rejects granting apps scope when creating or updating an application. Direct tokens are not saved/refreshed and never fall back to application configuration.
|
||||||
|
|
||||||
|
Existing scopes retain their original access. Use the resource's `:read` scope for read-only access to crons, subscriptions, envs, configs, scripts, dependencies, logs, or dashboard. `crons:read` excludes the mutating `GET /crons/import`; `scripts:read` and `logs:read` include downloads (POST), but exclude writes, deletion, and execution. `dashboard:read` excludes statistics reporting (POST). External `system` applications may only read system status and send notifications. `GET /apps` returns Client Secrets, so there is no `apps:read` scope.
|
||||||
|
|
||||||
`auth status --scope <scope>` tests a representative read; update has no read endpoint and is not offered by status. This does not prove permission for every write. `app list/create/update/reset-secret` 默认隐藏 client_secret/tokens;只有明确需要凭据时才加 --show-secrets,并避免把输出记录到共享日志。其他资源/通用请求可能含环境值、配置、脚本、订阅凭据和会话,按敏感数据处理。
|
`auth status --scope <scope>` tests a representative read; update has no read endpoint and is not offered by status. This does not prove permission for every write. `app list/create/update/reset-secret` 默认隐藏 client_secret/tokens;只有明确需要凭据时才加 --show-secrets,并避免把输出记录到共享日志。其他资源/通用请求可能含环境值、配置、脚本、订阅凭据和会话,按敏感数据处理。
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ ql script get --query '{"file":"demo.js","path":""}' --json
|
|||||||
ql script history-list --query '{"filename":"demo.js","path":""}' --json
|
ql script history-list --query '{"filename":"demo.js","path":""}' --json
|
||||||
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
|
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
|
||||||
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
|
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
|
||||||
|
# Requires the full configs scope or a panel session token.
|
||||||
ql config save --data '{"name":"example.sh","content":"# example"}' --json
|
ql config save --data '{"name":"example.sh","content":"# example"}' --json
|
||||||
ql config get --query '{"path":"example.sh"}' --json
|
ql config get --query '{"path":"example.sh"}' --json
|
||||||
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
|
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
|
||||||
@@ -221,7 +224,6 @@ System/user operations below execute remotely through the panel API. They do not
|
|||||||
| `ql user notification-get ` | GET | `user/notification` | --query |
|
| `ql user notification-get ` | GET | `user/notification` | --query |
|
||||||
| `ql user notification-set ` | PUT | `user/notification` | --data, --query |
|
| `ql user notification-set ` | PUT | `user/notification` | --data, --query |
|
||||||
| `ql user init ` | PUT | `user/init` | --data, --query |
|
| `ql user init ` | PUT | `user/init` | --data, --query |
|
||||||
| `ql user notification-init ` | PUT | `user/notification/init` | --data, --query |
|
|
||||||
| `ql user avatar ` | PUT | `user/avatar` | --data, --file (avatar), --query |
|
| `ql user avatar ` | PUT | `user/avatar` | --data, --file (avatar), --query |
|
||||||
| `ql system apply-reload ` | PUT | `update/reload` | --query |
|
| `ql system apply-reload ` | PUT | `update/reload` | --query |
|
||||||
| `ql system apply-system ` | PUT | `update/system` | --query |
|
| `ql system apply-system ` | PUT | `update/system` | --query |
|
||||||
|
|||||||
@@ -608,13 +608,6 @@ export const openOperations: readonly OpenOperation[] = [
|
|||||||
body: 'json',
|
body: 'json',
|
||||||
anonymous: true,
|
anonymous: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'user notification-init',
|
|
||||||
method: 'PUT',
|
|
||||||
path: 'user/notification/init',
|
|
||||||
body: 'json',
|
|
||||||
anonymous: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'user avatar',
|
name: 'user avatar',
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ export async function auth(
|
|||||||
{
|
{
|
||||||
crons: 'crons?page=1&size=1',
|
crons: 'crons?page=1&size=1',
|
||||||
configs: 'configs/files',
|
configs: 'configs/files',
|
||||||
|
'envs:read': 'envs',
|
||||||
|
'configs:read': 'configs/files',
|
||||||
|
'scripts:read': 'scripts',
|
||||||
|
'crons:read': 'crons?page=1&size=1',
|
||||||
|
'subscriptions:read': 'subscriptions',
|
||||||
|
'dependencies:read': 'dependencies',
|
||||||
|
'logs:read': 'logs',
|
||||||
|
'dashboard:read': 'dashboard/overview',
|
||||||
dashboard: 'dashboard/overview',
|
dashboard: 'dashboard/overview',
|
||||||
} as Record<string, string>
|
} as Record<string, string>
|
||||||
)[scope] ?? scope,
|
)[scope] ?? scope,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ export const commands: readonly CommandSpec[] = [
|
|||||||
default: 'crons',
|
default: 'crons',
|
||||||
choices: [
|
choices: [
|
||||||
...new Set(openOperations.map((op) => op.path.split('/')[0]!)),
|
...new Set(openOperations.map((op) => op.path.split('/')[0]!)),
|
||||||
|
'envs:read',
|
||||||
|
'configs:read',
|
||||||
|
'scripts:read',
|
||||||
|
'crons:read',
|
||||||
|
'subscriptions:read',
|
||||||
|
'dependencies:read',
|
||||||
|
'logs:read',
|
||||||
|
'dashboard:read',
|
||||||
].filter((scope) => !['auth', 'update'].includes(scope)),
|
].filter((scope) => !['auth', 'update'].includes(scope)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ const fs = require('node:fs/promises');
|
|||||||
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
|
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
|
||||||
return JSON.parse(result.stdout).data;
|
return JSON.parse(result.stdout).data;
|
||||||
};
|
};
|
||||||
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps', '--show-secrets'], true);
|
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard', '--show-secrets'], true);
|
||||||
assert.ok(app.client_id && app.client_secret);
|
assert.ok(app.client_id && app.client_secret);
|
||||||
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
|
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
|
||||||
cli(['auth', 'status', '--scope', 'apps']);
|
cli(['auth', 'status', '--scope', 'crons']);
|
||||||
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps']);
|
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard'], true);
|
||||||
assert.ok(cli(['app', 'list']).every(item => !('client_secret' in item) && !('tokens' in item)));
|
assert.ok(cli(['app', 'list'], true).every(item => !('client_secret' in item) && !('tokens' in item)));
|
||||||
const task = cli(['task', 'create', '--name', 'cli-task', '--command', 'echo OPENAPI_FIXTURE', '--schedule', '0 0 * * *']);
|
const task = cli(['task', 'create', '--name', 'cli-task', '--command', 'echo OPENAPI_FIXTURE', '--schedule', '0 0 * * *']);
|
||||||
cli(['task', 'update', String(task.id), '--name', 'cli-task-updated', '--command', 'echo OPENAPI_UPDATED', '--schedule', '0 1 * * *']);
|
cli(['task', 'update', String(task.id), '--name', 'cli-task-updated', '--command', 'echo OPENAPI_UPDATED', '--schedule', '0 1 * * *']);
|
||||||
cli(['task', 'disable', String(task.id)]); cli(['task', 'enable', String(task.id)]);
|
cli(['task', 'disable', String(task.id)]); cli(['task', 'enable', String(task.id)]);
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ async function fixture(t) {
|
|||||||
code: 200,
|
code: 200,
|
||||||
data: { data: [{ id: 12, name: 'example' }], total: 1 },
|
data: { data: [{ id: 12, name: 'example' }], total: 1 },
|
||||||
});
|
});
|
||||||
|
else if (
|
||||||
|
['/panel/open/envs', '/panel/open/configs/files', '/panel/open/scripts',
|
||||||
|
'/panel/open/subscriptions', '/panel/open/dependencies',
|
||||||
|
'/panel/open/logs', '/panel/open/dashboard/overview']
|
||||||
|
.includes(url.pathname)
|
||||||
|
)
|
||||||
|
json({ code: 200, data: [] });
|
||||||
else if (
|
else if (
|
||||||
['/panel/open/crons/run', '/panel/open/crons/stop'].includes(
|
['/panel/open/crons/run', '/panel/open/crons/stop'].includes(
|
||||||
url.pathname,
|
url.pathname,
|
||||||
@@ -155,6 +162,21 @@ test('compiled entry: login aliases, private storage, status, refresh and local
|
|||||||
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
|
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
|
||||||
assert.doesNotMatch(status.out, /test-secret|test-token/);
|
assert.doesNotMatch(status.out, /test-secret|test-token/);
|
||||||
assert.equal(f.requests.at(-1).url.pathname, '/panel/open/crons');
|
assert.equal(f.requests.at(-1).url.pathname, '/panel/open/crons');
|
||||||
|
for (const [scope, pathname] of [
|
||||||
|
['envs:read', '/panel/open/envs'],
|
||||||
|
['configs:read', '/panel/open/configs/files'],
|
||||||
|
['scripts:read', '/panel/open/scripts'],
|
||||||
|
['crons:read', '/panel/open/crons'],
|
||||||
|
['subscriptions:read', '/panel/open/subscriptions'],
|
||||||
|
['dependencies:read', '/panel/open/dependencies'],
|
||||||
|
['logs:read', '/panel/open/logs'],
|
||||||
|
['dashboard:read', '/panel/open/dashboard/overview'],
|
||||||
|
]) {
|
||||||
|
const checked = await f.invoke(['auth', 'status', '--scope', scope, '--json']);
|
||||||
|
assert.equal(checked.code, 0, checked.err);
|
||||||
|
assert.equal(JSON.parse(checked.out).data.scopeChecked, scope);
|
||||||
|
assert.equal(f.requests.at(-1).url.pathname, pathname);
|
||||||
|
}
|
||||||
const config = JSON.parse(fs.readFileSync(f.file));
|
const config = JSON.parse(fs.readFileSync(f.file));
|
||||||
config.expiration = 1;
|
config.expiration = 1;
|
||||||
fs.writeFileSync(f.file, JSON.stringify(config));
|
fs.writeFileSync(f.file, JSON.stringify(config));
|
||||||
|
|||||||
+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 \
|
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
|
||||||
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
|
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
|
||||||
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
|
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
|
||||||
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages:${QL_DIR}/python-base/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
||||||
|
|
||||||
RUN pip3 install --prefix ${PYTHON_HOME} requests
|
RUN pip3 install --ignore-installed --prefix ${QL_DIR}/python-base requests
|
||||||
|
|
||||||
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||||
|
|
||||||
|
|||||||
@@ -112,9 +112,9 @@ ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
|||||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
|
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \
|
||||||
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
|
NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules \
|
||||||
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
|
PIP_CACHE_DIR=${PYTHON_HOME}/pip \
|
||||||
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages:${QL_DIR}/python-base/lib/python${PYTHON_SHORT_VERSION}/site-packages
|
||||||
|
|
||||||
RUN pip3 install --prefix ${PYTHON_HOME} requests
|
RUN pip3 install --ignore-installed --prefix ${QL_DIR}/python-base requests
|
||||||
|
|
||||||
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
COPY --chown=qinglong:qinglong --from=builder /tmp/build/node_modules/. /ql/node_modules/
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"name": "@whyour/qinglong",
|
||||||
"packageManager": "pnpm@8.3.1",
|
"packageManager": "pnpm@8.3.1",
|
||||||
"version": "2.24.0",
|
"version": "2.25.0-rc.0",
|
||||||
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+78
-30
@@ -18,6 +18,11 @@ from email.utils import formataddr
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
# 单次 HTTP 请求的默认超时(秒),避免推送服务无响应时永久阻塞
|
||||||
|
REQUEST_TIMEOUT = 10
|
||||||
|
# 等待推送线程结束的最长时间(秒),超时后跳过并继续退出
|
||||||
|
NOTIFY_JOIN_TIMEOUT = 30
|
||||||
|
|
||||||
# 原先的 print 函数和主线程的锁
|
# 原先的 print 函数和主线程的锁
|
||||||
_print = print
|
_print = print
|
||||||
mutex = threading.Lock()
|
mutex = threading.Lock()
|
||||||
@@ -199,7 +204,7 @@ def bark(title: str, content: str) -> Optional[bool]:
|
|||||||
data[bark_params.get(pair[0])] = pair[1]
|
data[bark_params.get(pair[0])] = pair[1]
|
||||||
headers = {"Content-Type": "application/json;charset=utf-8"}
|
headers = {"Content-Type": "application/json;charset=utf-8"}
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url=url, data=json.dumps(data), headers=headers, timeout=15
|
url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
@@ -241,7 +246,7 @@ def dingding_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
headers = {"Content-Type": "application/json;charset=utf-8"}
|
headers = {"Content-Type": "application/json;charset=utf-8"}
|
||||||
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
|
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url=url, data=json.dumps(data), headers=headers, timeout=15
|
url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
if not response["errcode"]:
|
if not response["errcode"]:
|
||||||
@@ -278,7 +283,9 @@ def feishu_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
data["timestamp"] = timestamp
|
data["timestamp"] = timestamp
|
||||||
data["sign"] = sign
|
data["sign"] = sign
|
||||||
|
|
||||||
response = requests.post(url, data=json.dumps(data)).json()
|
response = requests.post(
|
||||||
|
url, data=json.dumps(data), timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response.get("StatusCode") == 0 or response.get("code") == 0:
|
if response.get("StatusCode") == 0 or response.get("code") == 0:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -299,7 +306,7 @@ def go_cqhttp(title: str, content: str) -> Optional[bool]:
|
|||||||
print("go-cqhttp 服务启动")
|
print("go-cqhttp 服务启动")
|
||||||
|
|
||||||
url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}'
|
url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}'
|
||||||
response = requests.get(url).json()
|
response = requests.get(url, timeout=REQUEST_TIMEOUT).json()
|
||||||
|
|
||||||
if response["status"] == "ok":
|
if response["status"] == "ok":
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -325,7 +332,7 @@ def gotify(title: str, content: str) -> Optional[bool]:
|
|||||||
"message": content,
|
"message": content,
|
||||||
"priority": push_config.get("GOTIFY_PRIORITY"),
|
"priority": push_config.get("GOTIFY_PRIORITY"),
|
||||||
}
|
}
|
||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
|
||||||
|
|
||||||
if response.get("id"):
|
if response.get("id"):
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -348,7 +355,9 @@ def iGot(title: str, content: str) -> Optional[bool]:
|
|||||||
url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}'
|
url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}'
|
||||||
data = {"title": title, "content": content}
|
data = {"title": title, "content": content}
|
||||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
response = requests.post(url, data=data, headers=headers).json()
|
response = requests.post(
|
||||||
|
url, data=data, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response["ret"] == 0:
|
if response["ret"] == 0:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -377,7 +386,7 @@ def serverJ(title: str, content: str) -> Optional[bool]:
|
|||||||
else:
|
else:
|
||||||
url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send'
|
url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send'
|
||||||
|
|
||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
|
||||||
|
|
||||||
if response.get("errno") == 0 or response.get("code") == 0:
|
if response.get("errno") == 0 or response.get("code") == 0:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -406,7 +415,7 @@ def pushdeer(title: str, content: str) -> Optional[bool]:
|
|||||||
if push_config.get("DEER_URL"):
|
if push_config.get("DEER_URL"):
|
||||||
url = push_config.get("DEER_URL")
|
url = push_config.get("DEER_URL")
|
||||||
|
|
||||||
response = requests.post(url, data=data).json()
|
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
|
||||||
|
|
||||||
if len(response.get("content").get("result")) > 0:
|
if len(response.get("content").get("result")) > 0:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -427,7 +436,7 @@ def chat(title: str, content: str) -> Optional[bool]:
|
|||||||
print("chat 服务启动")
|
print("chat 服务启动")
|
||||||
data = "payload=" + json.dumps({"text": title + "\n" + content})
|
data = "payload=" + json.dumps({"text": title + "\n" + content})
|
||||||
url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN")
|
url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN")
|
||||||
response = requests.post(url, data=data)
|
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -461,7 +470,9 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
}
|
}
|
||||||
body = json.dumps(data).encode(encoding="utf-8")
|
body = json.dumps(data).encode(encoding="utf-8")
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
response = requests.post(url=url, data=body, headers=headers).json()
|
response = requests.post(
|
||||||
|
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
code = response["code"]
|
code = response["code"]
|
||||||
if code == 200:
|
if code == 200:
|
||||||
@@ -477,7 +488,9 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
else:
|
else:
|
||||||
url_old = "http://pushplus.hxtrip.com/send"
|
url_old = "http://pushplus.hxtrip.com/send"
|
||||||
headers["Accept"] = "application/json"
|
headers["Accept"] = "application/json"
|
||||||
response = requests.post(url=url_old, data=body, headers=headers).json()
|
response = requests.post(
|
||||||
|
url=url_old, data=body, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -513,7 +526,9 @@ def weplus_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
}
|
}
|
||||||
body = json.dumps(data).encode(encoding="utf-8")
|
body = json.dumps(data).encode(encoding="utf-8")
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
response = requests.post(url=url, data=body, headers=headers).json()
|
response = requests.post(
|
||||||
|
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response["code"] == 200:
|
if response["code"] == 200:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -535,7 +550,9 @@ def qmsg_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
|
|
||||||
url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}'
|
url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}'
|
||||||
payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")}
|
payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")}
|
||||||
response = requests.post(url=url, params=payload).json()
|
response = requests.post(
|
||||||
|
url=url, params=payload, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response["code"] == 0:
|
if response["code"] == 0:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -599,7 +616,7 @@ class WeCom:
|
|||||||
"corpid": self.CORPID,
|
"corpid": self.CORPID,
|
||||||
"corpsecret": self.CORPSECRET,
|
"corpsecret": self.CORPSECRET,
|
||||||
}
|
}
|
||||||
req = requests.post(url, params=values)
|
req = requests.post(url, params=values, timeout=REQUEST_TIMEOUT)
|
||||||
data = json.loads(req.text)
|
data = json.loads(req.text)
|
||||||
return data["access_token"]
|
return data["access_token"]
|
||||||
|
|
||||||
@@ -615,7 +632,9 @@ class WeCom:
|
|||||||
"safe": "0",
|
"safe": "0",
|
||||||
}
|
}
|
||||||
send_msges = bytes(json.dumps(send_values), "utf-8")
|
send_msges = bytes(json.dumps(send_values), "utf-8")
|
||||||
respone = requests.post(send_url, send_msges)
|
respone = requests.post(
|
||||||
|
send_url, send_msges, timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
respone = respone.json()
|
respone = respone.json()
|
||||||
return respone["errmsg"]
|
return respone["errmsg"]
|
||||||
|
|
||||||
@@ -641,7 +660,9 @@ class WeCom:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
send_msges = bytes(json.dumps(send_values), "utf-8")
|
send_msges = bytes(json.dumps(send_values), "utf-8")
|
||||||
respone = requests.post(send_url, send_msges)
|
respone = requests.post(
|
||||||
|
send_url, send_msges, timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
respone = respone.json()
|
respone = respone.json()
|
||||||
return respone["errmsg"]
|
return respone["errmsg"]
|
||||||
|
|
||||||
@@ -663,7 +684,7 @@ def wecom_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
headers = {"Content-Type": "application/json;charset=utf-8"}
|
headers = {"Content-Type": "application/json;charset=utf-8"}
|
||||||
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
|
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url=url, data=json.dumps(data), headers=headers, timeout=15
|
url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["errcode"] == 0:
|
if response["errcode"] == 0:
|
||||||
@@ -711,7 +732,11 @@ def telegram_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
)
|
)
|
||||||
proxies = {"http": proxyStr, "https": proxyStr}
|
proxies = {"http": proxyStr, "https": proxyStr}
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url=url, headers=headers, params=payload, proxies=proxies
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
params=payload,
|
||||||
|
proxies=proxies,
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
if response["ok"]:
|
if response["ok"]:
|
||||||
@@ -752,7 +777,9 @@ def aibotk(title: str, content: str) -> Optional[bool]:
|
|||||||
}
|
}
|
||||||
body = json.dumps(data).encode(encoding="utf-8")
|
body = json.dumps(data).encode(encoding="utf-8")
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
response = requests.post(url=url, data=body, headers=headers).json()
|
response = requests.post(
|
||||||
|
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
print(response)
|
print(response)
|
||||||
if response["code"] == 0:
|
if response["code"] == 0:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -836,7 +863,7 @@ def pushme(title: str, content: str) -> Optional[bool]:
|
|||||||
"date": push_config.get("date") if push_config.get("date") else "",
|
"date": push_config.get("date") if push_config.get("date") else "",
|
||||||
"type": push_config.get("type") if push_config.get("type") else "",
|
"type": push_config.get("type") if push_config.get("type") else "",
|
||||||
}
|
}
|
||||||
response = requests.post(url, data=data)
|
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
if response.status_code == 200 and response.text == "success":
|
if response.status_code == 200 and response.text == "success":
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -883,7 +910,9 @@ def chronocat(title: str, content: str) -> Optional[bool]:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
response = requests.post(url, headers=headers, data=json.dumps(data))
|
response = requests.post(
|
||||||
|
url, headers=headers, data=json.dumps(data), timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
if chat_type == 1:
|
if chat_type == 1:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -936,7 +965,9 @@ def ntfy(title: str, content: str) -> Optional[bool]:
|
|||||||
headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS"))
|
headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS"))
|
||||||
|
|
||||||
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
|
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
|
||||||
response = requests.post(url, data=data, headers=headers)
|
response = requests.post(
|
||||||
|
url, data=data, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
if response.status_code == 200: # 使用 response.status_code 进行检查
|
if response.status_code == 200: # 使用 response.status_code 进行检查
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
print("Ntfy 推送成功!")
|
print("Ntfy 推送成功!")
|
||||||
@@ -995,7 +1026,9 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(
|
||||||
|
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response.get("code") == 1000:
|
if response.get("code") == 1000:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -1046,7 +1079,9 @@ def wxpusher_spt(title: str, content: str) -> Optional[bool]:
|
|||||||
data["sptList"] = spts
|
data["sptList"] = spts
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(
|
||||||
|
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response.get("code") == 1000:
|
if response.get("code") == 1000:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -1080,7 +1115,9 @@ def wpush(title: str, content: str) -> Optional[bool]:
|
|||||||
data["topic_code"] = push_config.get("WPUSH_TOPIC_CODE")
|
data["topic_code"] = push_config.get("WPUSH_TOPIC_CODE")
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
|
response = requests.post(
|
||||||
|
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response.get("code") == 0:
|
if response.get("code") == 0:
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -1121,7 +1158,9 @@ def openilink(title: str, content: str) -> Optional[bool]:
|
|||||||
if push_config.get("OPENILINK_CONTEXT_TOKEN"):
|
if push_config.get("OPENILINK_CONTEXT_TOKEN"):
|
||||||
data["context_token"] = push_config.get("OPENILINK_CONTEXT_TOKEN")
|
data["context_token"] = push_config.get("OPENILINK_CONTEXT_TOKEN")
|
||||||
|
|
||||||
response = requests.post(url=url, json=data, headers=headers).json()
|
response = requests.post(
|
||||||
|
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
|
||||||
|
).json()
|
||||||
|
|
||||||
if response.get("ok"):
|
if response.get("ok"):
|
||||||
audit_results.append(True)
|
audit_results.append(True)
|
||||||
@@ -1215,7 +1254,7 @@ def custom_notify(title: str, content: str) -> Optional[bool]:
|
|||||||
"$title", urllib.parse.quote_plus(title)
|
"$title", urllib.parse.quote_plus(title)
|
||||||
).replace("$content", urllib.parse.quote_plus(content))
|
).replace("$content", urllib.parse.quote_plus(content))
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body
|
method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=REQUEST_TIMEOUT, data=body
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -1233,7 +1272,7 @@ def one() -> str:
|
|||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
url = "https://v1.hitokoto.cn/"
|
url = "https://v1.hitokoto.cn/"
|
||||||
res = requests.get(url).json()
|
res = requests.get(url, timeout=REQUEST_TIMEOUT).json()
|
||||||
return res["hitokoto"] + " ----" + res["from"]
|
return res["hitokoto"] + " ----" + res["from"]
|
||||||
|
|
||||||
|
|
||||||
@@ -1374,11 +1413,20 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
|
|||||||
notify_function = add_notify_function()
|
notify_function = add_notify_function()
|
||||||
results = []
|
results = []
|
||||||
ts = [
|
ts = [
|
||||||
threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__)
|
threading.Thread(
|
||||||
|
target=audited_notification,
|
||||||
|
args=(mode, title, content, results),
|
||||||
|
name=mode.__name__,
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
for mode in notify_function
|
for mode in notify_function
|
||||||
]
|
]
|
||||||
[t.start() for t in ts]
|
[t.start() for t in ts]
|
||||||
[t.join() for t in ts]
|
for t in ts:
|
||||||
|
t.join(timeout=NOTIFY_JOIN_TIMEOUT)
|
||||||
|
if t.is_alive():
|
||||||
|
results.append((t.name, False))
|
||||||
|
print(f"{t.name} 推送超时(>{NOTIFY_JOIN_TIMEOUT}s),已跳过,任务继续结束")
|
||||||
report_notifications(results, title, content)
|
report_notifications(results, title, content)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
"代理端口": "Proxy Port",
|
"代理端口": "Proxy Port",
|
||||||
"以后再说": "Later",
|
"以后再说": "Later",
|
||||||
"仪表盘": "Dashboard",
|
"仪表盘": "Dashboard",
|
||||||
|
"仪表盘(只读)": "Dashboard (read only)",
|
||||||
"任务": "Task",
|
"任务": "Task",
|
||||||
"任务数": "Tasks",
|
"任务数": "Tasks",
|
||||||
"任务未运行": "Task not running",
|
"任务未运行": "Task not running",
|
||||||
@@ -126,6 +127,7 @@
|
|||||||
"使用手机应用扫描二维码,或者输入秘钥": "Scan the QR code with the mobile app or enter the key",
|
"使用手机应用扫描二维码,或者输入秘钥": "Scan the QR code with the mobile app or enter the key",
|
||||||
"依赖文件": "Dependency Files",
|
"依赖文件": "Dependency Files",
|
||||||
"依赖管理": "Dependency Management",
|
"依赖管理": "Dependency Management",
|
||||||
|
"依赖管理(只读)": "Dependencies (read only)",
|
||||||
"依赖类型": "Dependency Type",
|
"依赖类型": "Dependency Type",
|
||||||
"依赖缓存": "Dependency cache",
|
"依赖缓存": "Dependency cache",
|
||||||
"依赖设置": "Dependence Settings",
|
"依赖设置": "Dependence Settings",
|
||||||
@@ -192,6 +194,8 @@
|
|||||||
"可用,你使用的版本为": "available, the version you are using is",
|
"可用,你使用的版本为": "available, the version you are using is",
|
||||||
"同时删除关联任务和脚本": "Delete associated tasks and scripts as well",
|
"同时删除关联任务和脚本": "Delete associated tasks and scripts as well",
|
||||||
"名称": "Name",
|
"名称": "Name",
|
||||||
|
"名称受限": "Restricted name",
|
||||||
|
"名称受限,未注入任务": "Restricted name; not injected into tasks",
|
||||||
"名称不能为保留关键字": "The name cannot be a reserved keyword",
|
"名称不能为保留关键字": "The name cannot be a reserved keyword",
|
||||||
"吗": "?",
|
"吗": "?",
|
||||||
"否": "No",
|
"否": "No",
|
||||||
@@ -232,6 +236,7 @@
|
|||||||
"完成设置": "Finish Setup",
|
"完成设置": "Finish Setup",
|
||||||
"定时": "Schedule",
|
"定时": "Schedule",
|
||||||
"定时任务": "Scheduled Tasks",
|
"定时任务": "Scheduled Tasks",
|
||||||
|
"定时任务(只读)": "Scheduled Tasks (read only)",
|
||||||
"定时任务并发数": "Concurrent Scheduled Tasks",
|
"定时任务并发数": "Concurrent Scheduled Tasks",
|
||||||
"定时类型": "Schedule Type",
|
"定时类型": "Schedule Type",
|
||||||
"定时规则": "Schedule Rule",
|
"定时规则": "Schedule Rule",
|
||||||
@@ -335,6 +340,7 @@
|
|||||||
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators",
|
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators",
|
||||||
"日志文件": "Log files",
|
"日志文件": "Log files",
|
||||||
"日志管理": "Log Management",
|
"日志管理": "Log Management",
|
||||||
|
"日志管理(只读)": "Logs (read only)",
|
||||||
"时": "hour(s)",
|
"时": "hour(s)",
|
||||||
"时区": "Timezone",
|
"时区": "Timezone",
|
||||||
"是": "Yes",
|
"是": "Yes",
|
||||||
@@ -415,6 +421,7 @@
|
|||||||
"版本": "Version",
|
"版本": "Version",
|
||||||
"状态": "Status",
|
"状态": "Status",
|
||||||
"环境变量": "Environment variable",
|
"环境变量": "Environment variable",
|
||||||
|
"环境变量(只读)": "Environment variables (read only)",
|
||||||
"生成数据中...": "Generating data...",
|
"生成数据中...": "Generating data...",
|
||||||
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
|
"用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories",
|
||||||
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "Token, which can be obtained after scanning and logging in, is available under 'My Account' -> 'Settings' -> 'Tokens'. Please refer to the instructions for detailed steps: https://www.weplusbot.com/",
|
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "Token, which can be obtained after scanning and logging in, is available under 'My Account' -> 'Settings' -> 'Tokens'. Please refer to the instructions for detailed steps: https://www.weplusbot.com/",
|
||||||
@@ -498,6 +505,7 @@
|
|||||||
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "Working directory for script execution. Leave blank for auto-detection. Relative paths are based on the scripts directory. Absolute paths are also supported.",
|
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "Working directory for script execution. Leave blank for auto-detection. Relative paths are based on the scripts directory. Absolute paths are also supported.",
|
||||||
"脚本文件": "Script files",
|
"脚本文件": "Script files",
|
||||||
"脚本管理": "Script Management",
|
"脚本管理": "Script Management",
|
||||||
|
"脚本(只读)": "Scripts (read only)",
|
||||||
"自动删除任务": "Automatically Delete Tasks",
|
"自动删除任务": "Automatically Delete Tasks",
|
||||||
"自动拆分": "Auto Split",
|
"自动拆分": "Auto Split",
|
||||||
"自动换行": "Word wrap",
|
"自动换行": "Word wrap",
|
||||||
@@ -513,11 +521,13 @@
|
|||||||
"视图管理": "View Management",
|
"视图管理": "View Management",
|
||||||
"订阅": "Subscription",
|
"订阅": "Subscription",
|
||||||
"订阅管理": "Subscription Management",
|
"订阅管理": "Subscription Management",
|
||||||
|
"订阅管理(只读)": "Subscriptions (read only)",
|
||||||
"设置": "Settings",
|
"设置": "Settings",
|
||||||
"访问密钥": "Access Key",
|
"访问密钥": "Access Key",
|
||||||
"语言": "Language",
|
"语言": "Language",
|
||||||
"请": "Please",
|
"请": "Please",
|
||||||
"请先按如下方式修复:": "Please fix it as follows:",
|
"请先按如下方式修复:": "Please fix it as follows:",
|
||||||
|
"请先修改环境变量名称": "Rename the environment variable first",
|
||||||
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request body format: key1: value1 (separate multiple keys with line breaks). URL or body must contain $title, $content is optional and corresponds to the API content position.",
|
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request body format: key1: value1 (separate multiple keys with line breaks). URL or body must contain $title, $content is optional and corresponds to the API content position.",
|
||||||
"请求头Content-Type": "Request Header Content-Type",
|
"请求头Content-Type": "Request Header Content-Type",
|
||||||
"请求头格式Custom-Header1: Header1,多个换行分割": "Request header format: Custom-Header1: Header1 (separate multiple headers with line breaks)",
|
"请求头格式Custom-Header1: Header1,多个换行分割": "Request header format: Custom-Header1: Header1 (separate multiple headers with line breaks)",
|
||||||
@@ -611,6 +621,7 @@
|
|||||||
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
|
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
|
||||||
"邮箱认证地址": "Email address for SMTP authentication",
|
"邮箱认证地址": "Email address for SMTP authentication",
|
||||||
"配置文件": "Configuration Files",
|
"配置文件": "Configuration Files",
|
||||||
|
"配置文件(只读)": "Configuration files (read only)",
|
||||||
"重启": "Restart",
|
"重启": "Restart",
|
||||||
"重命名": "Rename",
|
"重命名": "Rename",
|
||||||
"重新下载": "Redownload",
|
"重新下载": "Redownload",
|
||||||
|
|||||||
@@ -115,6 +115,7 @@
|
|||||||
"代理端口": "代理端口",
|
"代理端口": "代理端口",
|
||||||
"以后再说": "以后再说",
|
"以后再说": "以后再说",
|
||||||
"仪表盘": "仪表盘",
|
"仪表盘": "仪表盘",
|
||||||
|
"仪表盘(只读)": "仪表盘(只读)",
|
||||||
"任务": "任务",
|
"任务": "任务",
|
||||||
"任务数": "任务数",
|
"任务数": "任务数",
|
||||||
"任务未运行": "任务未运行",
|
"任务未运行": "任务未运行",
|
||||||
@@ -125,6 +126,7 @@
|
|||||||
"使用手机应用扫描二维码,或者输入秘钥": "使用手机应用扫描二维码,或者输入秘钥",
|
"使用手机应用扫描二维码,或者输入秘钥": "使用手机应用扫描二维码,或者输入秘钥",
|
||||||
"依赖文件": "依赖文件",
|
"依赖文件": "依赖文件",
|
||||||
"依赖管理": "依赖管理",
|
"依赖管理": "依赖管理",
|
||||||
|
"依赖管理(只读)": "依赖管理(只读)",
|
||||||
"依赖类型": "依赖类型",
|
"依赖类型": "依赖类型",
|
||||||
"依赖缓存": "依赖缓存",
|
"依赖缓存": "依赖缓存",
|
||||||
"依赖设置": "依赖设置",
|
"依赖设置": "依赖设置",
|
||||||
@@ -191,6 +193,8 @@
|
|||||||
"可用,你使用的版本为": "可用,你使用的版本为",
|
"可用,你使用的版本为": "可用,你使用的版本为",
|
||||||
"同时删除关联任务和脚本": "同时删除关联任务和脚本",
|
"同时删除关联任务和脚本": "同时删除关联任务和脚本",
|
||||||
"名称": "名称",
|
"名称": "名称",
|
||||||
|
"名称受限": "名称受限",
|
||||||
|
"名称受限,未注入任务": "名称受限,未注入任务",
|
||||||
"名称不能为保留关键字": "名称不能为保留关键字",
|
"名称不能为保留关键字": "名称不能为保留关键字",
|
||||||
"吗": "吗",
|
"吗": "吗",
|
||||||
"否": "否",
|
"否": "否",
|
||||||
@@ -231,6 +235,7 @@
|
|||||||
"完成设置": "完成设置",
|
"完成设置": "完成设置",
|
||||||
"定时": "定时",
|
"定时": "定时",
|
||||||
"定时任务": "定时任务",
|
"定时任务": "定时任务",
|
||||||
|
"定时任务(只读)": "定时任务(只读)",
|
||||||
"定时任务并发数": "定时任务并发数",
|
"定时任务并发数": "定时任务并发数",
|
||||||
"定时类型": "定时类型",
|
"定时类型": "定时类型",
|
||||||
"定时规则": "定时规则",
|
"定时规则": "定时规则",
|
||||||
@@ -334,6 +339,7 @@
|
|||||||
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符",
|
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符",
|
||||||
"日志文件": "日志文件",
|
"日志文件": "日志文件",
|
||||||
"日志管理": "日志管理",
|
"日志管理": "日志管理",
|
||||||
|
"日志管理(只读)": "日志管理(只读)",
|
||||||
"时": "时",
|
"时": "时",
|
||||||
"时区": "时区",
|
"时区": "时区",
|
||||||
"是": "是",
|
"是": "是",
|
||||||
@@ -415,6 +421,7 @@
|
|||||||
"版本": "版本",
|
"版本": "版本",
|
||||||
"状态": "状态",
|
"状态": "状态",
|
||||||
"环境变量": "环境变量",
|
"环境变量": "环境变量",
|
||||||
|
"环境变量(只读)": "环境变量(只读)",
|
||||||
"生成数据中...": "生成数据中...",
|
"生成数据中...": "生成数据中...",
|
||||||
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
|
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
|
||||||
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/",
|
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/",
|
||||||
@@ -498,6 +505,7 @@
|
|||||||
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径",
|
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径",
|
||||||
"脚本文件": "脚本文件",
|
"脚本文件": "脚本文件",
|
||||||
"脚本管理": "脚本管理",
|
"脚本管理": "脚本管理",
|
||||||
|
"脚本(只读)": "脚本(只读)",
|
||||||
"自动删除任务": "自动删除任务",
|
"自动删除任务": "自动删除任务",
|
||||||
"自动拆分": "自动拆分",
|
"自动拆分": "自动拆分",
|
||||||
"自动换行": "自动换行",
|
"自动换行": "自动换行",
|
||||||
@@ -513,11 +521,13 @@
|
|||||||
"视图管理": "视图管理",
|
"视图管理": "视图管理",
|
||||||
"订阅": "订阅",
|
"订阅": "订阅",
|
||||||
"订阅管理": "订阅管理",
|
"订阅管理": "订阅管理",
|
||||||
|
"订阅管理(只读)": "订阅管理(只读)",
|
||||||
"设置": "设置",
|
"设置": "设置",
|
||||||
"访问密钥": "访问密钥",
|
"访问密钥": "访问密钥",
|
||||||
"语言": "语言",
|
"语言": "语言",
|
||||||
"请": "请",
|
"请": "请",
|
||||||
"请先按如下方式修复:": "请先按如下方式修复:",
|
"请先按如下方式修复:": "请先按如下方式修复:",
|
||||||
|
"请先修改环境变量名称": "请先修改环境变量名称",
|
||||||
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置",
|
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置",
|
||||||
"请求头Content-Type": "请求头Content-Type",
|
"请求头Content-Type": "请求头Content-Type",
|
||||||
"请求头格式Custom-Header1: Header1,多个换行分割": "请求头格式Custom-Header1: Header1,多个换行分割",
|
"请求头格式Custom-Header1: Header1,多个换行分割": "请求头格式Custom-Header1: Header1,多个换行分割",
|
||||||
@@ -611,6 +621,7 @@
|
|||||||
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
|
"邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json",
|
||||||
"邮箱认证地址": "邮箱认证地址",
|
"邮箱认证地址": "邮箱认证地址",
|
||||||
"配置文件": "配置文件",
|
"配置文件": "配置文件",
|
||||||
|
"配置文件(只读)": "配置文件(只读)",
|
||||||
"重启": "重启",
|
"重启": "重启",
|
||||||
"重命名": "重命名",
|
"重命名": "重命名",
|
||||||
"重新下载": "重新下载",
|
"重新下载": "重新下载",
|
||||||
|
|||||||
Vendored
+24
-2
@@ -174,7 +174,7 @@ const Env = () => {
|
|||||||
title: intl.get('状态'),
|
title: intl.get('状态'),
|
||||||
key: 'status',
|
key: 'status',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 170,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
text: intl.get('已启用'),
|
text: intl.get('已启用'),
|
||||||
@@ -192,6 +192,13 @@ const Env = () => {
|
|||||||
<Tag color={StatusColor[record.status]} style={{ marginRight: 0 }}>
|
<Tag color={StatusColor[record.status]} style={{ marginRight: 0 }}>
|
||||||
{intl.get(Status[record.status])}
|
{intl.get(Status[record.status])}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
{record.securityBlocked && (
|
||||||
|
<Tooltip title={intl.get('名称受限,未注入任务')}>
|
||||||
|
<Tag color="warning" style={{ marginRight: 0 }}>
|
||||||
|
{intl.get('名称受限')}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -202,6 +209,8 @@ const Env = () => {
|
|||||||
width: 160,
|
width: 160,
|
||||||
render: (text: string, record: any, index: number) => {
|
render: (text: string, record: any, index: number) => {
|
||||||
const isPc = !isPhone;
|
const isPc = !isPhone;
|
||||||
|
const isBlockedEnable =
|
||||||
|
record.status === Status.已禁用 && record.securityBlocked;
|
||||||
return (
|
return (
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
<Tooltip title={isPc ? intl.get('编辑') : ''}>
|
<Tooltip title={isPc ? intl.get('编辑') : ''}>
|
||||||
@@ -212,7 +221,9 @@ const Env = () => {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
isPc
|
isPc
|
||||||
? record.status === Status.已禁用
|
? isBlockedEnable
|
||||||
|
? intl.get('请先修改环境变量名称')
|
||||||
|
: record.status === Status.已禁用
|
||||||
? intl.get('启用')
|
? intl.get('启用')
|
||||||
: intl.get('禁用')
|
: intl.get('禁用')
|
||||||
: ''
|
: ''
|
||||||
@@ -278,6 +289,10 @@ const Env = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const enabledOrDisabledEnv = (record: any, index: number) => {
|
const enabledOrDisabledEnv = (record: any, index: number) => {
|
||||||
|
if (record.status === Status.已禁用 && record.securityBlocked) {
|
||||||
|
message.error(intl.get('请先修改环境变量名称'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: intl.get('确认操作', {
|
title: intl.get('确认操作', {
|
||||||
operation:
|
operation:
|
||||||
@@ -538,6 +553,13 @@ const Env = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const operateEnvs = (operationStatus: number) => {
|
const operateEnvs = (operationStatus: number) => {
|
||||||
|
if (
|
||||||
|
operationStatus === 0 &&
|
||||||
|
value.some((env) => selectedRowIds.includes(env.id) && env.securityBlocked)
|
||||||
|
) {
|
||||||
|
message.error(intl.get('请先修改环境变量名称'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: intl.get('确认操作', {
|
title: intl.get('确认操作', {
|
||||||
operation: intl.get(OperationName[operationStatus]),
|
operation: intl.get(OperationName[operationStatus]),
|
||||||
|
|||||||
@@ -1,38 +1,21 @@
|
|||||||
import intl from 'react-intl-universal';
|
import intl from 'react-intl-universal';
|
||||||
import React, { Fragment, useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import { Button, Input, Form, Typography, Steps } from 'antd';
|
||||||
Button,
|
|
||||||
Row,
|
|
||||||
Input,
|
|
||||||
Form,
|
|
||||||
message,
|
|
||||||
Typography,
|
|
||||||
Steps,
|
|
||||||
Select,
|
|
||||||
} from 'antd';
|
|
||||||
import config from '@/utils/config';
|
import config from '@/utils/config';
|
||||||
import { history } from '@umijs/max';
|
|
||||||
import styles from './index.less';
|
import styles from './index.less';
|
||||||
import { request } from '@/utils/http';
|
import { request } from '@/utils/http';
|
||||||
|
|
||||||
const FormItem = Form.Item;
|
|
||||||
const { Step } = Steps;
|
const { Step } = Steps;
|
||||||
const { Option } = Select;
|
|
||||||
const { Link } = Typography;
|
const { Link } = Typography;
|
||||||
|
|
||||||
const Initialization = () => {
|
const Initialization = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [current, setCurrent] = React.useState(0);
|
const [current, setCurrent] = React.useState(0);
|
||||||
const [fields, setFields] = useState<any[]>([]);
|
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
setCurrent(current + 1);
|
setCurrent(current + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const prev = () => {
|
|
||||||
setCurrent(current - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitAccountSetting = (values: any) => {
|
const submitAccountSetting = (values: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
request
|
request
|
||||||
@@ -48,23 +31,6 @@ const Initialization = () => {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitNotification = (values: any) => {
|
|
||||||
setLoading(true);
|
|
||||||
request
|
|
||||||
.put(`${config.apiPrefix}user/notification/init`, values)
|
|
||||||
.then(({ code, data }) => {
|
|
||||||
if (code === 200) {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const notificationModeChange = (value: string) => {
|
|
||||||
const _fields = (config.notificationModeMap as any)[value];
|
|
||||||
setFields(_fields || []);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.removeItem(config.authKey);
|
localStorage.removeItem(config.authKey);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -95,53 +61,6 @@ const Initialization = () => {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: intl.get('通知设置'),
|
|
||||||
content: (
|
|
||||||
<Form onFinish={submitNotification} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
label={intl.get('通知方式')}
|
|
||||||
name="type"
|
|
||||||
rules={[{ required: true, message: intl.get('请选择通知方式') }]}
|
|
||||||
style={{ maxWidth: 350 }}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
onChange={notificationModeChange}
|
|
||||||
placeholder={intl.get('请选择通知方式')}
|
|
||||||
>
|
|
||||||
{config.notificationModes
|
|
||||||
.filter((x) => x.value !== 'closed')
|
|
||||||
.map((x) => (
|
|
||||||
<Option key={x.value} value={x.value}>
|
|
||||||
{x.label}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
{fields.map((x) => (
|
|
||||||
<Form.Item
|
|
||||||
key={x.label}
|
|
||||||
label={x.label}
|
|
||||||
name={x.label}
|
|
||||||
extra={x.tip}
|
|
||||||
rules={[{ required: x.required }]}
|
|
||||||
style={{ maxWidth: 400 }}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
autoSize={{ minRows: 1, maxRows: 5 }}
|
|
||||||
placeholder={`${intl.get('请输入')} ${x.label}`}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
<Button type="primary" htmlType="submit" loading={loading}>
|
|
||||||
{intl.get('保存')}
|
|
||||||
</Button>
|
|
||||||
<Button type="link" htmlType="button" onClick={() => next()}>
|
|
||||||
{intl.get('跳过')}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: intl.get('账户设置'),
|
title: intl.get('账户设置'),
|
||||||
content: (
|
content: (
|
||||||
|
|||||||
@@ -20,30 +20,58 @@ export default {
|
|||||||
name: intl.get('定时任务'),
|
name: intl.get('定时任务'),
|
||||||
value: 'crons',
|
value: 'crons',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: intl.get('定时任务(只读)'),
|
||||||
|
value: 'crons:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: intl.get('环境变量'),
|
name: intl.get('环境变量'),
|
||||||
value: 'envs',
|
value: 'envs',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: intl.get('环境变量(只读)'),
|
||||||
|
value: 'envs:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: intl.get('订阅管理'),
|
name: intl.get('订阅管理'),
|
||||||
value: 'subscriptions',
|
value: 'subscriptions',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: intl.get('订阅管理(只读)'),
|
||||||
|
value: 'subscriptions:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: intl.get('配置文件'),
|
name: intl.get('配置文件'),
|
||||||
value: 'configs',
|
value: 'configs',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: intl.get('配置文件(只读)'),
|
||||||
|
value: 'configs:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: intl.get('脚本管理'),
|
name: intl.get('脚本管理'),
|
||||||
value: 'scripts',
|
value: 'scripts',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: intl.get('脚本(只读)'),
|
||||||
|
value: 'scripts:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: intl.get('日志管理'),
|
name: intl.get('日志管理'),
|
||||||
value: 'logs',
|
value: 'logs',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: intl.get('日志管理(只读)'),
|
||||||
|
value: 'logs:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: intl.get('依赖管理'),
|
name: intl.get('依赖管理'),
|
||||||
value: 'dependencies',
|
value: 'dependencies',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: intl.get('依赖管理(只读)'),
|
||||||
|
value: 'dependencies:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: intl.get('系统信息'),
|
name: intl.get('系统信息'),
|
||||||
value: 'system',
|
value: 'system',
|
||||||
@@ -52,17 +80,29 @@ export default {
|
|||||||
name: intl.get('仪表盘'),
|
name: intl.get('仪表盘'),
|
||||||
value: 'dashboard',
|
value: 'dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: intl.get('仪表盘(只读)'),
|
||||||
|
value: 'dashboard:read',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
scopesMap: {
|
scopesMap: {
|
||||||
crons: intl.get('定时任务'),
|
crons: intl.get('定时任务'),
|
||||||
|
'crons:read': intl.get('定时任务(只读)'),
|
||||||
envs: intl.get('环境变量'),
|
envs: intl.get('环境变量'),
|
||||||
|
'envs:read': intl.get('环境变量(只读)'),
|
||||||
subscriptions: intl.get('订阅管理'),
|
subscriptions: intl.get('订阅管理'),
|
||||||
|
'subscriptions:read': intl.get('订阅管理(只读)'),
|
||||||
configs: intl.get('配置文件'),
|
configs: intl.get('配置文件'),
|
||||||
|
'configs:read': intl.get('配置文件(只读)'),
|
||||||
scripts: intl.get('脚本管理'),
|
scripts: intl.get('脚本管理'),
|
||||||
|
'scripts:read': intl.get('脚本(只读)'),
|
||||||
logs: intl.get('日志管理'),
|
logs: intl.get('日志管理'),
|
||||||
|
'logs:read': intl.get('日志管理(只读)'),
|
||||||
dependencies: intl.get('依赖管理'),
|
dependencies: intl.get('依赖管理'),
|
||||||
|
'dependencies:read': intl.get('依赖管理(只读)'),
|
||||||
system: intl.get('系统信息'),
|
system: intl.get('系统信息'),
|
||||||
dashboard: intl.get('仪表盘'),
|
dashboard: intl.get('仪表盘'),
|
||||||
|
'dashboard:read': intl.get('仪表盘(只读)'),
|
||||||
},
|
},
|
||||||
notificationModes: [
|
notificationModes: [
|
||||||
{ value: 'gotify', label: 'Gotify' },
|
{ value: 'gotify', label: 'Gotify' },
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ const apiWhiteList = [
|
|||||||
`${config.baseUrl}api/user/two-factor/login`,
|
`${config.baseUrl}api/user/two-factor/login`,
|
||||||
`${config.baseUrl}api/system`,
|
`${config.baseUrl}api/system`,
|
||||||
`${config.baseUrl}api/user/init`,
|
`${config.baseUrl}api/user/init`,
|
||||||
`${config.baseUrl}api/user/notification/init`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
_request.interceptors.request.use((_config) => {
|
_request.interceptors.request.use((_config) => {
|
||||||
|
|||||||
@@ -43,6 +43,104 @@ test('environment values remain literal when the generated JavaScript loads', as
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('environment API rejects execution controls and legacy rows stay out of task files', async () => {
|
||||||
|
const output = {};
|
||||||
|
let writes = 0;
|
||||||
|
const warnings = [];
|
||||||
|
const legacy = [
|
||||||
|
{ id: 1, name: 'task_before', value: 'touch /tmp/untrusted' },
|
||||||
|
{ id: 2, name: 'NODE_OPTIONS', value: '--require /tmp/untrusted.js' },
|
||||||
|
{ id: 3, name: 'PREV_NODE_OPTIONS', value: '--import=data:text/javascript,bad' },
|
||||||
|
{ id: 4, name: 'GIT_SSH_COMMAND', value: 'sh -c bad' },
|
||||||
|
{ id: 5, name: 'PATH', value: '/tmp/untrusted' },
|
||||||
|
{ id: 6, name: 'file_task_before', value: '/tmp/untrusted.sh' },
|
||||||
|
{ id: 7, name: 'SAFE_VALUE', value: 'kept' },
|
||||||
|
{ id: 8, name: 'bad\nname', value: 'untrusted' },
|
||||||
|
{ id: 9, name: 'work_dir', value: '/tmp/untrusted' },
|
||||||
|
{ id: 10, name: 'task_shell_params', value: 'other.sh' },
|
||||||
|
{ id: 11, name: 'which_program', value: 'bash' },
|
||||||
|
{ id: 12, name: 'script_params', value: 'other.sh' },
|
||||||
|
{ id: 13, name: 'LOG_DIR', value: '../../config' },
|
||||||
|
{ id: 14, name: 'EXPORTED_VARIABLES', value: 'PATH' },
|
||||||
|
{ id: 15, name: 'begin_timestamp', value: 'a[$(printf BAD)]' },
|
||||||
|
{ id: 16, name: 'is_macos', value: 'a[$(printf BAD)]' },
|
||||||
|
{ id: 17, name: 'RandomDelayIgnoredMinutes', value: 'a[$(printf BAD)]' },
|
||||||
|
{ id: 18, name: 'ID', value: '999' },
|
||||||
|
{ id: 19, name: 'BASH_XTRACEFD', value: '2' },
|
||||||
|
];
|
||||||
|
const Env = load(path.resolve('back/services/env.ts'), {
|
||||||
|
typedi: decorators,
|
||||||
|
'../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' },
|
||||||
|
'../data/env': {
|
||||||
|
Env: class { constructor(value) { Object.assign(this, value); } },
|
||||||
|
EnvModel: {
|
||||||
|
update: async (value, query) => {
|
||||||
|
writes++;
|
||||||
|
},
|
||||||
|
findAll: async () => [legacy[0]],
|
||||||
|
},
|
||||||
|
EnvStatus: { normal: 0, disabled: 1 },
|
||||||
|
},
|
||||||
|
'../data': { sequelize: { literal: (value) => value } },
|
||||||
|
'../shared/utils': {
|
||||||
|
writeFileWithLock: async (file, value) => { output[file] = value; },
|
||||||
|
},
|
||||||
|
}).default;
|
||||||
|
const service = new Env({
|
||||||
|
...logger,
|
||||||
|
warn: (...args) => warnings.push(args),
|
||||||
|
});
|
||||||
|
service.envs = async () => legacy;
|
||||||
|
service.insert = async () => { writes++; return []; };
|
||||||
|
service.getDb = async () => ({ id: 1, name: 'SAFE_VALUE', value: 'kept' });
|
||||||
|
service.updateDb = async () => { writes++; return {}; };
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
'task_before', 'PREV_NODE_OPTIONS', 'GIT_SSH_COMMAND', 'work_dir',
|
||||||
|
'task_shell_params', 'which_program', 'script_params', 'LOG_DIR',
|
||||||
|
'EXPORTED_VARIABLES',
|
||||||
|
'begin_timestamp', 'is_macos', 'RandomDelayIgnoredMinutes', 'ID',
|
||||||
|
'BASH_XTRACEFD',
|
||||||
|
]) {
|
||||||
|
await assert.rejects(
|
||||||
|
service.create([
|
||||||
|
{ name: 'SAFE_VALUE', value: 'ok' },
|
||||||
|
{ name, value: 'untrusted' },
|
||||||
|
]),
|
||||||
|
/环境变量名称不可使用/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await assert.rejects(
|
||||||
|
service.update({ id: 1, name: 'BASH_ENV', value: '/tmp/untrusted.sh' }),
|
||||||
|
/环境变量名称不可使用/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
service.updateNames({ ids: [1], name: 'PYTHONPATH' }),
|
||||||
|
/环境变量名称不可使用/,
|
||||||
|
);
|
||||||
|
assert.equal(writes, 0);
|
||||||
|
|
||||||
|
await service.set_envs();
|
||||||
|
assert.equal(writes, 0);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0][1], /GIT_SSH_COMMAND/);
|
||||||
|
assert.equal(warnings[0][1].includes('\n'), false);
|
||||||
|
await assert.rejects(service.enabled([1]), /环境变量名称不可使用/);
|
||||||
|
assert.equal(writes, 0);
|
||||||
|
delete service.envs;
|
||||||
|
service.find = async () => legacy;
|
||||||
|
const listed = await service.envs();
|
||||||
|
assert.equal(listed[0].securityBlocked, true);
|
||||||
|
assert.equal(listed[6].securityBlocked, false);
|
||||||
|
for (const contents of Object.values(output)) {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
contents,
|
||||||
|
/task_before|NODE_OPTIONS|GIT_SSH_COMMAND|PATH|work_dir|task_shell_params|which_program|script_params|LOG_DIR|EXPORTED_VARIABLES|begin_timestamp|is_macos|RandomDelayIgnoredMinutes|ID|BASH_XTRACEFD/,
|
||||||
|
);
|
||||||
|
assert.match(contents, /SAFE_VALUE/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('subscription shell parameters round trip without expansion or command substitution', () => {
|
test('subscription shell parameters round trip without expansion or command substitution', () => {
|
||||||
const { formatCommand } = load(path.resolve('back/config/subscription.ts'));
|
const { formatCommand } = load(path.resolve('back/config/subscription.ts'));
|
||||||
const payload =
|
const payload =
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test('unknown API requests return JSON 404 while env.js and SPA deep links work'
|
|||||||
const app = express();
|
const app = express();
|
||||||
load('back/loaders/express.ts', {
|
load('back/loaders/express.ts', {
|
||||||
'../api': () => express.Router(),
|
'../api': () => express.Router(),
|
||||||
|
'./logger': { error() {} },
|
||||||
'../config': {
|
'../config': {
|
||||||
rootPath: dir,
|
rootPath: dir,
|
||||||
uploadPath: dir,
|
uploadPath: dir,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const express = require('express');
|
||||||
|
const { errors } = require('celebrate');
|
||||||
|
const path = require('node:path');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
|
||||||
|
test('application API rejects granting apps scope on create and update', async (t) => {
|
||||||
|
const calls = [];
|
||||||
|
class OpenService {}
|
||||||
|
const service = {
|
||||||
|
create: async (body) => {
|
||||||
|
calls.push(['create', body]);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
update: async (body) => {
|
||||||
|
calls.push(['update', body]);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const route = load(path.join(__dirname, '../../back/api/open.ts'), {
|
||||||
|
'../services/open': OpenService,
|
||||||
|
typedi: { Container: { get: () => service } },
|
||||||
|
}).default;
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
for (const prefix of ['/api', '/open']) {
|
||||||
|
const router = express.Router();
|
||||||
|
route(router);
|
||||||
|
app.use(prefix, router);
|
||||||
|
}
|
||||||
|
app.use(errors());
|
||||||
|
const server = app.listen(0, '127.0.0.1');
|
||||||
|
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('listening', resolve);
|
||||||
|
server.once('error', reject);
|
||||||
|
});
|
||||||
|
const request = async (prefix, method, body) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${server.address().port}${prefix}/apps`,
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.status;
|
||||||
|
};
|
||||||
|
for (const prefix of ['/api', '/open']) {
|
||||||
|
assert.equal(
|
||||||
|
await request(prefix, 'POST', { name: 'new', scopes: ['crons', 'apps'] }),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await request(prefix, 'PUT', { id: 1, scopes: ['apps'] }),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await request(prefix, 'POST', { name: 'new', scopes: ['crons:read'] }),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await request(prefix, 'PUT', { id: 1, scopes: ['crons'] }),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(calls.length, 4);
|
||||||
|
});
|
||||||
@@ -115,7 +115,9 @@ for (const customData of [false, true]) {
|
|||||||
);
|
);
|
||||||
for (const name of [
|
for (const name of [
|
||||||
'token.json',
|
'token.json',
|
||||||
|
'TOKEN.JSON',
|
||||||
'grpc/client.key',
|
'grpc/client.key',
|
||||||
|
'Grpc/client.key',
|
||||||
'secret-link',
|
'secret-link',
|
||||||
'data/scripts/secret-link',
|
'data/scripts/secret-link',
|
||||||
'data/scripts/../config/token.json',
|
'data/scripts/../config/token.json',
|
||||||
@@ -176,5 +178,23 @@ test('log APIs allow configuration-like names while retaining directory boundari
|
|||||||
])
|
])
|
||||||
assert.equal((await invoke(method, route, values)).code, 403, filename);
|
assert.equal((await invoke(method, route, values)).code, 403, filename);
|
||||||
}
|
}
|
||||||
|
const hidden = path.join(config.logPath, '.tmp', 'own_user.list');
|
||||||
|
fs.mkdirSync(path.dirname(hidden), { recursive: true });
|
||||||
|
fs.writeFileSync(hidden, 'hidden task command');
|
||||||
|
for (const values of [
|
||||||
|
{ path: '.tmp', file: 'own_user.list', filename: 'own_user.list' },
|
||||||
|
{ path: '.TMP', file: 'own_user.list', filename: 'own_user.list' },
|
||||||
|
{ path: '', file: '.tmp/own_user.list', filename: '.tmp/own_user.list' },
|
||||||
|
{ path: '', file: '.Tmp/own_user.list', filename: '.Tmp/own_user.list' },
|
||||||
|
]) {
|
||||||
|
for (const [method, route] of [
|
||||||
|
['get', '/detail'],
|
||||||
|
['post', '/download'],
|
||||||
|
['delete', '/'],
|
||||||
|
]) {
|
||||||
|
assert.equal((await invoke(method, route, values)).code, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(fs.readFileSync(hidden, 'utf8'), 'hidden task command');
|
||||||
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
|
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ test('file access enforces directory boundaries, blacklist descendants and symli
|
|||||||
'../config-other/secret',
|
'../config-other/secret',
|
||||||
'/etc/passwd',
|
'/etc/passwd',
|
||||||
'grpc/client.key',
|
'grpc/client.key',
|
||||||
|
'GRPC/client.key',
|
||||||
'alias/client.key',
|
'alias/client.key',
|
||||||
'outside/new.txt',
|
'outside/new.txt',
|
||||||
'dangling',
|
'dangling',
|
||||||
'auth.json',
|
'auth.json',
|
||||||
|
'AUTH.JSON',
|
||||||
'nested/auth.json',
|
'nested/auth.json',
|
||||||
|
'nested/Auth.Json',
|
||||||
'',
|
'',
|
||||||
]) {
|
]) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const test = require('node:test');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const util = require('node:util');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const load = require('../helpers/load-security-module.cjs');
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
@@ -13,6 +14,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
fs.mkdirSync(path.join(tmp, dir), { recursive: true });
|
fs.mkdirSync(path.join(tmp, dir), { recursive: true });
|
||||||
fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL');
|
fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL');
|
||||||
fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal');
|
fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal');
|
||||||
|
fs.writeFileSync(path.join(tmp, 'config/config.sh'), 'original');
|
||||||
const secret = 'http-security-test';
|
const secret = 'http-security-test';
|
||||||
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
||||||
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
|
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
|
||||||
@@ -51,6 +53,10 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
scopes: ['configs'],
|
scopes: ['configs'],
|
||||||
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
|
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scopes: ['configs:read'],
|
||||||
|
tokens: [{ value: 'config-read-app', expiration: Date.now() / 1000 + 3600 }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scopes: ['configs', 'scripts'],
|
scopes: ['configs', 'scripts'],
|
||||||
tokens: [
|
tokens: [
|
||||||
@@ -61,19 +67,67 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
scopes: ['scripts'],
|
scopes: ['scripts'],
|
||||||
tokens: [{ value: 'script-app', expiration: Date.now() / 1000 + 3600 }],
|
tokens: [{ value: 'script-app', expiration: Date.now() / 1000 + 3600 }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scopes: ['scripts:read'],
|
||||||
|
tokens: [{ value: 'script-read-app', expiration: Date.now() / 1000 + 3600 }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scopes: ['envs'],
|
scopes: ['envs'],
|
||||||
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
|
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scopes: ['envs:read'],
|
||||||
|
tokens: [{ value: 'env-read-app', expiration: Date.now() / 1000 + 3600 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'external-system',
|
||||||
|
scopes: ['system'],
|
||||||
|
tokens: [{ value: 'system-app', expiration: Date.now() / 1000 + 3600 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'system',
|
||||||
|
scopes: ['system'],
|
||||||
|
tokens: [{ value: 'internal-system', expiration: Date.now() / 1000 + 3600 }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
for (const scope of ['crons', 'subscriptions', 'dependencies', 'logs', 'dashboard']) {
|
||||||
|
for (const suffix of ['', ':read']) {
|
||||||
|
apps.push({
|
||||||
|
scopes: [`${scope}${suffix}`],
|
||||||
|
tokens: [{
|
||||||
|
value: `${scope}${suffix}-app`,
|
||||||
|
expiration: Date.now() / 1000 + 3600,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resetCalls = 0;
|
||||||
|
let commandCalls = 0;
|
||||||
|
let envWriteCalls = 0;
|
||||||
|
let scopedMutationCalls = 0;
|
||||||
let initialized = 0;
|
let initialized = 0;
|
||||||
|
let notificationCalls = 0;
|
||||||
|
const loggedErrors = [];
|
||||||
const User = class {};
|
const User = class {};
|
||||||
|
const Script = class {
|
||||||
|
getFile(folder, filename) {
|
||||||
|
return fs.promises.readFile(path.join(config.scriptPath, folder, filename), 'utf8');
|
||||||
|
}
|
||||||
|
checkFilePath(folder, filename) {
|
||||||
|
return path.join(config.scriptPath, folder || '', filename);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const script = new Script();
|
||||||
const user = {
|
const user = {
|
||||||
initializeUser: async () => {
|
initializeUser: async () => {
|
||||||
initialized++;
|
initialized++;
|
||||||
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,
|
||||||
@@ -89,8 +143,9 @@ 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': class {},
|
'../services/script': Script,
|
||||||
'../data/open': {},
|
'../data/open': {},
|
||||||
'../data/system': {},
|
'../data/system': {},
|
||||||
typedi: { Service: () => (x) => x },
|
typedi: { Service: () => (x) => x },
|
||||||
@@ -111,7 +166,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
mocks.typedi = {
|
mocks.typedi = {
|
||||||
...mocks.typedi,
|
...mocks.typedi,
|
||||||
Container: {
|
Container: {
|
||||||
get: (x) => (x === User ? user : x === History ? history : configService),
|
get: (x) =>
|
||||||
|
x === User ? user : x === History ? history : x === Script ? script : configService,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
mocks['../api'] = () => {
|
mocks['../api'] = () => {
|
||||||
@@ -126,6 +182,52 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
historyCache,
|
historyCache,
|
||||||
).default(router);
|
).default(router);
|
||||||
router.get('/envs', (_req, res) => res.json({ code: 200 }));
|
router.get('/envs', (_req, res) => res.json({ code: 200 }));
|
||||||
|
router.post('/envs', (_req, res) => {
|
||||||
|
envWriteCalls++;
|
||||||
|
res.json({ code: 200 });
|
||||||
|
});
|
||||||
|
for (const route of [
|
||||||
|
'/crons', '/subscriptions', '/dependencies', '/logs', '/dashboard/overview',
|
||||||
|
]) {
|
||||||
|
router.get(route, (_req, res) => res.json({ code: 200 }));
|
||||||
|
}
|
||||||
|
for (const [method, route] of [
|
||||||
|
['put', '/crons/run'],
|
||||||
|
['put', '/subscriptions/run'],
|
||||||
|
['post', '/dependencies'],
|
||||||
|
['delete', '/logs'],
|
||||||
|
['post', '/dashboard/record'],
|
||||||
|
['get', '/crons/import'],
|
||||||
|
]) {
|
||||||
|
router[method](route, (_req, res) => {
|
||||||
|
scopedMutationCalls++;
|
||||||
|
res.json({ code: 200 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
router.post('/logs/download', (_req, res) =>
|
||||||
|
res.json({ code: 200, data: 'downloaded' }),
|
||||||
|
);
|
||||||
|
router.get('/system', (_req, res) => res.json({ code: 200 }));
|
||||||
|
router.put('/system/notify', (_req, res) => res.json({ code: 200 }));
|
||||||
|
router.put('/system/auth/reset', (_req, res) => {
|
||||||
|
resetCalls++;
|
||||||
|
res.json({ code: 200 });
|
||||||
|
});
|
||||||
|
router.put('/system/command-run', (_req, res) => {
|
||||||
|
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;
|
return router;
|
||||||
};
|
};
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -155,6 +257,39 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
);
|
);
|
||||||
return { status: response.status, body: await response.json() };
|
return { status: response.status, body: await response.json() };
|
||||||
};
|
};
|
||||||
|
auth.username = 'admin';
|
||||||
|
auth.password = 'admin';
|
||||||
|
for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) {
|
||||||
|
const response = await request(
|
||||||
|
`${prefix}/user/notification/init`,
|
||||||
|
undefined,
|
||||||
|
'PUT',
|
||||||
|
{ type: 'webhook', webhookUrl: 'http://127.0.0.1/?title=$title' },
|
||||||
|
);
|
||||||
|
assert.equal(response.status, 401, prefix);
|
||||||
|
}
|
||||||
|
assert.equal(notificationCalls, 0);
|
||||||
|
auth.username = 'owner';
|
||||||
|
auth.password = 'configured';
|
||||||
|
assert.equal(
|
||||||
|
(await request('/api/user/notification', valid, 'PUT', {})).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(notificationCalls, 1);
|
||||||
|
for (const route of ['/security-test/error', '/security-test/db-error']) {
|
||||||
|
const response = await request(`/api${route}`, valid);
|
||||||
|
assert.equal(response.status, 500);
|
||||||
|
assert.deepEqual(response.body, { code: 500, message: '未知错误' });
|
||||||
|
}
|
||||||
|
assert.equal(loggedErrors.length, 2);
|
||||||
|
assert.match(loggedErrors[1], /type=SequelizeDatabaseError/);
|
||||||
|
assert.ok(loggedErrors.every((line) => !line.includes('sensitive')));
|
||||||
|
const malformedToken = await request('/api/envs', 'not-a-jwt');
|
||||||
|
assert.equal(malformedToken.status, 401);
|
||||||
|
assert.deepEqual(malformedToken.body, {
|
||||||
|
code: 401,
|
||||||
|
message: 'Token 已失效',
|
||||||
|
});
|
||||||
for (const url of [
|
for (const url of [
|
||||||
'/api/user/init',
|
'/api/user/init',
|
||||||
'/open/user/init',
|
'/open/user/init',
|
||||||
@@ -203,6 +338,68 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
assert.equal((await request(`${prefix}/envs`)).status, 401, prefix);
|
assert.equal((await request(`${prefix}/envs`)).status, 401, prefix);
|
||||||
}
|
}
|
||||||
assert.equal((await request('/open/envs', 'env-app')).body.code, 200);
|
assert.equal((await request('/open/envs', 'env-app')).body.code, 200);
|
||||||
|
assert.equal((await request('/open/envs', 'env-read-app')).body.code, 200);
|
||||||
|
for (const prefix of ['/open', '/panel/open']) {
|
||||||
|
assert.equal(
|
||||||
|
(await request(`${prefix}/envs`, 'env-read-app', 'POST', [
|
||||||
|
{ name: 'SAFE_VALUE', value: 'kept' },
|
||||||
|
])).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(envWriteCalls, 0);
|
||||||
|
assert.equal(
|
||||||
|
(await request('/open/envs', 'env-app', 'POST', [
|
||||||
|
{ name: 'SAFE_VALUE', value: 'kept' },
|
||||||
|
])).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(envWriteCalls, 1);
|
||||||
|
for (const [scope, readPath, method, writePath] of [
|
||||||
|
['crons', '/crons', 'PUT', '/crons/run'],
|
||||||
|
['subscriptions', '/subscriptions', 'PUT', '/subscriptions/run'],
|
||||||
|
['dependencies', '/dependencies', 'POST', '/dependencies'],
|
||||||
|
['logs', '/logs', 'DELETE', '/logs'],
|
||||||
|
['dashboard', '/dashboard/overview', 'POST', '/dashboard/record'],
|
||||||
|
]) {
|
||||||
|
for (const prefix of ['/open', '/panel/open']) {
|
||||||
|
assert.equal(
|
||||||
|
(await request(`${prefix}${readPath}`, `${scope}:read-app`)).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await request(`${prefix}${writePath}`, `${scope}:read-app`, method))
|
||||||
|
.status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
(await request(`/open${writePath}`, `${scope}-app`, method)).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(scopedMutationCalls, 5);
|
||||||
|
for (const prefix of ['/open', '/panel/open']) {
|
||||||
|
assert.equal(
|
||||||
|
(await request(`${prefix}/crons/import`, 'crons:read-app')).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await request(`${prefix}/logs/download`, 'logs:read-app', 'POST'))
|
||||||
|
.body.data,
|
||||||
|
'downloaded',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const importedWithHead = await fetch(
|
||||||
|
`http://127.0.0.1:${server.address().port}/open/crons/import`,
|
||||||
|
{ method: 'HEAD', headers: { Authorization: 'Bearer crons:read-app' } },
|
||||||
|
);
|
||||||
|
assert.equal(importedWithHead.status, 401);
|
||||||
|
assert.equal(
|
||||||
|
(await request('/open/crons/import', 'crons-app')).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(scopedMutationCalls, 6);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
|
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
|
||||||
401,
|
401,
|
||||||
@@ -212,6 +409,58 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
.data,
|
.data,
|
||||||
'normal',
|
'normal',
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await request('/open/configs/detail?path=normal.txt', 'config-read-app'))
|
||||||
|
.body.data,
|
||||||
|
'normal',
|
||||||
|
);
|
||||||
|
for (const prefix of ['/open', '/panel/open']) {
|
||||||
|
for (const name of ['normal.txt', 'config.sh', 'task_before.sh']) {
|
||||||
|
assert.equal(
|
||||||
|
(await request(`${prefix}/configs/save`, 'config-read-app', 'POST', {
|
||||||
|
name,
|
||||||
|
content: 'untrusted',
|
||||||
|
})).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmp, 'config/config.sh'), 'utf8'), 'original');
|
||||||
|
assert.equal((await request('/open/system', 'system-app')).body.code, 200);
|
||||||
|
assert.equal(
|
||||||
|
(await request('/open/system/notify', 'system-app', 'PUT', {
|
||||||
|
title: 'title',
|
||||||
|
content: 'content',
|
||||||
|
})).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
for (const prefix of ['/open', '/panel/open']) {
|
||||||
|
assert.equal(
|
||||||
|
(await request(`${prefix}/system/auth/reset`, 'system-app', 'PUT', {
|
||||||
|
password: 'changed',
|
||||||
|
})).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await request(`${prefix}/system/command-run`, 'system-app', 'PUT', {
|
||||||
|
command: 'id',
|
||||||
|
})).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(resetCalls, 0);
|
||||||
|
assert.equal(commandCalls, 0);
|
||||||
|
assert.equal(
|
||||||
|
(await request('/open/system/auth/reset', 'internal-system', 'PUT', {
|
||||||
|
retries: 0,
|
||||||
|
})).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await request('/api/system/auth/reset', valid, 'PUT', { retries: 0 })).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(resetCalls, 2);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
|
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
|
||||||
.body.code,
|
.body.code,
|
||||||
@@ -277,6 +526,14 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
).body.code,
|
).body.code,
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await request('/api/configs/save', valid, 'POST', {
|
||||||
|
name: 'config.sh',
|
||||||
|
content: 'owner config',
|
||||||
|
})).body.code,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmp, 'config/config.sh'), 'utf8'), 'owner config');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
await request('/api/configs/save', valid, 'POST', {
|
await request('/api/configs/save', valid, 'POST', {
|
||||||
@@ -296,6 +553,15 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
).status,
|
).status,
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await request(`${prefix}/scripts`, 'script-read-app', 'POST', {
|
||||||
|
filename: 'test.js',
|
||||||
|
content: 'unauthorized',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
await request(`${prefix}/scripts`, 'script-app', 'POST', {
|
await request(`${prefix}/scripts`, 'script-app', 'POST', {
|
||||||
@@ -310,4 +576,38 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
|||||||
'authorized script',
|
'authorized script',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert.equal(
|
||||||
|
(await request('/open/scripts/detail?file=test.js', 'script-read-app')).body
|
||||||
|
.data,
|
||||||
|
'authorized script',
|
||||||
|
);
|
||||||
|
const downloaded = await fetch(
|
||||||
|
`http://127.0.0.1:${server.address().port}/open/scripts/download`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer script-read-app',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filename: 'test.js' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(downloaded.status, 200);
|
||||||
|
assert.equal(await downloaded.text(), 'authorized script');
|
||||||
|
for (const method of ['PUT', 'DELETE']) {
|
||||||
|
assert.equal(
|
||||||
|
(await request('/open/scripts', 'script-read-app', method, {
|
||||||
|
filename: 'test.js', content: 'unauthorized',
|
||||||
|
})).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const path of ['run', 'stop', 'rename', 'history/restore']) {
|
||||||
|
assert.equal(
|
||||||
|
(await request(`/open/scripts/${path}`, 'script-read-app', 'PUT', {
|
||||||
|
filename: 'test.js', content: 'unauthorized',
|
||||||
|
})).status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const test = require('node:test'),
|
||||||
|
assert = require('node:assert/strict'),
|
||||||
|
fs = require('node:fs'),
|
||||||
|
path = require('node:path'),
|
||||||
|
{ spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
|
const notifyFile = path.resolve(
|
||||||
|
process.env.NOTIFY_PY_PATH || 'sample/notify.py',
|
||||||
|
);
|
||||||
|
const source = fs.readFileSync(notifyFile, 'utf8');
|
||||||
|
|
||||||
|
function httpCalls(text) {
|
||||||
|
const calls = [];
|
||||||
|
const pattern = /requests\.(?:get|post|request)\s*\(/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
let depth = 0;
|
||||||
|
let end = match.index + match[0].length - 1;
|
||||||
|
for (; end < text.length; end++) {
|
||||||
|
if (text[end] === '(') depth++;
|
||||||
|
else if (text[end] === ')' && --depth === 0) break;
|
||||||
|
}
|
||||||
|
calls.push(text.slice(match.index, end + 1));
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sample/notify.py sends every HTTP request with a timeout', () => {
|
||||||
|
const calls = httpCalls(source);
|
||||||
|
assert.ok(
|
||||||
|
calls.length >= 27,
|
||||||
|
`expected at least 27 HTTP calls, got ${calls.length}`,
|
||||||
|
);
|
||||||
|
const missing = calls.filter((call) => !/\btimeout\s*=/.test(call));
|
||||||
|
assert.deepEqual(missing, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notify.send returns after a bounded wait when a channel hangs', () => {
|
||||||
|
const script = `
|
||||||
|
import os, sys, time, types
|
||||||
|
requests = types.ModuleType("requests")
|
||||||
|
requests.get = requests.post = requests.request = lambda *a, **k: None
|
||||||
|
sys.modules["requests"] = requests
|
||||||
|
sys.path.insert(0, ${JSON.stringify(path.dirname(notifyFile))})
|
||||||
|
os.environ.pop("QL_DIR", None)
|
||||||
|
import notify
|
||||||
|
notify.NOTIFY_JOIN_TIMEOUT = 1
|
||||||
|
notify.push_config = {key: "" for key in notify.push_config}
|
||||||
|
notify.push_config["HITOKOTO"] = False
|
||||||
|
notify.push_config["CONSOLE"] = True
|
||||||
|
def hang(*args, **kwargs):
|
||||||
|
time.sleep(60)
|
||||||
|
notify.console = hang
|
||||||
|
start = time.monotonic()
|
||||||
|
notify.send("title", "content")
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
assert elapsed < 5, elapsed
|
||||||
|
print("elapsed=%.2f" % elapsed)
|
||||||
|
`;
|
||||||
|
const python = process.platform === 'win32' ? 'python' : 'python3';
|
||||||
|
const result = spawnSync(python, ['-'], {
|
||||||
|
input: script,
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 15000,
|
||||||
|
env: { ...process.env, QL_DIR: '' },
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||||
|
assert.match(result.stdout, /elapsed=/);
|
||||||
|
});
|
||||||
@@ -66,6 +66,9 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
GITHUB_EVENT_NAME: 'push',
|
||||||
|
GITHUB_REF: 'refs/heads/master',
|
||||||
|
NPM_RC_VERSION: '',
|
||||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||||
RUNNER_TEMP: root,
|
RUNNER_TEMP: root,
|
||||||
QL_PUBLISH_SCENARIO: scenario,
|
QL_PUBLISH_SCENARIO: scenario,
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ test('startup closes interrupted instances without inventing exit codes or rewri
|
|||||||
{ id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 },
|
{ id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 },
|
||||||
]);
|
]);
|
||||||
const systemConfig = { info: { lang: 'en-US' } };
|
const systemConfig = { info: { lang: 'en-US' } };
|
||||||
|
const startupEvents = [];
|
||||||
const service = {
|
const service = {
|
||||||
set_envs: async () => {},
|
set_envs: async () => { startupEvents.push('environment'); },
|
||||||
getAuthInfo: async () => ({}),
|
getAuthInfo: async () => ({}),
|
||||||
initializeSystemApp: async () => {},
|
initializeSystemApp: async () => {},
|
||||||
refreshApps: async () => {},
|
refreshApps: async () => {},
|
||||||
@@ -70,7 +71,10 @@ test('startup closes interrupted instances without inventing exit codes or rewri
|
|||||||
'../shared/i18n': { setLang() {} },
|
'../shared/i18n': { setLang() {} },
|
||||||
'./logger': {},
|
'./logger': {},
|
||||||
'../schedule/client': {
|
'../schedule/client': {
|
||||||
readiness: { configure() {}, recover: async () => {} },
|
readiness: {
|
||||||
|
configure() {},
|
||||||
|
recover: async () => { startupEvents.push('scheduler'); },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}).default;
|
}).default;
|
||||||
// Dependency installation is unrelated to startup reconciliation.
|
// Dependency installation is unrelated to startup reconciliation.
|
||||||
@@ -82,6 +86,7 @@ test('startup closes interrupted instances without inventing exit codes or rewri
|
|||||||
} finally {
|
} finally {
|
||||||
global.setTimeout = originalTimeout;
|
global.setTimeout = originalTimeout;
|
||||||
}
|
}
|
||||||
|
assert.deepEqual(startupEvents, ['environment', 'scheduler']);
|
||||||
const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true });
|
const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true });
|
||||||
assert.equal(rows[0].status, 2);
|
assert.equal(rows[0].status, 2);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -109,6 +114,9 @@ test('startup closes interrupted instances without inventing exit codes or rewri
|
|||||||
} finally {
|
} finally {
|
||||||
global.setTimeout = originalTimeout;
|
global.setTimeout = originalTimeout;
|
||||||
}
|
}
|
||||||
|
assert.deepEqual(startupEvents, [
|
||||||
|
'environment', 'scheduler', 'environment', 'scheduler',
|
||||||
|
]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await instances.findAll({ order: [['id', 'ASC']], raw: true }),
|
await instances.findAll({ order: [['id', 'ASC']], raw: true }),
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ test('health uses actual readiness and returns HTTP 503 until recovery',async(t)
|
|||||||
assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready);
|
assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
test('health failure does not reveal internal error details',async(t)=>{
|
||||||
|
const express=require('express');
|
||||||
|
const HealthService=class{};
|
||||||
|
const app=express();load('back/api/health.ts',{
|
||||||
|
typedi:{get:()=>({check:async()=>{throw Error('sensitive health detail');}})},
|
||||||
|
'../services/health':{HealthService},'../loaders/logger':{error(){}},
|
||||||
|
}).default(app);
|
||||||
|
const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
|
||||||
|
t.after(()=>new Promise(r=>{server.close(r);server.closeAllConnections();}));
|
||||||
|
const response=await fetch(`http://127.0.0.1:${server.address().port}/health`);
|
||||||
|
assert.equal(response.status,500);
|
||||||
|
assert.deepEqual(await response.json(),{code:500,message:'Health check failed'});
|
||||||
|
});
|
||||||
test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{
|
test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{
|
||||||
const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a);
|
const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a);
|
||||||
const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText;
|
const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const test = require('node:test');
|
|||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const { Sequelize, QueryTypes } = require('sequelize');
|
const { Sequelize, DataTypes, QueryTypes } = require('sequelize');
|
||||||
const { migrateSchema } = require('../../back/shared/schemaMigrations');
|
const { migrateSchema } = require('../../back/shared/schemaMigrations');
|
||||||
const load = require('../helpers/load-security-module.cjs');
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
|
||||||
@@ -46,6 +46,39 @@ test('legacy database upgrades without losing rows and repeated migration is ide
|
|||||||
assert.equal(applied.length, 15);
|
assert.equal(applied.length, 15);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('migration repairs malformed environment labels before ORM reads', async (t) => {
|
||||||
|
const database = await createLegacy();
|
||||||
|
t.after(() => database.close());
|
||||||
|
await migrateSchema(database);
|
||||||
|
for (const [id, labels] of [
|
||||||
|
[1, ''],
|
||||||
|
[2, null],
|
||||||
|
[3, '{invalid'],
|
||||||
|
[4, '{}'],
|
||||||
|
[5, '["keep"]'],
|
||||||
|
]) {
|
||||||
|
await database.query('INSERT INTO "Envs" (id, name, labels) VALUES (?, ?, ?)', {
|
||||||
|
replacements: [id, `env-${id}`, labels],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnvModel = database.define('Env', {
|
||||||
|
name: DataTypes.STRING,
|
||||||
|
labels: DataTypes.JSON,
|
||||||
|
}, { timestamps: false });
|
||||||
|
await assert.rejects(EnvModel.findAll(), /Unexpected end of JSON input/);
|
||||||
|
await migrateSchema(database);
|
||||||
|
const rows = await EnvModel.findAll({ order: [['id', 'ASC']] });
|
||||||
|
assert.deepEqual(rows.map((row) => row.labels), [[], [], [], [], ['keep']]);
|
||||||
|
await migrateSchema(database);
|
||||||
|
const repaired = await database.query('SELECT id, labels FROM "Envs" ORDER BY id', {
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
});
|
||||||
|
assert.deepEqual(repaired.map(({ labels }) => labels), [
|
||||||
|
'[]', '[]', '[]', '[]', '["keep"]',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('migration failure rolls back added columns and can be retried after repair', async (t) => {
|
test('migration failure rolls back added columns and can be retried after repair', async (t) => {
|
||||||
const database = await createLegacy(':memory:', true);
|
const database = await createLegacy(':memory:', true);
|
||||||
t.after(() => database.close());
|
t.after(() => database.close());
|
||||||
|
|||||||
@@ -397,6 +397,22 @@ for (const mutation of ['resetSecret', 'remove', 'update', 'create']) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('application mutations cannot change or expose the built-in system app', async (t) => {
|
||||||
|
const state = appsFixture(t);
|
||||||
|
for (const change of [
|
||||||
|
() => state.service.resetSecret(1),
|
||||||
|
() => state.service.update({ id: 1, name: 'system', scopes: ['crons'] }),
|
||||||
|
() => state.service.update({ id: 2, name: 'system', scopes: ['crons'] }),
|
||||||
|
() => state.service.remove([1, 2]),
|
||||||
|
]) {
|
||||||
|
await assert.rejects(change(), (error) => error.status === 403);
|
||||||
|
}
|
||||||
|
assert.equal(state.apps().length, 2);
|
||||||
|
assert.equal(state.apps()[0].client_secret, 'system-secret');
|
||||||
|
assert.equal(state.apps()[0].tokens[0].value, 'system-token');
|
||||||
|
assert.deepEqual(state.cached(), state.apps());
|
||||||
|
});
|
||||||
|
|
||||||
test('system initialization preserves active tokens while migrating dashboard scope', async (t) => {
|
test('system initialization preserves active tokens while migrating dashboard scope', async (t) => {
|
||||||
const state = appsFixture(t);
|
const state = appsFixture(t);
|
||||||
await AppModel.update({ scopes: ['crons', 'system'] }, { where: { id: 1 } });
|
await AppModel.update({ scopes: ['crons', 'system'] }, { where: { id: 1 } });
|
||||||
|
|||||||
+9
-11
@@ -1,12 +1,10 @@
|
|||||||
version: 2.24.0
|
version: 2.25.0-rc.0
|
||||||
changeLogLink: https://github.com/whyour/qinglong/blob/v2.24.0/docs/releases/2.24.0.md
|
changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.25.0-rc.0.md
|
||||||
publishTime: "2026-10-29 02:14:58"
|
publishTime: "2026-11-10 01:16:12"
|
||||||
changeLog: |
|
changeLog: |
|
||||||
1. Alpine 与 Debian 默认镜像统一使用 Node.js 24,SQLite 依赖升级至 1.1.4
|
1. 收紧应用权限、首次通知配置与任务环境变量边界;启动前修复不安全的环境变量标签
|
||||||
2. 新增 Node.js 原生部署的 Python venv 隔离支持,以及 Python Git URL、requirements、pyproject 依赖源
|
2. 依赖管理改为按需复查状态,配置与脚本编辑器新增自动换行开关
|
||||||
3. 新增通知审计日志,完善通知失败记录与配置提示
|
3. 通知请求及线程等待增加超时,避免无响应渠道阻塞任务
|
||||||
4. 升级包按平台构建并校验;支持在线暂存、离线安装、失败回退及保留数据迁移
|
4. 修复挂载空数据卷后 Python 通知缺少 requests 依赖的问题
|
||||||
5. 修复任务调度、额外规则、依赖安装、脚本发现和订阅同步等问题
|
5. 独立远程 CLI 候选版本为 0.1.5-rc.1,改善跨平台配置与 OpenAPI 命令
|
||||||
6. 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0
|
6. 此版本为开发候选;升级前备份数据并在目标设备验证任务、依赖、通知和应用权限
|
||||||
7. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7,Python 3.10 兼容镜像停止更新
|
|
||||||
8. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知
|
|
||||||
|
|||||||
Reference in New Issue
Block a user