Compare commits

...
Author SHA1 Message Date
whyour e555fcbb0b fix: honor Python requirement specifiers and portable script discovery 2026-10-15 23:36:21 +08:00
whyour 83e8f6bccd Merge tested dev regression fixes for integration review 2026-10-15 23:28:56 +08:00
whyour 5801f735bc fix: improve shell compatibility and process cleanup
Preserve literal paths and arguments, use portable command options, validate startup configuration, and retain scripts when downloads fail.

Ensure task watchdogs and process groups are cleaned up across Bash versions, with compatibility and process lifecycle regression coverage.
2026-10-15 23:20:15 +08:00
whyour 53e1129a12 fix: preserve Python source state and repair dev regressions 2026-10-15 09:53:22 +08:00
whyour 13c0a4d21b fix: handle mobile INI editing and silence timeout cleanup
Map INI to the CodeMirror properties language and fall back to plain text for unsupported modes. Reap the timeout watchdog immediately under stderr redirection to prevent Bash job notifications during cleanup.

Refs #3095, #3096
2026-10-15 01:17:43 +08:00
whyour 126fe8eb67 feat(notify): audit system and JavaScript/Python script notifications 2026-10-14 10:28:43 +08:00
whyour 73c41eed22 fix(i18n): retain Python dependency and notification audit translations 2026-10-14 10:25:13 +08:00
whyour 40149b6f17 feat(deps): support Python Git URLs and requirements files 2026-10-14 10:21:19 +08:00
whyour 6532775a25 feat(python): support isolated venv environments for native deployment 2026-10-14 10:19:50 +08:00
whyour 6469792546 chore(deps): update moment and brace-expansion security patches 2026-10-14 10:18:16 +08:00
whyour e435f6e886 fix(notify): audit system and JS/Python script notification results 2026-10-13 02:20:59 +08:00
whyour a0c09046c6 fix(deps): keep security updates within compatible dependency lines 2026-10-13 02:17:55 +08:00
dependabot[bot] 5a9e53c771 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [moment](https://github.com/moment/moment) and [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `moment` from 2.30.1 to 2.31.0
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.30.1...2.31.0)

Updates `brace-expansion` from 1.1.12 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.21)

---
updated-dependencies:
- dependency-name: moment
  dependency-version: 2.31.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 17:28:13 +00:00
whyour 7334566d3c fix(notification): align audit route and WPUSH fixtures 2026-10-12 01:13:53 +08:00
whyour 1503f07c75 fix(notification): bound audit logs without breaking delivery 2026-10-12 00:23:20 +08:00
copilot-swe-agent[bot]andwhyour 0d33d2321a fix: address code review feedback - improve notifyType logic and typing
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/4c9f0ab1-8b0e-4b94-b295-39c90ed942c2

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:50:47 +00:00
copilot-swe-agent[bot]andwhyour 79964f149c feat: add notification audit log feature
Agent-Logs-Url: https://github.com/whyour/qinglong/sessions/4c9f0ab1-8b0e-4b94-b295-39c90ed942c2

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
2026-04-25 06:49:18 +00:00
copilot-swe-agent[bot] bcb2471768 Initial plan 2026-04-25 06:44:54 +00:00
48 changed files with 2957 additions and 400 deletions
+13
View File
@@ -383,6 +383,19 @@ export default (app: Router) => {
},
);
route.get(
'/notify-log',
async (req: Request, res: Response, next: NextFunction) => {
try {
const systemService = Container.get(SystemService);
const data = await systemService.getNotifyLog();
res.send({ code: 200, data });
} catch (e) {
return next(e);
}
},
);
route.delete(
'/log',
async (req: Request, res: Response, next: NextFunction) => {
+38 -11
View File
@@ -716,17 +716,20 @@ function isPyprojectToml(name: string): boolean {
return name.endsWith('pyproject.toml');
}
export function isPythonSourceDependency(name: string): boolean {
const source = name.trim();
return (
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
);
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const trimmedName = name.trim();
// For Python dependencies installed from GitHub or requirements files,
// we can't reliably check if they're installed, so skip the check
if (type === DependenceTypes.python3) {
if (
isGitHubUrl(trimmedName) ||
isRequirementsFile(trimmedName) ||
isPyprojectToml(trimmedName)
) {
if (isPythonSourceDependency(trimmedName)) {
// Return a command that will always indicate not installed
// This ensures GitHub URLs and requirements files are always installed
return 'echo ""';
@@ -736,10 +739,30 @@ export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
`from importlib.metadata import version\nimport importlib.util as u\nname=${JSON.stringify(
trimmedName,
)}\ntry:\n print(version(name))\nexcept Exception:\n print(name if u.find_spec(name) else '')`,
)}`,
`from importlib.metadata import version, PackageNotFoundError
import importlib.util
import sys
try:
try:
from packaging.requirements import Requirement
except ImportError:
from pip._vendor.packaging.requirements import Requirement
requirement = Requirement(sys.argv[1])
if requirement.marker and not requirement.marker.evaluate():
print('not applicable')
else:
try:
installed = version(requirement.name)
except PackageNotFoundError:
if not requirement.specifier and not requirement.extras and not requirement.url:
if importlib.util.find_spec(requirement.name):
print(requirement.name)
else:
if requirement.specifier.contains(installed, prereleases=True):
print(installed)
except Exception:
pass`,
)} ${shellQuote(trimmedName)}`,
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? `apk info -es ${trimmedName}`
@@ -824,9 +847,13 @@ export function getUninstallCommand(
'Remove installed Python distributions separately by package name.',
)}`;
}
const packageName =
name.match(
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
)?.[1] || name;
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -- ${shellQuote(name)}`;
} -y -- ${shellQuote(packageName)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
@@ -917,7 +944,7 @@ async function _updateLinuxMirror(
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = isInContainer() ? 'sudo ' : '';
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
+16
View File
@@ -28,8 +28,12 @@ export enum AuthDataType {
'removeLogFrequency' = 'removeLogFrequency',
'systemConfig' = 'systemConfig',
'authConfig' = 'authConfig',
'notifyLog' = 'notifyLog',
}
export const NotifyStatus = LoginStatus;
export type NotifyStatus = LoginStatus;
export interface SystemConfigInfo {
lang?: string;
panelTitle?: string;
@@ -53,6 +57,17 @@ export interface LoginLogInfo {
status?: LoginStatus;
}
export interface NotifyLogInfo {
timestamp?: number;
title?: string;
content?: string;
status?: NotifyStatus;
notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
}
export interface TokenInfo {
value: string;
timestamp: number;
@@ -88,6 +103,7 @@ export interface AuthInfo {
export type SystemModelInfo = SystemConfigInfo &
Partial<NotificationInfo> &
LoginLogInfo &
Partial<NotifyLogInfo> &
Partial<AuthInfo>;
export interface SystemInstance
+10
View File
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
optional NotificationInfo notificationInfo = 3;
}
message RecordNotifyLogRequest {
string title = 1;
string content = 2;
bool success = 3;
string notifyType = 4;
string scriptName = 5;
string taskId = 6;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -271,6 +280,7 @@ service Api {
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
+175 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.17.3
// protoc v6.31.1
// source: back/protos/api.proto
/* eslint-disable */
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
notificationInfo?: NotificationInfo | undefined;
}
export interface RecordNotifyLogRequest {
title: string;
content: string;
success: boolean;
notifyType: string;
scriptName: string;
taskId: string;
}
function createBaseEnvItem(): EnvItem {
return {
id: undefined,
@@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
},
};
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
}
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.title !== "") {
writer.uint32(10).string(message.title);
}
if (message.content !== "") {
writer.uint32(18).string(message.content);
}
if (message.success !== false) {
writer.uint32(24).bool(message.success);
}
if (message.notifyType !== "") {
writer.uint32(34).string(message.notifyType);
}
if (message.scriptName !== "") {
writer.uint32(42).string(message.scriptName);
}
if (message.taskId !== "") {
writer.uint32(50).string(message.taskId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRecordNotifyLogRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.title = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.content = reader.string();
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.success = reader.bool();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.notifyType = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.scriptName = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.taskId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RecordNotifyLogRequest {
return {
title: isSet(object.title) ? globalThis.String(object.title) : "",
content: isSet(object.content) ? globalThis.String(object.content) : "",
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
};
},
toJSON(message: RecordNotifyLogRequest): unknown {
const obj: any = {};
if (message.title !== "") {
obj.title = message.title;
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.success !== false) {
obj.success = message.success;
}
if (message.notifyType !== "") {
obj.notifyType = message.notifyType;
}
if (message.scriptName !== "") {
obj.scriptName = message.scriptName;
}
if (message.taskId !== "") {
obj.taskId = message.taskId;
}
return obj;
},
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
const message = createBaseRecordNotifyLogRequest();
message.title = object.title ?? "";
message.content = object.content ?? "";
message.success = object.success ?? false;
message.notifyType = object.notifyType ?? "";
message.scriptName = object.scriptName ?? "";
message.taskId = object.taskId ?? "";
return message;
},
};
export type ApiService = typeof ApiService;
export const ApiService = {
getEnvs: {
@@ -4388,6 +4537,15 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
recordNotifyLog: {
path: "/com.ql.api.Api/RecordNotifyLog",
requestStream: false,
responseStream: false,
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
getCronDetail: {
path: "/com.ql.api.Api/GetCronDetail",
requestStream: false,
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
getCronDetail(
request: CronDetailRequest,
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
+25 -2
View File
@@ -16,6 +16,7 @@ import {
MoveEnvRequest,
Response,
SystemNotifyRequest,
RecordNotifyLogRequest,
UpdateEnvNamesRequest,
UpdateEnvRequest,
} from '../protos/api';
@@ -238,7 +239,8 @@ export const systemNotify = async (
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
notificationInfo: call.request
.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
@@ -246,10 +248,31 @@ export const systemNotify = async (
}
};
export const recordNotifyLog = async (
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
await Container.get(SystemService).recordNotifyLog({
title: call.request.title,
content: call.request.content,
status: call.request.success ? 0 : 1,
notifyType: call.request.notifyType,
source: 'script',
scriptName: call.request.scriptName,
taskId: call.request.taskId,
});
callback(null, { code: 200 });
} catch (error: any) {
callback(error);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters.
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
const cron =
data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
return {
...cron,
labels: cron.labels ?? [],
+35 -12
View File
@@ -20,6 +20,7 @@ import {
getInstallCommand,
getUninstallCommand,
getGetCommand,
isPythonSourceDependency,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
@@ -76,21 +77,25 @@ export default class DependenceService {
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter(
(x) => x.status === DependenceStatus.installed,
(x) =>
x.status === DependenceStatus.installed ||
// Older list refreshes marked unprobeable sources as failed. They may
// still have installed distributions (including partial pip installs).
(x.status === DependenceStatus.installFailed &&
x.type === DependenceTypes.python3 &&
isPythonSourceDependency(x.name)),
);
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: ids } },
{ where: { id: installedDeps.map((x) => x.id!) } },
);
this.installDependenceOneByOne(docs, false, force);
this.installDependenceOneByOne(installedDeps, false, force);
}
return docs;
}
@@ -156,9 +161,15 @@ export default class DependenceService {
},
});
const checks = await concurrentRun(
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
@@ -177,7 +188,10 @@ export default class DependenceService {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (depName.includes(depVersionStr)) {
if (
dependency.type !== DependenceTypes.python3 &&
depName.includes(depVersionStr)
) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -305,6 +319,9 @@ export default class DependenceService {
: getUninstallCommand(dependency.type, dependency.name.trim());
} catch (error) {
taskLimit.removeQueuedDependency(dependency);
if (!isInstall && force) {
return this.removeDb([dependency.id!]).then(() => null);
}
return DependenceModel.update(
{
status: isInstall
@@ -387,7 +404,13 @@ export default class DependenceService {
this.updateLog(depIds, message);
// 判断是否已经安装过依赖
if (isInstall && !force) {
// pip must resolve optional dependencies even if the base distribution
// is already present; its version alone cannot prove extras are ready.
if (
isInstall &&
!force &&
!(isPythonDependence && depName.includes('['))
) {
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
@@ -395,7 +418,7 @@ export default class DependenceService {
}
let depVersion = '';
if (depName.includes(depVersionStr)) {
if (!isPythonDependence && depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
+41 -25
View File
@@ -1,3 +1,4 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
@@ -55,27 +56,33 @@ export default class NotificationService {
content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> {
let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) {
this.title = title;
this.content = content;
let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
} catch (error: any) {
console.error(error);
let type: string | undefined;
let success = false;
try {
const saved = await this.userService.getNotificationMode();
type = notificationInfo?.type || saved.type;
if (type) {
this.title = title;
this.content = content;
const { type: _, ...overrides } = notificationInfo || {};
const { type: _saved, ...rest } = saved;
this.params = { ...rest, ...overrides };
try {
success = !!(await this.modeMap.get(type)?.call(this));
} catch (error) {
console.error(error);
}
}
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
}
return false;
}
public async testNotify(
@@ -84,14 +91,23 @@ export default class NotificationService {
content: string,
) {
const { type, ...rest } = info;
if (type) {
if (!type) return true;
let success = false;
try {
this.title = title;
this.content = content;
this.params = rest;
const notificationModeAction = this.modeMap.get(type);
return await notificationModeAction?.call(this);
success = !!(await this.modeMap.get(type)?.call(this));
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
}
return true;
}
private parseMailRecipients(value?: string) {
@@ -921,7 +937,8 @@ export default class NotificationService {
private async openiLink() {
const { openiLinkAppToken, openiLinkHubUrl, openiLinkContextToken } =
this.params;
const baseUrl = openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const baseUrl =
openiLinkHubUrl?.replace(/\/$/, '') || 'https://hub.openilink.com';
const url = `${baseUrl}/bot/v1/message/send`;
const body: Record<string, string> = {
type: 'text',
@@ -974,5 +991,4 @@ export default class NotificationService {
throw new Error(error.response ? error.response.body : error);
}
}
}
+58 -15
View File
@@ -1,3 +1,4 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
@@ -32,6 +33,8 @@ import {
SystemInstance,
SystemModel,
SystemModelInfo,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
import taskLimit from '../shared/pLimit';
import cronClient from '../schedule/client';
@@ -53,7 +56,7 @@ export default class SystemService {
@Inject('logger') private logger: winston.Logger,
private scheduleService: ScheduleService,
private sockService: SockService,
) { }
) {}
public async getSystemConfig() {
const doc = await this.getDb({ type: AuthDataType.systemConfig });
@@ -158,7 +161,9 @@ export default class SystemService {
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
`export http_proxy=${shellQuote(
info.dependenceProxy,
)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
@@ -293,7 +298,7 @@ export default class SystemService {
);
const text = await body.text();
lastVersionContent = parseContentVersion(text);
} catch (error) { }
} catch (error) {}
if (!lastVersionContent) {
lastVersionContent = currentVersionContent;
@@ -407,11 +412,13 @@ export default class SystemService {
if (notificationInfo && typeString) {
notificationInfo.type = typeString;
}
const isSuccess = await this.notificationService.notify(
title,
content,
notificationInfo,
);
if (isSuccess) {
return { code: 200, message: t('通知发送成功') };
} else {
@@ -419,16 +426,36 @@ export default class SystemService {
}
}
public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) {
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
await persistNotificationAudit(info);
}
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
order: [['id', 'DESC']],
limit: 200,
});
return docs.map((x) => ({ ...x.info, id: x.id }));
}
public async run(
{ command, logPath }: { command: string; logPath?: string },
callback: TaskCallbacks,
) {
if (!command.startsWith(TASK_COMMAND)) {
command = `${TASK_COMMAND} ${command}`;
}
const logPathPrefix = logPath ? `real_log_path=${logPath}` : ''
this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, {
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
});
const logPathPrefix = logPath ? `real_log_path=${logPath}` : '';
this.scheduleService.runTask(
`${logPathPrefix} real_time=true ${command}`,
callback,
{
command,
id: command.replace(/ /g, '-'),
runOrigin: 'system',
},
);
}
public async stop({ command, pid }: { command: string; pid: number }) {
@@ -459,8 +486,20 @@ export default class SystemService {
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
const allowed = new Set([
'db',
'upload',
'config',
'scripts',
'log',
'deps',
'syslog',
'dep_cache',
'raw',
'repo',
'ssh.d',
'script-history',
]);
if (dataDirs.some((dir) => !allowed.has(dir))) {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
@@ -470,9 +509,13 @@ export default class SystemService {
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
}
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
cwd: path.dirname(config.dataPath),
});
await promisify(execFile)(
'tar',
['-zcf', config.dataTgzFile, '--', ...dataPaths],
{
cwd: path.dirname(config.dataPath),
},
);
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
+48
View File
@@ -0,0 +1,48 @@
import { Op } from 'sequelize';
import {
SystemModel,
AuthDataType,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
// Both backend sends and script reports use the same bounded, best-effort sink.
export async function persistNotificationAudit(
info: NotifyLogInfo,
): Promise<void> {
try {
await SystemModel.create({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
title: String(info.title || '').slice(0, 512),
content: String(info.content || '').slice(0, 8192),
status:
info.status === NotifyStatus.success
? NotifyStatus.success
: NotifyStatus.fail,
notifyType: String(info.notifyType || '').slice(0, 64),
source: info.source === 'script' ? 'script' : 'system',
scriptName: String(info.scriptName || '').slice(0, 512),
taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined,
},
});
const oldest = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
attributes: ['id'],
order: [['id', 'DESC']],
offset: 200,
limit: 1,
});
if (oldest[0]?.id) {
await SystemModel.destroy({
where: {
type: AuthDataType.notifyLog,
id: { [Op.lte]: oldest[0].id },
},
});
}
} catch (error) {
console.error('Failed to persist notification audit log');
}
}
+12 -1
View File
@@ -12,6 +12,12 @@ export interface OpenOperation {
anonymous?: boolean;
}
export const openOperations: readonly OpenOperation[] = [
{
name: 'system notification-logs',
method: 'GET',
path: 'system/notify-log',
},
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
{
name: 'task view-create',
@@ -478,7 +484,12 @@ export const openOperations: readonly OpenOperation[] = [
path: 'system/config/dependence-clean',
body: 'json',
},
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
{
name: 'dashboard record',
method: 'POST',
path: 'dashboard/record',
body: 'json',
},
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
+2 -2
View File
@@ -119,12 +119,12 @@ reload_pm2
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
nohup ql bot >"$dir_log/bot.log" 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
nohup ql extra >"$dir_log/extra.log" 2>&1 &
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
+11
View File
@@ -0,0 +1,11 @@
# 通知日志
通知日志记录系统统一通知入口(包括登录通知、配置测试和系统通知 API),以及任务使用内置 `notify.js`、`notify.py` 或 `QLAPI.notify` 发出的通知。脚本通知按实际配置的渠道各记录一条结果,未配置渠道和跳过的标题不记账。
日志包含时间、标题、正文、渠道、结果和来源;脚本来源另包含文件名与任务 ID。成功表示通知渠道返回成功或接受请求,不表示最终收件人已经收到。多目标渠道只在所有目标成功时记为成功。
所有入口共用数据库中的最近 200 条日志。标题最多 512 字符、正文最多 8192 字符;上报字段不包含通知密码、令牌或渠道配置。日志页受系统设置的管理员权限保护。
脚本通过现有的本机 mTLS gRPC 服务上报,独立客户端最多等待 1.5 秒。旧面板、服务不可用或数据库写入失败不会改变通知发送结果。Python 使用 Node 上报子进程,并限制在 3 秒内。
自行保存的旧版通知库需要更新为面板内置版本才能上报。脚本自行调用 HTTP、SMTP 或第三方 SDK 时不经过这些入口,无法自动纳入通知日志。
+4 -2
View File
@@ -77,7 +77,9 @@
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8",
"websocket-driver": "0.7.5"
"websocket-driver": "0.7.5",
"brace-expansion@1": "1.1.21",
"brace-expansion@2": "2.1.7"
}
},
"dependencies": {
@@ -172,7 +174,7 @@
"concurrently": "^7.0.0",
"file-saver": "2.0.2",
"lint-staged": "^13.0.3",
"moment": "2.30.1",
"moment": "2.31.0",
"monaco-editor": "0.33.0",
"nodemon": "^3.0.1",
"prettier": "^2.5.1",
+14 -23
View File
@@ -6,6 +6,8 @@ overrides:
'@codemirror/view': 6.39.16
proxy-addr: 2.0.8
websocket-driver: 0.7.5
brace-expansion@1: 1.1.21
brace-expansion@2: 2.1.7
dependencies:
'@ant-design/plots':
@@ -278,8 +280,8 @@ devDependencies:
specifier: ^13.0.3
version: 13.3.0
moment:
specifier: 2.30.1
version: 2.30.1
specifier: 2.31.0
version: 2.31.0
monaco-editor:
specifier: 0.33.0
version: 0.33.0
@@ -876,7 +878,7 @@ packages:
'@babel/runtime': 7.26.0
antd: 4.24.16(react-dom@18.3.1)(react@18.3.1)
classnames: 2.5.1
moment: 2.30.1
moment: 2.31.0
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -5369,7 +5371,7 @@ packages:
fast-deep-equal: 3.1.3
intl: 1.2.5
lodash: 4.17.21
moment: 2.30.1
moment: 2.31.0
qiankun: 2.10.16
react-intl: 3.12.1(react@18.3.1)
react-redux: 8.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1)(react@18.3.1)(redux@4.2.1)
@@ -5866,7 +5868,7 @@ packages:
classnames: 2.5.1
copy-to-clipboard: 3.3.3
lodash: 4.17.21
moment: 2.30.1
moment: 2.31.0
rc-cascader: 3.7.3(react-dom@18.3.1)(react@18.3.1)
rc-checkbox: 3.0.1(react-dom@18.3.1)(react@18.3.1)
rc-collapse: 3.4.2(react-dom@18.3.1)(react@18.3.1)
@@ -6357,18 +6359,6 @@ packages:
big-integer: 1.6.52
dev: true
/brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
/brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
dependencies:
balanced-match: 1.0.2
dev: true
/braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -8662,7 +8652,7 @@ packages:
/file-stream-rotator@0.6.1:
resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==}
dependencies:
moment: 2.30.1
moment: 2.31.0
dev: false
/fill-range@7.1.1:
@@ -10733,11 +10723,12 @@ packages:
/moment-timezone@0.5.46:
resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==}
dependencies:
moment: 2.30.1
moment: 2.31.0
dev: false
/moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
/moment@2.31.0:
resolution:
integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==
/monaco-editor@0.33.0:
resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==}
@@ -12724,7 +12715,7 @@ packages:
classnames: 2.5.1
date-fns: 2.30.0
dayjs: 1.11.13
moment: 2.30.1
moment: 2.31.0
rc-trigger: 5.3.4(react-dom@18.3.1)(react@18.3.1)
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
react: 18.3.1
@@ -13951,7 +13942,7 @@ packages:
dottie: 2.0.6
inflection: 1.13.4
lodash: 4.17.21
moment: 2.30.1
moment: 2.31.0
moment-timezone: 0.5.46
pg-connection-string: 2.7.0
retry-as-promised: 7.0.4
+308 -135
View File
File diff suppressed because it is too large Load Diff
+162 -27
View File
@@ -10,6 +10,8 @@ import threading
import time
import urllib.parse
import smtplib
import subprocess
from typing import Optional
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr
@@ -161,10 +163,11 @@ for k in push_config:
push_config[k] = v
def bark(title: str, content: str) -> None:
def bark(title: str, content: str) -> Optional[bool]:
"""
使用 bark 推送消息。
"""
audit_results = []
if not push_config.get("BARK_PUSH"):
return
print("bark 服务启动")
@@ -200,22 +203,28 @@ def bark(title: str, content: str) -> None:
).json()
if response["code"] == 200:
audit_results.append(True)
print("bark 推送成功!")
else:
audit_results.append(False)
print("bark 推送失败!")
return all(audit_results) if audit_results else None
def console(title: str, content: str) -> None:
def console(title: str, content: str) -> Optional[bool]:
"""
使用 控制台 推送消息。
"""
audit_results = []
print(f"{title}\n\n{content}")
return True
def dingding_bot(title: str, content: str) -> None:
def dingding_bot(title: str, content: str) -> Optional[bool]:
"""
使用 钉钉机器人 推送消息。
"""
audit_results = []
if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
return
print("钉钉机器人 服务启动")
@@ -236,15 +245,19 @@ def dingding_bot(title: str, content: str) -> None:
).json()
if not response["errcode"]:
audit_results.append(True)
print("钉钉机器人 推送成功!")
else:
audit_results.append(False)
print("钉钉机器人 推送失败!")
return all(audit_results) if audit_results else None
def feishu_bot(title: str, content: str) -> None:
def feishu_bot(title: str, content: str) -> Optional[bool]:
"""
使用 飞书机器人 推送消息。
"""
audit_results = []
if not push_config.get("FSKEY"):
return
print("飞书 服务启动")
@@ -268,15 +281,19 @@ def feishu_bot(title: str, content: str) -> None:
response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0:
audit_results.append(True)
print("飞书 推送成功!")
else:
audit_results.append(False)
print("飞书 推送失败!错误信息如下:\n", response)
return all(audit_results) if audit_results else None
def go_cqhttp(title: str, content: str) -> None:
def go_cqhttp(title: str, content: str) -> Optional[bool]:
"""
使用 go_cqhttp 推送消息。
"""
audit_results = []
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
return
print("go-cqhttp 服务启动")
@@ -285,15 +302,19 @@ def go_cqhttp(title: str, content: str) -> None:
response = requests.get(url).json()
if response["status"] == "ok":
audit_results.append(True)
print("go-cqhttp 推送成功!")
else:
audit_results.append(False)
print("go-cqhttp 推送失败!")
return all(audit_results) if audit_results else None
def gotify(title: str, content: str) -> None:
def gotify(title: str, content: str) -> Optional[bool]:
"""
使用 gotify 推送消息。
"""
audit_results = []
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
return
print("gotify 服务启动")
@@ -307,15 +328,19 @@ def gotify(title: str, content: str) -> None:
response = requests.post(url, data=data).json()
if response.get("id"):
audit_results.append(True)
print("gotify 推送成功!")
else:
audit_results.append(False)
print("gotify 推送失败!")
return all(audit_results) if audit_results else None
def iGot(title: str, content: str) -> None:
def iGot(title: str, content: str) -> Optional[bool]:
"""
使用 iGot 推送消息。
"""
audit_results = []
if not push_config.get("IGOT_PUSH_KEY"):
return
print("iGot 服务启动")
@@ -326,15 +351,19 @@ def iGot(title: str, content: str) -> None:
response = requests.post(url, data=data, headers=headers).json()
if response["ret"] == 0:
audit_results.append(True)
print("iGot 推送成功!")
else:
audit_results.append(False)
print(f'iGot 推送失败!{response["errMsg"]}')
return all(audit_results) if audit_results else None
def serverJ(title: str, content: str) -> None:
def serverJ(title: str, content: str) -> Optional[bool]:
"""
通过 serverJ 推送消息。
"""
audit_results = []
if not push_config.get("PUSH_KEY"):
return
print("serverJ 服务启动")
@@ -351,15 +380,19 @@ def serverJ(title: str, content: str) -> None:
response = requests.post(url, data=data).json()
if response.get("errno") == 0 or response.get("code") == 0:
audit_results.append(True)
print("serverJ 推送成功!")
else:
audit_results.append(False)
print(f'serverJ 推送失败!错误码:{response["message"]}')
return all(audit_results) if audit_results else None
def pushdeer(title: str, content: str) -> None:
def pushdeer(title: str, content: str) -> Optional[bool]:
"""
通过PushDeer 推送消息
"""
audit_results = []
if not push_config.get("DEER_KEY"):
return
print("PushDeer 服务启动")
@@ -376,15 +409,19 @@ def pushdeer(title: str, content: str) -> None:
response = requests.post(url, data=data).json()
if len(response.get("content").get("result")) > 0:
audit_results.append(True)
print("PushDeer 推送成功!")
else:
audit_results.append(False)
print("PushDeer 推送失败!错误信息:", response)
return all(audit_results) if audit_results else None
def chat(title: str, content: str) -> None:
def chat(title: str, content: str) -> Optional[bool]:
"""
通过Chat 推送消息
"""
audit_results = []
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
return
print("chat 服务启动")
@@ -393,15 +430,19 @@ def chat(title: str, content: str) -> None:
response = requests.post(url, data=data)
if response.status_code == 200:
audit_results.append(True)
print("Chat 推送成功!")
else:
audit_results.append(False)
print("Chat 推送失败!错误信息:", response)
return all(audit_results) if audit_results else None
def pushplus_bot(title: str, content: str) -> None:
def pushplus_bot(title: str, content: str) -> Optional[bool]:
"""
通过 pushplus 推送消息。
"""
audit_results = []
if not push_config.get("PUSH_PLUS_TOKEN"):
return
print("PUSHPLUS 服务启动")
@@ -424,7 +465,9 @@ def pushplus_bot(title: str, content: str) -> None:
code = response["code"]
if code == 200:
audit_results.append(True)
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
audit_results.append(True)
print(
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
)
@@ -437,16 +480,20 @@ def pushplus_bot(title: str, content: str) -> None:
response = requests.post(url=url_old, data=body, headers=headers).json()
if response["code"] == 200:
audit_results.append(True)
print("PUSHPLUS(hxtrip) 推送成功!")
else:
audit_results.append(False)
print("PUSHPLUS 推送失败!")
return all(audit_results) if audit_results else None
def weplus_bot(title: str, content: str) -> None:
def weplus_bot(title: str, content: str) -> Optional[bool]:
"""
通过 微加机器人 推送消息。
"""
audit_results = []
if not push_config.get("WE_PLUS_BOT_TOKEN"):
return
print("微加机器人 服务启动")
@@ -469,15 +516,19 @@ def weplus_bot(title: str, content: str) -> None:
response = requests.post(url=url, data=body, headers=headers).json()
if response["code"] == 200:
audit_results.append(True)
print("微加机器人 推送成功!")
else:
audit_results.append(False)
print("微加机器人 推送失败!")
return all(audit_results) if audit_results else None
def qmsg_bot(title: str, content: str) -> None:
def qmsg_bot(title: str, content: str) -> Optional[bool]:
"""
使用 qmsg 推送消息。
"""
audit_results = []
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
return
print("qmsg 服务启动")
@@ -487,15 +538,19 @@ def qmsg_bot(title: str, content: str) -> None:
response = requests.post(url=url, params=payload).json()
if response["code"] == 0:
audit_results.append(True)
print("qmsg 推送成功!")
else:
audit_results.append(False)
print(f'qmsg 推送失败!{response["reason"]}')
return all(audit_results) if audit_results else None
def wecom_app(title: str, content: str) -> None:
def wecom_app(title: str, content: str) -> Optional[bool]:
"""
通过 企业微信 APP 推送消息。
"""
audit_results = []
if not push_config.get("QYWX_AM"):
return
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
@@ -521,9 +576,12 @@ def wecom_app(title: str, content: str) -> None:
response = wx.send_mpnews(title, content, media_id, touser)
if response == "ok":
audit_results.append(True)
print("企业微信推送成功!")
else:
audit_results.append(False)
print("企业微信推送失败!错误信息如下:\n", response)
return all(audit_results) if audit_results else None
class WeCom:
@@ -588,10 +646,11 @@ class WeCom:
return respone["errmsg"]
def wecom_bot(title: str, content: str) -> None:
def wecom_bot(title: str, content: str) -> Optional[bool]:
"""
通过 企业微信机器人 推送消息。
"""
audit_results = []
if not push_config.get("QYWX_KEY"):
return
print("企业微信机器人服务启动")
@@ -608,15 +667,19 @@ def wecom_bot(title: str, content: str) -> None:
).json()
if response["errcode"] == 0:
audit_results.append(True)
print("企业微信机器人推送成功!")
else:
audit_results.append(False)
print("企业微信机器人推送失败!")
return all(audit_results) if audit_results else None
def telegram_bot(title: str, content: str) -> None:
def telegram_bot(title: str, content: str) -> Optional[bool]:
"""
使用 telegram 机器人 推送消息。
"""
audit_results = []
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
return
print("tg 服务启动")
@@ -652,15 +715,19 @@ def telegram_bot(title: str, content: str) -> None:
).json()
if response["ok"]:
audit_results.append(True)
print("tg 推送成功!")
else:
audit_results.append(False)
print("tg 推送失败!")
return all(audit_results) if audit_results else None
def aibotk(title: str, content: str) -> None:
def aibotk(title: str, content: str) -> Optional[bool]:
"""
使用 智能微秘书 推送消息。
"""
audit_results = []
if (
not push_config.get("AIBOTK_KEY")
or not push_config.get("AIBOTK_TYPE")
@@ -688,15 +755,19 @@ def aibotk(title: str, content: str) -> None:
response = requests.post(url=url, data=body, headers=headers).json()
print(response)
if response["code"] == 0:
audit_results.append(True)
print("智能微秘书 推送成功!")
else:
audit_results.append(False)
print(f'智能微秘书 推送失败!{response["error"]}')
return all(audit_results) if audit_results else None
def smtp(title: str, content: str) -> None:
def smtp(title: str, content: str) -> Optional[bool]:
"""
使用 SMTP 邮件 推送消息。
"""
audit_results = []
if (
not push_config.get("SMTP_SERVER")
or not push_config.get("SMTP_SSL")
@@ -736,15 +807,19 @@ def smtp(title: str, content: str) -> None:
message.as_bytes(),
)
smtp_server.close()
audit_results.append(True)
print("SMTP 邮件 推送成功!")
except Exception as e:
audit_results.append(False)
print(f"SMTP 邮件 推送失败!{e}")
return all(audit_results) if audit_results else None
def pushme(title: str, content: str) -> None:
def pushme(title: str, content: str) -> Optional[bool]:
"""
使用 PushMe 推送消息。
"""
audit_results = []
if not push_config.get("PUSHME_KEY"):
return
print("PushMe 服务启动")
@@ -764,15 +839,19 @@ def pushme(title: str, content: str) -> None:
response = requests.post(url, data=data)
if response.status_code == 200 and response.text == "success":
audit_results.append(True)
print("PushMe 推送成功!")
else:
audit_results.append(False)
print(f"PushMe 推送失败!{response.status_code} {response.text}")
return all(audit_results) if audit_results else None
def chronocat(title: str, content: str) -> None:
def chronocat(title: str, content: str) -> Optional[bool]:
"""
使用 CHRONOCAT 推送消息。
"""
audit_results = []
if (
not push_config.get("CHRONOCAT_URL")
or not push_config.get("CHRONOCAT_QQ")
@@ -807,21 +886,27 @@ def chronocat(title: str, content: str) -> None:
response = requests.post(url, headers=headers, data=json.dumps(data))
if response.status_code == 200:
if chat_type == 1:
audit_results.append(True)
print(f"QQ个人消息:{ids}推送成功!")
else:
audit_results.append(True)
print(f"QQ群消息:{ids}推送成功!")
else:
if chat_type == 1:
audit_results.append(False)
print(f"QQ个人消息:{ids}推送失败!")
else:
audit_results.append(False)
print(f"QQ群消息:{ids}推送失败!")
return all(audit_results) if audit_results else None
def ntfy(title: str, content: str) -> None:
def ntfy(title: str, content: str) -> Optional[bool]:
"""
通过 Ntfy 推送消息
"""
audit_results = []
def encode_rfc2047(text: str) -> str:
"""将文本编码为符合 RFC 2047 标准的格式"""
encoded_bytes = base64.b64encode(text.encode("utf-8"))
@@ -853,12 +938,15 @@ def ntfy(title: str, content: str) -> None:
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers)
if response.status_code == 200: # 使用 response.status_code 进行检查
audit_results.append(True)
print("Ntfy 推送成功!")
else:
audit_results.append(False)
print("Ntfy 推送失败!错误信息:", response.text)
return all(audit_results) if audit_results else None
def wxpusher_bot(title: str, content: str) -> None:
def wxpusher_bot(title: str, content: str) -> Optional[bool]:
"""
通过 wxpusher 推送消息。
支持的环境变量:
@@ -866,6 +954,7 @@ def wxpusher_bot(title: str, content: str) -> None:
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
"""
audit_results = []
if not push_config.get("WXPUSHER_APP_TOKEN"):
return
@@ -909,17 +998,21 @@ def wxpusher_bot(title: str, content: str) -> None:
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("code") == 1000:
audit_results.append(True)
print("wxpusher 推送成功!")
else:
audit_results.append(False)
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}")
return all(audit_results) if audit_results else None
def wxpusher_spt(title: str, content: str) -> None:
def wxpusher_spt(title: str, content: str) -> Optional[bool]:
"""
通过 wxpusher 极简推送(SPT)推送消息。
支持的环境变量:
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
"""
audit_results = []
if not push_config.get("WXPUSHER_SPT_LIST"):
return
@@ -956,17 +1049,21 @@ def wxpusher_spt(title: str, content: str) -> None:
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("code") == 1000:
audit_results.append(True)
print("wxpusher SPT 推送成功!")
else:
audit_results.append(False)
print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}")
return all(audit_results) if audit_results else None
def wpush(title: str, content: str) -> None:
def wpush(title: str, content: str) -> Optional[bool]:
"""
通过 WPUSH 推送消息。
官方文档: https://wpush.cn/docs
"""
audit_results = []
if not push_config.get("WPUSH_APIKEY"):
return
@@ -986,12 +1083,15 @@ def wpush(title: str, content: str) -> None:
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
if response.get("code") == 0:
audit_results.append(True)
print("WPUSH 推送成功!")
else:
audit_results.append(False)
print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}')
return all(audit_results) if audit_results else None
def openilink(title: str, content: str) -> None:
def openilink(title: str, content: str) -> Optional[bool]:
"""
通过 OpeniLink 推送消息。
支持的环境变量:
@@ -999,6 +1099,7 @@ def openilink(title: str, content: str) -> None:
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
"""
audit_results = []
if not push_config.get("OPENILINK_APP_TOKEN"):
return
@@ -1023,9 +1124,12 @@ def openilink(title: str, content: str) -> None:
response = requests.post(url=url, json=data, headers=headers).json()
if response.get("ok"):
audit_results.append(True)
print("OpeniLink 推送成功!")
else:
audit_results.append(False)
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
return all(audit_results) if audit_results else None
def parse_headers(headers):
@@ -1079,10 +1183,11 @@ def parse_body(body, content_type, value_format_fn=None):
return parsed
def custom_notify(title: str, content: str) -> None:
def custom_notify(title: str, content: str) -> Optional[bool]:
"""
通过 自定义通知 推送消息。
"""
audit_results = []
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
return
@@ -1114,9 +1219,12 @@ def custom_notify(title: str, content: str) -> None:
)
if response.status_code == 200:
audit_results.append(True)
print("自定义通知推送成功!")
else:
audit_results.append(False)
print(f"自定义通知推送失败!{response.status_code} {response.text}")
return all(audit_results) if audit_results else None
def one() -> str:
@@ -1204,6 +1312,33 @@ def add_notify_function():
return notify_function
def report_notification(channel, title, content, success):
ql_dir = os.getenv("QL_DIR")
if not ql_dir:
return
payload = {"title": str(title or "")[:512], "content": str(content or "")[:8192],
"success": success, "notifyType": channel,
"scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")}
code = """const fs=require('node:fs'),path=require('node:path');
const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js'));
report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});"""
try:
subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True,
capture_output=True, timeout=3)
except Exception:
pass # Audit availability never changes notification delivery.
def audited_notification(mode, title, content):
result = None
try:
result = mode(title, content)
except Exception:
result = False
print(f"{mode.__name__} 推送失败")
report_notification(mode.__name__, title, content, result is True)
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
if kwargs:
global push_config
@@ -1228,7 +1363,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
notify_function = add_notify_function()
ts = [
threading.Thread(target=mode, args=(title, content), name=mode.__name__)
threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__)
for mode in notify_function
]
[t.start() for t in ts]
+3 -3
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
create_token() {
local token_command="ts-node-transpile-only ${dir_root}/back/token.ts"
local token_file="${dir_root}/static/build/token.js"
if [[ -f $token_file ]]; then
token_command="node ${token_file}"
__ql_token__=$(node "$token_file")
else
__ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts")
fi
__ql_token__=$(eval "$token_command")
}
get_token() {
+30 -28
View File
@@ -39,16 +39,16 @@ random_delay() {
gen_array_scripts() {
local dir_current=$(pwd)
local i="-1"
cd $dir_scripts
for file in $(ls); do
cd "$dir_scripts" || return $?
for file in *; do
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
let i++
array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g")
array_scripts_name[i]=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
array_scripts[i]=$file
array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F '[(]' '{print $2}' | awk -F '[)]' '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
fi
done
cd $dir_current
cd "$dir_current" || return $?
}
## 使用说明
@@ -72,11 +72,11 @@ usage() {
## run nohup,$1:文件名,不含路径,带后缀
run_nohup() {
local file_name=$1
nohup node $file_name &>$log_path &
nohup node "$file_name" &>"$log_path" &
}
env_str_to_array() {
. $file_env
. "$file_env"
local IFS="&"
read -ra array <<<"${!env_param}"
array_length=${#array[@]}
@@ -122,7 +122,7 @@ enter_script_workdir() {
_target_dir="${dir_scripts}/${work_dir}"
fi
if [[ -d "${_target_dir}" ]]; then
cd "${_target_dir}"
cd "${_target_dir}" || return $?
if [[ ${file_param} =~ "/" ]]; then
local script_name="${file_param##*/}"
if [[ "${use_dot_prefix}" == "true" ]]; then
@@ -136,13 +136,13 @@ enter_script_workdir() {
t '警告:工作目录不存在 %s' "${_target_dir}"
fi
cd $dir_scripts
cd "$dir_scripts" || return $?
if [[ ${file_param} =~ "/" ]]; then
local script_dir="${file_param%/*}"
local script_name="${file_param##*/}"
if [[ -d ${script_dir} ]]; then
cd ${script_dir}
cd "$script_dir" || return $?
if [[ "${use_dot_prefix}" == "true" ]]; then
file_param="./${script_name}"
else
@@ -159,12 +159,12 @@ run_normal() {
random_delay "$file_param"
fi
enter_script_workdir
enter_script_workdir || return $?
if [[ $isJsOrPythonFile == 'false' ]]; then
clear_non_sh_env
fi
run_task_command $which_program $file_param "${script_params[@]}"
run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
}
handle_env_split() {
@@ -175,7 +175,7 @@ handle_env_split() {
env_str_to_array
local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g")
local runArr=($(eval echo $tempArr))
array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]}))
array_run=($(awk -v RS=' ' '!a[$1]++' <<<"${runArr[*]}"))
}
## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行
@@ -192,10 +192,10 @@ run_concurrent() {
time=$(date "+$mtime_format")
single_log_time=$(format_log_time "$mtime_format" "$time")
enter_script_workdir
enter_script_workdir || return $?
local j=0
for i in ${array_run[@]}; do
for i in "${array_run[@]}"; do
single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log"
let j++
@@ -203,16 +203,16 @@ run_concurrent() {
export "${env_param}=${array[$i - 1]}"
clear_non_sh_env
fi
eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path &
envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" &
done
wait
local k=0
for i in ${array_run[@]}; do
for i in "${array_run[@]}"; do
single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log"
let k++
cat $single_log_path
[[ -f $single_log_path ]] && rm -f $single_log_path
cat "$single_log_path"
[[ -f $single_log_path ]] && rm -f "$single_log_path"
done
}
@@ -229,7 +229,7 @@ run_designated() {
if [[ $isJsOrPythonFile == 'false' ]]; then
local n=0
for i in ${array_run[@]}; do
for i in "${array_run[@]}"; do
array_str[n]=${array[$i - 1]}
let n++
done
@@ -241,9 +241,9 @@ run_designated() {
clear_non_sh_env
fi
enter_script_workdir
enter_script_workdir || return $?
envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}"
envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
}
## 运行其他命令
@@ -273,7 +273,7 @@ run_else() {
if [[ -n "$script_for_dir" ]]; then
local saved_file_param="$file_param"
file_param="$script_for_dir"
enter_script_workdir true
enter_script_workdir true || return $?
local adjusted_script="$file_param"
file_param="$saved_file_param"
@@ -289,17 +289,17 @@ run_else() {
set -- "${new_args[@]}"
else
# 没有找到脚本参数,只 cd 到 scripts 目录
enter_script_workdir true
enter_script_workdir true || return $?
shift
fi
else
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
enter_script_workdir true
enter_script_workdir true || return $?
shift
fi
clear_non_sh_env
run_task_command $which_program $file_param "$@"
run_task_command ${which_program:+"$which_program"} "$file_param" "$@"
}
check_file() {
@@ -313,12 +313,14 @@ check_file() {
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
export PREV_PYTHONPATH="${PYTHONPATH:=}"
if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then
export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}"
local preload_path=${file_preload_js//\\/\\\\}
preload_path=${preload_path//\"/\\\"}
export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}"
else
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
fi
else
. $file_env
. "$file_env"
fi
fi
}
+1
View File
@@ -47,6 +47,7 @@ class GrpcClient {
'updateEnvNames',
'getEnvById',
'systemNotify',
'recordNotifyLog',
'getCronDetail',
'createCron',
'updateCron',
+43
View File
@@ -0,0 +1,43 @@
const path = require('node:path');
const fs = require('node:fs');
// A reporting failure must never terminate a script or change its send result.
module.exports = async function reportNotificationAudit(payload) {
let client;
try {
if (!process.env.QL_DIR) return;
const grpc = require('@grpc/grpc-js');
const loader = require('@grpc/proto-loader');
const certDir = path.join(
process.env.QL_DATA_DIR || path.join(process.env.QL_DIR, 'data'),
'config/grpc',
);
const credentials = grpc.credentials.createSsl(
fs.readFileSync(path.join(certDir, 'ca.crt')),
fs.readFileSync(path.join(certDir, 'client.key')),
fs.readFileSync(path.join(certDir, 'client.crt')),
);
const definition = loader.loadSync(
path.join(process.env.QL_DIR, 'back/protos/api.proto'),
{ keepCase: true, defaults: true },
);
const Api = grpc.loadPackageDefinition(definition).com.ql.api.Api;
client = new Api(
`localhost:${process.env.GRPC_PORT || '5500'}`,
credentials,
{ 'grpc.enable_http_proxy': 0 },
);
await new Promise((resolve) => {
client.RecordNotifyLog(
payload,
new grpc.Metadata(),
{ deadline: new Date(Date.now() + 1500) },
() => resolve(),
);
});
} catch (_) {
// Missing certificates, old panels and offline services are best effort.
} finally {
if (client) client.close();
}
};
+8 -2
View File
@@ -1,4 +1,12 @@
#!/usr/bin/env bash
# BSD grep accepts \d as a digit; GNU grep treats it as a literal d.
string=$(awk '$1 == "version:" { print $2; exit }' version.yaml) || exit 1
if [[ ! $string =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?$ ]]; then
printf 'Invalid release version: %s\n' "$string" >&2
exit 1
fi
version="v$string"
echo -e "开始发布"
echo -e "切换master分支"
@@ -9,8 +17,6 @@ git push --set-upstream origin master -f
echo -e "更新cdn文件"
ts-node-transpile-only sample/tool.ts
string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*")
version="v$string"
echo -e "当前版本$version"
echo -e "删除已经存在的本地tag"
+21 -16
View File
@@ -3,42 +3,47 @@
days=$1
remove_js_log() {
local log_full_path_list=$(find $dir_log -name "*.log")
local diff_time
for log in $log_full_path_list; do
local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10)
local log log_date log_timestamp diff_time log_path encoded_path result
local now
now=$(date +%s) || return $?
while IFS= read -r -d '' log; do
log_date=${log##*/}
log_date=${log_date:0:10}
if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
if [[ $is_macos -eq 1 ]]; then
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log")
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue
else
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
fi
fi
if [[ $is_macos -eq 1 ]]; then
diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s)))
# BSD date otherwise fills the omitted time from the current clock.
log_timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "$log_date 00:00:00" +%s) || continue
else
diff_time=$(($(date +%s) - $(date +%s -d "$log_date")))
log_timestamp=$(date +%s -d "$log_date") || continue
fi
diff_time=$((now - log_timestamp))
if [[ $diff_time -gt $((${days} * 86400)) ]]; then
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g")
local result=$(find_cron_api "log_path=$log_path")
log_path=${log#"$dir_log"/}
encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue
result=$(find_cron_api "log_path=$encoded_path")
t '查询文件 %s' "$log_path"
if [[ -z $result ]]; then
t '删除中~'
rm -vf $log
rm -vf -- "$log"
else
t '正在被 %s 使用,跳过~' "$result"
fi
fi
done
done < <(find "$dir_log" -type f -name "*.log" -print0)
}
remove_empty_dir() {
cd $dir_log
for dir in $(ls); do
if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then
rm -rf $dir
fi
local dir
for dir in "$dir_log"/*/ "$dir_log"/.[!.]*/ "$dir_log"/..?*/; do
[[ -d "$dir" ]] || continue
# rmdir also protects a directory populated between discovery and removal.
rmdir -- "$dir" 2>/dev/null || true
done
}
+45 -41
View File
@@ -77,16 +77,19 @@ load_ql_envs() {
}
import_config() {
[[ -f $file_config_user ]] && . $file_config_user
[[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh
[[ -f $file_config_user ]] && . "$file_config_user"
[[ -f $dir_preload/lang_env.sh ]] && . "$dir_preload/lang_env.sh"
# 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key)
# Language packs need global associative arrays (Bash 4.2+). Probe the
# capability so Bash 3.2 and 4.0/4.1 retain the Chinese-key fallback.
local lang=${QL_LANG:-zh}
local lang_file="$dir_shell/lang/${lang}.sh"
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then
. $lang_file
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then
. "$dir_shell/lang/zh.sh"
if (declare -gA __ql_lang_probe) 2>/dev/null; then
if [[ -f $lang_file ]]; then
. "$lang_file"
elif [[ -f "$dir_shell/lang/zh.sh" ]]; then
. "$dir_shell/lang/zh.sh"
fi
fi
load_ql_envs
@@ -132,7 +135,7 @@ unset_proxy() {
make_dir() {
local dir=$1
if [[ ! -d $dir ]]; then
mkdir -p $dir
mkdir -p -- "$dir"
fi
}
@@ -176,57 +179,57 @@ define_cmd() {
}
fix_config() {
make_dir $dir_tmp
make_dir $dir_static
make_dir $dir_data
make_dir $dir_config
make_dir $dir_log
make_dir $dir_db
make_dir $dir_scripts
make_dir $dir_list_tmp
make_dir $dir_repo
make_dir $dir_raw
make_dir $dir_update_log
make_dir $dir_dep
make_dir "$dir_tmp"
make_dir "$dir_static"
make_dir "$dir_data"
make_dir "$dir_config"
make_dir "$dir_log"
make_dir "$dir_db"
make_dir "$dir_scripts"
make_dir "$dir_list_tmp"
make_dir "$dir_repo"
make_dir "$dir_raw"
make_dir "$dir_update_log"
make_dir "$dir_dep"
if [[ ! -s $file_config_user ]]; then
cp -f $file_config_sample $file_config_user
cp -f "$file_config_sample" "$file_config_user"
fi
if [[ ! -f $file_task_before ]]; then
cp -f $file_task_sample $file_task_before
cp -f "$file_task_sample" "$file_task_before"
fi
if [[ ! -f $file_task_after ]]; then
cp -f $file_task_sample $file_task_after
cp -f "$file_task_sample" "$file_task_after"
fi
if [[ ! -f $file_extra_shell ]]; then
cp -f $file_extra_sample $file_extra_shell
cp -f "$file_extra_sample" "$file_extra_shell"
fi
if [[ ! -s $file_notify_py ]]; then
cp -f $file_notify_py_sample $file_notify_py
cp -f "$file_notify_py_sample" "$file_notify_py"
fi
if [[ ! -s $file_notify_js ]]; then
cp -f $file_notify_js_sample $file_notify_js
cp -f "$file_notify_js_sample" "$file_notify_js"
fi
if [[ ! -s $file_test_js ]]; then
cp -f $file_test_js_sample $file_test_js
cp -f "$file_test_js_sample" "$file_test_js"
fi
if [[ ! -s $file_test_py ]]; then
cp -f $file_test_py_sample $file_test_py
cp -f "$file_test_py_sample" "$file_test_py"
fi
if [[ ! -s $dep_notify_js ]]; then
cp -f $file_notify_js_sample $dep_notify_js
cp -f "$file_notify_js_sample" "$dep_notify_js"
fi
if [[ ! -s $dep_notify_py ]]; then
cp -f $file_notify_py_sample $dep_notify_py
cp -f "$file_notify_py_sample" "$dep_notify_py"
fi
}
@@ -246,17 +249,17 @@ npm_install_2() {
local dir_current=$(pwd)
local dir_work=$1
cd $dir_work
cd "$dir_work" || return $?
t '安装 %s 依赖包...\n' "$dir_work"
npm_install_sub
cd $dir_current
cd "$dir_current" || return $?
}
diff_and_copy() {
local copy_source=$1
local copy_to=$2
if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then
cp -f $copy_source $copy_to
if [[ ! -s $copy_to ]] || [[ $(diff "$copy_source" "$copy_to") ]]; then
cp -f "$copy_source" "$copy_to"
fi
}
@@ -265,12 +268,13 @@ git_clone_scripts() {
local dir="$2"
local branch="$3"
local proxy="$4"
[[ $branch ]] && local part_cmd="-b $branch "
local branch_args=()
[[ $branch ]] && branch_args=(-b "$branch")
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
set_proxy "$proxy"
git clone -q --depth=1 $part_cmd $url $dir
git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir"
exit_status=$?
unset_proxy
@@ -283,7 +287,7 @@ random_range() {
}
delete_pm2() {
cd $dir_root
cd "$dir_root" || return $?
# Try to delete PM2 processes, but don't fail if PM2 is not available
pm2 delete ecosystem.config.js 2>/dev/null || true
# Also try to kill any directly spawned node processes
@@ -291,7 +295,7 @@ delete_pm2() {
}
reload_pm2() {
cd $dir_root
cd "$dir_root" || return $?
restore_env_vars
# Try to start PM2, but handle failures gracefully
@@ -310,7 +314,7 @@ reload_pm2() {
if [[ "$QL_CONTAINER" == "true" ]]; then
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
else
nohup node static/build/app.js >$dir_log/qinglong.log 2>&1 &
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
fi
local node_pid=$!
@@ -398,7 +402,7 @@ handle_task_start() {
}
run_task_before() {
. $file_task_before "$@"
. "$file_task_before" "$@"
if [[ ${task_before:=} ]]; then
t '执行前置命令\n'
@@ -408,7 +412,7 @@ run_task_before() {
}
run_task_after() {
. $file_task_after "$@"
. "$file_task_after" "$@"
if [[ ${task_after:=} ]]; then
t '\n执行后置命令\n'
+2 -2
View File
@@ -63,12 +63,12 @@ reload_pm2
if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
nohup ql bot >"$dir_log/bot.log" 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
nohup ql extra >"$dir_log/extra.log" 2>&1 &
fi
pm2 startup
+30 -11
View File
@@ -4,31 +4,46 @@
set -e
set -x
# Configuration and language helpers are not loaded during bootstrap errors.
startup_message() {
local format="$1"
shift
printf -- "$format\n" "$@" >&2
}
if [[ ! $QL_DIR ]]; then
npm_dir=$(npm root -g)
pnpm_dir=$(pnpm root -g)
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then
npm_dir=$(npm root -g 2>/dev/null) || npm_dir=""
pnpm_dir=""
if command -v pnpm >/dev/null 2>&1; then
pnpm_dir=$(pnpm root -g 2>/dev/null) || pnpm_dir=""
fi
if [[ -n "$npm_dir" && -d "$npm_dir/@whyour/qinglong" ]]; then
QL_DIR="$npm_dir/@whyour/qinglong"
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then
elif [[ -n "$pnpm_dir" && -d "$pnpm_dir/@whyour/qinglong" ]]; then
QL_DIR="$pnpm_dir/@whyour/qinglong"
else
t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
fi
if [[ $QL_DIR ]]; then
t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
startup_message '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
fi
exit 1
fi
if [[ $QL_DIR != /* ]]; then
startup_message 'QL_DIR 必须为绝对路径,例如 /ql'
exit 1
fi
if [[ ! $QL_DATA_DIR ]]; then
t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
exit 1
fi
if [[ $QL_DATA_DIR != */data ]]; then
t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
if [[ $QL_DATA_DIR != /*/data ]]; then
startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
exit 1
fi
@@ -38,7 +53,11 @@ if [[ $command != "reload" ]]; then
# 安装依赖
os_name="${QL_OS_TYPE:-}"
if [ -z "$os_name" ]; then
os_name=$(source /etc/os-release && echo "$ID")
if [[ -r /etc/os-release ]]; then
os_name=$(source /etc/os-release && echo "$ID")
else
os_name=$(uname -s)
fi
fi
# 非 root 用户使用 sudo
@@ -69,7 +88,7 @@ if [[ $command != "reload" ]]; then
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
;;
*)
t '暂不支持此系统部署 %s' "$os_name"
startup_message '暂不支持此系统部署 %s' "$os_name"
exit 1
;;
esac
+22 -10
View File
@@ -36,19 +36,31 @@ run_task_command() {
) &
timeout_watcher=$!
set +m
trap 'kill -KILL -- "-$timeout_watcher" 2>/dev/null || :
finish_task_timeout() {
local code="$1"
trap - EXIT
{
# Bash can report a killed job between any two cleanup commands.
# Keep redirection active until the watchdog is fully reaped.
kill -KILL -- "-$timeout_watcher" || :
wait "$timeout_watcher" || :
} 2>/dev/null
if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi
kill -KILL -- "-$timeout_worker" 2>/dev/null || :
wait "$timeout_worker" 2>/dev/null || :
wait "$timeout_watcher" 2>/dev/null || :
rm -rf -- "$timeout_dir"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
trap 'exit 131' QUIT
rm -rf -- "$timeout_dir"
exit "$code"
}
# Older Bash can omit a subshell's EXIT trap in sourced pipelines. Call
# cleanup explicitly on completion/signals, retaining EXIT as a fallback.
trap 'finish_task_timeout "$?"' EXIT
trap 'finish_task_timeout 130' INT
trap 'finish_task_timeout 143' TERM
trap 'finish_task_timeout 129' HUP
trap 'finish_task_timeout 131' QUIT
# Do not clean up a timer whose process group has not initialized yet.
while [[ ! -f "$timeout_dir/ready" ]]; do
kill -0 "$timeout_watcher" 2>/dev/null || exit 125
kill -0 "$timeout_watcher" 2>/dev/null || finish_task_timeout 125
sleep 0.001
done
wait "$timeout_worker"
@@ -56,8 +68,8 @@ run_task_command() {
if [[ -f "$timeout_dir/expired" ]]; then
# Let the timer finish escalation even if the task leader exited first.
wait "$timeout_watcher" 2>/dev/null || :
exit 124
finish_task_timeout 124
fi
exit "$timeout_code"
finish_task_timeout "$timeout_code"
)
}
+7 -7
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
dir_shell=$QL_DIR/shell
. $dir_shell/share.sh
. $dir_shell/api.sh
. $dir_shell/task-timeout.sh
. "$dir_shell/share.sh"
. "$dir_shell/api.sh"
. "$dir_shell/task-timeout.sh"
trap 'single_hanle SIGINT' INT
trap 'single_hanle SIGTERM' TERM
@@ -48,7 +48,7 @@ handle_log_path() {
fi
if [[ -z ${ID:=} ]]; then
ID=$(cat $list_crontab_user | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}')
ID=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}')
fi
local suffix=""
if [[ ! -z $ID ]]; then
@@ -89,10 +89,10 @@ handle_log_path() {
log_path="$real_log_path"
fi
cmd="2>&1 | tee -a $dir_log/$log_path"
printf -v cmd '2>&1 | tee -a %q' "$dir_log/$log_path"
make_dir "$dir_log/$log_dir"
if [[ "${no_tee:=}" == "true" ]]; then
cmd=">> $dir_log/$log_path 2>&1"
printf -v cmd '>> %q 2>&1' "$dir_log/$log_path"
fi
if [[ "${real_time:=}" == "true" ]]; then
@@ -162,5 +162,5 @@ define_program "${task_shell_params[@]}"
handle_log_path "${task_shell_params[@]}"
init_begin_time
eval . $dir_shell/otask.sh "$cmd"
eval '. "$dir_shell/otask.sh"' "$cmd"
exit 0
+23 -17
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
dir_shell=$QL_DIR/shell
. $dir_shell/share.sh
. $dir_shell/api.sh
. "$dir_shell/share.sh"
. "$dir_shell/api.sh"
load_ql_envs
. $dir_shell/env.sh
. "$dir_shell/env.sh"
send_mark=$dir_shell/send_mark
@@ -165,17 +165,20 @@ update_raw() {
t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
set_proxy "$proxy"
wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url}
exit_status=$?
if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then
exit_status=0
else
exit_status=$?
fi
unset_proxy
if [[ $? -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}"
if [[ $exit_status -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $?
t '下载 %s 成功...\n' "${raw_file_name}"
cd $dir_raw
cd "$dir_raw" || return $?
local filename="raw_${raw_file_name}"
local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
cp -f $raw_file_name $dir_scripts/${filename}
local cron_id=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
cp -f "$raw_file_name" "$dir_scripts/${filename}" || return $?
if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then
cron_line=$(
perl -ne "{
@@ -202,6 +205,7 @@ update_raw() {
else
t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}"
[[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new"
return "$exit_status"
fi
}
@@ -209,7 +213,7 @@ update_raw() {
## 调用用户自定义的extra.sh
run_extra_shell() {
if [[ -f $file_extra_shell ]]; then
. $file_extra_shell
. "$file_extra_shell"
else
t '%s文件不存在,跳过执行...\n' "$file_extra_shell"
fi
@@ -266,7 +270,8 @@ show_service_logs() {
# Winston writes the active runtime log by date. Follow the filename so
# size-based rotations are handled without involving the PM2 log files.
local current_system_log="$system_log_dir/$(date +%F).log"
tail -n 0 --retry -F "$current_system_log"
# GNU and BSD tail both support -F; --retry is GNU-only and implied by -F.
tail -n 0 -F "$current_system_log"
}
## 脚本用法
@@ -484,9 +489,9 @@ main() {
local log_path="${log_dir}/${log_time}.log"
local file_path="$dir_log/$log_path"
cmd="2>&1 | tee -a $file_path"
printf -v cmd '2>&1 | tee -a %q' "$file_path"
if [[ "$no_tee" == "true" ]]; then
cmd=">> $file_path 2>&1"
printf -v cmd '>> %q 2>&1' "$file_path"
fi
if [[ "$real_time" == "true" ]]; then
cmd=""
@@ -532,19 +537,20 @@ main() {
get_uniq_path "$p2"
if [[ -n $p2 ]]; then
update_raw "$p2" "$p3" "$p4" "$p5"
command_status=$?
else
t '命令输入错误...\n'
eval usage $cmd
fi
;;
rmlog)
eval . $dir_shell/rmlog.sh "$p2" $cmd
eval '. "$dir_shell/rmlog.sh" "$p2"' "$cmd"
;;
bot)
eval . $dir_shell/bot.sh $cmd
eval '. "$dir_shell/bot.sh"' "$cmd"
;;
check)
eval . $dir_shell/check.sh $cmd
eval '. "$dir_shell/check.sh"' "$cmd"
;;
resetlet)
eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd
+9
View File
@@ -699,6 +699,15 @@
"已保存,本次未记录历史版本": "Saved without a history snapshot",
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
"保存失败": "Unable to save",
"通知日志": "Notification log",
"标题": "Title",
"内容": "Content",
"发送时间": "Sent at",
"推送渠道": "Channel",
"发送状态": "Delivery status",
"通知来源": "Notification source",
"脚本通知": "Script notification",
"系统通知": "System notification",
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.",
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path"
}
+9
View File
@@ -699,6 +699,15 @@
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
"保存失败": "保存失败",
"通知日志": "通知日志",
"标题": "标题",
"内容": "内容",
"发送时间": "发送时间",
"推送渠道": "推送渠道",
"发送状态": "发送状态",
"通知来源": "通知来源",
"脚本通知": "脚本通知",
"系统通知": "系统通知",
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径",
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径"
}
+2 -4
View File
@@ -4,6 +4,7 @@ import { SharedContext } from '@/layouts';
import { depthFirstSearch, findNode, getEditorMode } from '@/utils';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { getCodeMirrorExtensions } from '@/utils/codemirror/language';
import { canPreviewInMonaco } from '@/utils/monaco';
import {
CloudDownloadOutlined,
@@ -15,7 +16,6 @@ import {
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import { langs } from '@uiw/codemirror-extensions-langs';
import CodeMirror from '@uiw/react-codemirror';
import { history, useOutletContext } from '@umijs/max';
import {
@@ -747,9 +747,7 @@ const Script = () => {
{isPhone && (
<CodeMirror
value={value}
extensions={
mode ? [langs[mode as keyof typeof langs]()] : undefined
}
extensions={getCodeMirrorExtensions(mode)}
theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing || saving}
onChange={(value) => {
+22
View File
@@ -26,6 +26,7 @@ import {
} from '@ant-design/icons';
import SecuritySettings from './security';
import LoginLog from './loginLog';
import NotifyLog from './notifyLog';
import NotificationSetting from './notification';
import Other from './other';
import About from './about';
@@ -126,6 +127,7 @@ const Setting = () => {
const [editedApp, setEditedApp] = useState<any>();
const [tabActiveKey, setTabActiveKey] = useState('security');
const [loginLogData, setLoginLogData] = useState<any[]>([]);
const [notifyLogData, setNotifyLogData] = useState<any[]>([]);
const [notificationInfo, setNotificationInfo] = useState<any>();
const containergRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number>(0);
@@ -254,6 +256,8 @@ const Setting = () => {
getApps();
} else if (activeKey === 'login') {
getLoginLog();
} else if (activeKey === 'notifylog') {
getNotifyLog();
} else if (activeKey === 'notification') {
getNotification();
}
@@ -272,6 +276,19 @@ const Setting = () => {
});
};
const getNotifyLog = () => {
request
.get(`${config.apiPrefix}system/notify-log`)
.then(({ code, data }) => {
if (code === 200) {
setNotifyLogData(data);
}
})
.catch((error: any) => {
console.log(error);
});
};
useEffect(() => {
if (isDemoEnv) {
getApps();
@@ -345,6 +362,11 @@ const Setting = () => {
label: intl.get('登录日志'),
children: <LoginLog height={height} data={loginLogData} />,
},
{
key: 'notifylog',
label: intl.get('通知日志'),
children: <NotifyLog height={height} data={notifyLogData} />,
},
{
key: 'client-ip',
label: intl.get('客户端 IP'),
+115
View File
@@ -0,0 +1,115 @@
import intl from 'react-intl-universal';
import React from 'react';
import { Table, Tag } from 'antd';
import dayjs from 'dayjs';
interface NotifyLogItem {
id?: number;
timestamp?: number;
title?: string;
content?: string;
status?: number;
notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
}
const NotifyStatusLabel: Record<number, string> = {
0: '成功',
1: '失败',
};
const NotifyStatusColor: Record<number, string> = {
0: 'success',
1: 'error',
};
const columns = [
{
title: intl.get('通知来源'),
key: 'source',
width: 220,
render: (_: unknown, record: NotifyLogItem) => (
<span>
{intl.get(record.source === 'script' ? '脚本通知' : '系统通知')}
{record.scriptName ? ` · ${record.scriptName}` : ''}
{record.taskId ? ` (#${record.taskId})` : ''}
</span>
),
},
{
title: intl.get('序号'),
width: 50,
render: (text: string, record: any, index: number) => {
return index + 1;
},
},
{
title: intl.get('发送时间'),
dataIndex: 'timestamp',
key: 'timestamp',
width: 160,
render: (text: string, record: any) => {
return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss');
},
},
{
title: intl.get('标题'),
dataIndex: 'title',
key: 'title',
width: 200,
},
{
title: intl.get('内容'),
dataIndex: 'content',
key: 'content',
render: (text: string) => {
if (!text) return '';
return text.length > 100 ? text.slice(0, 100) + '...' : text;
},
},
{
title: intl.get('推送渠道'),
dataIndex: 'notifyType',
key: 'notifyType',
width: 120,
},
{
title: intl.get('发送状态'),
dataIndex: 'status',
key: 'status',
width: 90,
render: (text: string, record: NotifyLogItem) => {
const statusKey = record.status ?? 1;
return (
<Tag color={NotifyStatusColor[statusKey]} style={{ marginRight: 0 }}>
{intl.get(NotifyStatusLabel[statusKey])}
</Tag>
);
},
},
];
const NotifyLog = ({
data,
height,
}: {
data: Array<NotifyLogItem>;
height: number;
}) => {
return (
<>
<Table
columns={columns}
pagination={false}
dataSource={data}
rowKey="id"
size="middle"
scroll={{ x: 1000, y: height }}
/>
</>
);
};
export default NotifyLog;
+11
View File
@@ -0,0 +1,11 @@
import { langs } from '@uiw/codemirror-extensions-langs';
export function getCodeMirrorExtensions(mode: string) {
// Monaco calls this language "ini"; CodeMirror exposes it as "properties".
const language = mode === 'ini' ? 'properties' : mode;
if (!Object.prototype.hasOwnProperty.call(langs, language)) {
return [];
}
const factory = langs[language as keyof typeof langs];
return typeof factory === 'function' ? [factory()] : [];
}
@@ -61,6 +61,7 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
fileExist: async () => false,
getGetCommand: (_type, name) => `check:${name}`,
getInstallCommand: () => '',
isPythonSourceDependency: () => false,
getPid: async () => 0,
getUninstallCommand: () => '',
killTask: async () => {},
+238
View File
@@ -0,0 +1,238 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
const types = { nodejs: 0, python3: 1, linux: 2 };
const statuses = { installed: 1, installFailed: 2, queued: 6, cancelled: 7 };
const util = load('back/config/util.ts', {
'./index': {},
'./const': {},
'../loaders/logger': {},
'../shared/utils': {},
'../data/dependence': { DependenceTypes: types },
});
function serviceFor(docs) {
const probes = [],
removed = [],
uninstalls = [];
const Service = load('back/services/dependence.ts', {
'../config': {},
'../data/dependence': {
Dependence: class {},
DependenceTypes: types,
DependenceStatus: statuses,
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
DependenceModel: {
findAll: async ({ where }) =>
docs.filter(
(d) =>
(where.status === undefined || d.status === where.status) &&
(!where.id || where.id.includes(d.id)),
),
update: async (values, { where }) => {
docs
.filter((d) => where.id.includes(d.id))
.forEach((d) => Object.assign(d, values));
},
destroy: async ({ where }) => removed.push(...where.id),
},
},
'../config/util': {
...util,
concurrentRun: (tasks) => Promise.all(tasks.map((fn) => fn())),
getGetCommand: (type, name) => {
probes.push(name);
return util.getGetCommand(type, name);
},
promiseExecSuccess: async () => '',
},
'../shared/pLimit': { removeQueuedDependency() {} },
'./sock': class {},
'../shared/i18n': {},
}).default;
const service = new Service({}, {});
service.installDependenceOneByOne = (dependencies, install, force) => {
uninstalls.push({ ids: dependencies.map((d) => d.id), install, force });
return Promise.resolve();
};
return { service, probes, removed, uninstalls };
}
test('listing preserves Python source results and still detects missing named packages', async () => {
const docs = [
' requirements with spaces/requirements.txt ',
'requirements-dev.in',
'project/pyproject.toml',
'git+https://example.invalid/repo.git',
'https://example.invalid/pkg.whl',
'missing-package',
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
const { service, probes } = serviceFor(docs);
for (let i = 0; i < 2; i++) {
await service.dependencies({
searchValue: '',
type: 'python3',
status: '',
});
}
assert.deepEqual(
docs.map((d) => d.status),
[1, 1, 1, 1, 1, 2],
);
assert.deepEqual(probes, ['missing-package']);
// Source installs must continue to execute pip rather than treating the source as a package name.
assert.equal(util.getGetCommand(1, docs[0].name), 'echo ""');
});
test('deletion cleans previously misclassified Python sources and only queues intended records', async () => {
const docs = [
{ id: 1, name: 'requirements.txt', type: 1, status: 2 },
{ id: 2, name: 'present-package', type: 0, status: 1 },
{ id: 3, name: 'missing-package', type: 0, status: 2 },
{ id: 4, name: 'queued/requirements.txt', type: 1, status: 6 },
{ id: 5, name: 'cancelled/requirements.txt', type: 1, status: 7 },
{ id: 6, name: 'git+https://example.invalid/repo.git', type: 1, status: 2 },
];
const { service, removed, uninstalls } = serviceFor(docs);
await service.remove(docs.map((d) => d.id));
assert.deepEqual(removed, [3, 4, 5]);
assert.deepEqual(uninstalls, [
{ ids: [1, 2, 6], install: false, force: false },
]);
assert.equal(docs[2].status, 2);
});
test('Alpine container mirror command runs without sudo and writes valid repositories', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-alpine-mirror-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source =
'https://dl-cdn.alpinelinux.org/alpine/v3.23/main\nhttps://dl-cdn.alpinelinux.org/alpine/v3.23/community\n';
const api = load('back/config/util.ts', {
'fs/promises': { readFile: async () => source },
'./index': {},
'./const': {},
'./share': {},
'./container': { isInContainer: () => true },
'../loaders/logger': {},
'../shared/utils': {},
'../shared/fileAccess': {},
'../data/dependence': {},
});
const previous = process.env.QL_OS_TYPE;
let command;
try {
process.env.QL_OS_TYPE = 'alpine';
command = await api.updateLinuxMirrorFile('https://mirror.example/alpine');
} finally {
if (previous === undefined) delete process.env.QL_OS_TYPE;
else process.env.QL_OS_TYPE = previous;
}
// Redirect the generated command's paths and executable into the fixture only.
const file = path.join(root, 'repositories');
const update = path.join(root, 'updated');
const sandbox = command
.replaceAll('/etc/apk/repositories', file)
.replaceAll('/etc/apk', root);
execFileSync('/bin/bash', [
'-c',
`sudo(){ return 127; }; apk(){ test "$1" = update && touch '${update}'; }; ${sandbox}`,
]);
assert.ok(fs.existsSync(update));
assert.equal(
fs.readFileSync(file, 'utf8'),
source.replaceAll(
'https://dl-cdn.alpinelinux.org/alpine',
'https://mirror.example/alpine',
),
);
});
test('force deletion removes a source record even when its requirements file is gone', async () => {
const removed = [],
updates = [];
const Service = load('back/services/dependence.ts', {
'../config': {},
'../config/util': {
getUninstallCommand() {
throw Error('Python dependency file must be inside scripts or repo');
},
},
'../data/dependence': {
Dependence: class {},
DependenceTypes: types,
DependenceStatus: { ...statuses, removeFailed: 5 },
DependenceModel: {
destroy: async ({ where }) => removed.push(...where.id),
update: async (values) => updates.push(values),
},
},
'../shared/pLimit': {
runDependeny: (_dep, fn) => fn(),
removeQueuedDependency() {},
},
'./sock': class {},
'../shared/i18n': {},
}).default;
const service = new Service({}, {});
const dep = { id: 1, name: 'gone/requirements.txt', type: 1, status: 2 };
await service.installOrUninstallDependency(dep, false, false);
assert.deepEqual(removed, []);
assert.equal(updates[0].status, 5);
await service.installOrUninstallDependency(dep, false, true);
assert.deepEqual(removed, [1]);
});
test('Python extras run pip even when the base package version is already installed', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-extra-install-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const marker = path.join(root, 'pip-ran');
const dep = {
id: 1,
name: 'fixture[optional]>=1',
type: 1,
status: 6,
log: [],
};
let probes = 0;
const Service = load('back/services/dependence.ts', {
'../config': {},
'../config/util': {
getInstallCommand: () => `printf installed > '${marker}'`,
getGetCommand: () => {
probes++;
return 'probe';
},
promiseExecSuccess: async () => '1.0',
detectOS: async () => 'Alpine',
fileExist: async () => false,
},
'../data/dependence': {
Dependence: class {},
DependenceTypes: types,
DependenceStatus: { ...statuses, installing: 0, cancelled: 7 },
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
DependenceModel: {
findAll: async () => [dep],
update: async (values) => Object.assign(dep, values),
},
},
'../shared/pLimit': {
firstDependencyId: 1,
runDependeny: (_dep, fn) => fn(),
removeQueuedDependency() {},
updateDepLog: (fn) => fn(),
},
'./sock': class {},
'../shared/i18n': { t: (text) => text, tf: (text) => text },
}).default;
const service = new Service({}, { sendMessage() {} });
await service.installOrUninstallDependency(dep, true, false);
assert.equal(probes, 0);
assert.equal(fs.readFileSync(marker, 'utf8'), 'installed');
assert.equal(dep.status, 1);
});
+123
View File
@@ -0,0 +1,123 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
{ Sequelize, DataTypes } = require('sequelize');
const load = require('../helpers/load-security-module.cjs');
const decorators = { Service: () => (x) => x, Inject: () => () => {} };
async function fixture(t) {
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(() => db.close());
const rows = db.define('System', {
type: DataTypes.STRING,
info: DataTypes.JSON,
});
await db.sync();
const logger = { error() {} };
const S = load('back/services/system.ts', {
typedi: decorators,
'../config': {},
'../config/const': { NotificationModeStringMap: { 0: 'gotify' } },
'../config/util': {},
'../data/dependence': {},
'../data/system': {
SystemModel: rows,
AuthDataType: { notifyLog: 'notifyLog', notification: 'notification' },
NotifyStatus: { success: 0, fail: 1 },
},
'./schedule': class {},
'./sock': class {},
'./notify': class {},
'../shared/pLimit': {},
'../schedule/client': {},
'../config/index': {},
'../shared/store': {},
'../shared/i18n': { t: (s) => s },
}).default;
const service = Object.assign(Object.create(S.prototype), {
logger,
notificationService: new (load('back/services/notify.ts', {
typedi: decorators,
'../config/util': {},
'../data/system': {
SystemModel: rows,
AuthDataType: { notifyLog: 'notifyLog' },
NotifyStatus: { success: 0, fail: 1 },
},
'../shared/i18n': { t: (s) => s },
'./user': {},
}).default)(),
});
service.notificationService.userService = {
getNotificationMode: async () => ({ type: 'email' }),
};
service.notificationService.modeMap.set('email', async () => true);
service.getDb = async () => ({ info: { type: 'email' } });
return { rows, service };
}
test('writes cap logs without reading the page and preserve other system data', async (t) => {
const { rows, service } = await fixture(t);
await rows.create({ type: 'authConfig', info: { keep: true } });
for (let i = 0; i < 205; i++)
await service.notify({ title: String(i), content: 'body' });
assert.equal(await rows.count({ where: { type: 'notifyLog' } }), 200);
const logs = await service.getNotifyLog();
assert.equal(logs[0].title, '204');
assert.equal(logs.at(-1).title, '5');
assert.equal(logs[0].notifyType, 'email');
assert.equal(await rows.count({ where: { type: 'authConfig' } }), 1);
});
test('audit DB failure does not turn a successful notification into failure', async (t) => {
const { rows, service } = await fixture(t);
const create = rows.create;
rows.create = async () => {
throw Error('disk full');
};
try {
assert.equal(
(await service.notify({ title: 'ok', content: 'ok' })).code,
200,
);
} finally {
rows.create = create;
}
});
test('failed sends are recorded and message size is bounded', async (t) => {
const { service } = await fixture(t);
service.notificationService.modeMap.set('gotify', async () => false);
assert.equal(
(
await service.notify({
title: 'x'.repeat(1000),
content: 'y'.repeat(20000),
notificationInfo: { type: 0 },
})
).code,
400,
);
const [row] = await service.getNotifyLog();
assert.equal(row.status, 1);
assert.equal(row.title.length, 512);
assert.equal(row.content.length, 8192);
assert.equal(row.notifyType, 'gotify');
});
test('script reports share retention and preserve source without sending twice', async (t) => {
const { service } = await fixture(t);
await service.recordNotifyLog({
title: 'task result',
content: 'body',
status: 1,
notifyType: 'smtp',
source: 'script',
scriptName: 'job.py',
taskId: '42',
});
const [row] = await service.getNotifyLog();
assert.equal(row.source, 'script');
assert.equal(row.scriptName, 'job.py');
assert.equal(row.taskId, '42');
assert.equal(row.status, 1);
});
+127
View File
@@ -0,0 +1,127 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
test('#3097 Python requirement probes preserve version constraints, extras and installed distributions', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-specifiers-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const venv = path.join(root, 'virtual env');
execFileSync('python3', ['-m', 'venv', venv]);
execFileSync('python3', [
'-c',
`import zipfile,sys,os
for version in ['1.2.3','2.1.0']:
name='ql_specifier_fixture'
with zipfile.ZipFile(os.path.join(sys.argv[1],name+'-'+version+'-py3-none-any.whl'),'w') as z:
z.writestr(name+'.py','value=42\\n')
info=name+'-'+version+'.dist-info/'
z.writestr(info+'METADATA','Metadata-Version: 2.1\\nName: ql-specifier-fixture\\nVersion: '+version+'\\nProvides-Extra: optional\\n')
z.writestr(info+'WHEEL','Wheel-Version: 1.0\\nGenerator: fixture\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n')
z.writestr(info+'RECORD','')`,
root,
]);
const api = load('back/config/util.ts', {
'./index': { rootPath: root },
'./const': { PYTHON_VENV_DIR: venv, PYTHON_INSTALL_DIR: '/unused-prefix' },
'../loaders/logger': {},
'../shared/utils': {},
'../data/dependence': {
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
},
});
const env = { ...process.env, PIP_NO_INDEX: '1', PIP_FIND_LINKS: root };
for (const key of ['PYTHONPATH', 'PYTHONUSERBASE', 'PYTHON_HOME'])
delete env[key];
const run = (command) =>
execFileSync('/bin/bash', ['-c', command], {
encoding: 'utf8',
env,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
run(api.getInstallCommand(1, 'ql-specifier-fixture==1.2.3'));
const Service = load('back/services/dependence.ts', {
'../config': {},
'../config/util': {
...api,
promiseExecSuccess: async (command) => run(command),
},
'../data/dependence': {
Dependence: class {},
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
},
'../shared/pLimit': {},
'./sock': class {},
'../shared/i18n': {},
}).default;
const service = new Service({}, {});
assert.equal(
await service.isDependenceInstalled({
type: 1,
name: 'ql-specifier-fixture==1.2.3.0',
}),
true,
);
assert.equal(
await service.isDependenceInstalled({
type: 1,
name: 'ql-specifier-fixture>=2',
}),
false,
);
for (const suffix of [
'',
'>=1.0',
'<2',
'>=1,<2',
'~=1.2',
'==1.*',
'!=2.0',
'[optional]>=1',
'==1.2.3.0',
]) {
assert.equal(
run(api.getGetCommand(1, 'ql-specifier-fixture' + suffix)),
'1.2.3',
suffix,
);
}
for (const suffix of ['>=2', '<1', '!=1.2.3', '==1.2.30']) {
assert.equal(
run(api.getGetCommand(1, 'ql-specifier-fixture' + suffix)),
'',
suffix,
);
}
assert.equal(run(api.getGetCommand(1, 'ql_specifier_fixture')), '1.2.3');
assert.equal(run(api.getGetCommand(1, 'json')), 'json');
assert.equal(
run(api.getGetCommand(1, 'ql-missing-specifier-package>=1')),
'',
);
assert.equal(
run(api.getGetCommand(1, 'ql-specifier-fixture; python_version < "0"')),
'not applicable',
);
run(api.getInstallCommand(1, 'ql-specifier-fixture>=2'));
assert.equal(
run(api.getGetCommand(1, 'ql-specifier-fixture>=2,<3')),
'2.1.0',
);
run(api.getUninstallCommand(1, 'ql-specifier-fixture[optional]>=2,<3'));
assert.equal(run(api.getGetCommand(1, 'ql-specifier-fixture')), '');
assert.equal(fs.existsSync(path.join(root, 'INJECTED')), false);
assert.equal(
run(
api.getGetCommand(
1,
`ql-specifier-fixture; __import__('os').system('touch ${root}/INJECTED')`,
),
),
'',
);
});
+1 -1
View File
@@ -101,7 +101,7 @@ test('ql log follows the current Winston system log without PM2', () => {
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`);
assert.equal(result.stdout, `tail <-n> <0> <-F> <${currentLog}>\n`);
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
});
@@ -0,0 +1,152 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
fs = require('node:fs'),
path = require('node:path'),
vm = require('node:vm');
const load = require('../helpers/load-security-module.cjs');
function script(config = {}, response = {}, auditError = false) {
const logs = [],
source = fs.readFileSync('sample/notify.js', 'utf8');
const transport = async () => ({
statusCode: response.httpStatus || 200,
body: { text: async () => JSON.stringify(response) },
});
const context = {
module: { exports: {} },
console: { log() {}, info() {} },
process: {
env: { QL_DIR: '/fixture', HITOKOTO: 'false', ...config },
argv: ['node', '/fixture/scripts/test-job.js'],
},
Buffer,
URL,
setTimeout,
clearTimeout,
};
context.require = (name) => {
if (name === 'undici')
return { request: transport, ProxyAgent: class {}, FormData };
if (name === '/fixture/shell/preload/notificationAudit.js')
return async (x) => {
if (auditError) throw Error('offline');
logs.push(x);
};
if (name === 'nodemailer')
return {
createTransport: () => ({
sendMail: async () => {
if (response.smtpFailure) throw Error('auth rejected');
return { messageId: 'fixture' };
},
close() {},
}),
};
return require(name);
};
vm.runInNewContext(source, context, {
filename: path.resolve('sample/notify.js'),
});
return { send: context.module.exports.sendNotify, logs };
}
test('JS helper logs only configured channels, success/failure and script/task provenance', async () => {
for (const fail of [false, true]) {
const s = script(
{
SMTP_EMAIL: 'sender@example.test',
SMTP_PASSWORD: 'private-password',
SMTP_SERVICE: 'fixture',
ID: '17',
},
{ smtpFailure: fail },
);
await s.send('标题', '正文');
assert.equal(s.logs.length, 1);
assert.equal(s.logs[0].success, !fail);
assert.equal(s.logs[0].notifyType, 'smtp');
assert.equal(s.logs[0].scriptName, 'test-job.js');
assert.equal(s.logs[0].taskId, '17');
assert.ok(!JSON.stringify(s.logs).includes('private-password'));
}
const s = script();
await s.send('none', 'none');
assert.equal(s.logs.length, 0);
});
test('HTTP business rejection is audited as failure and logging errors do not change sends', async () => {
for (const [code, success] of [
[200, true],
[500, false],
]) {
const s = script({ BARK_PUSH: 'https://fixture.invalid' }, { code });
await s.send('title', 'body');
assert.equal(s.logs.length, 1);
assert.equal(s.logs[0].success, success);
}
const offline = script(
{
SMTP_EMAIL: 'sender@example.test',
SMTP_PASSWORD: 'fixture',
SMTP_SERVICE: 'fixture',
},
{},
true,
);
await offline.send('still sends', 'body');
});
test('multi-channel helpers write one result per actual channel', async () => {
const s = script(
{
GOTIFY_URL: 'https://fixture.invalid',
GOTIFY_TOKEN: 'private-token',
SMTP_EMAIL: 'sender@example.test',
SMTP_PASSWORD: 'fixture',
SMTP_SERVICE: 'fixture',
},
{ id: 123 },
);
await s.send('title', 'body');
assert.deepEqual(s.logs.map((x) => x.notifyType).sort(), ['gotify', 'smtp']);
assert.ok(s.logs.every((x) => x.success));
});
test('script audit protobuf round trips without notification credentials', () => {
const p = load('back/protos/api.ts');
const value = p.RecordNotifyLogRequest.create({
title: '中文',
content: '内容',
success: false,
notifyType: 'smtp',
scriptName: 'job.py',
taskId: '12',
});
assert.deepEqual(
p.RecordNotifyLogRequest.decode(
p.RecordNotifyLogRequest.encode(value).finish(),
),
value,
);
assert.ok(p.ApiService.recordNotifyLog);
});
test('multi-target Chronocat records one failure if any recipient fails', async () => {
let calls = 0;
const response = {};
Object.defineProperty(response, 'httpStatus', {
get: () => (++calls === 1 ? 200 : 500),
});
const s = script(
{
CHRONOCAT_URL: 'https://fixture.invalid',
CHRONOCAT_TOKEN: 'private-token',
CHRONOCAT_QQ: 'user_id=123&group_id=456',
},
response,
);
await s.send('title', 'body');
assert.equal(calls, 2);
assert.equal(s.logs.length, 1);
assert.equal(s.logs[0].success, false);
});
+114
View File
@@ -0,0 +1,114 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { promisify } = require('node:util');
const { execFile } = require('node:child_process');
const grpc = require('@grpc/grpc-js');
const loader = require('@grpc/proto-loader');
const load = require('../helpers/load-security-module.cjs');
const run = promisify(execFile);
test('JS and Python script reports reach the same authenticated RPC with bounded failure', async (t) => {
const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-audit-rpc-'));
t.after(() => fs.rmSync(data, { recursive: true, force: true }));
fs.mkdirSync(path.join(data, 'config'));
const certs = await load('back/config/grpcCerts.ts', {
'./index': { configPath: path.join(data, 'config') },
'./util': { fileExist: async (p) => fs.existsSync(p) },
'../loaders/logger': { info() {}, error() {} },
}).initGrpcCerts();
const definition = loader.loadSync(path.resolve('back/protos/api.proto'), {
keepCase: true,
defaults: true,
});
const Api = grpc.loadPackageDefinition(definition).com.ql.api.Api;
const received = [];
const server = new grpc.Server();
server.addService(Api.service, {
RecordNotifyLog: (call, callback) => {
received.push(call.request);
callback(null, { code: 200 });
},
});
const credentials = grpc.ServerCredentials.createSsl(
Buffer.from(certs.caCert),
[
{
private_key: Buffer.from(certs.serverKey),
cert_chain: Buffer.from(certs.serverCert),
},
],
true,
);
const port = await new Promise((resolve, reject) =>
server.bindAsync('127.0.0.1:0', credentials, (error, port) =>
error ? reject(error) : resolve(port),
),
);
t.after(() => server.forceShutdown());
const env = {
...process.env,
QL_DIR: process.cwd(),
QL_DATA_DIR: data,
GRPC_PORT: String(port),
ID: '9',
HITOKOTO: 'false',
};
await run(
process.execPath,
[
'-e',
`require('./shell/preload/notificationAudit.js')({title:'JS task',content:'body',success:true,notifyType:'smtp',scriptName:'task.js',taskId:process.env.ID})`,
],
{ env, timeout: 8000 },
);
const python = `import importlib.util,sys,types,smtplib,os
sys.modules['requests']=types.SimpleNamespace()
spec=importlib.util.spec_from_file_location('notify',os.path.join(os.environ['QL_DIR'],'sample/notify.py'))
n=importlib.util.module_from_spec(spec);spec.loader.exec_module(n)
class Mail:
def __init__(self,*a,**kw): pass
def login(self,*a): pass
def sendmail(self,*a): return {}
def close(self): pass
smtplib.SMTP_SSL=Mail
n.push_config={'SMTP_SERVER':'fixture','SMTP_SSL':'true','SMTP_EMAIL':'sender@example.test','SMTP_PASSWORD':'private-password','SMTP_NAME':'fixture','HITOKOTO':False}
n.send('Python success','body')
def rejected(*a,**kw): raise RuntimeError('private-password')
smtplib.SMTP_SSL=rejected
n.send('Python failure','body')
`;
await run('python3', ['-c', python], { env, timeout: 12000 });
assert.deepEqual(
received.map((x) => [x.title, x.success]),
[
['JS task', true],
['Python success', true],
['Python failure', false],
],
);
assert.ok(received.every((x) => x.taskId === '9'));
assert.ok(!JSON.stringify(received).includes('private-password'));
// Missing certificates and an older/unavailable backend must not kill scripts.
await run(
process.execPath,
[
'-e',
`require('./shell/preload/notificationAudit.js')({title:'offline'}).then(()=>console.log('done'))`,
],
{ env: { ...env, QL_DATA_DIR: path.join(data, 'missing') }, timeout: 8000 },
);
server.forceShutdown();
const started = Date.now();
await run(
process.execPath,
[
'-e',
`require('./shell/preload/notificationAudit.js')({title:'offline'})`,
],
{ env, timeout: 8000 },
);
assert.ok(Date.now() - started < 5000);
});
+560
View File
@@ -0,0 +1,560 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const repo = path.resolve(__dirname, '../..');
const bash = process.env.QL_TEST_BASH || '/bin/bash';
const source = (file) => fs.readFileSync(path.join(repo, file), 'utf8');
function extract(file, name) {
const text = source(file);
const start = text.indexOf(`${name}() {`);
assert.ok(start >= 0, name);
return text.slice(start, text.indexOf('\n}', start) + 2);
}
function fixture(t, prefix = `ql compatibility '" $literal `) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const write = (name, content, executable = false) => {
const file = path.join(root, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content, { mode: executable ? 0o755 : 0o644 });
return file;
};
const run = (code, env = {}, args = [], cwd = root) =>
spawnSync(bash, ['-c', code, 'compatibility-test', ...args], {
cwd,
env: { ...process.env, ...env },
encoding: 'utf8',
timeout: 15000,
});
return { root, write, run };
}
for (const [label, args] of [
['normal', ['nested folder/probe [1] $name.sh', '--']],
['explicit bash', ['bash', 'nested folder/probe [1] $name.sh']],
[
'designated',
['nested folder/probe [1] $name.sh', 'desi', 'QA_PANEL', '1-2', '--'],
],
[
'concurrent',
['nested folder/probe [1] $name.sh', 'conc', 'QA_PANEL', '1-2', '--'],
],
['automatic Node', ['nested folder/probe [1] $name.js', '--']],
['explicit Node', ['node', 'nested folder/probe [1] $name.js']],
]) {
for (const logging of ['tee', 'file']) {
test(`${label} task keeps literal paths and arguments with ${logging} logging`, (t) => {
const f = fixture(t);
for (const name of ['share.sh', 'task.sh', 'otask.sh', 'task-timeout.sh'])
f.write(`shell/${name}`, source(`shell/${name}`));
f.write(
'shell/api.sh',
'update_cron(){ :; }; record_cron_stat(){ :; }\n',
);
f.write('shell/preload/env.sh', 'export QA_PANEL="one&two"\n');
f.write('shell/preload/sitecustomize.js', '// fixture preloader\n');
f.write(
'data/config/config.sh',
'DefaultCronRule="0 0 * * *"\nCommandTimeoutTime="2s"\n',
);
f.write('data/config/task_before.sh', 'printf "BEFORE\\n"\n');
f.write('data/config/task_after.sh', 'printf "AFTER\\n"\n');
f.write('bin/pnpm', '#!/bin/bash\nprintf /fixture/global\n', true);
const literal = `argument with 'quotes' $literal ; echo NOT_EXECUTED`;
f.write(
'data/scripts/nested folder/probe [1] $name.sh',
'printf "ARG=%s\\nACCOUNT=%s\\n" "$1" "$QA_PANEL"\n',
);
f.write(
'data/scripts/nested folder/probe [1] $name.js',
'console.log("ARG=" + process.argv[2]);\n',
);
const started = Date.now();
const result = f.run(
'exec "$QA_BASH" "$QL_DIR/shell/task.sh" "$@"',
{
QA_BASH: bash,
QL_DIR: f.root,
QL_DATA_DIR: path.join(f.root, 'data'),
ID: '42',
no_delay: 'true',
real_time: 'false',
no_tee: logging === 'file' ? 'true' : 'false',
real_log_path: 'literal [log] $name.log',
PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH,
},
[...args, literal],
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.ok(
Date.now() - started < 1800,
'completion must not wait for the two-second watchdog',
);
const log = fs.readFileSync(
path.join(f.root, 'data/log/literal [log] $name.log'),
'utf8',
);
assert.ok(log.includes(`ARG=${literal}\n`), log);
assert.match(log, /AFTER\n/);
assert.match(log, /完成 ✅/);
if (label === 'concurrent') {
assert.match(log, /ACCOUNT=one\n/);
assert.match(log, /ACCOUNT=two\n/);
}
});
}
}
test('shared initialization copies configuration and hooks under a directory containing spaces', (t) => {
const f = fixture(t);
f.write('shell/share.sh', source('shell/share.sh'));
for (const name of [
'config.sample.sh',
'task.sample.sh',
'extra.sample.sh',
'notify.py',
'notify.js',
'ql_sample.js',
'ql_sample.py',
])
f.write(
`sample/${name}`,
name === 'config.sample.sh'
? 'DefaultCronRule="0 0 * * *"\n'
: '# fixture\n',
);
const result = f.run(
'. "$QL_DIR/shell/share.sh"; fix_config; import_config; printf "%s" "$default_cron"',
{
QL_DIR: f.root,
QL_DATA_DIR: path.join(f.root, 'custom data'),
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.equal(result.stdout, '0 0 * * *');
assert.ok(
fs.existsSync(path.join(f.root, 'custom data/config/task_after.sh')),
);
});
test('token generation passes a compiled token filename literally', (t) => {
const f = fixture(t);
f.write('static/build/token.js', 'console.log("fixture-token");\n');
const result = f.run(
extract('shell/api.sh', 'create_token') +
'\ncreate_token; printf "%s" "$__ql_token__"',
{ dir_root: f.root },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'fixture-token');
});
test('script discovery keeps filenames with spaces, glob characters and leading dashes', (t) => {
const f = fixture(t, 'ql-script-list | ');
for (const name of ['-dash name.js', 'probe [1].js', 'sendNotify.js'])
f.write(`scripts/${name}`, "new Env('fixture');\n");
const result = f.run(
extract('shell/otask.sh', 'gen_array_scripts') +
'\ngen_array_scripts; printf "%s\\n" "${array_scripts[@]}"; printf "NAME=%s\\n" "${array_scripts_name[@]}"',
{
dir_scripts: path.join(f.root, 'scripts'),
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.deepEqual(result.stdout.trim().split('\n').sort(), [
'-dash name.js',
'NAME=fixture',
'NAME=fixture',
'probe [1].js',
]);
});
test('token generation passes the TypeScript fallback filename literally', (t) => {
const f = fixture(t);
const result = f.run(
extract('shell/api.sh', 'create_token') +
`
ts-node-transpile-only(){ [[ "$1" == "$dir_root/back/token.ts" ]] || return 99; printf fallback-token; }
create_token || exit $?
printf '%s' "$__ql_token__"
`,
{ dir_root: f.root },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'fallback-token');
});
test('repository cloning preserves a literal destination with and without a branch', (t) => {
const f = fixture(t);
const origin = path.join(f.root, 'origin repo');
for (const args of [
['init', '-b', 'develop', origin],
['-C', origin, 'config', 'user.name', 'Fixture'],
['-C', origin, 'config', 'user.email', 'fixture@example.invalid'],
]) {
const result = spawnSync('git', args, { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
}
fs.writeFileSync(path.join(origin, 'probe.txt'), 'fixture');
for (const args of [
['-C', origin, 'add', '.'],
['-C', origin, 'commit', '-m', 'fixture'],
]) {
const result = spawnSync('git', args, { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
}
for (const branch of ['', 'develop']) {
const target = path.join(f.root, branch || 'default clone');
const result = f.run(
[
extract('shell/share.sh', 'set_proxy'),
extract('shell/share.sh', 'unset_proxy'),
extract('shell/share.sh', 'git_clone_scripts'),
't(){ :; }; git_clone_scripts "$QA_ORIGIN" "$QA_TARGET" "$QA_BRANCH"; exit "$exit_status"',
].join('\n'),
{ QA_ORIGIN: origin, QA_TARGET: target, QA_BRANCH: branch },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(
fs.readFileSync(path.join(target, 'probe.txt'), 'utf8'),
'fixture',
);
}
});
for (const condition of [
'missing data',
'invalid data',
'invalid root',
'npm only',
'missing module',
'unsupported OS',
]) {
test(`native startup reports ${condition} without depending on translation initialization`, (t) => {
const f = fixture(t);
const npmRoot = path.join(f.root, 'npm global');
fs.mkdirSync(path.join(npmRoot, '@whyour/qinglong'), { recursive: true });
f.write('bin/npm', '#!/bin/bash\nprintf "%s" "$QA_NPM_ROOT"\n', true);
f.write('bin/id', '#!/bin/bash\nprintf 1000\n', true);
const env = {
PATH: path.join(f.root, 'bin'),
QL_DIR: f.root,
QL_DATA_DIR: path.join(f.root, 'data'),
QA_NPM_ROOT: npmRoot,
QL_OS_TYPE: 'unsupported-fixture',
};
if (condition === 'missing data') env.QL_DATA_DIR = '';
if (condition === 'invalid data') env.QL_DATA_DIR = 'relative/data';
if (condition === 'invalid root') env.QL_DIR = 'relative';
if (condition === 'npm only' || condition === 'missing module')
env.QL_DIR = '';
if (condition === 'missing module')
fs.rmdirSync(path.join(npmRoot, '@whyour/qinglong'));
const result = f.run(source('shell/start.sh'), env);
assert.equal(result.status, 1, result.stderr);
assert.doesNotMatch(
result.stderr,
/command not found|No such file|integer expression/,
);
assert.match(
result.stderr,
condition === 'npm only'
? /export QL_DIR=/
: condition === 'missing module'
? /npm i -g @whyour\/qinglong/
: condition === 'unsupported OS'
? /暂不支持此系统/
: condition === 'invalid root'
? /QL_DIR/
: /QL_DATA_DIR/,
);
});
}
test('language loading falls back when global associative arrays are unavailable', (t) => {
const f = fixture(t, 'ql-locale-compat-');
f.write(
'shell/lang/en.sh',
'printf loaded > "$QA_MARKER"\ndeclare -gA LANG_MESSAGES=([probe]=translated)\n',
);
const result = f.run(
source('shell/share.sh') +
`
declare(){ if [[ "$1" == -gA ]]; then return 2; fi; builtin declare "$@"; }
import_config
t probe
`,
{
QL_DIR: f.root,
QL_LANG: 'en',
QA_MARKER: path.join(f.root, 'loaded'),
DefaultCronRule: '0 0 * * *',
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'probe\n');
assert.equal(fs.existsSync(path.join(f.root, 'loaded')), false);
});
test(
'supported Bash keeps translated messages global after configuration loading',
{
skip:
spawnSync(bash, ['-c', 'declare -gA probe'], { stdio: 'ignore' })
.status !== 0,
},
(t) => {
const f = fixture(t);
f.write(
'shell/lang/en.sh',
'declare -gA LANG_MESSAGES=([probe]=translated)\n',
);
const result = f.run(
source('shell/share.sh') + '\nimport_config; t probe',
{
QL_DIR: f.root,
QL_LANG: 'en',
DefaultCronRule: '0 0 * * *',
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'translated\n');
},
);
for (const fail of [false, true]) {
test(`raw download ${
fail
? 'failure preserves the installed script'
: 'success installs the new script'
} and clears its proxy`, (t) => {
const f = fixture(t);
f.write('raw/probe.js', 'previous-good-version');
f.write('scripts/raw_probe.js', 'previous-good-version');
f.write('crontab.list', '');
const result = f.run(
[
extract('shell/share.sh', 'set_proxy'),
extract('shell/share.sh', 'unset_proxy'),
extract('shell/update.sh', 'update_raw'),
`t(){ :; }
wget(){ printf '%s' "$QA_CONTENT" > "$4"; return "$QA_STATUS"; }
update_raw 'https://fixture.invalid/probe.js' 'http://fixture.invalid:8080' false false
result=$?
if env | grep -qE '^https?_proxy='; then exit 99; fi
exit "$result"`,
].join('\n'),
{
dir_raw: path.join(f.root, 'raw'),
dir_scripts: path.join(f.root, 'scripts'),
list_crontab_user: path.join(f.root, 'crontab.list'),
cmd_task: 'task',
uniq_path: 'probe',
QA_CONTENT: fail ? 'partial-download' : 'new-version',
QA_STATUS: fail ? '8' : '0',
},
);
assert.equal(result.status, fail ? 8 : 0, result.stderr);
const expected = fail ? 'previous-good-version' : 'new-version';
assert.equal(
fs.readFileSync(path.join(f.root, 'raw/probe.js'), 'utf8'),
expected,
);
assert.equal(
fs.readFileSync(path.join(f.root, 'scripts/raw_probe.js'), 'utf8'),
expected,
);
assert.equal(fs.existsSync(path.join(f.root, 'raw/probe.js.new')), false);
});
}
test('ql raw propagates download failure without replacing either installed copy', (t) => {
const f = fixture(t);
for (const name of ['share.sh', 'update.sh', 'env.sh'])
f.write(`shell/${name}`, source(`shell/${name}`));
f.write('shell/api.sh', '');
f.write('data/config/config.sh', 'DefaultCronRule="0 0 * * *"\n');
f.write('data/config/crontab.list', '');
f.write('data/raw/invalid_probe.js', 'old-version');
f.write('data/scripts/raw_invalid_probe.js', 'old-version');
f.write('bin/wget', '#!/bin/bash\nprintf partial > "$4"\nexit 8\n', true);
const result = f.run(
'exec "$QA_BASH" "$QL_DIR/shell/update.sh" raw https://fixture.invalid/probe.js "" false false',
{
QA_BASH: bash,
QL_DIR: f.root,
QL_DATA_DIR: path.join(f.root, 'data'),
ID: '',
PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH,
},
);
assert.equal(result.status, 8, result.stderr);
assert.equal(
fs.readFileSync(path.join(f.root, 'data/raw/invalid_probe.js'), 'utf8'),
'old-version',
);
assert.equal(
fs.readFileSync(
path.join(f.root, 'data/scripts/raw_invalid_probe.js'),
'utf8',
),
'old-version',
);
});
const dateTools = [
{
name: 'host',
command: 'date',
macos: process.platform === 'darwin' ? '1' : '0',
},
];
if (
process.platform === 'darwin' &&
spawnSync('gdate', ['--version']).status === 0
)
dateTools.push({ name: 'GNU', command: 'gdate', macos: '0' });
for (const dateTool of dateTools) {
test(`${dateTool.name} retention handles literal filenames, active references and empty directories`, (t) => {
const f = fixture(t);
const old = f.write('logs/2020-01-01 two [words].log', 'old');
const active = f.write('logs/2020-01-01 active & protected.log', 'active');
f.write('logs/keep/important.txt', 'keep');
fs.mkdirSync(path.join(f.root, 'logs/empty directory'));
const result = f.run(
`
t(){ :; }
date(){ command "$QA_DATE" "$@"; }
find_cron_api(){ printf '%s\n' "$1" >> "$QA_QUERIES"; [[ "$1" == *active* ]] && printf active; }
. "$QA_SCRIPT" 7
`,
{
dir_log: path.join(f.root, 'logs'),
is_macos: dateTool.macos,
QA_DATE: dateTool.command,
QA_QUERIES: path.join(f.root, 'queries'),
QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'),
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.equal(fs.existsSync(old), false);
assert.equal(fs.existsSync(active), true);
assert.equal(
fs.existsSync(path.join(f.root, 'logs/empty directory')),
false,
);
assert.ok(fs.existsSync(path.join(f.root, 'logs/keep/important.txt')));
const queries = fs
.readFileSync(path.join(f.root, 'queries'), 'utf8')
.trim()
.split('\n');
for (const query of queries)
assert.equal(
new URLSearchParams(query).get('log_path').includes('.log'),
true,
);
assert.ok(
queries.some(
(query) =>
new URLSearchParams(query).get('log_path') ===
'2020-01-01 active & protected.log',
),
);
});
test(`${dateTool.name} retention measures a dated log from local midnight`, (t) => {
const f = fixture(t, 'ql-retention-boundary-');
const file = f.write('logs/2026-01-01.log', 'boundary');
const result = f.run(
`
t(){ :; }; find_cron_api(){ :; }
date(){ if [[ "$*" == +%s ]]; then printf 1767830401; else command "$QA_DATE" "$@"; fi; }
. "$QA_SCRIPT" 7
`,
{
dir_log: path.join(f.root, 'logs'),
is_macos: dateTool.macos,
QA_DATE: dateTool.command,
QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'),
TZ: 'UTC',
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(
fs.existsSync(file),
false,
'seven days plus one second is past retention',
);
});
}
test('log streaming options are accepted by the actual platform tail', (t) => {
const f = fixture(t);
const today = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim();
f.write(`syslog/${today}.log`, 'ready\n');
const result = f.run(
extract('shell/update.sh', 'show_service_logs') +
`
tail(){ printf '%s\\0' "$@"; }
show_service_logs --lines 0
`,
{ dir_data: f.root },
);
assert.equal(result.status, 0, result.stderr);
const args = result.stdout.split('\0').slice(0, -1);
const native = spawnSync('tail', args, {
encoding: 'utf8',
timeout: 250,
killSignal: 'SIGKILL',
});
assert.equal(native.error?.code, 'ETIMEDOUT', native.stderr);
assert.equal(native.stderr, '');
});
for (const version of ['2.23.0', '2.23.0-beta.1+build.2', 'invalid']) {
test(`legacy publishing ${
version === 'invalid'
? 'rejects an invalid version before Git changes'
: 'uses the complete version with GNU-compatible tools'
}`, (t) => {
const f = fixture(t);
f.write('version.yaml', `version: ${version}\n`);
f.write(
'bin/git',
'#!/bin/bash\nprintf "%s\\n" "$*" >> "$QA_GIT_CALLS"\n',
true,
);
f.write('bin/ts-node-transpile-only', '#!/bin/bash\nexit 0\n', true);
const result = f.run(source('shell/pub.sh'), {
PATH:
path.join(f.root, 'bin') +
path.delimiter +
(process.platform === 'darwin' &&
fs.existsSync('/opt/homebrew/opt/grep/libexec/gnubin')
? '/opt/homebrew/opt/grep/libexec/gnubin:'
: '') +
process.env.PATH,
QA_GIT_CALLS: path.join(f.root, 'git-calls'),
});
if (version === 'invalid') {
assert.notEqual(result.status, 0);
assert.equal(fs.existsSync(path.join(f.root, 'git-calls')), false);
} else {
assert.equal(result.status, 0, result.stderr);
assert.ok(
fs
.readFileSync(path.join(f.root, 'git-calls'), 'utf8')
.includes(`tag -a v${version} -m release v${version}`),
);
}
});
}
+34
View File
@@ -0,0 +1,34 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const source = fs.readFileSync(
path.resolve(__dirname, '../../shell/task-timeout.sh'),
'utf8',
);
test('timed task cleanup preserves output and exit codes without Bash job notifications', () => {
const result = spawnSync(
process.env.QL_TEST_BASH || '/bin/bash',
[
'-c',
source +
`
command_timeout_time=1h
for code in 0 7 0 7 0 7 0 7 0 7; do
# Keep a descendant alive after the task leader exits so cleanup must
# terminate the worker group as well as the sleeping watchdog.
run_task_command bash -c 'sleep 10 & echo TASK_OUTPUT; echo TASK_ERROR >&2; exit "$1"' task "$code"
actual=$?
[[ $actual == "$code" ]] || exit 99
done
`,
],
{ encoding: 'utf8', timeout: 15000 },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'TASK_OUTPUT\n'.repeat(10));
assert.equal(result.stderr, 'TASK_ERROR\n'.repeat(10));
});
+153
View File
@@ -0,0 +1,153 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const psTree = require('ps-tree');
const test = require('node:test');
const bash = process.env.QL_TEST_BASH || '/bin/bash';
const helper = path.resolve(__dirname, '../../shell/task-timeout.sh');
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const tree = (pid) =>
new Promise((resolve, reject) =>
psTree(pid, (error, children) =>
error ? reject(error) : resolve(children),
),
);
async function until(predicate, message) {
const deadline = Date.now() + 5000;
while (!(await predicate())) {
assert.ok(Date.now() < deadline, message);
await delay(20);
}
}
function alive(pid) {
try {
process.kill(pid, 0);
if (process.platform === 'linux') {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
if (
['Z', 'X', 'x'].includes(
stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0],
)
)
return false;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}
return true;
} catch (error) {
if (error.code === 'ESRCH') return false;
throw error;
}
}
for (const mode of ['timeout', 'manual']) {
test(
`${mode} cleans up TERM-resistant children, grandchildren and the watchdog`,
{ timeout: 12000, skip: process.platform === 'win32' },
async (t) => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql-timeout-processes-'),
);
const known = new Set();
let child;
t.after(async () => {
if (child) {
for (const p of await tree(child.pid).catch(() => []))
known.add(Number(p.PID));
}
for (const pid of known) {
if (pid > 1) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
}
fs.rmSync(root, { recursive: true, force: true });
});
fs.writeFileSync(
path.join(root, 'child.sh'),
`
trap '' TERM
printf '%s' "$$" > "$QA_ROOT/child"
sleep 30 &
printf '%s' "$!" > "$QA_ROOT/grandchild"
wait
`,
);
child = spawn(
bash,
[
'-c',
`
. "$QA_HELPER"
task() {
trap '' TERM
/bin/sh -c 'printf "%s" "$PPID"' > "$QA_ROOT/worker"
"$QA_BASH" "$QA_ROOT/child.sh" &
wait
}
run_task_command task
exit $?
`,
],
{
detached: true,
env: {
...process.env,
QA_ROOT: root,
QA_HELPER: helper,
QA_BASH: bash,
command_timeout_time: mode === 'timeout' ? '1s' : '1h',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
known.add(child.pid);
let result,
stderr = '';
child.stdout.resume();
child.stderr.on('data', (chunk) => (stderr += chunk));
child.on('exit', (code, signal) => {
result = { code, signal };
});
await until(
() =>
['worker', 'child', 'grandchild'].every((name) => {
const file = path.join(root, name);
return (
fs.existsSync(file) && Number(fs.readFileSync(file, 'utf8')) > 1
);
}),
'task process tree did not initialize',
);
const descendants = await tree(child.pid);
for (const p of descendants) known.add(Number(p.PID));
for (const name of ['worker', 'child', 'grandchild'])
known.add(Number(fs.readFileSync(path.join(root, name), 'utf8')));
assert.ok(
known.size >= 6,
'must observe the wrapper, watchdog and task descendants',
);
if (mode === 'manual') {
const worker = Number(
fs.readFileSync(path.join(root, 'worker'), 'utf8'),
);
const wrapper = descendants.find((p) => Number(p.PID) === worker);
assert.ok(wrapper, 'worker must belong to this test process tree');
process.kill(Number(wrapper.PPID), 'SIGTERM');
}
await until(() => result, 'task wrapper did not exit');
assert.equal(result.code, mode === 'timeout' ? 124 : 143, stderr);
await until(
() => [...known].every((pid) => !alive(pid)),
'task or watchdog process survived cleanup',
);
},
);
}
+2 -2
View File
@@ -64,7 +64,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura
'../config/const': constants,
'../config/util': {},
'../data/dependence': {},
'../data/system': {},
'../data/system': { AuthDataType: { notifyLog: 'notifyLog' }, NotifyStatus: { success: 0, fail: 1 }, SystemModel: { create: async () => {}, findAll: async () => [] } },
'../shared/pLimit': {},
'../schedule/client': {},
'../shared/i18n': { t: (x) => x },
@@ -79,7 +79,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura
wpushApiKey: 'saved-key',
}),
};
const system = new SystemService();
const system = new SystemService({ error() {} });
system.notificationService = notification;
for (const info of [
+57
View File
@@ -0,0 +1,57 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const { createRequire } = require('node:module');
const test = require('node:test');
const { EditorState } = require('@codemirror/state');
const load = require('../helpers/load-security-module.cjs');
const { getCodeMirrorExtensions } = load(
path.resolve(__dirname, '../../src/utils/codemirror/language.ts'),
);
const { LANG_MAP } = load(path.resolve(__dirname, '../../src/utils/const.ts'));
const languageRequire = createRequire(
require.resolve('@uiw/codemirror-extensions-langs'),
);
const { syntaxTree } = languageRequire('@codemirror/language');
test('INI documents load and remain editable with CodeMirror syntax support', () => {
const doc = '; comment\n[section]\nkey=value\n';
const state = EditorState.create({
doc,
extensions: getCodeMirrorExtensions(LANG_MAP['.ini']),
});
assert.ok(syntaxTree(state).length > 0);
assert.ok(syntaxTree(state).topNode.firstChild, 'INI tokens were parsed');
const updated = state.update({
changes: {
from: doc.indexOf('value'),
to: doc.indexOf('value') + 5,
insert: 'edited',
},
}).state;
assert.equal(updated.doc.toString(), '; comment\n[section]\nkey=edited\n');
});
test('every configured script language supplies a valid CodeMirror extension', () => {
for (const mode of new Set(Object.values(LANG_MAP))) {
const extensions = getCodeMirrorExtensions(mode);
assert.equal(extensions.length, 1, mode);
assert.doesNotThrow(
() => EditorState.create({ doc: 'sample', extensions }),
mode,
);
}
});
test('unknown or empty languages fall back to editable plain text', () => {
for (const mode of ['', undefined, 'unknown', 'constructor', 'toString']) {
const extensions = getCodeMirrorExtensions(mode);
assert.deepEqual(extensions, []);
const state = EditorState.create({ doc: 'text', extensions });
assert.equal(
state
.update({ changes: { from: 4, insert: ' edited' } })
.state.doc.toString(),
'text edited',
);
}
});
+20
View File
@@ -0,0 +1,20 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
test('notification log column titles resolve in every supported locale', () => {
const source = fs.readFileSync(
path.join(__dirname, '../../src/pages/setting/notifyLog.tsx'),
'utf8',
);
const titles = [...source.matchAll(/title:\s*intl\.get\('([^']+)'\)/g)].map(
(m) => m[1],
);
assert.ok(titles.includes('标题') && titles.includes('内容'));
for (const locale of ['zh-CN', 'en-US']) {
const messages = require(`../../src/locales/${locale}.json`);
for (const title of titles)
assert.ok(messages[title]?.trim(), `${locale}: ${title}`);
}
});