Compare commits

...
Author SHA1 Message Date
whyour 13aa99f67d fix(db): repair invalid environment labels before startup
Normalize malformed Envs.labels values to JSON arrays during schema migration so ORM reads cannot abort HTTP worker initialization. Preserve valid label arrays and cover corrupt legacy rows. Fixes #3128.
2026-11-09 01:12:53 +08:00
Henglie 13e0ff55d4 fix(notify): bound notification requests and thread waits (#3129)
Add a 10s timeout to every requests call in sample/notify.py so a
non-responsive push service cannot block a task indefinitely. Mark
notification threads as daemon and join them with a bounded timeout,
recording channels that do not finish as failures.

Add a regression test covering missing timeouts and send() returning
after a hung channel.
2026-11-09 01:11:32 +08:00
whyour 2bf814e562 feat(editor): add word wrap toggle for config and scripts 2026-11-08 03:04:17 +08:00
whyour 41910b5236 fix(dependencies): avoid repeated checks when opening dependency management (#3122)
* fix(dependencies): cache status reads and refresh asynchronously

* fix(cli): expose asynchronous dependency refresh operations
2026-11-03 23:08:16 +08:00
whyour 442ab2c616 fix(security): restrict task environment controls and app scopes (#3121)
* fix(security): restrict task environment controls and app scopes

* fix(security): preserve legacy scopes and add read-only access

* feat(security): add read-only scopes for remaining resources

* fix(security): reject apps scope grants in app API

* fix(security): protect system app and task file boundaries

* fix: block case-variant secret paths and task runner env overrides
2026-11-03 23:04:43 +08:00
45 changed files with 1774 additions and 124 deletions
+68 -6
View File
@@ -11,12 +11,11 @@ export default (app: Router) => {
route.get(
'/',
celebrate({
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
query: Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
@@ -31,6 +30,69 @@ export default (app: Router) => {
},
);
route.post(
'/refresh',
celebrate({
body: Joi.object({
type: Joi.string().valid('nodejs', 'python3').required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const { job, accepted } = dependenceService.startRefresh(req.body.type);
if (!accepted) {
return res.status(409).send({
code: 409,
message: 'Another dependency refresh is already running',
});
}
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/active',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
res.set('Cache-Control', 'no-store');
return res.send({
code: 200,
data: dependenceService.getActiveRefreshJob() || null,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/:id',
celebrate({
params: Joi.object({ id: Joi.string().uuid().required() }),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const job = dependenceService.getRefreshJob(req.params.id);
if (!job) {
return res.status(404).send({
code: 404,
message: 'Dependency refresh job not found',
});
}
res.set('Cache-Control', 'no-store');
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
celebrate({
+2 -2
View File
@@ -27,7 +27,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
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) => {
@@ -47,7 +47,7 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
name: Joi.string().optional().allow(''),
scopes: Joi.array().items(Joi.string()),
scopes: Joi.array().items(Joi.string().invalid('apps')),
id: Joi.number().required(),
}),
}),
+19 -1
View File
@@ -24,7 +24,25 @@ export interface AppToken {
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 const AppModel = sequelize.define<AppInstance>('App', {
+37 -1
View File
@@ -112,7 +112,43 @@ export default ({ app }: { app: Application }) => {
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
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', {
message: t('暂无权限'),
});
+3 -2
View File
@@ -222,6 +222,9 @@ export default async () => {
}
} catch { }
// Replace legacy task environment files before recovered tasks can run.
await envService.set_envs();
// 初始化保存一次ck和定时任务数据
cronClient.readiness.configure(async () => {
await cronService.autosave_crontab(true);
@@ -229,8 +232,6 @@ export default async () => {
});
await cronClient.readiness.recover();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
await shareStore.updateAuthInfo(authInfo);
await openService.refreshApps();
+76 -19
View File
@@ -27,9 +27,20 @@ import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
import { randomUUID } from 'crypto';
type RefreshType = 'nodejs' | 'python3';
type RefreshJob = {
id: string;
type: RefreshType;
state: 'running' | 'completed' | 'failed';
};
@Service()
export default class DependenceService {
private activeRefreshJob?: RefreshJob;
private refreshJobs = new Map<string, RefreshJob>();
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
@@ -118,11 +129,6 @@ export default class DependenceService {
query: any = {},
): Promise<Dependence[]> {
let condition = query;
const dependenceType =
type && DependenceTypes[type] !== undefined
? DependenceTypes[type]
: undefined;
await this.refreshInstalledStatuses(dependenceType);
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
@@ -145,6 +151,52 @@ export default class DependenceService {
}
}
public startRefresh(type: RefreshType): {
job: RefreshJob;
accepted: boolean;
} {
if (this.activeRefreshJob) {
return {
job: this.activeRefreshJob,
accepted: this.activeRefreshJob.type === type,
};
}
const job: RefreshJob = { id: randomUUID(), type, state: 'running' };
this.activeRefreshJob = job;
this.refreshJobs.set(job.id, job);
while (this.refreshJobs.size > 20) {
const oldestId = this.refreshJobs.keys().next().value;
this.refreshJobs.delete(oldestId);
}
void this.refreshInstalledStatuses(DependenceTypes[type])
.then(() => {
job.state = 'completed';
})
.catch((error) => {
this.logger.error('Dependency refresh failed: %o', error);
job.state = 'failed';
})
.finally(() => {
if (this.activeRefreshJob === job) {
this.activeRefreshJob = undefined;
}
});
return { job, accepted: true };
}
public getRefreshJob(id: string): RefreshJob | undefined {
return this.refreshJobs.get(id);
}
public getActiveRefreshJob(): RefreshJob | undefined {
return this.activeRefreshJob?.state === 'running'
? this.activeRefreshJob
: undefined;
}
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
@@ -160,26 +212,31 @@ export default class DependenceService {
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
},
});
const checkableDocs = docs.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
);
const checks = await concurrentRun(
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
if (missingIds.length) {
for (const [index, installed] of (checks || []).entries()) {
if (installed) continue;
const doc = checkableDocs[index];
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{ where: { id: missingIds } },
{
where: {
id: doc.id,
status: DependenceStatus.installed,
name: doc.name,
type: doc.type,
updatedAt: (doc as typeof doc & { updatedAt: Date }).updatedAt,
// Sequelize's automatic timestamp is absent from Dependence's attribute type.
} as any,
},
);
}
}
+22 -3
View File
@@ -14,12 +14,17 @@ import {
} from '../data/env';
import { writeFileWithLock } from '../shared/utils';
import { sequelize } from '../data';
import {
assertAllowedTaskEnvName,
isAllowedTaskEnvName,
} from '../shared/taskEnvName';
@Service()
export default class EnvService {
constructor(@Inject('logger') private logger: winston.Logger) { }
public async create(payloads: Env[]): Promise<Env[]> {
payloads.forEach((payload) => assertAllowedTaskEnvName(payload.name));
const envs = await this.envs();
let position = initPosition;
if (
@@ -52,6 +57,7 @@ export default class EnvService {
public async update(payload: Env): Promise<Env> {
const doc = await this.getDb({ id: payload.id });
const tab = new Env({ ...doc, ...payload });
assertAllowedTaskEnvName(tab.name);
const newDoc = await this.updateDb(tab);
await this.set_envs();
return newDoc;
@@ -151,7 +157,10 @@ export default class EnvService {
['position', 'DESC'],
['createdAt', 'ASC'],
]);
return result;
return result.map((env) => ({
...env,
securityBlocked: !isAllowedTaskEnvName(env.name),
}));
} catch (error) {
throw error;
}
@@ -182,11 +191,14 @@ export default class EnvService {
}
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 this.set_envs();
}
public async updateNames({ ids, name }: { ids: number[]; name: string }) {
assertAllowedTaskEnvName(name);
await EnvModel.update({ name }, { where: { id: ids } });
await this.set_envs();
}
@@ -242,6 +254,13 @@ export default class EnvService {
name: { [Op.not]: null },
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');
let env_string = '';
let js_env_string = '';
@@ -250,8 +269,8 @@ export default class EnvService {
if (Object.prototype.hasOwnProperty.call(groups, key)) {
const group = groups[key];
// 忽略不符合bash要求的环境变量名称
if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) {
// Ignore invalid and reserved names, including legacy database rows.
if (isAllowedTaskEnvName(key)) {
let value = group
.map((x) => x.value)
.join('&')
+1 -1
View File
@@ -8,6 +8,6 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
return resolveFileAccess(config.logPath, [filePath || '', fileName], ['.tmp']);
}
}
+10
View File
@@ -92,6 +92,10 @@ export default class OpenService {
private async updateDb(payload: Partial<App>): Promise<App> {
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 } });
const apps = await this.find({});
await shareStore.updateApps(apps);
@@ -109,6 +113,12 @@ export default class OpenService {
public async remove(ids: number[]) {
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 } });
const apps = await this.find({});
await shareStore.updateApps(apps);
+4 -1
View File
@@ -24,8 +24,11 @@ export function resolveFileAccess(
const resolvedRoot = path.resolve(root);
const target = path.resolve(resolvedRoot, ...parts);
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) =>
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 '';
try {
const realRoot = fs.realpathSync(resolvedRoot);
+1
View File
@@ -23,6 +23,7 @@ const messages: Record<string, Record<string, string>> = {
'代理层数不能超过 20': 'Proxy hop count cannot exceed 20',
'环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖':
'QL_TRUST_PROXY is active and cannot be overridden by system settings',
环境变量名称不可使用: 'Environment variable name is reserved or invalid',
系统配置不存在: 'System configuration does not exist',
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本':
'Version history only supports UTF-8 text scripts up to 1 MiB',
+11
View File
@@ -66,5 +66,16 @@ export async function migrateSchema(database: Sequelize): Promise<void> {
);
}
}
// Older databases can contain non-JSON values in Envs.labels. Sequelize
// parses this column on every environment read, including HTTP startup.
await database.query(
`UPDATE "Envs" SET "labels" = '[]'
WHERE CASE WHEN json_valid("labels")
THEN json_type("labels") <> 'array'
ELSE 1
END`,
{ transaction },
);
});
}
+79
View File
@@ -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
View File
@@ -48,7 +48,7 @@ The CLI also supports task/subscription CRUD, application and secret management,
```sh
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 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 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
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
ql login --url https://ql.example.com
+2 -2
View File
@@ -48,7 +48,7 @@ ql auth logout --json
```sh
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 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 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
ql login --url https://ql.example.com
+18 -5
View File
@@ -1,16 +1,18 @@
# OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
CLI 对应本版本后端的 151 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
The catalogue covers 151 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
## 认证与权限 / 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,并避免把输出记录到共享日志。其他资源/通用请求可能含环境值、配置、脚本、订阅凭据和会话,按敏感数据处理。
@@ -49,16 +51,24 @@ ql script get --query '{"file":"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-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 get --query '{"path":"example.sh"}' --json
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
ql dependency create --data '[{"name":"example-package","type":0}]' --json
ql dependency refresh --data '{"type":"python3"}' --json
ql dependency refresh-active --json
ql dependency refresh-status <JOB_UUID> --json
ql system data-export --data '{}' --output ./panel.tgz --json
ql system data-import --file ./panel.tgz --json
ql api request PUT /open/crons/run --data '[12,13]' --json
ql api request GET /open/crons --query '{"page":1,"size":100,"searchValue":"demo"}' --json
```
依赖 refresh 的 type 为 nodejs 或 python3,立即返回后台任务 id 与 state;用 refresh-status 查询该 UUID,直到 completed 或 failed。refresh-active 返回运行中的任务或 null,普通 list 不触发检查。
Dependency refresh accepts type nodejs or python3 and immediately returns a background job id and state. Poll refresh-status with that UUID until completed or failed. refresh-active returns the running job or null; listing dependencies does not trigger checks.
任务 create/update 需要 command/schedule;name、labels、sub_id、extra_schedules、task_before/after、log_name、allow_multiple_instances、work_dir 等字段可通过 --data 提交。订阅 create 需要 type/url/alias/schedule_type,update 需要 id/type/url/alias;interval_schedule、pull_option、dependences、extensions、sub_before/after、proxy、autoAddCron/autoDelCron 等用 --data。private-repo 凭据仅通过受保护文件或 stdin 提供。环境变量与依赖 create 接受对象数组,应用 scopes 为字符串数组。其余准确字段/枚举由当前后端 Joi schema 校验:见 [back/api](https://github.com/whyour/qinglong/tree/develop/back/api) 和 [定时规则 schema](https://github.com/whyour/qinglong/blob/develop/back/validation/schedule.ts)。
Task creation/update needs command/schedule; additional fields use --data. Subscription creation needs type/url/alias/schedule_type; updates need id/type/url/alias. Interval schedules, private repository credentials, filters, hooks and booleans use --data. Env/dependency creation accepts arrays; application scopes is a string array. The server validates field types/enums; the linked backend schemas are authoritative.
@@ -151,6 +161,9 @@ System/user operations below execute remotely through the panel API. They do not
| `ql log delete ` | DELETE | `logs` | --data, --query |
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
| `ql dependency list ` | GET | `dependencies` | --query |
| `ql dependency refresh ` | POST | `dependencies/refresh` | --data, --query |
| `ql dependency refresh-active ` | GET | `dependencies/refresh/active` | --query |
| `ql dependency refresh-status <id>` | GET | `dependencies/refresh/:id` | --query |
| `ql dependency create ` | POST | `dependencies` | --data, --query |
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
+19
View File
@@ -5,6 +5,7 @@ export interface OpenOperation {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: string[];
uuidParams?: string[];
body?: 'ids' | 'json' | 'object-id';
upload?: string;
download?: boolean;
@@ -334,6 +335,24 @@ export const openOperations: readonly OpenOperation[] = [
download: true,
},
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
{
name: 'dependency refresh',
method: 'POST',
path: 'dependencies/refresh',
body: 'json',
},
{
name: 'dependency refresh-active',
method: 'GET',
path: 'dependencies/refresh/active',
},
{
name: 'dependency refresh-status',
method: 'GET',
path: 'dependencies/refresh/:id',
params: ['id'],
uuidParams: ['id'],
},
{
name: 'dependency create',
method: 'POST',
+8
View File
@@ -107,6 +107,14 @@ export async function auth(
{
crons: 'crons?page=1&size=1',
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',
} as Record<string, string>
)[scope] ?? scope,
+15 -3
View File
@@ -49,12 +49,19 @@ async function input(value: string | boolean | undefined): Promise<unknown> {
}
}
const UUID_PATTERN =
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
function route(method: string, path: string): OpenOperation {
const match = openOperations.find(
(op) =>
op.method === method &&
new RegExp(
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
'^' +
op.path.replace(/:([A-Za-z]+)/g, (_match, name) =>
op.uuidParams?.includes(name) ? UUID_PATTERN : '[1-9][0-9]*',
) +
'$',
).test(path),
);
if (!match)
@@ -63,7 +70,8 @@ function route(method: string, path: string): OpenOperation {
'Method/path is not a supported OpenAPI route.',
);
for (const [index, part] of match.path.split('/').entries())
if (part.startsWith(':')) integer(path.split('/')[index]!);
if (part.startsWith(':') && !match.uuidParams?.includes(part.slice(1)))
integer(path.split('/')[index]!);
return match;
}
@@ -107,7 +115,11 @@ export async function openCommand(
if (!generic) {
endpoint = op.path;
for (const [index, name] of (op.params ?? []).entries()) {
const value = integer(command.positionals[index]!);
const positional = command.positionals[index]!;
const isUuid = op.uuidParams?.includes(name);
if (isUuid && !new RegExp(`^${UUID_PATTERN}$`).test(positional))
usage('任务 ID 必须为 UUID。', 'Job ID must be a UUID.');
const value = isUuid ? positional : integer(positional);
endpoint = endpoint.replace(`:${name}`, String(value));
if (op.body === 'object-id') {
if (body !== undefined && !isRecord(body))
+8
View File
@@ -26,6 +26,14 @@ export const commands: readonly CommandSpec[] = [
default: 'crons',
choices: [
...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)),
},
},
+4 -4
View File
@@ -28,12 +28,12 @@ const fs = require('node:fs/promises');
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
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);
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
cli(['auth', 'status', '--scope', 'apps']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps']);
assert.ok(cli(['app', 'list']).every(item => !('client_secret' in item) && !('tokens' in item)));
cli(['auth', 'status', '--scope', 'crons']);
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'], 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 * * *']);
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)]);
+22
View File
@@ -81,6 +81,13 @@ async function fixture(t) {
code: 200,
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 (
['/panel/open/crons/run', '/panel/open/crons/stop'].includes(
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.doesNotMatch(status.out, /test-secret|test-token/);
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));
config.expiration = 1;
fs.writeFileSync(f.file, JSON.stringify(config));
+24 -2
View File
@@ -83,6 +83,27 @@ test('script history commands send exact query parameters and restore payloads',
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
});
test('dependency refresh commands send the dependency type and address background jobs', async t => {
const f = await fixture(t);
success(await f.run(['dependency', 'refresh', '--data', '{"type":"python3"}']));
assert.equal(f.requests[0].method, 'POST');
assert.equal(f.requests[0].url, '/panel/open/dependencies/refresh');
assert.deepEqual(JSON.parse(f.requests[0].body), { type: 'python3' });
const id = '32ec8b22-2c48-4bcf-85d4-b394198417ba';
success(await f.run(['dependency', 'refresh-status', id]));
assert.equal(f.requests[1].method, 'GET');
assert.equal(f.requests[1].url, `/panel/open/dependencies/refresh/${id}`);
success(await f.run(['dependency', 'refresh-active']));
assert.equal(f.requests[2].method, 'GET');
assert.equal(f.requests[2].url, '/panel/open/dependencies/refresh/active');
success(await f.run(['api', 'request', 'GET', `/open/dependencies/refresh/${id}`]));
assert.equal(f.requests[3].url, `/panel/open/dependencies/refresh/${id}`);
assert.equal((await f.run(['dependency', 'refresh-status', '7'])).code, 2);
assert.equal((await f.run(['api', 'request', 'GET', '/open/dependencies/refresh/7'])).code, 2);
assert.equal((await f.run(['dependency', 'refresh'])).code, 2);
assert.equal(f.requests.length, 4, 'invalid arguments must not issue a request');
});
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
const root = path.resolve(__dirname, '../../../back/api');
const expected = [];
@@ -132,7 +153,8 @@ test('all added named operations reach their registered method/path, including u
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
for (const op of openOperations.filter(op => !op.existing)) {
const args = op.name.split(' ');
for (const _ of op.params ?? []) args.push('7');
const parameter = name => op.uuidParams?.includes(name) ? '32ec8b22-2c48-4bcf-85d4-b394198417ba' : '7';
for (const name of op.params ?? []) args.push(parameter(name));
if (op.body === 'ids') args.push('7', '8');
else if (op.body) {
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
@@ -144,7 +166,7 @@ test('all added named operations reach their registered method/path, including u
success(await f.run(args));
const last = f.requests.at(-1);
assert.equal(last.method, op.method, op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:([A-Za-z]+)/g, (_match, name) => parameter(name)), op.name);
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
}
+78 -30
View File
@@ -18,6 +18,11 @@ from email.utils import formataddr
import requests
# 单次 HTTP 请求的默认超时(秒),避免推送服务无响应时永久阻塞
REQUEST_TIMEOUT = 10
# 等待推送线程结束的最长时间(秒),超时后跳过并继续退出
NOTIFY_JOIN_TIMEOUT = 30
# 原先的 print 函数和主线程的锁
_print = print
mutex = threading.Lock()
@@ -199,7 +204,7 @@ def bark(title: str, content: str) -> Optional[bool]:
data[bark_params.get(pair[0])] = pair[1]
headers = {"Content-Type": "application/json;charset=utf-8"}
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()
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"}
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
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()
if not response["errcode"]:
@@ -278,7 +283,9 @@ def feishu_bot(title: str, content: str) -> Optional[bool]:
data["timestamp"] = timestamp
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:
audit_results.append(True)
@@ -299,7 +306,7 @@ def go_cqhttp(title: str, content: str) -> Optional[bool]:
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}'
response = requests.get(url).json()
response = requests.get(url, timeout=REQUEST_TIMEOUT).json()
if response["status"] == "ok":
audit_results.append(True)
@@ -325,7 +332,7 @@ def gotify(title: str, content: str) -> Optional[bool]:
"message": content,
"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"):
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")}'
data = {"title": title, "content": content}
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:
audit_results.append(True)
@@ -377,7 +386,7 @@ def serverJ(title: str, content: str) -> Optional[bool]:
else:
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:
audit_results.append(True)
@@ -406,7 +415,7 @@ def pushdeer(title: str, content: str) -> Optional[bool]:
if 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:
audit_results.append(True)
@@ -427,7 +436,7 @@ def chat(title: str, content: str) -> Optional[bool]:
print("chat 服务启动")
data = "payload=" + json.dumps({"text": title + "\n" + content})
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:
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")
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"]
if code == 200:
@@ -477,7 +488,9 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
else:
url_old = "http://pushplus.hxtrip.com/send"
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:
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")
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:
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")}'
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:
audit_results.append(True)
@@ -599,7 +616,7 @@ class WeCom:
"corpid": self.CORPID,
"corpsecret": self.CORPSECRET,
}
req = requests.post(url, params=values)
req = requests.post(url, params=values, timeout=REQUEST_TIMEOUT)
data = json.loads(req.text)
return data["access_token"]
@@ -615,7 +632,9 @@ class WeCom:
"safe": "0",
}
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()
return respone["errmsg"]
@@ -641,7 +660,9 @@ class WeCom:
},
}
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()
return respone["errmsg"]
@@ -663,7 +684,7 @@ def wecom_bot(title: str, content: str) -> Optional[bool]:
headers = {"Content-Type": "application/json;charset=utf-8"}
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
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()
if response["errcode"] == 0:
@@ -711,7 +732,11 @@ def telegram_bot(title: str, content: str) -> Optional[bool]:
)
proxies = {"http": proxyStr, "https": proxyStr}
response = requests.post(
url=url, headers=headers, params=payload, proxies=proxies
url=url,
headers=headers,
params=payload,
proxies=proxies,
timeout=REQUEST_TIMEOUT,
).json()
if response["ok"]:
@@ -752,7 +777,9 @@ def aibotk(title: str, content: str) -> Optional[bool]:
}
body = json.dumps(data).encode(encoding="utf-8")
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)
if response["code"] == 0:
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 "",
"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":
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 chat_type == 1:
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"))
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 进行检查
audit_results.append(True)
print("Ntfy 推送成功!")
@@ -995,7 +1026,9 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
}
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:
audit_results.append(True)
@@ -1046,7 +1079,9 @@ def wxpusher_spt(title: str, content: str) -> Optional[bool]:
data["sptList"] = spts
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:
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")
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:
audit_results.append(True)
@@ -1121,7 +1158,9 @@ def openilink(title: str, content: str) -> Optional[bool]:
if 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"):
audit_results.append(True)
@@ -1215,7 +1254,7 @@ def custom_notify(title: str, content: str) -> Optional[bool]:
"$title", urllib.parse.quote_plus(title)
).replace("$content", urllib.parse.quote_plus(content))
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:
@@ -1233,7 +1272,7 @@ def one() -> str:
:return:
"""
url = "https://v1.hitokoto.cn/"
res = requests.get(url).json()
res = requests.get(url, timeout=REQUEST_TIMEOUT).json()
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()
results = []
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
]
[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)
+16
View File
@@ -1,5 +1,9 @@
{
"选中条数": "Selected {count} items",
"重新检查依赖": "Recheck dependencies",
"依赖检查失败": "Dependency check failed",
"正在检查依赖": "Checking {type} dependencies",
"依赖检查状态已中断": "Dependency check status was lost. Please retry.",
"登录失败次数": "Failed {count} time(s)",
"间隔定时规则": "Every {value} {unit}",
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
@@ -112,6 +116,7 @@
"代理端口": "Proxy Port",
"以后再说": "Later",
"仪表盘": "Dashboard",
"仪表盘(只读)": "Dashboard (read only)",
"任务": "Task",
"任务数": "Tasks",
"任务未运行": "Task not running",
@@ -122,6 +127,7 @@
"使用手机应用扫描二维码,或者输入秘钥": "Scan the QR code with the mobile app or enter the key",
"依赖文件": "Dependency Files",
"依赖管理": "Dependency Management",
"依赖管理(只读)": "Dependencies (read only)",
"依赖类型": "Dependency Type",
"依赖缓存": "Dependency cache",
"依赖设置": "Dependence Settings",
@@ -188,6 +194,8 @@
"可用,你使用的版本为": "available, the version you are using is",
"同时删除关联任务和脚本": "Delete associated tasks and scripts as well",
"名称": "Name",
"名称受限": "Restricted name",
"名称受限,未注入任务": "Restricted name; not injected into tasks",
"名称不能为保留关键字": "The name cannot be a reserved keyword",
"吗": "?",
"否": "No",
@@ -228,6 +236,7 @@
"完成设置": "Finish Setup",
"定时": "Schedule",
"定时任务": "Scheduled Tasks",
"定时任务(只读)": "Scheduled Tasks (read only)",
"定时任务并发数": "Concurrent Scheduled Tasks",
"定时类型": "Schedule Type",
"定时规则": "Schedule Rule",
@@ -331,6 +340,7 @@
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators",
"日志文件": "Log files",
"日志管理": "Log Management",
"日志管理(只读)": "Logs (read only)",
"时": "hour(s)",
"时区": "Timezone",
"是": "Yes",
@@ -411,6 +421,7 @@
"版本": "Version",
"状态": "Status",
"环境变量": "Environment variable",
"环境变量(只读)": "Environment variables (read only)",
"生成数据中...": "Generating data...",
"用于访问所有私有仓库的全局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/",
@@ -494,8 +505,10 @@
"脚本执行时的工作目录,留空则自动检测。相对路径基于 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 Management",
"脚本(只读)": "Scripts (read only)",
"自动删除任务": "Automatically Delete Tasks",
"自动拆分": "Auto Split",
"自动换行": "Word wrap",
"自动添加任务": "Automatically Add Tasks",
"自动生成": "Auto-generated",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate",
@@ -508,11 +521,13 @@
"视图管理": "View Management",
"订阅": "Subscription",
"订阅管理": "Subscription Management",
"订阅管理(只读)": "Subscriptions (read only)",
"设置": "Settings",
"访问密钥": "Access Key",
"语言": "Language",
"请": "Please",
"请先按如下方式修复:": "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.",
"请求头Content-Type": "Request Header Content-Type",
"请求头格式Custom-Header1: Header1,多个换行分割": "Request header format: Custom-Header1: Header1 (separate multiple headers with line breaks)",
@@ -606,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",
"邮箱认证地址": "Email address for SMTP authentication",
"配置文件": "Configuration Files",
"配置文件(只读)": "Configuration files (read only)",
"重启": "Restart",
"重命名": "Rename",
"重新下载": "Redownload",
+16
View File
@@ -1,5 +1,9 @@
{
"选中条数": "已选择 {count} 项",
"重新检查依赖": "重新检查依赖",
"依赖检查失败": "依赖检查失败",
"正在检查依赖": "正在检查 {type} 依赖",
"依赖检查状态已中断": "依赖检查状态已中断,请重新检查",
"登录失败次数": "失败{count}次",
"间隔定时规则": "每{value}{unit}",
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
@@ -111,6 +115,7 @@
"代理端口": "代理端口",
"以后再说": "以后再说",
"仪表盘": "仪表盘",
"仪表盘(只读)": "仪表盘(只读)",
"任务": "任务",
"任务数": "任务数",
"任务未运行": "任务未运行",
@@ -121,6 +126,7 @@
"使用手机应用扫描二维码,或者输入秘钥": "使用手机应用扫描二维码,或者输入秘钥",
"依赖文件": "依赖文件",
"依赖管理": "依赖管理",
"依赖管理(只读)": "依赖管理(只读)",
"依赖类型": "依赖类型",
"依赖缓存": "依赖缓存",
"依赖设置": "依赖设置",
@@ -187,6 +193,8 @@
"可用,你使用的版本为": "可用,你使用的版本为",
"同时删除关联任务和脚本": "同时删除关联任务和脚本",
"名称": "名称",
"名称受限": "名称受限",
"名称受限,未注入任务": "名称受限,未注入任务",
"名称不能为保留关键字": "名称不能为保留关键字",
"吗": "吗",
"否": "否",
@@ -227,6 +235,7 @@
"完成设置": "完成设置",
"定时": "定时",
"定时任务": "定时任务",
"定时任务(只读)": "定时任务(只读)",
"定时任务并发数": "定时任务并发数",
"定时类型": "定时类型",
"定时规则": "定时规则",
@@ -330,6 +339,7 @@
"日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符": "日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符",
"日志文件": "日志文件",
"日志管理": "日志管理",
"日志管理(只读)": "日志管理(只读)",
"时": "时",
"时区": "时区",
"是": "是",
@@ -411,6 +421,7 @@
"版本": "版本",
"状态": "状态",
"环境变量": "环境变量",
"环境变量(只读)": "环境变量(只读)",
"生成数据中...": "生成数据中...",
"用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥",
"用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/",
@@ -494,8 +505,10 @@
"脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径": "脚本执行时的工作目录,留空则自动检测。相对路径基于 scripts 目录,也支持绝对路径",
"脚本文件": "脚本文件",
"脚本管理": "脚本管理",
"脚本(只读)": "脚本(只读)",
"自动删除任务": "自动删除任务",
"自动拆分": "自动拆分",
"自动换行": "自动换行",
"自动添加任务": "自动添加任务",
"自动生成": "自动生成",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成",
@@ -508,11 +521,13 @@
"视图管理": "视图管理",
"订阅": "订阅",
"订阅管理": "订阅管理",
"订阅管理(只读)": "订阅管理(只读)",
"设置": "设置",
"访问密钥": "访问密钥",
"语言": "语言",
"请": "请",
"请先按如下方式修复:": "请先按如下方式修复:",
"请先修改环境变量名称": "请先修改环境变量名称",
"请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置",
"请求头Content-Type": "请求头Content-Type",
"请求头格式Custom-Header1: Header1,多个换行分割": "请求头格式Custom-Header1: Header1,多个换行分割",
@@ -606,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",
"邮箱认证地址": "邮箱认证地址",
"配置文件": "配置文件",
"配置文件(只读)": "配置文件(只读)",
"重启": "重启",
"重命名": "重命名",
"重新下载": "重新下载",
+27 -3
View File
@@ -6,7 +6,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import { Button, message, Modal, TreeSelect } from 'antd';
import { Button, message, Modal, Space, Switch, TreeSelect } from 'antd';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http';
@@ -15,6 +15,7 @@ import CodeMirror from '@uiw/react-codemirror';
import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts';
import { langs } from '@uiw/codemirror-extensions-langs';
import { EditorView } from '@codemirror/view';
import { useHotkeys } from 'react-hotkeys-hook';
import { getEditorMode } from '@/utils';
@@ -28,6 +29,11 @@ const Config = () => {
const editorRef = useRef<any>(null);
const [confirmLoading, setConfirmLoading] = useState(false);
const [language, setLanguage] = useState<string>('shell');
const [wordWrap, setWordWrap] = useState(
() =>
(localStorage.getItem('qinglong_editor_word_wrap') ??
localStorage.getItem('qinglong_config_word_wrap')) === 'true',
);
const getConfig = (name: string) => {
request
@@ -104,6 +110,20 @@ const Config = () => {
defaultValue="config.sh"
onSelect={onSelect}
/>,
<Space key="word-wrap" size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={(checked) => {
setWordWrap(checked);
localStorage.setItem(
'qinglong_editor_word_wrap',
String(checked),
);
}}
/>
</Space>,
<Button
key="1"
loading={confirmLoading}
@@ -121,7 +141,10 @@ const Config = () => {
<CodeMirror
value={value}
theme={theme.includes('dark') ? 'dark' : 'light'}
extensions={[langs.shell()]}
extensions={[
langs.shell(),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
onChange={(value) => {
setValue(value);
}}
@@ -136,7 +159,8 @@ const Config = () => {
lineNumbersMinChars: 3,
folding: false,
glyphMargin: false,
accessibilitySupport: 'off'
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
+175 -10
View File
@@ -87,6 +87,7 @@ const StatusMap: Record<number, { icon: React.ReactNode; color: string }> = {
const Dependence = () => {
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
const [statusFilter, setStatusFilter] = useState<number[]>([]);
const columns: any = [
{
title: intl.get('序号'),
@@ -106,6 +107,7 @@ const Dependence = () => {
key: 'status',
width: 120,
dataIndex: 'status',
filteredValue: statusFilter.length ? statusFilter : null,
filters: [
{
text: intl.get('队列中'),
@@ -233,6 +235,9 @@ const Dependence = () => {
];
const [value, setValue] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [refreshingType, setRefreshingType] = useState<string | null>(null);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [isModalVisible, setIsModalVisible] = useState(false);
const [editedDependence, setEditedDependence] = useState();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
@@ -240,9 +245,26 @@ const Dependence = () => {
const [logDependence, setLogDependence] = useState<any>();
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
const [type, setType] = useState('nodejs');
const listQuery = useRef({ type, searchText, statusFilter });
listQuery.current = { type, searchText, statusFilter };
const tableRef = useRef<HTMLDivElement>(null);
const latestListRequest = useRef(0);
const refreshTimer = useRef<ReturnType<typeof setTimeout>>();
const activeRefreshId = useRef<string | null>(null);
const refreshRequestVersion = useRef(0);
const alive = useRef(true);
const [height, setHeight] = useState<number>(0);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
latestListRequest.current += 1;
refreshRequestVersion.current += 1;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
};
}, []);
useResizeObserver(tableRef, (entry) => {
const _height =
entry.target?.parentElement?.parentElement?.parentElement?.offsetHeight;
@@ -256,23 +278,146 @@ const Dependence = () => {
});
const getDependencies = (status?: number[]) => {
const query = listQuery.current;
const requestId = ++latestListRequest.current;
setLoading(true);
request
.get(
`${
config.apiPrefix
}dependencies?searchValue=${searchText}&type=${type}&status=${
status || ''
}`,
`${config.apiPrefix}dependencies?searchValue=${query.searchText}&type=${
query.type
}&status=${status ?? query.statusFilter}`,
)
.then(({ code, data }) => {
if (code === 200) {
if (
alive.current &&
requestId === latestListRequest.current &&
code === 200
) {
setValue(data);
}
})
.finally(() => setLoading(false));
.finally(() => {
if (alive.current && requestId === latestListRequest.current) {
setLoading(false);
}
});
};
const scheduleRefresh = (callback: () => void, delay: number) => {
if (refreshTimer.current) clearTimeout(refreshTimer.current);
refreshTimer.current = setTimeout(callback, delay);
};
const finishRefresh = (id: string, completed: boolean) => {
if (activeRefreshId.current !== id) return;
refreshRequestVersion.current += 1;
activeRefreshId.current = null;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
setRefreshing(false);
setRefreshingType(null);
setRefreshGeneration((generation) => generation + 1);
if (!completed) {
message.error(intl.get('依赖检查失败'));
}
};
const attachRefresh = (job: { id: string; type: string }) => {
if (!alive.current) return;
refreshRequestVersion.current += 1;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
activeRefreshId.current = job.id;
setRefreshing(true);
setRefreshingType(job.type);
scheduleRefresh(() => pollRefresh(job.id), 0);
};
const recoverRefresh = (
reloadWhenIdle = false,
retryOnError = false,
notifyWhenIdle = false,
) => {
const version = ++refreshRequestVersion.current;
request
.get(`${config.apiPrefix}dependencies/refresh/active`)
.then(({ code, data }) => {
if (!alive.current || version !== refreshRequestVersion.current) return;
if (code === 200 && data) {
attachRefresh(data);
} else if (reloadWhenIdle) {
activeRefreshId.current = null;
setRefreshing(false);
setRefreshingType(null);
setRefreshGeneration((generation) => generation + 1);
if (notifyWhenIdle) message.warning(intl.get('依赖检查状态已中断'));
}
})
.catch(() => {
if (
alive.current &&
version === refreshRequestVersion.current &&
retryOnError
) {
scheduleRefresh(() => {
if (alive.current && version === refreshRequestVersion.current) {
recoverRefresh(reloadWhenIdle, true, notifyWhenIdle);
}
}, 5000);
}
});
};
const pollRefresh = (id: string) => {
request
.get(`${config.apiPrefix}dependencies/refresh/${id}`)
.then(({ code, data }) => {
if (!alive.current || activeRefreshId.current !== id) return;
if (code !== 200) {
scheduleRefresh(() => pollRefresh(id), 5000);
return;
}
if (data.state === 'running') {
scheduleRefresh(() => pollRefresh(id), 2000);
} else {
finishRefresh(id, data.state === 'completed');
}
})
.catch((error) => {
if (!alive.current || activeRefreshId.current !== id) return;
if (error?.response?.status === 404) {
activeRefreshId.current = null;
recoverRefresh(true, true, true);
} else {
scheduleRefresh(() => pollRefresh(id), 5000);
}
});
};
const startRefresh = () => {
if (activeRefreshId.current) return;
const version = ++refreshRequestVersion.current;
setRefreshing(true);
setRefreshingType(type);
request
.post(`${config.apiPrefix}dependencies/refresh`, { type })
.then(({ code, data }) => {
if (!alive.current || version !== refreshRequestVersion.current) return;
if (code === 200) {
attachRefresh(data);
} else {
recoverRefresh(true, true);
}
})
.catch(() => {
if (alive.current && version === refreshRequestVersion.current) {
recoverRefresh(true, true);
}
});
};
useEffect(() => {
recoverRefresh(false, true);
}, []);
const addDependence = () => {
setEditedDependence(null as any);
setIsModalVisible(true);
@@ -450,12 +595,16 @@ const Dependence = () => {
};
const onSearch = (value: string) => {
setSearchText(value.trim());
const trimmed = value.trim();
if (trimmed !== searchText) {
latestListRequest.current += 1;
setSearchText(trimmed);
}
};
useEffect(() => {
getDependencies();
}, [searchText, type]);
}, [searchText, type, refreshGeneration]);
useEffect(() => {
if (logDependence) {
@@ -506,6 +655,7 @@ const Dependence = () => {
}, []);
const onTabChange = (activeKey: string) => {
latestListRequest.current += 1;
setSelectedRowIds([]);
setType(activeKey);
};
@@ -551,7 +701,9 @@ const Dependence = () => {
scroll={{ x: 768, y: height }}
loading={loading}
onChange={(pagination, filters) => {
getDependencies(filters?.status as number[]);
const status = (filters?.status as number[]) || [];
setStatusFilter(status);
getDependencies(status);
}}
/>
</DndProvider>
@@ -563,6 +715,19 @@ const Dependence = () => {
className="ql-container-wrapper dependence-wrapper ql-container-wrapper-has-tab"
title={intl.get('依赖管理')}
extra={[
<Button
key="refresh"
icon={<SyncOutlined />}
loading={refreshing}
disabled={type === 'linux' || loading}
onClick={startRefresh}
>
{refreshingType
? intl.get('正在检查依赖', {
type: refreshingType === 'nodejs' ? 'Node' : 'Python',
})
: intl.get('重新检查依赖')}
</Button>,
<Search
placeholder={intl.get('请输入名称')}
style={{ width: 'auto' }}
+24 -2
View File
@@ -174,7 +174,7 @@ const Env = () => {
title: intl.get('状态'),
key: 'status',
dataIndex: 'status',
width: 100,
width: 170,
filters: [
{
text: intl.get('已启用'),
@@ -192,6 +192,13 @@ const Env = () => {
<Tag color={StatusColor[record.status]} style={{ marginRight: 0 }}>
{intl.get(Status[record.status])}
</Tag>
{record.securityBlocked && (
<Tooltip title={intl.get('名称受限,未注入任务')}>
<Tag color="warning" style={{ marginRight: 0 }}>
{intl.get('名称受限')}
</Tag>
</Tooltip>
)}
</Space>
);
},
@@ -202,6 +209,8 @@ const Env = () => {
width: 160,
render: (text: string, record: any, index: number) => {
const isPc = !isPhone;
const isBlockedEnable =
record.status === Status.已禁用 && record.securityBlocked;
return (
<Space size="middle">
<Tooltip title={isPc ? intl.get('编辑') : ''}>
@@ -212,7 +221,9 @@ const Env = () => {
<Tooltip
title={
isPc
? record.status === Status.已禁用
? isBlockedEnable
? intl.get('请先修改环境变量名称')
: record.status === Status.已禁用
? intl.get('启用')
: intl.get('禁用')
: ''
@@ -278,6 +289,10 @@ const Env = () => {
};
const enabledOrDisabledEnv = (record: any, index: number) => {
if (record.status === Status.已禁用 && record.securityBlocked) {
message.error(intl.get('请先修改环境变量名称'));
return;
}
Modal.confirm({
title: intl.get('确认操作', {
operation:
@@ -538,6 +553,13 @@ const Env = () => {
};
const operateEnvs = (operationStatus: number) => {
if (
operationStatus === 0 &&
value.some((env) => selectedRowIds.includes(env.id) && env.securityBlocked)
) {
message.error(intl.get('请先修改环境变量名称'));
return;
}
Modal.confirm({
title: intl.get('确认操作', {
operation: intl.get(OperationName[operationStatus]),
+25 -1
View File
@@ -6,7 +6,16 @@ import React, {
useCallback,
useReducer,
} from 'react';
import { Drawer, Button, Tabs, Badge, Select, TreeSelect } from 'antd';
import {
Drawer,
Button,
Tabs,
Badge,
Select,
Space,
Switch,
TreeSelect,
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import SplitPane from 'react-split-pane';
@@ -24,11 +33,15 @@ const EditModal = ({
treeData,
currentNode,
content,
wordWrap,
onWordWrapChange,
handleCancel,
}: {
treeData?: any;
content?: string;
currentNode: any;
wordWrap: boolean;
onWordWrapChange: (checked: boolean) => void;
handleCancel: () => void;
}) => {
const [value, setValue] = useState('');
@@ -219,6 +232,16 @@ const EditModal = ({
</>
}
width={'100%'}
extra={
<Space size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={onWordWrapChange}
/>
</Space>
}
headerStyle={{ padding: '11px 24px' }}
onClose={cancel}
open={true}
@@ -241,6 +264,7 @@ const EditModal = ({
lineNumbersMinChars: 3,
glyphMargin: false,
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
+28 -1
View File
@@ -15,6 +15,7 @@ import {
PlusOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { EditorView } from '@codemirror/view';
import Editor from '@monaco-editor/react';
import CodeMirror from '@uiw/react-codemirror';
import { history, useOutletContext } from '@umijs/max';
@@ -26,6 +27,8 @@ import {
MenuProps,
message,
Modal,
Space,
Switch,
Tooltip,
Tree,
TreeSelect,
@@ -70,6 +73,11 @@ const Script = () => {
const [showMonaco, setShowMonaco] = useState(true);
const [historyVisible, setHistoryVisible] = useState(false);
const [saving, setSaving] = useState(false);
const [wordWrap, setWordWrap] = useState(
() =>
(localStorage.getItem('qinglong_editor_word_wrap') ??
localStorage.getItem('qinglong_config_word_wrap')) === 'true',
);
const savingRef = useRef(false);
const savedContentRef = useRef('');
const currentKeyRef = useRef(currentNode?.key);
@@ -544,6 +552,11 @@ const Script = () => {
});
};
const changeWordWrap = (checked: boolean) => {
setWordWrap(checked);
localStorage.setItem('qinglong_editor_word_wrap', String(checked));
};
return (
<PageContainer
className="ql-container-wrapper log-wrapper"
@@ -567,6 +580,14 @@ const Script = () => {
}
loading={loading}
extra={[
<Space key="word-wrap" size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={changeWordWrap}
/>
</Space>,
...(currentNode?.type === 'file' && showMonaco
? [
<Button
@@ -734,6 +755,7 @@ const Script = () => {
lineNumbersMinChars: 3,
glyphMargin: false,
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
@@ -747,7 +769,10 @@ const Script = () => {
{isPhone && (
<CodeMirror
value={value}
extensions={getCodeMirrorExtensions(mode)}
extensions={[
...getCodeMirrorExtensions(mode),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing || saving}
onChange={(value) => {
@@ -760,6 +785,8 @@ const Script = () => {
treeData={data}
currentNode={currentNode}
content={value}
wordWrap={wordWrap}
onWordWrapChange={changeWordWrap}
handleCancel={() => {
setIsLogModalVisible(false);
}}
+40
View File
@@ -20,30 +20,58 @@ export default {
name: intl.get('定时任务'),
value: 'crons',
},
{
name: intl.get('定时任务(只读)'),
value: 'crons:read',
},
{
name: intl.get('环境变量'),
value: 'envs',
},
{
name: intl.get('环境变量(只读)'),
value: 'envs:read',
},
{
name: intl.get('订阅管理'),
value: 'subscriptions',
},
{
name: intl.get('订阅管理(只读)'),
value: 'subscriptions:read',
},
{
name: intl.get('配置文件'),
value: 'configs',
},
{
name: intl.get('配置文件(只读)'),
value: 'configs:read',
},
{
name: intl.get('脚本管理'),
value: 'scripts',
},
{
name: intl.get('脚本(只读)'),
value: 'scripts:read',
},
{
name: intl.get('日志管理'),
value: 'logs',
},
{
name: intl.get('日志管理(只读)'),
value: 'logs:read',
},
{
name: intl.get('依赖管理'),
value: 'dependencies',
},
{
name: intl.get('依赖管理(只读)'),
value: 'dependencies:read',
},
{
name: intl.get('系统信息'),
value: 'system',
@@ -52,17 +80,29 @@ export default {
name: intl.get('仪表盘'),
value: 'dashboard',
},
{
name: intl.get('仪表盘(只读)'),
value: 'dashboard:read',
},
],
scopesMap: {
crons: intl.get('定时任务'),
'crons:read': intl.get('定时任务(只读)'),
envs: intl.get('环境变量'),
'envs:read': intl.get('环境变量(只读)'),
subscriptions: intl.get('订阅管理'),
'subscriptions:read': intl.get('订阅管理(只读)'),
configs: intl.get('配置文件'),
'configs:read': intl.get('配置文件(只读)'),
scripts: intl.get('脚本管理'),
'scripts:read': intl.get('脚本(只读)'),
logs: intl.get('日志管理'),
'logs:read': intl.get('日志管理(只读)'),
dependencies: intl.get('依赖管理'),
'dependencies:read': intl.get('依赖管理(只读)'),
system: intl.get('系统信息'),
dashboard: intl.get('仪表盘'),
'dashboard:read': intl.get('仪表盘(只读)'),
},
notificationModes: [
{ value: 'gotify', label: 'Gotify' },
@@ -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', () => {
const { formatCommand } = load(path.resolve('back/config/subscription.ts'));
const payload =
+69
View File
@@ -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);
});
+93 -15
View File
@@ -1,7 +1,7 @@
const assert = require('node:assert/strict');
const test = require('node:test');
test('dependency listing marks cache entries missing from disk for reinstall', async (t) => {
test('dependency listing uses stored status until explicitly refreshed', async (t) => {
const moduleStubs = new Map();
const stubModule = (modulePath, exports) => {
const resolved = require.resolve(modulePath);
@@ -23,24 +23,47 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
};
const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 };
const docs = [
{ id: 1, name: 'missing-package', type: 0, status: 1 },
{ id: 2, name: 'present-package', type: 0, status: 1 },
{
id: 1,
name: 'missing-package',
type: 0,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
{
id: 2,
name: 'present-package',
type: 0,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
];
const updates = [];
const probes = [];
const DependenceModel = {
findAll: async ({ where }) => {
if (where.status === DependenceStatus.installed) {
return docs;
return docs
.filter((doc) => doc.status === DependenceStatus.installed)
.map((doc) => ({ ...doc }));
}
return docs.map((doc) => ({
...doc,
status: updates.some((update) => update.ids.includes(doc.id))
? DependenceStatus.installFailed
: doc.status,
}));
return docs.map((doc) => ({ ...doc }));
},
update: async ({ status }, { where }) => {
updates.push({ status, ids: where.id });
const changed = docs.filter(
(doc) =>
doc.id === where.id &&
doc.status === where.status &&
doc.name === where.name &&
doc.type === where.type &&
doc.updatedAt.getTime() === where.updatedAt.getTime(),
);
for (const doc of changed) {
doc.status = status;
doc.updatedAt = new Date(doc.updatedAt.getTime() + 1000);
}
if (changed.length)
updates.push({ status, ids: changed.map((d) => d.id) });
},
};
@@ -65,8 +88,12 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
getPid: async () => 0,
getUninstallCommand: () => '',
killTask: async () => {},
promiseExecSuccess: async (command) =>
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
promiseExecSuccess: async (command) => {
probes.push(command);
return command === 'check:present-package'
? 'present-package 1.0.0\n'
: '';
},
});
stubModule('../../back/config/const', {
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
@@ -104,15 +131,29 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
const DependenceService = require('../../back/services/dependence').default;
const service = new DependenceService({}, {});
const result = await service.dependencies({
const query = {
searchValue: '',
type: 'nodejs',
status: '',
});
};
const cached = await service.dependencies(query);
assert.deepEqual(updates, []);
assert.deepEqual(probes, []);
assert.deepEqual(
cached.map(({ status }) => status),
[1, 1],
);
const { job } = service.startRefresh('nodejs');
assert.equal(job.state, 'running');
await new Promise(setImmediate);
assert.equal(service.getRefreshJob(job.id).state, 'completed');
const result = await service.dependencies(query);
assert.deepEqual(updates, [
{ status: DependenceStatus.installFailed, ids: [1] },
]);
assert.deepEqual(probes, ['check:missing-package', 'check:present-package']);
assert.deepEqual(
result.map(({ id, status }) => ({ id, status })),
[
@@ -123,4 +164,41 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
await service.dependencies({ searchValue: '', type: 'linux', status: '' });
assert.equal(updates.length, 1);
assert.equal(probes.length, 2);
let finishProbe;
service.isDependenceInstalled = async () => {
await new Promise((resolve) => {
finishProbe = resolve;
});
return false;
};
const racing = service.startRefresh('nodejs');
await new Promise(setImmediate);
docs[1].updatedAt = new Date(docs[1].updatedAt.getTime() + 1000);
finishProbe();
await new Promise(setImmediate);
assert.equal(racing.job.state, 'completed');
assert.equal(docs[1].status, DependenceStatus.installed);
assert.equal(updates.length, 1);
let finishRefresh;
service.refreshInstalledStatuses = () =>
new Promise((resolve) => {
finishRefresh = resolve;
});
const first = service.startRefresh('nodejs');
assert.equal(first.accepted, true);
assert.equal(service.getActiveRefreshJob().id, first.job.id);
assert.equal(service.startRefresh('nodejs').job.id, first.job.id);
assert.equal(service.startRefresh('python3').accepted, false);
finishRefresh();
await new Promise(setImmediate);
assert.equal(first.job.state, 'completed');
assert.equal(service.getActiveRefreshJob(), undefined);
const second = service.startRefresh('python3');
assert.equal(second.accepted, true);
finishRefresh();
await new Promise(setImmediate);
assert.equal(second.job.state, 'completed');
});
+62
View File
@@ -0,0 +1,62 @@
require('reflect-metadata');
const assert = require('node:assert/strict');
const test = require('node:test');
const { randomUUID } = require('node:crypto');
const express = require('express');
const { errors } = require('celebrate');
const { Container } = require('typedi');
const load = require('../helpers/load-security-module.cjs');
test('dependency refresh API starts one job and exposes its status', async (t) => {
const job = { id: randomUUID(), type: 'nodejs', state: 'running' };
class DependenceService {
startRefresh(type) {
return { job, accepted: type === job.type };
}
getRefreshJob(id) {
return id === job.id ? job : undefined;
}
getActiveRefreshJob() {
return job.state === 'running' ? job : undefined;
}
}
Container.set(DependenceService, new DependenceService());
t.after(() => Container.remove(DependenceService));
const api = load('back/api/dependence.ts', {
'../services/dependence': { __esModule: true, default: DependenceService },
});
const app = express();
app.use(express.json());
api.default(app);
app.use(errors());
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const base = `http://127.0.0.1:${server.address().port}/dependencies/refresh`;
const start = (type) =>
fetch(base, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type }),
});
const started = await start('nodejs');
assert.equal(started.status, 200);
assert.deepEqual((await started.json()).data, job);
assert.equal((await start('python3')).status, 409);
assert.equal((await start('linux')).status, 400);
const active = await fetch(`${base}/active`);
assert.equal(active.headers.get('cache-control'), 'no-store');
assert.deepEqual((await active.json()).data, job);
const running = await fetch(`${base}/${job.id}`);
assert.equal(running.headers.get('cache-control'), 'no-store');
assert.equal((await running.json()).data.state, 'running');
job.state = 'completed';
assert.equal((await (await fetch(`${base}/active`)).json()).data, null);
assert.equal(
(await (await fetch(`${base}/${job.id}`)).json()).data.state,
'completed',
);
assert.equal((await fetch(`${base}/${randomUUID()}`)).status, 404);
});
+20 -3
View File
@@ -35,8 +35,15 @@ function serviceFor(docs) {
(!where.id || where.id.includes(d.id)),
),
update: async (values, { where }) => {
const ids = Array.isArray(where.id) ? where.id : [where.id];
docs
.filter((d) => where.id.includes(d.id))
.filter(
(d) =>
ids.includes(d.id) &&
(where.status === undefined || d.status === where.status) &&
(where.updatedAt === undefined ||
d.updatedAt === where.updatedAt),
)
.forEach((d) => Object.assign(d, values));
},
destroy: async ({ where }) => removed.push(...where.id),
@@ -63,7 +70,7 @@ function serviceFor(docs) {
return { service, probes, removed, uninstalls };
}
test('listing preserves Python source results and still detects missing named packages', async () => {
test('explicit refresh preserves Python sources and detects missing named packages', async () => {
const docs = [
' requirements with spaces/requirements.txt ',
'requirements-dev.in',
@@ -71,7 +78,13 @@ test('listing preserves Python source results and still detects missing named pa
'git+https://example.invalid/repo.git',
'https://example.invalid/pkg.whl',
'missing-package',
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
].map((name, i) => ({
id: i + 1,
name,
type: 1,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
}));
const { service, probes } = serviceFor(docs);
for (let i = 0; i < 2; i++) {
await service.dependencies({
@@ -80,6 +93,10 @@ test('listing preserves Python source results and still detects missing named pa
status: '',
});
}
assert.deepEqual(probes, []);
const { job } = service.startRefresh('python3');
await new Promise(setImmediate);
assert.equal(job.state, 'completed');
assert.deepEqual(
docs.map((d) => d.status),
[1, 1, 1, 1, 1, 2],
+20
View File
@@ -115,7 +115,9 @@ for (const customData of [false, true]) {
);
for (const name of [
'token.json',
'TOKEN.JSON',
'grpc/client.key',
'Grpc/client.key',
'secret-link',
'data/scripts/secret-link',
'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);
}
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');
});
+3
View File
@@ -24,11 +24,14 @@ test('file access enforces directory boundaries, blacklist descendants and symli
'../config-other/secret',
'/etc/passwd',
'grpc/client.key',
'GRPC/client.key',
'alias/client.key',
'outside/new.txt',
'dangling',
'auth.json',
'AUTH.JSON',
'nested/auth.json',
'nested/Auth.Json',
'',
]) {
assert.equal(
+250 -2
View File
@@ -13,6 +13,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
fs.mkdirSync(path.join(tmp, dir), { recursive: true });
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/config.sh'), 'original');
const secret = 'http-security-test';
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
@@ -51,6 +52,10 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
scopes: ['configs'],
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'],
tokens: [
@@ -61,13 +66,55 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
scopes: ['scripts'],
tokens: [{ value: 'script-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['scripts:read'],
tokens: [{ value: 'script-read-app', expiration: Date.now() / 1000 + 3600 }],
},
{
scopes: ['envs'],
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;
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 = {
initializeUser: async () => {
initialized++;
@@ -90,7 +137,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
},
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
'../services/user': User,
'../services/script': class {},
'../services/script': Script,
'../data/open': {},
'../data/system': {},
typedi: { Service: () => (x) => x },
@@ -111,7 +158,8 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
mocks.typedi = {
...mocks.typedi,
Container: {
get: (x) => (x === User ? user : x === History ? history : configService),
get: (x) =>
x === User ? user : x === History ? history : x === Script ? script : configService,
},
};
mocks['../api'] = () => {
@@ -126,6 +174,41 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
historyCache,
).default(router);
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 });
});
return router;
};
const app = express();
@@ -203,6 +286,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('/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(
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
401,
@@ -212,6 +357,58 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
.data,
'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(
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
.body.code,
@@ -277,6 +474,14 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
).body.code,
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(
(
await request('/api/configs/save', valid, 'POST', {
@@ -296,6 +501,15 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
).status,
401,
);
assert.equal(
(
await request(`${prefix}/scripts`, 'script-read-app', 'POST', {
filename: 'test.js',
content: 'unauthorized',
})
).status,
401,
);
assert.equal(
(
await request(`${prefix}/scripts`, 'script-app', 'POST', {
@@ -310,4 +524,38 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
'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,
);
}
});
+69
View File
@@ -0,0 +1,69 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
fs = require('node:fs'),
path = require('node:path'),
{ spawnSync } = require('node:child_process');
const notifyFile = path.resolve(
process.env.NOTIFY_PY_PATH || 'sample/notify.py',
);
const source = fs.readFileSync(notifyFile, 'utf8');
function httpCalls(text) {
const calls = [];
const pattern = /requests\.(?:get|post|request)\s*\(/g;
let match;
while ((match = pattern.exec(text))) {
let depth = 0;
let end = match.index + match[0].length - 1;
for (; end < text.length; end++) {
if (text[end] === '(') depth++;
else if (text[end] === ')' && --depth === 0) break;
}
calls.push(text.slice(match.index, end + 1));
}
return calls;
}
test('sample/notify.py sends every HTTP request with a timeout', () => {
const calls = httpCalls(source);
assert.ok(
calls.length >= 27,
`expected at least 27 HTTP calls, got ${calls.length}`,
);
const missing = calls.filter((call) => !/\btimeout\s*=/.test(call));
assert.deepEqual(missing, []);
});
test('notify.send returns after a bounded wait when a channel hangs', () => {
const script = `
import os, sys, time, types
requests = types.ModuleType("requests")
requests.get = requests.post = requests.request = lambda *a, **k: None
sys.modules["requests"] = requests
sys.path.insert(0, ${JSON.stringify(path.dirname(notifyFile))})
os.environ.pop("QL_DIR", None)
import notify
notify.NOTIFY_JOIN_TIMEOUT = 1
notify.push_config = {key: "" for key in notify.push_config}
notify.push_config["HITOKOTO"] = False
notify.push_config["CONSOLE"] = True
def hang(*args, **kwargs):
time.sleep(60)
notify.console = hang
start = time.monotonic()
notify.send("title", "content")
elapsed = time.monotonic() - start
assert elapsed < 5, elapsed
print("elapsed=%.2f" % elapsed)
`;
const python = process.platform === 'win32' ? 'python' : 'python3';
const result = spawnSync(python, ['-'], {
input: script,
encoding: 'utf8',
timeout: 15000,
env: { ...process.env, QL_DIR: '' },
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /elapsed=/);
});
+10 -2
View File
@@ -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 },
]);
const systemConfig = { info: { lang: 'en-US' } };
const startupEvents = [];
const service = {
set_envs: async () => {},
set_envs: async () => { startupEvents.push('environment'); },
getAuthInfo: async () => ({}),
initializeSystemApp: async () => {},
refreshApps: async () => {},
@@ -70,7 +71,10 @@ test('startup closes interrupted instances without inventing exit codes or rewri
'../shared/i18n': { setLang() {} },
'./logger': {},
'../schedule/client': {
readiness: { configure() {}, recover: async () => {} },
readiness: {
configure() {},
recover: async () => { startupEvents.push('scheduler'); },
},
},
}).default;
// Dependency installation is unrelated to startup reconciliation.
@@ -82,6 +86,7 @@ test('startup closes interrupted instances without inventing exit codes or rewri
} finally {
global.setTimeout = originalTimeout;
}
assert.deepEqual(startupEvents, ['environment', 'scheduler']);
const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true });
assert.equal(rows[0].status, 2);
assert.ok(
@@ -109,6 +114,9 @@ test('startup closes interrupted instances without inventing exit codes or rewri
} finally {
global.setTimeout = originalTimeout;
}
assert.deepEqual(startupEvents, [
'environment', 'scheduler', 'environment', 'scheduler',
]);
assert.deepEqual(
await instances.findAll({ order: [['id', 'ASC']], raw: true }),
rows,
+34 -1
View File
@@ -3,7 +3,7 @@ const test = require('node:test');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { Sequelize, QueryTypes } = require('sequelize');
const { Sequelize, DataTypes, QueryTypes } = require('sequelize');
const { migrateSchema } = require('../../back/shared/schemaMigrations');
const load = require('../helpers/load-security-module.cjs');
@@ -46,6 +46,39 @@ test('legacy database upgrades without losing rows and repeated migration is ide
assert.equal(applied.length, 15);
});
test('migration repairs malformed environment labels before ORM reads', async (t) => {
const database = await createLegacy();
t.after(() => database.close());
await migrateSchema(database);
for (const [id, labels] of [
[1, ''],
[2, null],
[3, '{invalid'],
[4, '{}'],
[5, '["keep"]'],
]) {
await database.query('INSERT INTO "Envs" (id, name, labels) VALUES (?, ?, ?)', {
replacements: [id, `env-${id}`, labels],
});
}
const EnvModel = database.define('Env', {
name: DataTypes.STRING,
labels: DataTypes.JSON,
}, { timestamps: false });
await assert.rejects(EnvModel.findAll(), /Unexpected end of JSON input/);
await migrateSchema(database);
const rows = await EnvModel.findAll({ order: [['id', 'ASC']] });
assert.deepEqual(rows.map((row) => row.labels), [[], [], [], [], ['keep']]);
await migrateSchema(database);
const repaired = await database.query('SELECT id, labels FROM "Envs" ORDER BY id', {
type: QueryTypes.SELECT,
});
assert.deepEqual(repaired.map(({ labels }) => labels), [
'[]', '[]', '[]', '[]', '["keep"]',
]);
});
test('migration failure rolls back added columns and can be retried after repair', async (t) => {
const database = await createLegacy(':memory:', true);
t.after(() => database.close());
+16
View File
@@ -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) => {
const state = appsFixture(t);
await AppModel.update({ scopes: ['crons', 'system'] }, { where: { id: 1 } });
+146
View File
@@ -0,0 +1,146 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const source = ts.createSourceFile(
'index.tsx',
fs.readFileSync(
path.join(__dirname, '../../src/pages/dependence/index.tsx'),
'utf8',
),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const names = new Set([
'scheduleRefresh',
'finishRefresh',
'attachRefresh',
'recoverRefresh',
'pollRefresh',
'startRefresh',
]);
const declarations = [];
function visit(node) {
if (ts.isVariableDeclaration(node) && names.has(node.name.getText(source))) {
declarations.push(`var ${node.getText(source)};`);
}
ts.forEachChild(node, visit);
}
visit(source);
function fixture() {
const requests = [];
const timers = new Map();
let timerId = 0;
const state = { refreshing: false, type: null, reloads: 0 };
const request = (url) => {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
requests.push({ url, resolve, reject });
return promise;
};
const context = {
config: { apiPrefix: '/api/' },
type: 'python3',
alive: { current: true },
activeRefreshId: { current: null },
refreshRequestVersion: { current: 0 },
refreshTimer: {},
request: { get: request, post: request },
setRefreshing: (value) => {
state.refreshing = value;
},
setRefreshingType: (value) => {
state.type = value;
},
setRefreshGeneration: () => {
state.reloads += 1;
},
message: { error() {}, warning() {} },
intl: { get: (value) => value },
setTimeout: (fn, delay) => {
timers.set(++timerId, { fn, delay });
return timerId;
},
clearTimeout: (id) => timers.delete(id),
};
vm.createContext(context);
vm.runInContext(
ts.transpile(declarations.join('\n'), { target: ts.ScriptTarget.ES2020 }),
context,
);
return { context, requests, timers, state };
}
const settle = () => new Promise(setImmediate);
const newJob = { id: 'new-job', type: 'python3' };
test('late recovery cannot replace a newly attached job or clear it as idle', async () => {
for (const data of [{ id: 'old-job', type: 'nodejs' }, null]) {
const f = fixture();
f.context.recoverRefresh(true, true);
f.context.attachRefresh(newJob);
f.requests[0].resolve({ code: 200, data });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
assert.equal(f.state.refreshing, true);
assert.equal(f.state.reloads, 0);
assert.equal([...f.timers.values()][0].delay, 0);
}
});
test('late recovery failure cannot replace the new job poll timer', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.context.attachRefresh(newJob);
f.requests[0].reject(new Error('offline'));
await settle();
assert.equal(f.timers.size, 1);
assert.equal([...f.timers.values()][0].delay, 0);
});
test('a queued recovery retry is invalidated when a new job starts', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.requests[0].reject(new Error('offline'));
await settle();
const retry = [...f.timers.values()][0].fn;
f.context.startRefresh();
retry();
assert.equal(f.requests.length, 2);
f.requests[1].resolve({ code: 200, data: newJob });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
});
test('starting a job invalidates recovery before the start response arrives', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.context.startRefresh();
f.requests[0].resolve({ code: 200, data: { id: 'old-job', type: 'nodejs' } });
await settle();
assert.equal(f.context.activeRefreshId.current, null);
f.requests[1].resolve({ code: 200, data: newJob });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
});
test('current recovery still attaches and polls the running job', async () => {
const f = fixture();
f.context.recoverRefresh();
f.requests[0].resolve({ code: 200, data: newJob });
await settle();
[...f.timers.values()][0].fn();
assert.equal(f.requests[1].url, '/api/dependencies/refresh/new-job');
f.requests[1].resolve({ code: 200, data: { ...newJob, state: 'completed' } });
await settle();
assert.equal(f.state.refreshing, false);
assert.equal(f.state.reloads, 1);
});