fix(notify): audit system and JS/Python script notification results

This commit is contained in:
whyour
2026-10-13 02:20:59 +08:00
30 changed files with 2038 additions and 335 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
run: | run: |
docker run --rm --network none --entrypoint node \ docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \ qinglong-readiness:${{ matrix.image }} \
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.3.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")' -e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
- name: Export native Debian image for isolated dev validation - name: Export native Debian image for isolated dev validation
if: matrix.image == 'debian' if: matrix.image == 'debian'
run: | run: |
+61 -64
View File
@@ -13,6 +13,7 @@ import { FormData } from 'undici';
import os from 'os'; import os from 'os';
import { maybeSudo, isInContainer } from './container'; import { maybeSudo, isInContainer } from './container';
import { resolveFileAccess } from '../shared/fileAccess'; import { resolveFileAccess } from '../shared/fileAccess';
import { mirrorWriteCommand, normalizeMirror, rewriteMirrorSources } from '../shared/linuxMirror';
export * from './share'; export * from './share';
@@ -806,77 +807,73 @@ export async function detectOS(): Promise<
return osType; return osType;
} }
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror( async function _updateLinuxMirror(
osType: string, osType: string,
mirrorDomainWithScheme: string, mirror: string,
): Promise<string> { ): Promise<string> {
const defaults: Record<string, string> = {
Debian: 'http://deb.debian.org/debian',
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
};
if (!defaults[osType])
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = isInContainer() ? 'sudo ' : ''; const S = isInContainer() ? 'sudo ' : '';
// APT otherwise returns zero when index downloads fail with transient errors. const update =
const aptUpdate = `${S}apt-get update --error-on=any`; osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let filePath: string, currentDomainWithScheme: string | null; let file =
switch (osType) { osType === 'Alpine'
case 'Debian': ? '/etc/apk/repositories'
filePath = '/etc/apt/sources.list.d/debian.sources'; : `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
currentDomainWithScheme = await getCurrentMirrorDomain(filePath); let content = '';
if (currentDomainWithScheme) { try {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`; content = await fs.readFile(file, 'utf8');
} catch (error: any) {
if (error.code !== 'ENOENT') throw error;
if (osType !== 'Alpine') {
try {
file = '/etc/apt/sources.list';
content = await fs.readFile(file, 'utf8');
} catch (legacyError: any) {
if (legacyError.code !== 'ENOENT') throw legacyError;
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
}
}
}
if (!content.trim()) {
const release = await fs.readFile('/etc/os-release', 'utf8');
if (osType === 'Alpine') {
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
if (!version)
throw new Error('Cannot determine Alpine repository version');
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
} else { } else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`; // The fallback may have read an empty legacy .list. Deb822 stanzas must
// be written to a .sources file, leaving the legacy file untouched.
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
if (!codename)
throw new Error('Cannot determine APT repository codename');
const ubuntu = osType === 'Ubuntu';
content = `Types: deb\nURIs: ${
defaults[osType]
}\nSuites: ${codename} ${codename}-updates${
ubuntu ? ` ${codename}-backports` : ''
}\nComponents: ${
ubuntu ? 'main restricted universe multiverse' : 'main'
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
if (!ubuntu)
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
} }
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
} }
return mirrorWriteCommand(
file,
rewriteMirrorSources(content, target),
S,
update,
);
} }
export async function updateLinuxMirrorFile(mirror: string): Promise<string> { export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
+3
View File
@@ -63,6 +63,9 @@ export interface NotifyLogInfo {
content?: string; content?: string;
status?: NotifyStatus; status?: NotifyStatus;
notifyType?: string; notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
} }
export interface TokenInfo { export interface TokenInfo {
+8 -1
View File
@@ -179,7 +179,14 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1')); app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes()); app.use(config.api.prefix, routes());
app.get('*', (_, res, next) => { app.get('*', (req, res, next) => {
// Don't serve index.html for API routes
if (
req.path === config.api.prefix ||
req.path.startsWith(`${config.api.prefix}/`)
) {
return res.status(404).json({ code: 404, message: 'Not Found' });
}
const indexPath = path.join(frontendPath, 'index.html'); const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => { res.sendFile(indexPath, (err) => {
if (err) { if (err) {
+10
View File
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
optional NotificationInfo notificationInfo = 3; 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 { service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {} rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {} rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -271,6 +280,7 @@ service Api {
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {} rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {} rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {} rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {} rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) 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. // Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions: // versions:
// protoc-gen-ts_proto v2.6.1 // protoc-gen-ts_proto v2.6.1
// protoc v3.17.3 // protoc v6.31.1
// source: back/protos/api.proto // source: back/protos/api.proto
/* eslint-disable */ /* eslint-disable */
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
notificationInfo?: NotificationInfo | undefined; notificationInfo?: NotificationInfo | undefined;
} }
export interface RecordNotifyLogRequest {
title: string;
content: string;
success: boolean;
notifyType: string;
scriptName: string;
taskId: string;
}
function createBaseEnvItem(): EnvItem { function createBaseEnvItem(): EnvItem {
return { return {
id: undefined, 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 type ApiService = typeof ApiService;
export const ApiService = { export const ApiService = {
getEnvs: { getEnvs: {
@@ -4388,6 +4537,15 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value), 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: { getCronDetail: {
path: "/com.ql.api.Api/GetCronDetail", path: "/com.ql.api.Api/GetCronDetail",
requestStream: false, requestStream: false,
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>; updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>; getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>; systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>; getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
createCron: handleUnaryCall<CreateCronRequest, CronResponse>; createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>; updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>, options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void, callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall; ): 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( getCronDetail(
request: CronDetailRequest, request: CronDetailRequest,
callback: (error: ServiceError | null, response: CronDetailResponse) => void, callback: (error: ServiceError | null, response: CronDetailResponse) => void,
+25 -2
View File
@@ -16,6 +16,7 @@ import {
MoveEnvRequest, MoveEnvRequest,
Response, Response,
SystemNotifyRequest, SystemNotifyRequest,
RecordNotifyLogRequest,
UpdateEnvNamesRequest, UpdateEnvNamesRequest,
UpdateEnvRequest, UpdateEnvRequest,
} from '../protos/api'; } from '../protos/api';
@@ -238,7 +239,8 @@ export const systemNotify = async (
const data = await systemService.notify({ const data = await systemService.notify({
title: call.request.title, title: call.request.title,
content: call.request.content, content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo, notificationInfo: call.request
.notificationInfo as unknown as NotificationInfo,
}); });
callback(null, data); callback(null, data);
} catch (e: any) { } 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 => { const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined; if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters. // 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 { return {
...cron, ...cron,
labels: cron.labels ?? [], labels: cron.labels ?? [],
+34 -18
View File
@@ -1,3 +1,4 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import crypto from 'crypto'; import crypto from 'crypto';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
@@ -55,27 +56,33 @@ export default class NotificationService {
content: string, content: string,
notificationInfo?: NotificationInfo, notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> { ): Promise<boolean | undefined> {
let { type, ...rest } = await this.userService.getNotificationMode(); let type: string | undefined;
if (notificationInfo?.type) { let success = false;
type = notificationInfo?.type; try {
} const saved = await this.userService.getNotificationMode();
type = notificationInfo?.type || saved.type;
if (type) { if (type) {
this.title = title; this.title = title;
this.content = content; this.content = content;
let params = rest; const { type: _, ...overrides } = notificationInfo || {};
if (notificationInfo) { const { type: _saved, ...rest } = saved;
const { type: _, ...others } = notificationInfo; this.params = { ...rest, ...overrides };
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try { try {
return await notificationModeAction?.call(this); success = !!(await this.modeMap.get(type)?.call(this));
} catch (error: any) { } catch (error) {
console.error(error); console.error(error);
} }
} }
return false; return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
}
} }
public async testNotify( public async testNotify(
@@ -84,14 +91,23 @@ export default class NotificationService {
content: string, content: string,
) { ) {
const { type, ...rest } = info; const { type, ...rest } = info;
if (type) { if (!type) return true;
let success = false;
try {
this.title = title; this.title = title;
this.content = content; this.content = content;
this.params = rest; this.params = rest;
const notificationModeAction = this.modeMap.get(type); success = !!(await this.modeMap.get(type)?.call(this));
return await notificationModeAction?.call(this); return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
} }
return true;
} }
private parseMailRecipients(value?: string) { private parseMailRecipients(value?: string) {
+5 -43
View File
@@ -1,4 +1,4 @@
import { Op } from 'sequelize'; import { persistNotificationAudit } from '../shared/notificationAudit';
import { spawn } from 'cross-spawn'; import { spawn } from 'cross-spawn';
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
@@ -413,54 +413,12 @@ export default class SystemService {
notificationInfo.type = typeString; notificationInfo.type = typeString;
} }
let notifyType: string | undefined;
if (notificationInfo?.type) {
notifyType = typeString || (notificationInfo.type as string);
} else {
try {
const notifConfig = await this.getDb({
type: AuthDataType.notification,
});
notifyType = notifConfig.info?.type as string | undefined;
} catch (e) {}
}
const isSuccess = await this.notificationService.notify( const isSuccess = await this.notificationService.notify(
title, title,
content, content,
notificationInfo, notificationInfo,
); );
try {
await SystemModel.create({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
title: title.slice(0, 512),
content: content.slice(0, 8192),
status: isSuccess ? NotifyStatus.success : NotifyStatus.fail,
notifyType,
},
});
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) {
this.logger.error('Failed to persist notification audit log: %o', error);
}
if (isSuccess) { if (isSuccess) {
return { code: 200, message: t('通知发送成功') }; return { code: 200, message: t('通知发送成功') };
} else { } else {
@@ -468,6 +426,10 @@ export default class SystemService {
} }
} }
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
await persistNotificationAudit(info);
}
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> { public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.findAll({ const docs = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog }, where: { type: AuthDataType.notifyLog },
+121
View File
@@ -0,0 +1,121 @@
import { shellQuote } from './shellQuote';
const repositoryNames =
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
export function normalizeMirror(mirror: string): URL {
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
const url = new URL(mirror);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error(
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
);
}
return url;
}
export function replaceMirrorUrl(current: string, mirror: string): string {
const target = normalizeMirror(mirror);
const previous = new URL(current);
const oldSegments = previous.pathname.split('/').filter(Boolean);
const newSegments = target.pathname.split('/').filter(Boolean);
const repositoryIndex = oldSegments.findIndex((part) =>
repositoryNames.test(part),
);
const targetIndex = newSegments.findIndex((part) =>
repositoryNames.test(part),
);
const prefix =
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
if (repositoryIndex >= 0) {
const repository = oldSegments[repositoryIndex];
let suffix = oldSegments.slice(repositoryIndex + 1);
// Repair paths already corrupted by repeated authority-only replacement.
while (suffix[0] === repository) suffix.shift();
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
}
// Unknown repository paths: a full URL replaces the old root, a hostname
// preserves it. Never append the newly entered root to the existing root.
return `${target.origin}/${(newSegments.length
? newSegments
: oldSegments
).join('/')}`;
}
export function rewriteMirrorSources(content: string, mirror: string): string {
normalizeMirror(mirror);
return content
.split(/(\r?\n[ \t]*\r?\n)/)
.map((stanza) => {
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
return stanza
.split('\n')
.map((line) => {
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
return line;
const legacy = /^\s*deb(?:-src)?\s/.test(line);
const key = legacy
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
: signedBy;
// In .list files a # starts an inline comment; its URLs are metadata.
const comment = legacy ? line.indexOf('#') : -1;
const source = comment < 0 ? line : line.slice(0, comment);
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
);
return rewritten + (comment < 0 ? '' : line.slice(comment));
})
.join('\n');
})
.join('');
}
function isSystemRepository(url: string, signedBy?: string): boolean {
const keys =
signedBy
?.trim()
.split(/[\s,]+/)
.filter(Boolean) || [];
const archiveKeyring =
keys.length > 0 &&
keys.every((key) =>
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
);
// A separate signing key identifies a third-party archive, even if its
// repository happens to be called /debian or /ubuntu.
if (keys.length > 0 && !archiveKeyring) return false;
const segments = new URL(url).pathname.split('/').filter(Boolean);
const repositoryIndex = segments.findIndex((part) =>
repositoryNames.test(part),
);
if (repositoryIndex < 0) return false;
// Without archive metadata, accept only conventional archive roots. A
// vendor path such as Docker's /linux/debian is not the Debian archive.
return (
archiveKeyring ||
repositoryIndex === 0 ||
segments
.slice(0, repositoryIndex)
.every((part) => /^mirrors?$/.test(part)) ||
segments[repositoryIndex] === 'alpine'
);
}
export function mirrorWriteCommand(
file: string,
content: string,
prefix: string,
update: string,
): string {
return `${prefix}mkdir -p ${shellQuote(
file.slice(0, file.lastIndexOf('/')),
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
file,
)} >/dev/null && ${update}`;
}
+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');
}
}
@@ -409,7 +409,7 @@ test('cron metadata precedence agrees with unchanged legacy add_cron', (t) => {
'--noprofile', '--noprofile',
'--norc', '--norc',
'-c', '-c',
`${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s' "$1"; }\nadd_cron "$dir_scripts/list" repo`, `${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s:%s:%s:%s' "$1" "$2" "$3" "$4"; }\nadd_cron "$dir_scripts/list" repo`,
], ],
{ {
encoding: 'utf8', encoding: 'utf8',
+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 时不经过这些入口,无法自动纳入通知日志。
+2 -2
View File
@@ -108,9 +108,9 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3", "keyv": "^5.2.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"multer": "2.3.0", "multer": "2.4.0",
"node-cron": "4.6.0", "node-cron": "4.6.0",
"nodemailer": "^8.0.1", "nodemailer": "^10.0.9",
"p-queue-cjs": "7.3.4", "p-queue-cjs": "7.3.4",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0", "ps-tree": "^1.2.0",
+354 -20
View File
@@ -28,7 +28,7 @@ dependencies:
version: 12.0.1 version: 12.0.1
body-parser: body-parser:
specifier: ^1.20.3 specifier: ^1.20.3
version: 1.20.3 version: 1.20.6
celebrate: celebrate:
specifier: ^15.0.3 specifier: ^15.0.3
version: 15.0.3 version: 15.0.3
@@ -73,7 +73,7 @@ dependencies:
version: 1.2.0 version: 1.2.0
http-proxy-middleware: http-proxy-middleware:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.7
iconv-lite: iconv-lite:
specifier: ^0.6.3 specifier: ^0.6.3
version: 0.6.3 version: 0.6.3
@@ -82,7 +82,7 @@ dependencies:
version: 2.3.0(@types/node@17.0.45) version: 2.3.0(@types/node@17.0.45)
js-yaml: js-yaml:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.3.2
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.2 version: 9.0.2
@@ -93,14 +93,14 @@ dependencies:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
multer: multer:
specifier: 2.3.0 specifier: 2.4.0
version: 2.3.0 version: 2.4.0
node-cron: node-cron:
specifier: 4.6.0 specifier: 4.6.0
version: 4.6.0 version: 4.6.0
nodemailer: nodemailer:
specifier: ^8.0.1 specifier: ^10.0.9
version: 8.0.1 version: 10.0.9
p-queue-cjs: p-queue-cjs:
specifier: 7.3.4 specifier: 7.3.4
version: 7.3.4 version: 7.3.4
@@ -133,7 +133,7 @@ dependencies:
version: 0.10.0 version: 0.10.0
undici: undici:
specifier: ^7.9.0 specifier: ^7.9.0
version: 7.9.0 version: 7.29.1
uuid: uuid:
specifier: ^11.0.3 specifier: ^11.0.3
version: 11.0.3 version: 11.0.3
@@ -264,7 +264,7 @@ devDependencies:
version: 4.23.0(antd@4.24.16)(react-dom@18.3.1)(react@18.3.1) version: 4.23.0(antd@4.24.16)(react-dom@18.3.1)(react@18.3.1)
axios: axios:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.7.8 version: 1.20.0
compression-webpack-plugin: compression-webpack-plugin:
specifier: 9.2.0 specifier: 9.2.0
version: 9.2.0 version: 9.2.0
@@ -2879,7 +2879,7 @@ packages:
globals: 13.24.0 globals: 13.24.0
ignore: 5.3.2 ignore: 5.3.2
import-fresh: 3.3.0 import-fresh: 3.3.0
js-yaml: 4.1.0 js-yaml: 4.3.2
minimatch: 3.1.5 minimatch: 3.1.5
strip-json-comments: 3.1.1 strip-json-comments: 3.1.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -3060,7 +3060,7 @@ packages:
camelcase: 5.3.1 camelcase: 5.3.1
find-up: 4.1.0 find-up: 4.1.0
get-package-type: 0.1.0 get-package-type: 0.1.0
js-yaml: 3.14.1 js-yaml: 3.15.2
resolve-from: 5.0.0 resolve-from: 5.0.0
dev: true dev: true
@@ -5677,7 +5677,6 @@ packages:
debug: 4.4.3 debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: false
/agentkeepalive@4.5.0: /agentkeepalive@4.5.0:
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
@@ -8302,7 +8301,7 @@ packages:
is-glob: 4.0.3 is-glob: 4.0.3
is-path-inside: 3.0.3 is-path-inside: 3.0.3
js-sdsl: 4.4.2 js-sdsl: 4.4.2
js-yaml: 4.1.0 js-yaml: 4.3.2
json-stable-stringify-without-jsonify: 1.0.1 json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1 levn: 0.4.1
lodash.merge: 4.6.2 lodash.merge: 4.6.2
@@ -8513,7 +8512,7 @@ packages:
dependencies: dependencies:
accepts: 1.3.8 accepts: 1.3.8
array-flatten: 1.1.1 array-flatten: 1.1.1
body-parser: 1.20.3 body-parser: 1.20.6
content-disposition: 0.5.4 content-disposition: 0.5.4
content-type: 1.0.5 content-type: 1.0.5
cookie: 0.7.1 cookie: 0.7.1
@@ -9334,7 +9333,6 @@ packages:
debug: 4.4.3 debug: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
dev: false
/human-signals@2.1.0: /human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
@@ -10587,7 +10585,7 @@ packages:
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
dependencies: dependencies:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.2
/miller-rabin@4.0.1: /miller-rabin@4.0.1:
resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
@@ -10654,19 +10652,19 @@ packages:
/minimatch@3.1.2: /minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.21
dev: true dev: true
/minimatch@3.1.5: /minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.21
/minimatch@9.0.9: /minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
dependencies: dependencies:
brace-expansion: 2.0.2 brace-expansion: 2.1.7
dev: true dev: true
/minimist-options@4.1.0: /minimist-options@4.1.0:
@@ -10870,7 +10868,7 @@ packages:
semver: 7.7.4 semver: 7.7.4
tar: 7.5.20 tar: 7.5.20
tinyglobby: 0.2.17 tinyglobby: 0.2.17
undici: 6.27.0 undici: 6.29.0
which: 6.0.1 which: 6.0.1
dev: false dev: false
optional: true optional: true
@@ -15794,6 +15792,342 @@ packages:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
dev: true dev: true
/brace-expansion@2.1.7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
dependencies:
balanced-match: 1.0.2
dev: true
/brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
/axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
dependencies:
follow-redirects: 1.16.1(debug@4.4.3)
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
dev: true
/proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
dev: true
/form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
dev: true
/hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
dependencies:
function-bind: 1.1.2
/es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
dev: true
/get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
/math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
/gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
/get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
/es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
/dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
/call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
/es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
/follow-redirects@1.16.1(debug@4.4.3):
resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dependencies:
debug: 4.4.3
/undici@7.29.1:
resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
engines: {node: '>=20.18.1'}
dev: false
/undici@6.29.0:
resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==}
engines: {node: '>=18.17'}
dev: false
optional: true
/nodemailer@10.0.9:
resolution: {integrity: sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==}
engines: {node: '>=20.0.0'}
dev: false
/multer@2.4.0:
resolution: {integrity: sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==}
engines: {node: '>= 10.16.0'}
dependencies:
append-field: 1.0.0
busboy: 1.6.0
type-is: 1.6.18
dev: false
/js-yaml@4.3.2:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
dependencies:
argparse: 2.0.1
/js-yaml@3.15.2:
resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==}
hasBin: true
dependencies:
argparse: 1.0.10
esprima: 4.0.1
dev: true
/http-proxy-middleware@3.0.7:
resolution: {integrity: sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==}
engines: {node: ^14.18.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@types/http-proxy': 1.17.17
debug: 4.4.3
http-proxy: 1.18.1(debug@4.4.3)
is-glob: 4.0.3
is-plain-object: 5.1.0
micromatch: 4.0.8
transitivePeerDependencies:
- supports-color
dev: false
/picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
/is-plain-object@5.1.0:
resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==}
engines: {node: '>=0.10.0'}
/http-proxy@1.18.1(debug@4.4.3):
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
dependencies:
eventemitter3: 4.0.7
follow-redirects: 1.16.1(debug@4.4.3)
requires-port: 1.0.0
transitivePeerDependencies:
- debug
dev: false
/@types/http-proxy@1.17.17:
resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==}
dependencies:
'@types/node': 17.0.45
dev: false
/body-parser@1.20.6:
resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
http-errors: 2.0.1
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.15.3
raw-body: 2.5.3
type-is: 1.6.18
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
dev: false
/raw-body@2.5.3:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.4.24
unpipe: 1.0.0
/http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
/statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
/qs@6.15.3:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'}
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
dev: false
/side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
/side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
/side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
/object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
/call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
/side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
settings: settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
+289 -116
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 time
import urllib.parse import urllib.parse
import smtplib import smtplib
import subprocess
from typing import Optional
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.header import Header from email.header import Header
from email.utils import formataddr from email.utils import formataddr
@@ -161,10 +163,11 @@ for k in push_config:
push_config[k] = v push_config[k] = v
def bark(title: str, content: str) -> None: def bark(title: str, content: str) -> Optional[bool]:
""" """
使用 bark 推送消息。 使用 bark 推送消息。
""" """
audit_results = []
if not push_config.get("BARK_PUSH"): if not push_config.get("BARK_PUSH"):
return return
print("bark 服务启动") print("bark 服务启动")
@@ -200,22 +203,28 @@ def bark(title: str, content: str) -> None:
).json() ).json()
if response["code"] == 200: if response["code"] == 200:
audit_results.append(True)
print("bark 推送成功!") print("bark 推送成功!")
else: else:
audit_results.append(False)
print("bark 推送失败!") 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}") 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"): if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
return return
print("钉钉机器人 服务启动") print("钉钉机器人 服务启动")
@@ -236,15 +245,19 @@ def dingding_bot(title: str, content: str) -> None:
).json() ).json()
if not response["errcode"]: if not response["errcode"]:
audit_results.append(True)
print("钉钉机器人 推送成功!") print("钉钉机器人 推送成功!")
else: else:
audit_results.append(False)
print("钉钉机器人 推送失败!") 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"): if not push_config.get("FSKEY"):
return return
print("飞书 服务启动") print("飞书 服务启动")
@@ -268,15 +281,19 @@ def feishu_bot(title: str, content: str) -> None:
response = requests.post(url, data=json.dumps(data)).json() response = requests.post(url, data=json.dumps(data)).json()
if response.get("StatusCode") == 0 or response.get("code") == 0: if response.get("StatusCode") == 0 or response.get("code") == 0:
audit_results.append(True)
print("飞书 推送成功!") print("飞书 推送成功!")
else: else:
audit_results.append(False)
print("飞书 推送失败!错误信息如下:\n", response) 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 推送消息。 使用 go_cqhttp 推送消息。
""" """
audit_results = []
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"): if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
return return
print("go-cqhttp 服务启动") print("go-cqhttp 服务启动")
@@ -285,15 +302,19 @@ def go_cqhttp(title: str, content: str) -> None:
response = requests.get(url).json() response = requests.get(url).json()
if response["status"] == "ok": if response["status"] == "ok":
audit_results.append(True)
print("go-cqhttp 推送成功!") print("go-cqhttp 推送成功!")
else: else:
audit_results.append(False)
print("go-cqhttp 推送失败!") 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 推送消息。 使用 gotify 推送消息。
""" """
audit_results = []
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"): if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
return return
print("gotify 服务启动") print("gotify 服务启动")
@@ -307,15 +328,19 @@ def gotify(title: str, content: str) -> None:
response = requests.post(url, data=data).json() response = requests.post(url, data=data).json()
if response.get("id"): if response.get("id"):
audit_results.append(True)
print("gotify 推送成功!") print("gotify 推送成功!")
else: else:
audit_results.append(False)
print("gotify 推送失败!") 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 推送消息。 使用 iGot 推送消息。
""" """
audit_results = []
if not push_config.get("IGOT_PUSH_KEY"): if not push_config.get("IGOT_PUSH_KEY"):
return return
print("iGot 服务启动") print("iGot 服务启动")
@@ -326,15 +351,19 @@ def iGot(title: str, content: str) -> None:
response = requests.post(url, data=data, headers=headers).json() response = requests.post(url, data=data, headers=headers).json()
if response["ret"] == 0: if response["ret"] == 0:
audit_results.append(True)
print("iGot 推送成功!") print("iGot 推送成功!")
else: else:
audit_results.append(False)
print(f'iGot 推送失败!{response["errMsg"]}') 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 推送消息。 通过 serverJ 推送消息。
""" """
audit_results = []
if not push_config.get("PUSH_KEY"): if not push_config.get("PUSH_KEY"):
return return
print("serverJ 服务启动") print("serverJ 服务启动")
@@ -351,15 +380,19 @@ def serverJ(title: str, content: str) -> None:
response = requests.post(url, data=data).json() response = requests.post(url, data=data).json()
if response.get("errno") == 0 or response.get("code") == 0: if response.get("errno") == 0 or response.get("code") == 0:
audit_results.append(True)
print("serverJ 推送成功!") print("serverJ 推送成功!")
else: else:
audit_results.append(False)
print(f'serverJ 推送失败!错误码:{response["message"]}') 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 推送消息 通过PushDeer 推送消息
""" """
audit_results = []
if not push_config.get("DEER_KEY"): if not push_config.get("DEER_KEY"):
return return
print("PushDeer 服务启动") print("PushDeer 服务启动")
@@ -376,15 +409,19 @@ def pushdeer(title: str, content: str) -> None:
response = requests.post(url, data=data).json() response = requests.post(url, data=data).json()
if len(response.get("content").get("result")) > 0: if len(response.get("content").get("result")) > 0:
audit_results.append(True)
print("PushDeer 推送成功!") print("PushDeer 推送成功!")
else: else:
audit_results.append(False)
print("PushDeer 推送失败!错误信息:", response) 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 推送消息 通过Chat 推送消息
""" """
audit_results = []
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"): if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
return return
print("chat 服务启动") print("chat 服务启动")
@@ -393,15 +430,19 @@ def chat(title: str, content: str) -> None:
response = requests.post(url, data=data) response = requests.post(url, data=data)
if response.status_code == 200: if response.status_code == 200:
audit_results.append(True)
print("Chat 推送成功!") print("Chat 推送成功!")
else: else:
audit_results.append(False)
print("Chat 推送失败!错误信息:", response) 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 推送消息。 通过 pushplus 推送消息。
""" """
audit_results = []
if not push_config.get("PUSH_PLUS_TOKEN"): if not push_config.get("PUSH_PLUS_TOKEN"):
return return
print("PUSHPLUS 服务启动") print("PUSHPLUS 服务启动")
@@ -424,7 +465,9 @@ def pushplus_bot(title: str, content: str) -> None:
code = response["code"] code = response["code"]
if code == 200: if code == 200:
audit_results.append(True)
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"]) print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
audit_results.append(True)
print( print(
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果" "注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
) )
@@ -437,16 +480,20 @@ def pushplus_bot(title: str, content: str) -> None:
response = requests.post(url=url_old, data=body, headers=headers).json() response = requests.post(url=url_old, data=body, headers=headers).json()
if response["code"] == 200: if response["code"] == 200:
audit_results.append(True)
print("PUSHPLUS(hxtrip) 推送成功!") print("PUSHPLUS(hxtrip) 推送成功!")
else: else:
audit_results.append(False)
print("PUSHPLUS 推送失败!") 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"): if not push_config.get("WE_PLUS_BOT_TOKEN"):
return return
print("微加机器人 服务启动") print("微加机器人 服务启动")
@@ -469,15 +516,19 @@ def weplus_bot(title: str, content: str) -> None:
response = requests.post(url=url, data=body, headers=headers).json() response = requests.post(url=url, data=body, headers=headers).json()
if response["code"] == 200: if response["code"] == 200:
audit_results.append(True)
print("微加机器人 推送成功!") print("微加机器人 推送成功!")
else: else:
audit_results.append(False)
print("微加机器人 推送失败!") 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 推送消息。 使用 qmsg 推送消息。
""" """
audit_results = []
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"): if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
return return
print("qmsg 服务启动") print("qmsg 服务启动")
@@ -487,15 +538,19 @@ def qmsg_bot(title: str, content: str) -> None:
response = requests.post(url=url, params=payload).json() response = requests.post(url=url, params=payload).json()
if response["code"] == 0: if response["code"] == 0:
audit_results.append(True)
print("qmsg 推送成功!") print("qmsg 推送成功!")
else: else:
audit_results.append(False)
print(f'qmsg 推送失败!{response["reason"]}') 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 推送消息。 通过 企业微信 APP 推送消息。
""" """
audit_results = []
if not push_config.get("QYWX_AM"): if not push_config.get("QYWX_AM"):
return return
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM")) 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) response = wx.send_mpnews(title, content, media_id, touser)
if response == "ok": if response == "ok":
audit_results.append(True)
print("企业微信推送成功!") print("企业微信推送成功!")
else: else:
audit_results.append(False)
print("企业微信推送失败!错误信息如下:\n", response) print("企业微信推送失败!错误信息如下:\n", response)
return all(audit_results) if audit_results else None
class WeCom: class WeCom:
@@ -588,10 +646,11 @@ class WeCom:
return respone["errmsg"] 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"): if not push_config.get("QYWX_KEY"):
return return
print("企业微信机器人服务启动") print("企业微信机器人服务启动")
@@ -608,15 +667,19 @@ def wecom_bot(title: str, content: str) -> None:
).json() ).json()
if response["errcode"] == 0: if response["errcode"] == 0:
audit_results.append(True)
print("企业微信机器人推送成功!") print("企业微信机器人推送成功!")
else: else:
audit_results.append(False)
print("企业微信机器人推送失败!") 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 机器人 推送消息。 使用 telegram 机器人 推送消息。
""" """
audit_results = []
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"): if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
return return
print("tg 服务启动") print("tg 服务启动")
@@ -652,15 +715,19 @@ def telegram_bot(title: str, content: str) -> None:
).json() ).json()
if response["ok"]: if response["ok"]:
audit_results.append(True)
print("tg 推送成功!") print("tg 推送成功!")
else: else:
audit_results.append(False)
print("tg 推送失败!") 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 ( if (
not push_config.get("AIBOTK_KEY") not push_config.get("AIBOTK_KEY")
or not push_config.get("AIBOTK_TYPE") 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() response = requests.post(url=url, data=body, headers=headers).json()
print(response) print(response)
if response["code"] == 0: if response["code"] == 0:
audit_results.append(True)
print("智能微秘书 推送成功!") print("智能微秘书 推送成功!")
else: else:
audit_results.append(False)
print(f'智能微秘书 推送失败!{response["error"]}') 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 邮件 推送消息。 使用 SMTP 邮件 推送消息。
""" """
audit_results = []
if ( if (
not push_config.get("SMTP_SERVER") not push_config.get("SMTP_SERVER")
or not push_config.get("SMTP_SSL") or not push_config.get("SMTP_SSL")
@@ -736,15 +807,19 @@ def smtp(title: str, content: str) -> None:
message.as_bytes(), message.as_bytes(),
) )
smtp_server.close() smtp_server.close()
audit_results.append(True)
print("SMTP 邮件 推送成功!") print("SMTP 邮件 推送成功!")
except Exception as e: except Exception as e:
audit_results.append(False)
print(f"SMTP 邮件 推送失败!{e}") 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 推送消息。 使用 PushMe 推送消息。
""" """
audit_results = []
if not push_config.get("PUSHME_KEY"): if not push_config.get("PUSHME_KEY"):
return return
print("PushMe 服务启动") print("PushMe 服务启动")
@@ -764,15 +839,19 @@ def pushme(title: str, content: str) -> None:
response = requests.post(url, data=data) response = requests.post(url, data=data)
if response.status_code == 200 and response.text == "success": if response.status_code == 200 and response.text == "success":
audit_results.append(True)
print("PushMe 推送成功!") print("PushMe 推送成功!")
else: else:
audit_results.append(False)
print(f"PushMe 推送失败!{response.status_code} {response.text}") 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 推送消息。 使用 CHRONOCAT 推送消息。
""" """
audit_results = []
if ( if (
not push_config.get("CHRONOCAT_URL") not push_config.get("CHRONOCAT_URL")
or not push_config.get("CHRONOCAT_QQ") 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)) response = requests.post(url, headers=headers, data=json.dumps(data))
if response.status_code == 200: if response.status_code == 200:
if chat_type == 1: if chat_type == 1:
audit_results.append(True)
print(f"QQ个人消息:{ids}推送成功!") print(f"QQ个人消息:{ids}推送成功!")
else: else:
audit_results.append(True)
print(f"QQ群消息:{ids}推送成功!") print(f"QQ群消息:{ids}推送成功!")
else: else:
if chat_type == 1: if chat_type == 1:
audit_results.append(False)
print(f"QQ个人消息:{ids}推送失败!") print(f"QQ个人消息:{ids}推送失败!")
else: else:
audit_results.append(False)
print(f"QQ群消息:{ids}推送失败!") 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 推送消息 通过 Ntfy 推送消息
""" """
audit_results = []
def encode_rfc2047(text: str) -> str: def encode_rfc2047(text: str) -> str:
"""将文本编码为符合 RFC 2047 标准的格式""" """将文本编码为符合 RFC 2047 标准的格式"""
encoded_bytes = base64.b64encode(text.encode("utf-8")) 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") url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers) response = requests.post(url, data=data, headers=headers)
if response.status_code == 200: # 使用 response.status_code 进行检查 if response.status_code == 200: # 使用 response.status_code 进行检查
audit_results.append(True)
print("Ntfy 推送成功!") print("Ntfy 推送成功!")
else: else:
audit_results.append(False)
print("Ntfy 推送失败!错误信息:", response.text) 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 推送消息。 通过 wxpusher 推送消息。
支持的环境变量: 支持的环境变量:
@@ -866,6 +954,7 @@ def wxpusher_bot(title: str, content: str) -> None:
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔 - WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔 - WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
""" """
audit_results = []
if not push_config.get("WXPUSHER_APP_TOKEN"): if not push_config.get("WXPUSHER_APP_TOKEN"):
return return
@@ -909,17 +998,21 @@ def wxpusher_bot(title: str, content: str) -> None:
response = requests.post(url=url, json=data, headers=headers).json() response = requests.post(url=url, json=data, headers=headers).json()
if response.get("code") == 1000: if response.get("code") == 1000:
audit_results.append(True)
print("wxpusher 推送成功!") print("wxpusher 推送成功!")
else: else:
audit_results.append(False)
print(f"wxpusher 推送失败!错误信息:{response.get('msg')}") 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)推送消息。
支持的环境变量: 支持的环境变量:
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个 - WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
""" """
audit_results = []
if not push_config.get("WXPUSHER_SPT_LIST"): if not push_config.get("WXPUSHER_SPT_LIST"):
return return
@@ -956,17 +1049,21 @@ def wxpusher_spt(title: str, content: str) -> None:
response = requests.post(url=url, json=data, headers=headers).json() response = requests.post(url=url, json=data, headers=headers).json()
if response.get("code") == 1000: if response.get("code") == 1000:
audit_results.append(True)
print("wxpusher SPT 推送成功!") print("wxpusher SPT 推送成功!")
else: else:
audit_results.append(False)
print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}") 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 推送消息。 通过 WPUSH 推送消息。
官方文档: https://wpush.cn/docs 官方文档: https://wpush.cn/docs
""" """
audit_results = []
if not push_config.get("WPUSH_APIKEY"): if not push_config.get("WPUSH_APIKEY"):
return return
@@ -986,12 +1083,15 @@ def wpush(title: str, content: str) -> None:
response = requests.post(url=url, json=data, headers=headers, timeout=15).json() response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
if response.get("code") == 0: if response.get("code") == 0:
audit_results.append(True)
print("WPUSH 推送成功!") print("WPUSH 推送成功!")
else: else:
audit_results.append(False)
print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}') 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 推送消息。 通过 OpeniLink 推送消息。
支持的环境变量: 支持的环境变量:
@@ -999,6 +1099,7 @@ def openilink(title: str, content: str) -> None:
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com - OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取 - OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
""" """
audit_results = []
if not push_config.get("OPENILINK_APP_TOKEN"): if not push_config.get("OPENILINK_APP_TOKEN"):
return return
@@ -1023,9 +1124,12 @@ def openilink(title: str, content: str) -> None:
response = requests.post(url=url, json=data, headers=headers).json() response = requests.post(url=url, json=data, headers=headers).json()
if response.get("ok"): if response.get("ok"):
audit_results.append(True)
print("OpeniLink 推送成功!") print("OpeniLink 推送成功!")
else: else:
audit_results.append(False)
print(f'OpeniLink 推送失败!错误信息:{response.get("error")}') print(f'OpeniLink 推送失败!错误信息:{response.get("error")}')
return all(audit_results) if audit_results else None
def parse_headers(headers): def parse_headers(headers):
@@ -1079,10 +1183,11 @@ def parse_body(body, content_type, value_format_fn=None):
return parsed 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"): if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
return return
@@ -1114,9 +1219,12 @@ def custom_notify(title: str, content: str) -> None:
) )
if response.status_code == 200: if response.status_code == 200:
audit_results.append(True)
print("自定义通知推送成功!") print("自定义通知推送成功!")
else: else:
audit_results.append(False)
print(f"自定义通知推送失败!{response.status_code} {response.text}") print(f"自定义通知推送失败!{response.status_code} {response.text}")
return all(audit_results) if audit_results else None
def one() -> str: def one() -> str:
@@ -1204,6 +1312,33 @@ def add_notify_function():
return 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): def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
if kwargs: if kwargs:
global push_config global push_config
@@ -1228,7 +1363,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
notify_function = add_notify_function() notify_function = add_notify_function()
ts = [ 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 for mode in notify_function
] ]
[t.start() for t in ts] [t.start() for t in ts]
+14 -3
View File
@@ -85,9 +85,13 @@ add_cron_api() {
sub_id="null" sub_id="null"
fi fi
local payload
payload=$(jq -cn --arg name "$name" --arg command "$command" \
--arg schedule "$schedule" --argjson sub_id "$sub_id" \
'{name: $name, command: $command, schedule: $schedule, sub_id: $sub_id}') || return $?
local api=$(ql_api_request \ local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ --data-raw "$payload" \
-X POST) -X POST)
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
@@ -112,10 +116,14 @@ update_cron_api() {
local id="$4" local id="$4"
fi fi
local payload
payload=$(jq -cn --arg name "$name" --arg command "$command" \
--arg schedule "$schedule" --arg id "$id" \
'{name: $name, command: $command, schedule: $schedule, id: $id}') || return $?
local api=$(ql_api_request \ local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X PUT \ -X PUT \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}") --data-raw "$payload")
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then if [[ $code == 200 ]]; then
@@ -135,10 +143,13 @@ update_cron_command_api() {
local id="$2" local id="$2"
fi fi
local payload
payload=$(jq -cn --arg command "$command" --arg id "$id" \
'{command: $command, id: $id}') || return $?
local api=$(ql_api_request \ local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X PUT \ -X PUT \
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}") --data-raw "$payload")
code=$(echo "$api" | jq -r .code) code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message) message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then if [[ $code == 200 ]]; then
+1
View File
@@ -47,6 +47,7 @@ class GrpcClient {
'updateEnvNames', 'updateEnvNames',
'getEnvById', 'getEnvById',
'systemNotify', 'systemNotify',
'recordNotifyLog',
'getCronDetail', 'getCronDetail',
'createCron', 'createCron',
'updateCron', '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();
}
};
+6 -2
View File
@@ -99,7 +99,9 @@ add_cron() {
cron_name=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) cron_name=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
[[ -z $cron_name ]] && cron_name=$(grep "name:" $file | awk -F ":" '{print $2}' | head -1 | xargs) [[ -z $cron_name ]] && cron_name=$(grep "name:" $file | awk -F ":" '{print $2}' | head -1 | xargs)
[[ -z $cron_name ]] && cron_name=$(basename "$file_name") [[ -z $cron_name ]] && cron_name=$(basename "$file_name")
result=$(add_cron_api "${cron_line}:${cmd_task} ${file}:${cron_name}:${SUB_ID}") cron_name=${cron_name//$'\r'/}
cron_line=${cron_line//$'\r'/}
result=$(add_cron_api "$cron_line" "${cmd_task} ${file}" "$cron_name" "$SUB_ID")
echo -e "$result" echo -e "$result"
if [[ $detail ]]; then if [[ $detail ]]; then
detail="${detail}${result}\n" detail="${detail}${result}\n"
@@ -190,7 +192,9 @@ update_raw() {
[[ -z $cron_line ]] && cron_line=$(grep "cron:" $raw_file_name | awk -F ":" '{print $2}' | head -1 | xargs) [[ -z $cron_line ]] && cron_line=$(grep "cron:" $raw_file_name | awk -F ":" '{print $2}' | head -1 | xargs)
[[ -z $cron_line ]] && cron_line=$(grep "cron " $raw_file_name | awk -F "cron \"" '{print $2}' | awk -F "\" " '{print $1}' | head -1 | xargs) [[ -z $cron_line ]] && cron_line=$(grep "cron " $raw_file_name | awk -F "cron \"" '{print $2}' | awk -F "\" " '{print $1}' | head -1 | xargs)
[[ -z $cron_line ]] && cron_line="$default_cron" [[ -z $cron_line ]] && cron_line="$default_cron"
result=$(add_cron_api "${cron_line}:${cmd_task} ${filename}:${cron_name}:${SUB_ID}") cron_name=${cron_name//$'\r'/}
cron_line=${cron_line//$'\r'/}
result=$(add_cron_api "$cron_line" "${cmd_task} ${filename}" "$cron_name" "$SUB_ID")
echo -e "$result\n" echo -e "$result\n"
notify_api "新增任务通知" "\n$result" notify_api "新增任务通知" "\n$result"
# update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id" # update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id"
+4 -1
View File
@@ -702,5 +702,8 @@
"通知日志": "Notification log", "通知日志": "Notification log",
"发送时间": "Sent at", "发送时间": "Sent at",
"推送渠道": "Channel", "推送渠道": "Channel",
"发送状态": "Delivery status" "发送状态": "Delivery status",
"通知来源": "Notification source",
"脚本通知": "Script notification",
"系统通知": "System notification"
} }
+4 -1
View File
@@ -702,5 +702,8 @@
"通知日志": "通知日志", "通知日志": "通知日志",
"发送时间": "发送时间", "发送时间": "发送时间",
"推送渠道": "推送渠道", "推送渠道": "推送渠道",
"发送状态": "发送状态" "发送状态": "发送状态",
"通知来源": "通知来源",
"脚本通知": "脚本通知",
"系统通知": "系统通知"
} }
+16 -4
View File
@@ -10,6 +10,9 @@ interface NotifyLogItem {
content?: string; content?: string;
status?: number; status?: number;
notifyType?: string; notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
} }
const NotifyStatusLabel: Record<number, string> = { const NotifyStatusLabel: Record<number, string> = {
@@ -23,6 +26,18 @@ const NotifyStatusColor: Record<number, string> = {
}; };
const columns = [ 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('序号'), title: intl.get('序号'),
width: 50, width: 50,
@@ -68,10 +83,7 @@ const columns = [
render: (text: string, record: NotifyLogItem) => { render: (text: string, record: NotifyLogItem) => {
const statusKey = record.status ?? 1; const statusKey = record.status ?? 1;
return ( return (
<Tag <Tag color={NotifyStatusColor[statusKey]} style={{ marginRight: 0 }}>
color={NotifyStatusColor[statusKey]}
style={{ marginRight: 0 }}
>
{intl.get(NotifyStatusLabel[statusKey])} {intl.get(NotifyStatusLabel[statusKey])}
</Tag> </Tag>
); );
+61
View File
@@ -0,0 +1,61 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
fs = require('node:fs'),
os = require('node:os'),
path = require('node:path'),
express = require('express');
const load = require('../helpers/load-security-module.cjs');
test('unknown API requests return JSON 404 while env.js and SPA deep links work', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-fallback-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(path.join(dir, 'static/dist'), { recursive: true });
fs.writeFileSync(path.join(dir, 'static/dist/index.html'), 'SPA');
const app = express();
load('back/loaders/express.ts', {
'../api': () => express.Router(),
'../config': {
rootPath: dir,
uploadPath: dir,
api: { prefix: '/api' },
apiWhiteList: ['/api/env.js'],
jwt: { secret: 'test' },
},
'express-jwt': {
expressjwt: () => ({ unless: () => (_q, _r, next) => next() }),
UnauthorizedError: class extends Error {},
},
'../config/util': {
getPlatform: () => 'desktop',
getToken: () => 'stored',
},
'../config/serverEnv': {
serveEnv: (_q, r) => r.type('js').send('window.QL_CONFIG={};'),
},
'../shared/store': {
shareStore: { getAuthInfo: async () => ({}), getApps: async () => [] },
},
'../shared/auth': {
isValidToken: () => true,
isDefaultAuthInfo: () => false,
},
'../shared/i18n': { t: (s) => s },
'../middlewares/uniqueConstraintError': (_q, _r, next) => next(),
'../middlewares/protectedPathCase': (_q, _r, next) => next(),
'../data/system': {},
'../data/open': {},
}).default({ app });
const server = await new Promise((r) => {
const s = app.listen(0, '127.0.0.1', () => r(s));
});
t.after(() => new Promise((r) => server.close(r)));
const url = `http://127.0.0.1:${server.address().port}`;
for (const p of ['/api', '/api/missing', '/open/missing']) {
const res = await fetch(url + p);
assert.equal(res.status, 404, p);
assert.match(res.headers.get('content-type'), /json/);
assert.equal((await res.json()).code, 404);
}
const env = await fetch(url + '/api/env.js');
assert.match(await env.text(), /QL_CONFIG/);
assert.equal(await (await fetch(url + '/script')).text(), 'SPA');
});
+253
View File
@@ -0,0 +1,253 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
const hasApt = spawnSync('apt-get', ['--version']).status === 0;
const {
rewriteMirrorSources,
replaceMirrorUrl,
normalizeMirror,
mirrorWriteCommand,
} = require('../../back/shared/linuxMirror');
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-issue-fixes-'));
t.after(() => fs.rm(root, { force: true, recursive: true }));
return root;
}
test('#2902/#2871 mirror writes are idempotent, preserve suites, security/ports/version paths and quote URLs', async (t) => {
const original = `# http://leave.example/debian\nTypes: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm bookworm-updates\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: bookworm-security\n`;
const url = 'https://mirror.example/mirrors/debian/';
const changed = rewriteMirrorSources(original, url);
assert.equal(rewriteMirrorSources(changed, url), changed);
assert.match(
changed,
/URIs: https:\/\/mirror.example\/mirrors\/debian-security/,
);
assert.match(changed, /Suites: bookworm-security/);
assert.match(changed, /# http:\/\/leave.example\/debian/);
assert.equal(
replaceMirrorUrl('http://old/debian/debian/debian', url),
'https://mirror.example/mirrors/debian',
);
assert.equal(
replaceMirrorUrl('http://old/armbian/armbian', 'https://new/armbian'),
'https://new/armbian',
);
assert.equal(
replaceMirrorUrl('http://old/ubuntu-ports', 'https://new/ubuntu'),
'https://new/ubuntu-ports',
);
assert.equal(
replaceMirrorUrl('http://old/alpine/v3.22/main', 'https://new/alpine'),
'https://new/alpine/v3.22/main',
);
assert.equal(
replaceMirrorUrl('http://old/debian', 'https://new'),
'https://new/debian',
);
for (const bad of [
'file:///tmp/a',
'http://u:p@host',
'https://host?x=1',
'https://host/\nanything',
])
assert.throws(() => normalizeMirror(bad));
const root = await fixture(t);
const file = path.join(root, "quote's", 'sources');
const command = mirrorWriteCommand(file, changed, '', 'true');
const result = spawnSync('bash', ['-c', command], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(await fs.readFile(file, 'utf8'), changed);
});
test('#2701 shell API serializes CR/LF, quotes, backslashes and colons without corrupting JSON', async () => {
const result = spawnSync(
'bash',
[
'-c',
`source shell/api.sh
ql_api_request() { while [[ $# -gt 0 ]]; do if [[ "$1" == --data-raw ]]; then printf '%s\\n' "$2" >&3; break; fi; shift; done; printf '{"code":200}'; }
t() { :; }
add_cron_api '0 0 1 1 *' 'task a:b.js' $'quote"slash\\\\colon:\\r\\n\\t中文' 23
update_cron_api '0 0 1 1 *' 'task a:b.js' $'quote"slash\\\\colon:\\r\\n\\t中文' 27
update_cron_command_api $'task path\\\\with"quotes.js' 27
`,
],
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe', 'pipe'] },
);
assert.equal(result.status, 0, result.stderr);
const payloads = result.output[3].trim().split('\n').map(JSON.parse);
assert.equal(payloads[0].name, 'quote"slash\\colon:\r\n\t中文');
assert.equal(payloads[0].sub_id, 23);
assert.equal(payloads[1].id, '27');
assert.equal(payloads[2].command, 'task path\\with"quotes.js');
});
for (const distribution of ['Debian', 'Ubuntu']) {
for (const legacyContent of ['', ' \t\r\n']) {
test(`${distribution}: empty legacy sources.list falls back to deb822 .sources`, async (t) => {
const root = await fixture(t);
const legacy = path.join(root, 'sources.list');
const sources = path.join(
root,
'sources.list.d',
`${distribution.toLowerCase()}.sources`,
);
await fs.writeFile(legacy, legacyContent);
const util = load('back/config/util.ts', {
'fs/promises': {
readFile: async (file) => {
if (file === '/etc/os-release')
return `ID=${distribution.toLowerCase()}\nVERSION_CODENAME=${
distribution === 'Debian' ? 'bookworm' : 'noble'
}\n`;
if (file === '/etc/apt/sources.list')
return fs.readFile(legacy, 'utf8');
throw Object.assign(new Error('missing fixture source'), {
code: 'ENOENT',
});
},
},
os: { platform: () => 'linux' },
'./index': {},
'./const': {},
'./share': {},
'./container': { isInContainer: () => false },
'../loaders/logger': {},
'../shared/utils': {},
'../shared/fileAccess': {},
'../data/dependence': {},
});
const previousOS = process.env.QL_OS_TYPE;
let command;
try {
process.env.QL_OS_TYPE = distribution.toLowerCase();
command = await util.updateLinuxMirrorFile('https://mirror.example');
} finally {
if (previousOS === undefined) delete process.env.QL_OS_TYPE;
else process.env.QL_OS_TYPE = previousOS;
}
// Execute the entire generated writer, redirecting its APT paths into
// the fixture and replacing only the package-index refresh.
const isolated = command
.replaceAll('/etc/apt', root)
.replace('apt-get update --error-on=any', 'true');
const result = spawnSync('bash', ['-c', isolated], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(await fs.readFile(legacy, 'utf8'), legacyContent);
const generated = await fs.readFile(sources, 'utf8');
assert.match(generated, /^Types: deb\nURIs: https:\/\/mirror\.example\//);
assert.match(generated, /Signed-By: \/usr\/share\/keyrings\//);
assert.match(
generated,
distribution === 'Debian' ? /bookworm-security/ : /noble-backports/,
);
if (hasApt) {
// Parse both source locations with real APT, without refreshing indexes
// or touching the host's configuration/cache.
const parsed = spawnSync(
'apt-get',
[
'indextargets',
'-o',
`Dir::Etc::sourcelist=${legacy}`,
'-o',
`Dir::Etc::sourceparts=${path.dirname(sources)}`,
'-o',
`Dir::State::lists=${path.join(root, 'lists')}`,
],
{ encoding: 'utf8' },
);
assert.equal(parsed.status, 0, parsed.stderr);
}
});
}
}
test('deb822 mirror changes preserve vendor archives, signing keys and comments across repeated saves', () => {
const system =
'Types: deb\nURIs: https://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n';
const vendors = [
'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: bookworm\nComponents: stable\nSigned-By: /etc/apt/keyrings/docker.asc\n',
'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: bookworm\nComponents: stable\n',
'Types: deb\nURIs: https://packages.microsoft.com/repos/code\nSuites: stable\nComponents: main\n',
'Types: deb\nURIs: https://vendor.example/debian\nSuites: bookworm\nComponents: main\nSigned-By: /etc/apt/keyrings/vendor.gpg\n',
];
const comment = '# Keep https://vendor.example/debian unchanged\n';
const original = [system, ...vendors, comment].join('\n');
const first = rewriteMirrorSources(
original,
'https://mirror.example/mirrors/debian',
);
assert.ok(first.includes('URIs: https://mirror.example/mirrors/debian\n'));
for (const unchanged of [...vendors, comment])
assert.ok(first.includes(unchanged));
assert.equal(
rewriteMirrorSources(first, 'https://mirror.example/mirrors/debian'),
first,
);
const second = rewriteMirrorSources(first, 'https://second.example/debian');
assert.ok(second.includes('URIs: https://second.example/debian\n'));
for (const unchanged of [...vendors, comment])
assert.ok(second.includes(unchanged));
});
test('legacy mirror changes preserve third-party lines, inline comments and source options', () => {
const system =
'deb [arch=amd64 signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://deb.debian.org/debian bookworm main # https://leave.example/debian\n';
const security =
'deb-src https://security.debian.org/debian-security bookworm-security main\n';
const vendors = [
'deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable\n',
'deb https://download.docker.com/linux/debian bookworm stable\n',
'deb [signed-by=/etc/apt/keyrings/vendor.gpg] https://vendor.example/debian bookworm main\n',
'deb https://packages.microsoft.com/repos/code stable main\n',
'# deb https://deb.debian.org/debian bookworm main\n',
];
const original = system + security + vendors.join('');
const changed = rewriteMirrorSources(
original,
'https://mirror.example/debian',
);
assert.ok(
changed.includes(
system.replace(
'https://deb.debian.org/debian',
'https://mirror.example/debian',
),
),
);
assert.ok(
changed.includes(
security.replace(
'https://security.debian.org/debian-security',
'https://mirror.example/debian-security',
),
),
);
for (const unchanged of vendors) assert.ok(changed.includes(unchanged));
assert.equal(
rewriteMirrorSources(changed, 'https://mirror.example/debian'),
changed,
);
});
test('Ubuntu ports, Armbian and Alpine roots still switch mirrors without altering repository metadata', () => {
for (const url of [
'https://ports.ubuntu.com/ubuntu-ports',
'https://apt.armbian.com/armbian',
'https://old.example/alpine/v3.22/main',
'https://old.example/alpine/v3.22/community',
]) {
const original = `URIs: ${url}\nSuites: noble\nComponents: main\n`;
assert.equal(
rewriteMirrorSources(original, 'https://mirror.example'),
original.replace(url, replaceMirrorUrl(url, 'https://mirror.example')),
);
}
});
+1 -1
View File
@@ -129,7 +129,7 @@ Acquire::http::Timeout "1";
'fs/promises': { 'fs/promises': {
readFile: async (file) => readFile: async (file) =>
file === '/etc/os-release' file === '/etc/os-release'
? distribution ? `${distribution}\nVERSION_CODENAME=bookworm\n`
: existingMirror : existingMirror
? 'URIs: http://old.example/debian\n' ? 'URIs: http://old.example/debian\n'
: '', : '',
+34 -2
View File
@@ -38,8 +38,22 @@ async function fixture(t) {
}).default; }).default;
const service = Object.assign(Object.create(S.prototype), { const service = Object.assign(Object.create(S.prototype), {
logger, logger,
notificationService: { notify: async () => true }, 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' } }); service.getDb = async () => ({ info: { type: 'email' } });
return { rows, service }; return { rows, service };
} }
@@ -72,7 +86,7 @@ test('audit DB failure does not turn a successful notification into failure', as
}); });
test('failed sends are recorded and message size is bounded', async (t) => { test('failed sends are recorded and message size is bounded', async (t) => {
const { service } = await fixture(t); const { service } = await fixture(t);
service.notificationService.notify = async () => false; service.notificationService.modeMap.set('gotify', async () => false);
assert.equal( assert.equal(
( (
await service.notify({ await service.notify({
@@ -89,3 +103,21 @@ test('failed sends are recorded and message size is bounded', async (t) => {
assert.equal(row.content.length, 8192); assert.equal(row.content.length, 8192);
assert.equal(row.notifyType, 'gotify'); 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);
});
@@ -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);
});