diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml index fcb6784c..46e77022 100644 --- a/.github/workflows/release-readiness.yml +++ b/.github/workflows/release-readiness.yml @@ -120,7 +120,7 @@ jobs: run: | docker run --rm --network none --entrypoint node \ 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 if: matrix.image == 'debian' run: | diff --git a/back/config/util.ts b/back/config/util.ts index 352e8712..88d591c7 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -13,6 +13,7 @@ import { FormData } from 'undici'; import os from 'os'; import { maybeSudo, isInContainer } from './container'; import { resolveFileAccess } from '../shared/fileAccess'; +import { mirrorWriteCommand, normalizeMirror, rewriteMirrorSources } from '../shared/linuxMirror'; export * from './share'; @@ -806,77 +807,73 @@ export async function detectOS(): Promise< return osType; } -async function getCurrentMirrorDomain( - filePath: string, -): Promise { - 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 { - 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( osType: string, - mirrorDomainWithScheme: string, + mirror: string, ): Promise { + const defaults: Record = { + 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 ' : ''; - // APT otherwise returns zero when index downloads fail with transient errors. - const aptUpdate = `${S}apt-get update --error-on=any`; - let filePath: string, currentDomainWithScheme: string | null; - switch (osType) { - case 'Debian': - filePath = '/etc/apt/sources.list.d/debian.sources'; - currentDomainWithScheme = await getCurrentMirrorDomain(filePath); - if (currentDomainWithScheme) { - 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}`; - } 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}`; + const update = + osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`; + let file = + osType === 'Alpine' + ? '/etc/apk/repositories' + : `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`; + let content = ''; + try { + 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`; } - 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.'); + } } + 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 { + // 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`; + } + } + return mirrorWriteCommand( + file, + rewriteMirrorSources(content, target), + S, + update, + ); } export async function updateLinuxMirrorFile(mirror: string): Promise { diff --git a/back/data/system.ts b/back/data/system.ts index 837f0aac..974aed0c 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -63,6 +63,9 @@ export interface NotifyLogInfo { content?: string; status?: NotifyStatus; notifyType?: string; + source?: 'system' | 'script'; + scriptName?: string; + taskId?: string; } export interface TokenInfo { diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 8ef1f06d..3d68aa95 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -179,7 +179,14 @@ export default ({ app }: { app: Application }) => { app.use(rewrite('/open/*', '/api/$1')); 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'); res.sendFile(indexPath, (err) => { if (err) { diff --git a/back/protos/api.proto b/back/protos/api.proto index 4c3269eb..b35406d4 100644 --- a/back/protos/api.proto +++ b/back/protos/api.proto @@ -260,6 +260,15 @@ message SystemNotifyRequest { optional NotificationInfo notificationInfo = 3; } +message RecordNotifyLogRequest { + string title = 1; + string content = 2; + bool success = 3; + string notifyType = 4; + string scriptName = 5; + string taskId = 6; +} + service Api { rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {} rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {} @@ -271,6 +280,7 @@ service Api { rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {} rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {} rpc SystemNotify(SystemNotifyRequest) returns (Response) {} + rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {} rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} diff --git a/back/protos/api.ts b/back/protos/api.ts index babc07b1..11e759bc 100644 --- a/back/protos/api.ts +++ b/back/protos/api.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 -// protoc v3.17.3 +// protoc v6.31.1 // source: back/protos/api.proto /* eslint-disable */ @@ -417,6 +417,15 @@ export interface SystemNotifyRequest { notificationInfo?: NotificationInfo | undefined; } +export interface RecordNotifyLogRequest { + title: string; + content: string; + success: boolean; + notifyType: string; + scriptName: string; + taskId: string; +} + function createBaseEnvItem(): EnvItem { return { id: undefined, @@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns = { }, }; +function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest { + return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" }; +} + +export const RecordNotifyLogRequest: MessageFns = { + 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>>(base?: I): RecordNotifyLogRequest { + return RecordNotifyLogRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RecordNotifyLogRequest { + const message = createBaseRecordNotifyLogRequest(); + message.title = object.title ?? ""; + message.content = object.content ?? ""; + message.success = object.success ?? false; + message.notifyType = object.notifyType ?? ""; + message.scriptName = object.scriptName ?? ""; + message.taskId = object.taskId ?? ""; + return message; + }, +}; + export type ApiService = typeof ApiService; export const ApiService = { getEnvs: { @@ -4388,6 +4537,15 @@ export const ApiService = { responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, + recordNotifyLog: { + path: "/com.ql.api.Api/RecordNotifyLog", + requestStream: false, + responseStream: false, + requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()), + requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value), + responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), + responseDeserialize: (value: Buffer) => Response.decode(value), + }, getCronDetail: { path: "/com.ql.api.Api/GetCronDetail", requestStream: false, @@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation { updateEnvNames: handleUnaryCall; getEnvById: handleUnaryCall; systemNotify: handleUnaryCall; + recordNotifyLog: handleUnaryCall; getCronDetail: handleUnaryCall; createCron: handleUnaryCall; updateCron: handleUnaryCall; @@ -4644,6 +4803,21 @@ export interface ApiClient extends Client { options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; + recordNotifyLog( + request: RecordNotifyLogRequest, + callback: (error: ServiceError | null, response: Response) => void, + ): ClientUnaryCall; + recordNotifyLog( + request: RecordNotifyLogRequest, + metadata: Metadata, + callback: (error: ServiceError | null, response: Response) => void, + ): ClientUnaryCall; + recordNotifyLog( + request: RecordNotifyLogRequest, + metadata: Metadata, + options: Partial, + callback: (error: ServiceError | null, response: Response) => void, + ): ClientUnaryCall; getCronDetail( request: CronDetailRequest, callback: (error: ServiceError | null, response: CronDetailResponse) => void, diff --git a/back/schedule/api.ts b/back/schedule/api.ts index 08f838ee..bfbfbbaf 100644 --- a/back/schedule/api.ts +++ b/back/schedule/api.ts @@ -16,6 +16,7 @@ import { MoveEnvRequest, Response, SystemNotifyRequest, + RecordNotifyLogRequest, UpdateEnvNamesRequest, UpdateEnvRequest, } from '../protos/api'; @@ -238,7 +239,8 @@ export const systemNotify = async ( const data = await systemService.notify({ title: call.request.title, content: call.request.content, - notificationInfo: call.request.notificationInfo as unknown as NotificationInfo, + notificationInfo: call.request + .notificationInfo as unknown as NotificationInfo, }); callback(null, data); } catch (e: any) { @@ -246,10 +248,31 @@ export const systemNotify = async ( } }; +export const recordNotifyLog = async ( + call: ServerUnaryCall, + callback: sendUnaryData, +) => { + try { + await Container.get(SystemService).recordNotifyLog({ + title: call.request.title, + content: call.request.content, + status: call.request.success ? 0 : 1, + notifyType: call.request.notifyType, + source: 'script', + scriptName: call.request.scriptName, + taskId: call.request.taskId, + }); + callback(null, { code: 200 }); + } catch (error: any) { + callback(error); + } +}; + const normalizeCronData = (data: CronItem | null): CronItem | undefined => { if (!data) return undefined; // create() returns a Sequelize instance; spreading it omits attribute getters. - const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data; + const cron = + data instanceof Model ? (data.get({ plain: true }) as CronItem) : data; return { ...cron, labels: cron.labels ?? [], diff --git a/back/services/notify.ts b/back/services/notify.ts index 11175312..2e9426b9 100644 --- a/back/services/notify.ts +++ b/back/services/notify.ts @@ -1,3 +1,4 @@ +import { persistNotificationAudit } from '../shared/notificationAudit'; import crypto from 'crypto'; import nodemailer from 'nodemailer'; import { Inject, Service } from 'typedi'; @@ -55,27 +56,33 @@ export default class NotificationService { content: string, notificationInfo?: NotificationInfo, ): Promise { - let { type, ...rest } = await this.userService.getNotificationMode(); - if (notificationInfo?.type) { - type = notificationInfo?.type; - } - if (type) { - this.title = title; - this.content = content; - let params = rest; - if (notificationInfo) { - const { type: _, ...others } = notificationInfo; - params = { ...rest, ...others }; - } - this.params = params; - const notificationModeAction = this.modeMap.get(type); - try { - return await notificationModeAction?.call(this); - } catch (error: any) { - console.error(error); + let type: string | undefined; + let success = false; + try { + const saved = await this.userService.getNotificationMode(); + type = notificationInfo?.type || saved.type; + if (type) { + this.title = title; + this.content = content; + const { type: _, ...overrides } = notificationInfo || {}; + const { type: _saved, ...rest } = saved; + this.params = { ...rest, ...overrides }; + try { + success = !!(await this.modeMap.get(type)?.call(this)); + } catch (error) { + console.error(error); + } } + return success; + } finally { + await persistNotificationAudit({ + title, + content, + notifyType: type, + source: 'system', + status: success ? 0 : 1, + }); } - return false; } public async testNotify( @@ -84,14 +91,23 @@ export default class NotificationService { content: string, ) { const { type, ...rest } = info; - if (type) { + if (!type) return true; + let success = false; + try { this.title = title; this.content = content; this.params = rest; - const notificationModeAction = this.modeMap.get(type); - return await notificationModeAction?.call(this); + success = !!(await this.modeMap.get(type)?.call(this)); + return success; + } finally { + await persistNotificationAudit({ + title, + content, + notifyType: type, + source: 'system', + status: success ? 0 : 1, + }); } - return true; } private parseMailRecipients(value?: string) { diff --git a/back/services/system.ts b/back/services/system.ts index c4a965b0..b8ac8e64 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -1,4 +1,4 @@ -import { Op } from 'sequelize'; +import { persistNotificationAudit } from '../shared/notificationAudit'; import { spawn } from 'cross-spawn'; import { execFile } from 'child_process'; import { promisify } from 'util'; @@ -413,54 +413,12 @@ export default class SystemService { 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( title, content, 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) { return { code: 200, message: t('通知发送成功') }; } else { @@ -468,6 +426,10 @@ export default class SystemService { } } + public async recordNotifyLog(info: NotifyLogInfo): Promise { + await persistNotificationAudit(info); + } + public async getNotifyLog(): Promise> { const docs = await SystemModel.findAll({ where: { type: AuthDataType.notifyLog }, diff --git a/back/shared/linuxMirror.ts b/back/shared/linuxMirror.ts new file mode 100644 index 00000000..2f6b5895 --- /dev/null +++ b/back/shared/linuxMirror.ts @@ -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}`; +} diff --git a/back/shared/notificationAudit.ts b/back/shared/notificationAudit.ts new file mode 100644 index 00000000..ec2575e2 --- /dev/null +++ b/back/shared/notificationAudit.ts @@ -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 { + 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'); + } +} diff --git a/cli/test/internal/subscriptionRunner.test.cjs b/cli/test/internal/subscriptionRunner.test.cjs index b576fbbb..719916ec 100644 --- a/cli/test/internal/subscriptionRunner.test.cjs +++ b/cli/test/internal/subscriptionRunner.test.cjs @@ -409,7 +409,7 @@ test('cron metadata precedence agrees with unchanged legacy add_cron', (t) => { '--noprofile', '--norc', '-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', diff --git a/docs/development/notification-audit.md b/docs/development/notification-audit.md new file mode 100644 index 00000000..84d1350a --- /dev/null +++ b/docs/development/notification-audit.md @@ -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 时不经过这些入口,无法自动纳入通知日志。 diff --git a/package.json b/package.json index 7a789fb6..3f5c3ffa 100644 --- a/package.json +++ b/package.json @@ -108,9 +108,9 @@ "jsonwebtoken": "^9.0.2", "keyv": "^5.2.3", "lodash": "^4.17.21", - "multer": "2.3.0", + "multer": "2.4.0", "node-cron": "4.6.0", - "nodemailer": "^8.0.1", + "nodemailer": "^10.0.9", "p-queue-cjs": "7.3.4", "proper-lockfile": "^4.1.2", "ps-tree": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e426f08..b2028fca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ dependencies: version: 12.0.1 body-parser: specifier: ^1.20.3 - version: 1.20.3 + version: 1.20.6 celebrate: specifier: ^15.0.3 version: 15.0.3 @@ -73,7 +73,7 @@ dependencies: version: 1.2.0 http-proxy-middleware: specifier: ^3.0.3 - version: 3.0.3 + version: 3.0.7 iconv-lite: specifier: ^0.6.3 version: 0.6.3 @@ -82,7 +82,7 @@ dependencies: version: 2.3.0(@types/node@17.0.45) js-yaml: specifier: ^4.1.0 - version: 4.1.0 + version: 4.3.2 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -93,14 +93,14 @@ dependencies: specifier: ^4.17.21 version: 4.17.21 multer: - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 node-cron: specifier: 4.6.0 version: 4.6.0 nodemailer: - specifier: ^8.0.1 - version: 8.0.1 + specifier: ^10.0.9 + version: 10.0.9 p-queue-cjs: specifier: 7.3.4 version: 7.3.4 @@ -133,7 +133,7 @@ dependencies: version: 0.10.0 undici: specifier: ^7.9.0 - version: 7.9.0 + version: 7.29.1 uuid: specifier: ^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) axios: specifier: ^1.4.0 - version: 1.7.8 + version: 1.20.0 compression-webpack-plugin: specifier: 9.2.0 version: 9.2.0 @@ -2879,7 +2879,7 @@ packages: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3060,7 +3060,7 @@ packages: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.1 + js-yaml: 3.15.2 resolve-from: 5.0.0 dev: true @@ -5677,7 +5677,6 @@ packages: debug: 4.4.3 transitivePeerDependencies: - supports-color - dev: false /agentkeepalive@4.5.0: resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} @@ -8302,7 +8301,7 @@ packages: is-glob: 4.0.3 is-path-inside: 3.0.3 js-sdsl: 4.4.2 - js-yaml: 4.1.0 + js-yaml: 4.3.2 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -8513,7 +8512,7 @@ packages: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.3 + body-parser: 1.20.6 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.1 @@ -9334,7 +9333,6 @@ packages: debug: 4.4.3 transitivePeerDependencies: - supports-color - dev: false /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} @@ -10587,7 +10585,7 @@ packages: engines: {node: '>=8.6'} dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 /miller-rabin@4.0.1: resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} @@ -10654,19 +10652,19 @@ packages: /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.21 dev: true /minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.21 /minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.7 dev: true /minimist-options@4.1.0: @@ -10870,7 +10868,7 @@ packages: semver: 7.7.4 tar: 7.5.20 tinyglobby: 0.2.17 - undici: 6.27.0 + undici: 6.29.0 which: 6.0.1 dev: false optional: true @@ -15794,6 +15792,342 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} 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: autoInstallPeers: true excludeLinksFromLockfile: false diff --git a/sample/notify.js b/sample/notify.js index 35dcce1d..1b1a630f 100644 --- a/sample/notify.js +++ b/sample/notify.js @@ -153,7 +153,7 @@ const push_config = { WXPUSHER_APP_TOKEN: '', // wxpusher 的 appToken WXPUSHER_TOPIC_IDS: '', // wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 WXPUSHER_UIDS: '', // wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 - // 方式二:极简发送(最简单,简单好用,一键配置,更推荐) + // 方式二:极简发送(最简单,简单好用,一键配置,更推荐) // wxPusher 的 SPT(极简推送),扫码获取 https://wxpusher.zjiecode.com/docs/#/?id=spt // 多个用英文逗号,分隔,最多10个;与上面的 appToken 方式二选一即可 WXPUSHER_SPT_LIST: '', // wxpusher 的 SPT(极简推送),多个用英文逗号,分隔,最多10个 @@ -218,6 +218,8 @@ async function one() { } function gotifyNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { GOTIFY_URL, GOTIFY_TOKEN, GOTIFY_PRIORITY } = push_config; if (GOTIFY_URL && GOTIFY_TOKEN) { @@ -233,18 +235,22 @@ function gotifyNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Gotify 发送通知调用API失败😞\n', err); } else { if (data.id) { + auditSucceeded = true; console.log('Gotify 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Gotify 发送通知调用API失败😞 ${data.message}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -254,6 +260,8 @@ function gotifyNotify(text, desp) { } function gobotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { GOBOT_URL, GOBOT_TOKEN, GOBOT_QQ } = push_config; if (GOBOT_URL) { @@ -268,20 +276,25 @@ function gobotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Go-cqhttp 通知调用API失败😞\n', err); } else { if (data.retcode === 0) { + auditSucceeded = true; console.log('Go-cqhttp 发送通知消息成功🎉\n'); } else if (data.retcode === 100) { + auditFailed = true; console.log(`Go-cqhttp 发送通知消息异常 ${data.errmsg}\n`); } else { + auditFailed = true; console.log(`Go-cqhttp 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -291,6 +304,8 @@ function gobotNotify(text, desp) { } function serverNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { PUSH_KEY } = push_config; if (PUSH_KEY) { @@ -314,22 +329,27 @@ function serverNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Server 酱发送通知调用API失败😞\n', err); } else { // server酱和Server酱·Turbo版的返回json格式不太一样 if (data.errno === 0 || data.code === 0) { + auditSucceeded = true; console.log('Server 酱发送通知消息成功🎉\n'); } else if (data.errno === 1024) { // 一分钟内发送相同的内容会触发 + auditFailed = true; console.log(`Server 酱发送通知消息异常 ${data.errmsg}\n`); } else { + auditFailed = true; console.log(`Server 酱发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -339,6 +359,8 @@ function serverNotify(text, desp) { } function pushDeerNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { DEER_KEY, DEER_URL } = push_config; if (DEER_KEY) { @@ -355,6 +377,7 @@ function pushDeerNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('PushDeer 通知调用API失败😞\n', err); } else { // 通过返回的result的长度来判断是否成功 @@ -362,17 +385,20 @@ function pushDeerNotify(text, desp) { data.content.result.length !== undefined && data.content.result.length > 0 ) { + auditSucceeded = true; console.log('PushDeer 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log( `PushDeer 发送通知消息异常😞 ${JSON.stringify(data)}`, ); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -382,6 +408,8 @@ function pushDeerNotify(text, desp) { } function chatNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { CHAT_URL, CHAT_TOKEN } = push_config; if (CHAT_URL && CHAT_TOKEN) { @@ -397,18 +425,22 @@ function chatNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Chat 发送通知调用API失败😞\n', err); } else { if (data.success) { + auditSucceeded = true; console.log('Chat 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Chat 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { + auditFailed = true; $.logErr(e); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -418,6 +450,8 @@ function chatNotify(text, desp) { } function barkNotify(text, desp, params = {}) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { let { BARK_PUSH, @@ -454,18 +488,22 @@ function barkNotify(text, desp, params = {}) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Bark APP 发送通知调用API失败😞\n', err); } else { if (data.code === 200) { + auditSucceeded = true; console.log('Bark APP 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Bark APP 发送通知消息异常 ${data.message}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -475,6 +513,8 @@ function barkNotify(text, desp, params = {}) { } function tgBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { TG_BOT_TOKEN, @@ -511,9 +551,11 @@ function tgBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Telegram 发送通知消息失败😞\n', err); } else { if (data.ok) { + auditSucceeded = true; console.log('Telegram 发送通知消息成功🎉。\n'); } else if (data.error_code === 400) { console.log( @@ -524,9 +566,10 @@ function tgBotNotify(text, desp) { } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -535,6 +578,8 @@ function tgBotNotify(text, desp) { }); } function ddBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { DD_BOT_TOKEN, DD_BOT_SECRET } = push_config; const options = { @@ -560,36 +605,44 @@ function ddBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('钉钉发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { + auditSucceeded = true; console.log('钉钉发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`钉钉发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else if (DD_BOT_TOKEN) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('钉钉发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { + auditSucceeded = true; console.log('钉钉发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`钉钉发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -599,6 +652,8 @@ function ddBotNotify(text, desp) { } function qywxBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { QYWX_ORIGIN, QYWX_KEY } = push_config; const options = { @@ -618,18 +673,22 @@ function qywxBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('企业微信发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { + auditSucceeded = true; console.log('企业微信发送通知消息成功🎉。\n'); } else { + auditFailed = true; console.log(`企业微信发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -659,17 +718,22 @@ function ChangeUserId(desp) { } async function qywxamNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; const MAX_LENGTH = 900; if (desp.length > MAX_LENGTH) { let d = desp.substr(0, MAX_LENGTH) + '\n==More=='; - await do_qywxamNotify(text, d); - await qywxamNotify(text, desp.substr(MAX_LENGTH)); + const first = await do_qywxamNotify(text, d); + const rest = await qywxamNotify(text, desp.substr(MAX_LENGTH)); + return first === undefined ? undefined : first && rest; } else { return await do_qywxamNotify(text, desp); } } function do_qywxamNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { QYWX_AM, QYWX_ORIGIN } = push_config; if (QYWX_AM) { @@ -754,6 +818,7 @@ function do_qywxamNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log( '成员ID:' + ChangeUserId(desp) + @@ -762,21 +827,24 @@ function do_qywxamNotify(text, desp) { ); } else { if (data.errcode === 0) { + auditSucceeded = true; console.log( '成员ID:' + ChangeUserId(desp) + '企业微信应用消息发送通知消息成功🎉。\n', ); } else { + auditFailed = true; console.log( `企业微信应用消息发送通知消息异常 ${data.errmsg}\n`, ); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); }); @@ -787,6 +855,8 @@ function do_qywxamNotify(text, desp) { } function iGotNotify(text, desp, params = {}) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { IGOT_PUSH_KEY } = push_config; if (IGOT_PUSH_KEY) { @@ -808,18 +878,22 @@ function iGotNotify(text, desp, params = {}) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('IGot 发送通知调用API失败😞\n', err); } else { if (data.ret === 0) { + auditSucceeded = true; console.log('IGot 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`IGot 发送通知消息异常 ${data.errMsg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -829,6 +903,8 @@ function iGotNotify(text, desp, params = {}) { } function pushPlusNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { PUSH_PLUS_TOKEN, @@ -863,6 +939,7 @@ function pushPlusNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' @@ -871,6 +948,7 @@ function pushPlusNotify(text, desp) { ); } else { if (data.code === 200) { + auditSucceeded = true; console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' @@ -879,6 +957,7 @@ function pushPlusNotify(text, desp) { }\n注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果`, ); } else { + auditFailed = true; console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' @@ -887,9 +966,10 @@ function pushPlusNotify(text, desp) { } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -899,6 +979,8 @@ function pushPlusNotify(text, desp) { } function wePlusBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WE_PLUS_BOT_TOKEN, WE_PLUS_BOT_RECEIVER, WE_PLUS_BOT_VERSION } = push_config; @@ -927,18 +1009,22 @@ function wePlusBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log(`微加机器人发送通知消息失败😞\n`, err); } else { if (data.code === 200) { + auditSucceeded = true; console.log(`微加机器人发送通知消息完成🎉\n`); } else { + auditFailed = true; console.log(`微加机器人发送通知消息异常 ${data.msg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -948,6 +1034,8 @@ function wePlusBotNotify(text, desp) { } function aibotkNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { AIBOTK_KEY, AIBOTK_TYPE, AIBOTK_NAME } = push_config; if (AIBOTK_KEY && AIBOTK_TYPE && AIBOTK_NAME) { @@ -988,18 +1076,22 @@ function aibotkNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('智能微秘书发送通知消息失败😞\n', err); } else { if (data.code === 0) { + auditSucceeded = true; console.log('智能微秘书发送通知消息成功🎉。\n'); } else { + auditFailed = true; console.log(`智能微秘书发送通知消息异常 ${data.error}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1009,6 +1101,8 @@ function aibotkNotify(text, desp) { } function fsBotNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { FSKEY, FSSECRET } = push_config; if (FSKEY) { @@ -1041,18 +1135,22 @@ function fsBotNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('飞书发送通知调用API失败😞\n', err); } else { if (data.StatusCode === 0 || data.code === 0) { + auditSucceeded = true; console.log('飞书发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`飞书发送通知消息异常 ${data.msg}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1113,9 +1211,12 @@ async function smtpNotify(text, desp) { } catch (e) { console.log('SMTP 发送通知消息出现异常😞\n', e); } + return false; } function pushMeNotify(text, desp, params = {}) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { PUSHME_KEY, PUSHME_URL } = push_config; if (PUSHME_KEY) { @@ -1130,18 +1231,22 @@ function pushMeNotify(text, desp, params = {}) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('PushMe 发送通知调用API失败😞\n', err); } else { if (data === 'success') { + auditSucceeded = true; console.log('PushMe 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`PushMe 发送通知消息异常 ${data}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1150,78 +1255,55 @@ function pushMeNotify(text, desp, params = {}) { }); } -function chronocatNotify(title, desp) { - return new Promise((resolve) => { - const { CHRONOCAT_TOKEN, CHRONOCAT_QQ, CHRONOCAT_URL } = push_config; - if (!CHRONOCAT_TOKEN || !CHRONOCAT_QQ || !CHRONOCAT_URL) { - resolve(); - return; +async function chronocatNotify(text, desp) { + const { CHRONOCAT_TOKEN, CHRONOCAT_QQ, CHRONOCAT_URL } = push_config; + if (!CHRONOCAT_TOKEN || !CHRONOCAT_QQ || !CHRONOCAT_URL) return; + const targets = []; + for (const [chatType, prefix] of [ + [1, 'user_id'], + [2, 'group_id'], + ]) { + for (const match of CHRONOCAT_QQ.matchAll( + new RegExp(prefix + '=(\\d+)', 'g'), + )) { + targets.push({ chatType, peerUin: match[1] }); } - - const user_ids = CHRONOCAT_QQ.match(/user_id=(\d+)/g)?.map( - (match) => match.split('=')[1], - ); - const group_ids = CHRONOCAT_QQ.match(/group_id=(\d+)/g)?.map( - (match) => match.split('=')[1], - ); - - const url = `${CHRONOCAT_URL}/api/message/send`; - const headers = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${CHRONOCAT_TOKEN}`, - }; - - for (const [chat_type, ids] of [ - [1, user_ids], - [2, group_ids], - ]) { - if (!ids) { - continue; - } - for (const chat_id of ids) { - const data = { - peer: { - chatType: chat_type, - peerUin: chat_id, - }, - elements: [ - { - elementType: 1, - textElement: { - content: `${title}\n\n${desp}`, - }, + } + const results = await Promise.all( + targets.map(async (peer) => { + try { + const response = await httpClient.post( + `${CHRONOCAT_URL}/api/message/send`, + { + json: { + peer, + elements: [ + { + elementType: 1, + textElement: { content: `${text}\n\n${desp}` }, + }, + ], }, - ], - }; - const options = { - url: url, - json: data, - headers, - timeout, - }; - $.post(options, (err, resp, data) => { - try { - if (err) { - console.log('Chronocat 发送QQ通知消息失败😞\n', err); - } else { - if (chat_type === 1) { - console.log(`Chronocat 个人消息 ${ids}推送成功🎉`); - } else { - console.log(`Chronocat 群消息 ${ids}推送成功🎉`); - } - } - } catch (e) { - $.logErr(e, resp); - } finally { - resolve(data); - } - }); + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${CHRONOCAT_TOKEN}`, + }, + timeout, + }, + ); + await response.body.text(); + return response.statusCode >= 200 && response.statusCode < 300; + } catch (_) { + return false; } - } - }); + }), + ); + return results.length > 0 && results.every(Boolean); } function qmsgNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { QMSG_KEY, QMSG_TYPE } = push_config; if (QMSG_KEY && QMSG_TYPE) { @@ -1236,18 +1318,22 @@ function qmsgNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Qmsg 发送通知调用API失败😞\n', err); } else { if (data.code === 0) { + auditSucceeded = true; console.log('Qmsg 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Qmsg 发送通知消息异常 ${data}\n`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1257,6 +1343,8 @@ function qmsgNotify(text, desp) { } function webhookNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WEBHOOK_URL, @@ -1294,24 +1382,35 @@ function webhookNotify(text, desp) { '$title', encodeURIComponent(text), ).replaceAll('$content', encodeURIComponent(desp)); - httpClient.request(formatUrl, options).then(async (resp) => { - const body = await resp.body.text(); - try { - if (resp.statusCode !== 200) { - console.log(`自定义发送通知消息失败😞 ${body}\n`); - } else { - console.log(`自定义发送通知消息成功🎉 ${body}\n`); + httpClient + .request(formatUrl, options) + .then(async (resp) => { + const body = await resp.body.text(); + try { + if (resp.statusCode !== 200) { + auditFailed = true; + console.log(`自定义发送通知消息失败😞 ${body}\n`); + } else { + auditSucceeded = true; + console.log(`自定义发送通知消息成功🎉 ${body}\n`); + } + } catch (e) { + auditFailed = true; + $.logErr(e, resp); + } finally { + resolve(auditSucceeded && !auditFailed); } - } catch (e) { - $.logErr(e, resp); - } finally { - resolve(body); - } - }); + }) + .catch(() => { + console.log('自定义发送通知消息失败'); + resolve(false); + }); }); } function ntfyNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; function encodeRFC2047(text) { const encodedBase64 = Buffer.from(text).toString('base64'); return `=?utf-8?B?${encodedBase64}?=`; @@ -1341,8 +1440,9 @@ function ntfyNotify(text, desp) { if (NTFY_TOKEN) { options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`; } else if (NTFY_USERNAME && NTFY_PASSWORD) { - options.headers['Authorization'] = - `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`; + options.headers['Authorization'] = `Basic ${Buffer.from( + `${NTFY_USERNAME}:${NTFY_PASSWORD}`, + ).toString('base64')}`; } if (NTFY_ACTIONS) { options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS); @@ -1351,18 +1451,22 @@ function ntfyNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('Ntfy 通知调用API失败😞\n', err); } else { if (data.id) { + auditSucceeded = true; console.log('Ntfy 发送通知消息成功🎉\n'); } else { + auditFailed = true; console.log(`Ntfy 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1372,6 +1476,8 @@ function ntfyNotify(text, desp) { } function wxPusherNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WXPUSHER_APP_TOKEN, WXPUSHER_TOPIC_IDS, WXPUSHER_UIDS } = push_config; @@ -1421,18 +1527,22 @@ function wxPusherNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('wxpusher发送通知消息失败!\n', err); } else { if (data.code === 1000) { + auditSucceeded = true; console.log('wxpusher发送通知消息完成!'); } else { + auditFailed = true; console.log(`wxpusher发送通知消息异常:${data.msg}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1442,6 +1552,8 @@ function wxPusherNotify(text, desp) { } function wxPusherSptNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WXPUSHER_SPT_LIST } = push_config; if (WXPUSHER_SPT_LIST) { @@ -1479,18 +1591,22 @@ function wxPusherSptNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('wxpusher SPT 发送通知消息失败!\n', err); } else { if (data.code === 1000) { + auditSucceeded = true; console.log('wxpusher SPT 发送通知消息完成!'); } else { + auditFailed = true; console.log(`wxpusher SPT 发送通知消息异常:${data.msg}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1499,9 +1615,9 @@ function wxPusherSptNotify(text, desp) { }); } - - function wpushNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { WPUSH_APIKEY, WPUSH_CHANNEL, WPUSH_TOPIC_CODE } = push_config; if (WPUSH_APIKEY) { @@ -1525,20 +1641,26 @@ function wpushNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('WPUSH 发送通知消息失败!\n', err); } else { if (data.code === 0) { + auditSucceeded = true; console.log('WPUSH 发送通知消息成功!'); } else { + auditFailed = true; console.log( - `WPUSH 发送通知消息异常:${data.message || JSON.stringify(data)}`, + `WPUSH 发送通知消息异常:${ + data.message || JSON.stringify(data) + }`, ); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1548,6 +1670,8 @@ function wpushNotify(text, desp) { } function openiLinkNotify(text, desp) { + let auditSucceeded = false; + let auditFailed = false; return new Promise((resolve) => { const { OPENILINK_APP_TOKEN, OPENILINK_HUB_URL, OPENILINK_CONTEXT_TOKEN } = push_config; @@ -1575,18 +1699,22 @@ function openiLinkNotify(text, desp) { $.post(options, (err, resp, data) => { try { if (err) { + auditFailed = true; console.log('OpeniLink 发送通知消息失败!\n', err); } else { if (data.ok) { + auditSucceeded = true; console.log('OpeniLink 发送通知消息成功!'); } else { + auditFailed = true; console.log(`OpeniLink 发送通知消息异常:${data.error}`); } } } catch (e) { + auditFailed = true; $.logErr(e, resp); } finally { - resolve(data); + resolve(auditSucceeded && !auditFailed); } }); } else { @@ -1681,6 +1809,41 @@ function formatBodyFun(contentType, body) { return {}; } +async function auditNotification(channel, title, content, send) { + let result; + try { + result = await send(); + } catch (error) { + await reportNotification(channel, title, content, false); + throw error; + } + // Unconfigured channels do not create audit entries. + if (typeof result === 'boolean') + await reportNotification(channel, title, content, result); + return result; +} + +async function reportNotification(channel, title, content, success) { + if (!process.env.QL_DIR) return; + try { + const path = require('node:path'); + const report = require(path.join( + process.env.QL_DIR, + 'shell/preload/notificationAudit.js', + )); + await report({ + title: String(title || '').slice(0, 512), + content: String(content || '').slice(0, 8192), + success, + notifyType: channel, + scriptName: path.basename(process.argv[1] || ''), + taskId: process.env.ID || '', + }); + } catch (_) { + // Older/offline panels must not prevent script notification delivery. + } +} + /** * sendNotify 推送通知功能 * @param text 通知头 @@ -1703,31 +1866,41 @@ async function sendNotify(text, desp, params = {}) { } await Promise.all([ - serverNotify(text, desp), // 微信server酱 - pushPlusNotify(text, desp), // pushplus - wePlusBotNotify(text, desp), // 微加机器人 - barkNotify(text, desp, params), // iOS Bark APP - tgBotNotify(text, desp), // telegram 机器人 - ddBotNotify(text, desp), // 钉钉机器人 - qywxBotNotify(text, desp), // 企业微信机器人 - qywxamNotify(text, desp), // 企业微信应用消息推送 - iGotNotify(text, desp, params), // iGot - gobotNotify(text, desp), // go-cqhttp - gotifyNotify(text, desp), // gotify - chatNotify(text, desp), // synology chat - pushDeerNotify(text, desp), // PushDeer - aibotkNotify(text, desp), // 智能微秘书 - fsBotNotify(text, desp), // 飞书机器人 - smtpNotify(text, desp), // SMTP 邮件 - pushMeNotify(text, desp, params), // PushMe - chronocatNotify(text, desp), // Chronocat - webhookNotify(text, desp), // 自定义通知 - qmsgNotify(text, desp), // 自定义通知 - ntfyNotify(text, desp), // Ntfy - wxPusherNotify(text, desp), // wxpusher - wxPusherSptNotify(text, desp), // wxpusher SPT - openiLinkNotify(text, desp), // OpeniLink - wpushNotify(text, desp), // WPUSH + auditNotification('server', text, desp, () => serverNotify(text, desp)), // 微信server酱 + auditNotification('pushPlus', text, desp, () => pushPlusNotify(text, desp)), // pushplus + auditNotification('wePlusBot', text, desp, () => + wePlusBotNotify(text, desp), + ), // 微加机器人 + auditNotification('bark', text, desp, () => barkNotify(text, desp, params)), // iOS Bark APP + auditNotification('tgBot', text, desp, () => tgBotNotify(text, desp)), // telegram 机器人 + auditNotification('ddBot', text, desp, () => ddBotNotify(text, desp)), // 钉钉机器人 + auditNotification('qywxBot', text, desp, () => qywxBotNotify(text, desp)), // 企业微信机器人 + auditNotification('qywxam', text, desp, () => qywxamNotify(text, desp)), // 企业微信应用消息推送 + auditNotification('iGot', text, desp, () => iGotNotify(text, desp, params)), // iGot + auditNotification('gobot', text, desp, () => gobotNotify(text, desp)), // go-cqhttp + auditNotification('gotify', text, desp, () => gotifyNotify(text, desp)), // gotify + auditNotification('chat', text, desp, () => chatNotify(text, desp)), // synology chat + auditNotification('pushDeer', text, desp, () => pushDeerNotify(text, desp)), // PushDeer + auditNotification('aibotk', text, desp, () => aibotkNotify(text, desp)), // 智能微秘书 + auditNotification('fsBot', text, desp, () => fsBotNotify(text, desp)), // 飞书机器人 + auditNotification('smtp', text, desp, () => smtpNotify(text, desp)), // SMTP 邮件 + auditNotification('pushMe', text, desp, () => + pushMeNotify(text, desp, params), + ), // PushMe + auditNotification('chronocat', text, desp, () => + chronocatNotify(text, desp), + ), // Chronocat + auditNotification('webhook', text, desp, () => webhookNotify(text, desp)), // 自定义通知 + auditNotification('qmsg', text, desp, () => qmsgNotify(text, desp)), // 自定义通知 + auditNotification('ntfy', text, desp, () => ntfyNotify(text, desp)), // Ntfy + auditNotification('wxPusher', text, desp, () => wxPusherNotify(text, desp)), // wxpusher + auditNotification('wxPusherSpt', text, desp, () => + wxPusherSptNotify(text, desp), + ), // wxpusher SPT + auditNotification('openiLink', text, desp, () => + openiLinkNotify(text, desp), + ), // OpeniLink + auditNotification('wpush', text, desp, () => wpushNotify(text, desp)), // WPUSH ]); } diff --git a/sample/notify.py b/sample/notify.py index eb2ebb0b..a19f1b2f 100644 --- a/sample/notify.py +++ b/sample/notify.py @@ -10,6 +10,8 @@ import threading import time import urllib.parse import smtplib +import subprocess +from typing import Optional from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr @@ -161,10 +163,11 @@ for k in push_config: push_config[k] = v -def bark(title: str, content: str) -> None: +def bark(title: str, content: str) -> Optional[bool]: """ 使用 bark 推送消息。 """ + audit_results = [] if not push_config.get("BARK_PUSH"): return print("bark 服务启动") @@ -200,22 +203,28 @@ def bark(title: str, content: str) -> None: ).json() if response["code"] == 200: + audit_results.append(True) print("bark 推送成功!") else: + audit_results.append(False) print("bark 推送失败!") + return all(audit_results) if audit_results else None -def console(title: str, content: str) -> None: +def console(title: str, content: str) -> Optional[bool]: """ 使用 控制台 推送消息。 """ + audit_results = [] print(f"{title}\n\n{content}") + return True -def dingding_bot(title: str, content: str) -> None: +def dingding_bot(title: str, content: str) -> Optional[bool]: """ 使用 钉钉机器人 推送消息。 """ + audit_results = [] if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"): return print("钉钉机器人 服务启动") @@ -236,15 +245,19 @@ def dingding_bot(title: str, content: str) -> None: ).json() if not response["errcode"]: + audit_results.append(True) print("钉钉机器人 推送成功!") else: + audit_results.append(False) print("钉钉机器人 推送失败!") + return all(audit_results) if audit_results else None -def feishu_bot(title: str, content: str) -> None: +def feishu_bot(title: str, content: str) -> Optional[bool]: """ 使用 飞书机器人 推送消息。 """ + audit_results = [] if not push_config.get("FSKEY"): return print("飞书 服务启动") @@ -268,15 +281,19 @@ def feishu_bot(title: str, content: str) -> None: response = requests.post(url, data=json.dumps(data)).json() if response.get("StatusCode") == 0 or response.get("code") == 0: + audit_results.append(True) print("飞书 推送成功!") else: + audit_results.append(False) print("飞书 推送失败!错误信息如下:\n", response) + return all(audit_results) if audit_results else None -def go_cqhttp(title: str, content: str) -> None: +def go_cqhttp(title: str, content: str) -> Optional[bool]: """ 使用 go_cqhttp 推送消息。 """ + audit_results = [] if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"): return print("go-cqhttp 服务启动") @@ -285,15 +302,19 @@ def go_cqhttp(title: str, content: str) -> None: response = requests.get(url).json() if response["status"] == "ok": + audit_results.append(True) print("go-cqhttp 推送成功!") else: + audit_results.append(False) print("go-cqhttp 推送失败!") + return all(audit_results) if audit_results else None -def gotify(title: str, content: str) -> None: +def gotify(title: str, content: str) -> Optional[bool]: """ 使用 gotify 推送消息。 """ + audit_results = [] if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"): return print("gotify 服务启动") @@ -307,15 +328,19 @@ def gotify(title: str, content: str) -> None: response = requests.post(url, data=data).json() if response.get("id"): + audit_results.append(True) print("gotify 推送成功!") else: + audit_results.append(False) print("gotify 推送失败!") + return all(audit_results) if audit_results else None -def iGot(title: str, content: str) -> None: +def iGot(title: str, content: str) -> Optional[bool]: """ 使用 iGot 推送消息。 """ + audit_results = [] if not push_config.get("IGOT_PUSH_KEY"): return print("iGot 服务启动") @@ -326,15 +351,19 @@ def iGot(title: str, content: str) -> None: response = requests.post(url, data=data, headers=headers).json() if response["ret"] == 0: + audit_results.append(True) print("iGot 推送成功!") else: + audit_results.append(False) print(f'iGot 推送失败!{response["errMsg"]}') + return all(audit_results) if audit_results else None -def serverJ(title: str, content: str) -> None: +def serverJ(title: str, content: str) -> Optional[bool]: """ 通过 serverJ 推送消息。 """ + audit_results = [] if not push_config.get("PUSH_KEY"): return print("serverJ 服务启动") @@ -351,15 +380,19 @@ def serverJ(title: str, content: str) -> None: response = requests.post(url, data=data).json() if response.get("errno") == 0 or response.get("code") == 0: + audit_results.append(True) print("serverJ 推送成功!") else: + audit_results.append(False) print(f'serverJ 推送失败!错误码:{response["message"]}') + return all(audit_results) if audit_results else None -def pushdeer(title: str, content: str) -> None: +def pushdeer(title: str, content: str) -> Optional[bool]: """ 通过PushDeer 推送消息 """ + audit_results = [] if not push_config.get("DEER_KEY"): return print("PushDeer 服务启动") @@ -376,15 +409,19 @@ def pushdeer(title: str, content: str) -> None: response = requests.post(url, data=data).json() if len(response.get("content").get("result")) > 0: + audit_results.append(True) print("PushDeer 推送成功!") else: + audit_results.append(False) print("PushDeer 推送失败!错误信息:", response) + return all(audit_results) if audit_results else None -def chat(title: str, content: str) -> None: +def chat(title: str, content: str) -> Optional[bool]: """ 通过Chat 推送消息 """ + audit_results = [] if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"): return print("chat 服务启动") @@ -393,15 +430,19 @@ def chat(title: str, content: str) -> None: response = requests.post(url, data=data) if response.status_code == 200: + audit_results.append(True) print("Chat 推送成功!") else: + audit_results.append(False) print("Chat 推送失败!错误信息:", response) + return all(audit_results) if audit_results else None -def pushplus_bot(title: str, content: str) -> None: +def pushplus_bot(title: str, content: str) -> Optional[bool]: """ 通过 pushplus 推送消息。 """ + audit_results = [] if not push_config.get("PUSH_PLUS_TOKEN"): return print("PUSHPLUS 服务启动") @@ -424,7 +465,9 @@ def pushplus_bot(title: str, content: str) -> None: code = response["code"] if code == 200: + audit_results.append(True) print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"]) + audit_results.append(True) print( "注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果" ) @@ -437,16 +480,20 @@ def pushplus_bot(title: str, content: str) -> None: response = requests.post(url=url_old, data=body, headers=headers).json() if response["code"] == 200: + audit_results.append(True) print("PUSHPLUS(hxtrip) 推送成功!") else: + audit_results.append(False) print("PUSHPLUS 推送失败!") + return all(audit_results) if audit_results else None -def weplus_bot(title: str, content: str) -> None: +def weplus_bot(title: str, content: str) -> Optional[bool]: """ 通过 微加机器人 推送消息。 """ + audit_results = [] if not push_config.get("WE_PLUS_BOT_TOKEN"): return print("微加机器人 服务启动") @@ -469,15 +516,19 @@ def weplus_bot(title: str, content: str) -> None: response = requests.post(url=url, data=body, headers=headers).json() if response["code"] == 200: + audit_results.append(True) print("微加机器人 推送成功!") else: + audit_results.append(False) print("微加机器人 推送失败!") + return all(audit_results) if audit_results else None -def qmsg_bot(title: str, content: str) -> None: +def qmsg_bot(title: str, content: str) -> Optional[bool]: """ 使用 qmsg 推送消息。 """ + audit_results = [] if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"): return print("qmsg 服务启动") @@ -487,15 +538,19 @@ def qmsg_bot(title: str, content: str) -> None: response = requests.post(url=url, params=payload).json() if response["code"] == 0: + audit_results.append(True) print("qmsg 推送成功!") else: + audit_results.append(False) print(f'qmsg 推送失败!{response["reason"]}') + return all(audit_results) if audit_results else None -def wecom_app(title: str, content: str) -> None: +def wecom_app(title: str, content: str) -> Optional[bool]: """ 通过 企业微信 APP 推送消息。 """ + audit_results = [] if not push_config.get("QYWX_AM"): return QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM")) @@ -521,9 +576,12 @@ def wecom_app(title: str, content: str) -> None: response = wx.send_mpnews(title, content, media_id, touser) if response == "ok": + audit_results.append(True) print("企业微信推送成功!") else: + audit_results.append(False) print("企业微信推送失败!错误信息如下:\n", response) + return all(audit_results) if audit_results else None class WeCom: @@ -588,10 +646,11 @@ class WeCom: return respone["errmsg"] -def wecom_bot(title: str, content: str) -> None: +def wecom_bot(title: str, content: str) -> Optional[bool]: """ 通过 企业微信机器人 推送消息。 """ + audit_results = [] if not push_config.get("QYWX_KEY"): return print("企业微信机器人服务启动") @@ -608,15 +667,19 @@ def wecom_bot(title: str, content: str) -> None: ).json() if response["errcode"] == 0: + audit_results.append(True) print("企业微信机器人推送成功!") else: + audit_results.append(False) print("企业微信机器人推送失败!") + return all(audit_results) if audit_results else None -def telegram_bot(title: str, content: str) -> None: +def telegram_bot(title: str, content: str) -> Optional[bool]: """ 使用 telegram 机器人 推送消息。 """ + audit_results = [] if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"): return print("tg 服务启动") @@ -652,15 +715,19 @@ def telegram_bot(title: str, content: str) -> None: ).json() if response["ok"]: + audit_results.append(True) print("tg 推送成功!") else: + audit_results.append(False) print("tg 推送失败!") + return all(audit_results) if audit_results else None -def aibotk(title: str, content: str) -> None: +def aibotk(title: str, content: str) -> Optional[bool]: """ 使用 智能微秘书 推送消息。 """ + audit_results = [] if ( not push_config.get("AIBOTK_KEY") or not push_config.get("AIBOTK_TYPE") @@ -688,15 +755,19 @@ def aibotk(title: str, content: str) -> None: response = requests.post(url=url, data=body, headers=headers).json() print(response) if response["code"] == 0: + audit_results.append(True) print("智能微秘书 推送成功!") else: + audit_results.append(False) print(f'智能微秘书 推送失败!{response["error"]}') + return all(audit_results) if audit_results else None -def smtp(title: str, content: str) -> None: +def smtp(title: str, content: str) -> Optional[bool]: """ 使用 SMTP 邮件 推送消息。 """ + audit_results = [] if ( not push_config.get("SMTP_SERVER") or not push_config.get("SMTP_SSL") @@ -736,15 +807,19 @@ def smtp(title: str, content: str) -> None: message.as_bytes(), ) smtp_server.close() + audit_results.append(True) print("SMTP 邮件 推送成功!") except Exception as e: + audit_results.append(False) print(f"SMTP 邮件 推送失败!{e}") + return all(audit_results) if audit_results else None -def pushme(title: str, content: str) -> None: +def pushme(title: str, content: str) -> Optional[bool]: """ 使用 PushMe 推送消息。 """ + audit_results = [] if not push_config.get("PUSHME_KEY"): return print("PushMe 服务启动") @@ -764,15 +839,19 @@ def pushme(title: str, content: str) -> None: response = requests.post(url, data=data) if response.status_code == 200 and response.text == "success": + audit_results.append(True) print("PushMe 推送成功!") else: + audit_results.append(False) print(f"PushMe 推送失败!{response.status_code} {response.text}") + return all(audit_results) if audit_results else None -def chronocat(title: str, content: str) -> None: +def chronocat(title: str, content: str) -> Optional[bool]: """ 使用 CHRONOCAT 推送消息。 """ + audit_results = [] if ( not push_config.get("CHRONOCAT_URL") or not push_config.get("CHRONOCAT_QQ") @@ -807,21 +886,27 @@ def chronocat(title: str, content: str) -> None: response = requests.post(url, headers=headers, data=json.dumps(data)) if response.status_code == 200: if chat_type == 1: + audit_results.append(True) print(f"QQ个人消息:{ids}推送成功!") else: + audit_results.append(True) print(f"QQ群消息:{ids}推送成功!") else: if chat_type == 1: + audit_results.append(False) print(f"QQ个人消息:{ids}推送失败!") else: + audit_results.append(False) print(f"QQ群消息:{ids}推送失败!") + return all(audit_results) if audit_results else None -def ntfy(title: str, content: str) -> None: +def ntfy(title: str, content: str) -> Optional[bool]: """ 通过 Ntfy 推送消息 """ + audit_results = [] def encode_rfc2047(text: str) -> str: """将文本编码为符合 RFC 2047 标准的格式""" encoded_bytes = base64.b64encode(text.encode("utf-8")) @@ -853,12 +938,15 @@ def ntfy(title: str, content: str) -> None: url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC") response = requests.post(url, data=data, headers=headers) if response.status_code == 200: # 使用 response.status_code 进行检查 + audit_results.append(True) print("Ntfy 推送成功!") else: + audit_results.append(False) print("Ntfy 推送失败!错误信息:", response.text) + return all(audit_results) if audit_results else None -def wxpusher_bot(title: str, content: str) -> None: +def wxpusher_bot(title: str, content: str) -> Optional[bool]: """ 通过 wxpusher 推送消息。 支持的环境变量: @@ -866,6 +954,7 @@ def wxpusher_bot(title: str, content: str) -> None: - WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔 - WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔 """ + audit_results = [] if not push_config.get("WXPUSHER_APP_TOKEN"): return @@ -909,17 +998,21 @@ def wxpusher_bot(title: str, content: str) -> None: response = requests.post(url=url, json=data, headers=headers).json() if response.get("code") == 1000: + audit_results.append(True) print("wxpusher 推送成功!") else: + audit_results.append(False) print(f"wxpusher 推送失败!错误信息:{response.get('msg')}") + return all(audit_results) if audit_results else None -def wxpusher_spt(title: str, content: str) -> None: +def wxpusher_spt(title: str, content: str) -> Optional[bool]: """ 通过 wxpusher 极简推送(SPT)推送消息。 支持的环境变量: - WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个 """ + audit_results = [] if not push_config.get("WXPUSHER_SPT_LIST"): return @@ -956,17 +1049,21 @@ def wxpusher_spt(title: str, content: str) -> None: response = requests.post(url=url, json=data, headers=headers).json() if response.get("code") == 1000: + audit_results.append(True) print("wxpusher SPT 推送成功!") else: + audit_results.append(False) print(f"wxpusher SPT 推送失败!错误信息:{response.get('msg')}") + return all(audit_results) if audit_results else None -def wpush(title: str, content: str) -> None: +def wpush(title: str, content: str) -> Optional[bool]: """ 通过 WPUSH 推送消息。 官方文档: https://wpush.cn/docs """ + audit_results = [] if not push_config.get("WPUSH_APIKEY"): return @@ -986,12 +1083,15 @@ def wpush(title: str, content: str) -> None: response = requests.post(url=url, json=data, headers=headers, timeout=15).json() if response.get("code") == 0: + audit_results.append(True) print("WPUSH 推送成功!") else: + audit_results.append(False) print(f'WPUSH 推送失败!错误信息:{response.get("message") or response}') + return all(audit_results) if audit_results else None -def openilink(title: str, content: str) -> None: +def openilink(title: str, content: str) -> Optional[bool]: """ 通过 OpeniLink 推送消息。 支持的环境变量: @@ -999,6 +1099,7 @@ def openilink(title: str, content: str) -> None: - OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com - OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取 """ + audit_results = [] if not push_config.get("OPENILINK_APP_TOKEN"): return @@ -1023,9 +1124,12 @@ def openilink(title: str, content: str) -> None: response = requests.post(url=url, json=data, headers=headers).json() if response.get("ok"): + audit_results.append(True) print("OpeniLink 推送成功!") else: + audit_results.append(False) print(f'OpeniLink 推送失败!错误信息:{response.get("error")}') + return all(audit_results) if audit_results else None def parse_headers(headers): @@ -1079,10 +1183,11 @@ def parse_body(body, content_type, value_format_fn=None): return parsed -def custom_notify(title: str, content: str) -> None: +def custom_notify(title: str, content: str) -> Optional[bool]: """ 通过 自定义通知 推送消息。 """ + audit_results = [] if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"): return @@ -1114,9 +1219,12 @@ def custom_notify(title: str, content: str) -> None: ) if response.status_code == 200: + audit_results.append(True) print("自定义通知推送成功!") else: + audit_results.append(False) print(f"自定义通知推送失败!{response.status_code} {response.text}") + return all(audit_results) if audit_results else None def one() -> str: @@ -1204,6 +1312,33 @@ def add_notify_function(): return notify_function +def report_notification(channel, title, content, success): + ql_dir = os.getenv("QL_DIR") + if not ql_dir: + return + payload = {"title": str(title or "")[:512], "content": str(content or "")[:8192], + "success": success, "notifyType": channel, + "scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")} + code = """const fs=require('node:fs'),path=require('node:path'); +const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js')); +report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});""" + try: + subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True, + capture_output=True, timeout=3) + except Exception: + pass # Audit availability never changes notification delivery. + + +def audited_notification(mode, title, content): + result = None + try: + result = mode(title, content) + except Exception: + result = False + print(f"{mode.__name__} 推送失败") + report_notification(mode.__name__, title, content, result is True) + + def send(title: str, content: str, ignore_default_config: bool = False, **kwargs): if kwargs: global push_config @@ -1228,7 +1363,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs notify_function = add_notify_function() ts = [ - threading.Thread(target=mode, args=(title, content), name=mode.__name__) + threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__) for mode in notify_function ] [t.start() for t in ts] diff --git a/shell/api.sh b/shell/api.sh index e4b1afe8..ecacf56a 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -85,9 +85,13 @@ add_cron_api() { sub_id="null" 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 \ "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) code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) @@ -112,10 +116,14 @@ update_cron_api() { local id="$4" 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 \ "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ -X PUT \ - --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}") + --data-raw "$payload") code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then @@ -135,10 +143,13 @@ update_cron_command_api() { local id="$2" fi + local payload + payload=$(jq -cn --arg command "$command" --arg id "$id" \ + '{command: $command, id: $id}') || return $? local api=$(ql_api_request \ "http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \ -X PUT \ - --data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}") + --data-raw "$payload") code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then diff --git a/shell/preload/client.js b/shell/preload/client.js index 7b0420f8..86f4720d 100644 --- a/shell/preload/client.js +++ b/shell/preload/client.js @@ -47,6 +47,7 @@ class GrpcClient { 'updateEnvNames', 'getEnvById', 'systemNotify', + 'recordNotifyLog', 'getCronDetail', 'createCron', 'updateCron', diff --git a/shell/preload/notificationAudit.js b/shell/preload/notificationAudit.js new file mode 100644 index 00000000..0afddc1b --- /dev/null +++ b/shell/preload/notificationAudit.js @@ -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(); + } +}; diff --git a/shell/update.sh b/shell/update.sh index db8f3eb5..2008cc9a 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -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) [[ -z $cron_name ]] && cron_name=$(grep "name:" $file | awk -F ":" '{print $2}' | head -1 | xargs) [[ -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" if [[ $detail ]]; then 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 "cron \"" '{print $2}' | awk -F "\" " '{print $1}' | head -1 | xargs) [[ -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" notify_api "新增任务通知" "\n$result" # update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id" diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 3f8be3c8..862561c3 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -702,5 +702,8 @@ "通知日志": "Notification log", "发送时间": "Sent at", "推送渠道": "Channel", - "发送状态": "Delivery status" + "发送状态": "Delivery status", + "通知来源": "Notification source", + "脚本通知": "Script notification", + "系统通知": "System notification" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index df66a014..e97138fa 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -702,5 +702,8 @@ "通知日志": "通知日志", "发送时间": "发送时间", "推送渠道": "推送渠道", - "发送状态": "发送状态" + "发送状态": "发送状态", + "通知来源": "通知来源", + "脚本通知": "脚本通知", + "系统通知": "系统通知" } diff --git a/src/pages/setting/notifyLog.tsx b/src/pages/setting/notifyLog.tsx index 7af6befb..491fe2ba 100644 --- a/src/pages/setting/notifyLog.tsx +++ b/src/pages/setting/notifyLog.tsx @@ -10,6 +10,9 @@ interface NotifyLogItem { content?: string; status?: number; notifyType?: string; + source?: 'system' | 'script'; + scriptName?: string; + taskId?: string; } const NotifyStatusLabel: Record = { @@ -23,6 +26,18 @@ const NotifyStatusColor: Record = { }; const columns = [ + { + title: intl.get('通知来源'), + key: 'source', + width: 220, + render: (_: unknown, record: NotifyLogItem) => ( + + {intl.get(record.source === 'script' ? '脚本通知' : '系统通知')} + {record.scriptName ? ` · ${record.scriptName}` : ''} + {record.taskId ? ` (#${record.taskId})` : ''} + + ), + }, { title: intl.get('序号'), width: 50, @@ -68,10 +83,7 @@ const columns = [ render: (text: string, record: NotifyLogItem) => { const statusKey = record.status ?? 1; return ( - + {intl.get(NotifyStatusLabel[statusKey])} ); diff --git a/test/back/api-fallback.test.cjs b/test/back/api-fallback.test.cjs new file mode 100644 index 00000000..d0e4888a --- /dev/null +++ b/test/back/api-fallback.test.cjs @@ -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'); +}); diff --git a/test/back/issue-regressions-2230.test.cjs b/test/back/issue-regressions-2230.test.cjs new file mode 100644 index 00000000..7233b625 --- /dev/null +++ b/test/back/issue-regressions-2230.test.cjs @@ -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')), + ); + } +}); diff --git a/test/back/linux-mirror-result.test.cjs b/test/back/linux-mirror-result.test.cjs index 4945fb7d..8964d2a4 100644 --- a/test/back/linux-mirror-result.test.cjs +++ b/test/back/linux-mirror-result.test.cjs @@ -129,7 +129,7 @@ Acquire::http::Timeout "1"; 'fs/promises': { readFile: async (file) => file === '/etc/os-release' - ? distribution + ? `${distribution}\nVERSION_CODENAME=bookworm\n` : existingMirror ? 'URIs: http://old.example/debian\n' : '', diff --git a/test/back/notification-audit.test.cjs b/test/back/notification-audit.test.cjs index d7ec173d..d88cdae1 100644 --- a/test/back/notification-audit.test.cjs +++ b/test/back/notification-audit.test.cjs @@ -38,8 +38,22 @@ async function fixture(t) { }).default; const service = Object.assign(Object.create(S.prototype), { 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' } }); 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) => { const { service } = await fixture(t); - service.notificationService.notify = async () => false; + service.notificationService.modeMap.set('gotify', async () => false); assert.equal( ( 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.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); +}); diff --git a/test/back/script-notification-audit.test.cjs b/test/back/script-notification-audit.test.cjs new file mode 100644 index 00000000..eb9a15f6 --- /dev/null +++ b/test/back/script-notification-audit.test.cjs @@ -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); +}); diff --git a/test/back/script-notification-rpc.test.cjs b/test/back/script-notification-rpc.test.cjs new file mode 100644 index 00000000..5f06c7f2 --- /dev/null +++ b/test/back/script-notification-rpc.test.cjs @@ -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); +});