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

This commit is contained in:
whyour
2026-10-13 02:20:59 +08:00
30 changed files with 2038 additions and 335 deletions
+62 -65
View File
@@ -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<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
mirror: string,
): Promise<string> {
const defaults: Record<string, string> = {
Debian: 'http://deb.debian.org/debian',
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
};
if (!defaults[osType])
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = isInContainer() ? 'sudo ' : '';
// 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<string> {
+3
View File
@@ -63,6 +63,9 @@ export interface NotifyLogInfo {
content?: string;
status?: NotifyStatus;
notifyType?: string;
source?: 'system' | 'script';
scriptName?: string;
taskId?: string;
}
export interface TokenInfo {
+8 -1
View File
@@ -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) {
+10
View File
@@ -260,6 +260,15 @@ message SystemNotifyRequest {
optional NotificationInfo notificationInfo = 3;
}
message RecordNotifyLogRequest {
string title = 1;
string content = 2;
bool success = 3;
string notifyType = 4;
string scriptName = 5;
string taskId = 6;
}
service Api {
rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {}
rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {}
@@ -271,6 +280,7 @@ service Api {
rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {}
rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {}
rpc SystemNotify(SystemNotifyRequest) returns (Response) {}
rpc RecordNotifyLog(RecordNotifyLogRequest) returns (Response) {}
rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {}
rpc CreateCron(CreateCronRequest) returns (CronResponse) {}
rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {}
+175 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.17.3
// protoc v6.31.1
// source: back/protos/api.proto
/* eslint-disable */
@@ -417,6 +417,15 @@ export interface SystemNotifyRequest {
notificationInfo?: NotificationInfo | undefined;
}
export interface RecordNotifyLogRequest {
title: string;
content: string;
success: boolean;
notifyType: string;
scriptName: string;
taskId: string;
}
function createBaseEnvItem(): EnvItem {
return {
id: undefined,
@@ -4296,6 +4305,146 @@ export const SystemNotifyRequest: MessageFns<SystemNotifyRequest> = {
},
};
function createBaseRecordNotifyLogRequest(): RecordNotifyLogRequest {
return { title: "", content: "", success: false, notifyType: "", scriptName: "", taskId: "" };
}
export const RecordNotifyLogRequest: MessageFns<RecordNotifyLogRequest> = {
encode(message: RecordNotifyLogRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.title !== "") {
writer.uint32(10).string(message.title);
}
if (message.content !== "") {
writer.uint32(18).string(message.content);
}
if (message.success !== false) {
writer.uint32(24).bool(message.success);
}
if (message.notifyType !== "") {
writer.uint32(34).string(message.notifyType);
}
if (message.scriptName !== "") {
writer.uint32(42).string(message.scriptName);
}
if (message.taskId !== "") {
writer.uint32(50).string(message.taskId);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RecordNotifyLogRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRecordNotifyLogRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.title = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.content = reader.string();
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.success = reader.bool();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.notifyType = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.scriptName = reader.string();
continue;
}
case 6: {
if (tag !== 50) {
break;
}
message.taskId = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RecordNotifyLogRequest {
return {
title: isSet(object.title) ? globalThis.String(object.title) : "",
content: isSet(object.content) ? globalThis.String(object.content) : "",
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
notifyType: isSet(object.notifyType) ? globalThis.String(object.notifyType) : "",
scriptName: isSet(object.scriptName) ? globalThis.String(object.scriptName) : "",
taskId: isSet(object.taskId) ? globalThis.String(object.taskId) : "",
};
},
toJSON(message: RecordNotifyLogRequest): unknown {
const obj: any = {};
if (message.title !== "") {
obj.title = message.title;
}
if (message.content !== "") {
obj.content = message.content;
}
if (message.success !== false) {
obj.success = message.success;
}
if (message.notifyType !== "") {
obj.notifyType = message.notifyType;
}
if (message.scriptName !== "") {
obj.scriptName = message.scriptName;
}
if (message.taskId !== "") {
obj.taskId = message.taskId;
}
return obj;
},
create<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(base?: I): RecordNotifyLogRequest {
return RecordNotifyLogRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RecordNotifyLogRequest>, I>>(object: I): RecordNotifyLogRequest {
const message = createBaseRecordNotifyLogRequest();
message.title = object.title ?? "";
message.content = object.content ?? "";
message.success = object.success ?? false;
message.notifyType = object.notifyType ?? "";
message.scriptName = object.scriptName ?? "";
message.taskId = object.taskId ?? "";
return message;
},
};
export type ApiService = typeof ApiService;
export const ApiService = {
getEnvs: {
@@ -4388,6 +4537,15 @@ export const ApiService = {
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
recordNotifyLog: {
path: "/com.ql.api.Api/RecordNotifyLog",
requestStream: false,
responseStream: false,
requestSerialize: (value: RecordNotifyLogRequest) => Buffer.from(RecordNotifyLogRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => RecordNotifyLogRequest.decode(value),
responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()),
responseDeserialize: (value: Buffer) => Response.decode(value),
},
getCronDetail: {
path: "/com.ql.api.Api/GetCronDetail",
requestStream: false,
@@ -4482,6 +4640,7 @@ export interface ApiServer extends UntypedServiceImplementation {
updateEnvNames: handleUnaryCall<UpdateEnvNamesRequest, Response>;
getEnvById: handleUnaryCall<GetEnvByIdRequest, EnvResponse>;
systemNotify: handleUnaryCall<SystemNotifyRequest, Response>;
recordNotifyLog: handleUnaryCall<RecordNotifyLogRequest, Response>;
getCronDetail: handleUnaryCall<CronDetailRequest, CronDetailResponse>;
createCron: handleUnaryCall<CreateCronRequest, CronResponse>;
updateCron: handleUnaryCall<UpdateCronRequest, CronResponse>;
@@ -4644,6 +4803,21 @@ export interface ApiClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
recordNotifyLog(
request: RecordNotifyLogRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Response) => void,
): ClientUnaryCall;
getCronDetail(
request: CronDetailRequest,
callback: (error: ServiceError | null, response: CronDetailResponse) => void,
+25 -2
View File
@@ -16,6 +16,7 @@ import {
MoveEnvRequest,
Response,
SystemNotifyRequest,
RecordNotifyLogRequest,
UpdateEnvNamesRequest,
UpdateEnvRequest,
} from '../protos/api';
@@ -238,7 +239,8 @@ export const systemNotify = async (
const data = await systemService.notify({
title: call.request.title,
content: call.request.content,
notificationInfo: call.request.notificationInfo as unknown as NotificationInfo,
notificationInfo: call.request
.notificationInfo as unknown as NotificationInfo,
});
callback(null, data);
} catch (e: any) {
@@ -246,10 +248,31 @@ export const systemNotify = async (
}
};
export const recordNotifyLog = async (
call: ServerUnaryCall<RecordNotifyLogRequest, Response>,
callback: sendUnaryData<Response>,
) => {
try {
await Container.get(SystemService).recordNotifyLog({
title: call.request.title,
content: call.request.content,
status: call.request.success ? 0 : 1,
notifyType: call.request.notifyType,
source: 'script',
scriptName: call.request.scriptName,
taskId: call.request.taskId,
});
callback(null, { code: 200 });
} catch (error: any) {
callback(error);
}
};
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters.
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
const cron =
data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
return {
...cron,
labels: cron.labels ?? [],
+39 -23
View File
@@ -1,3 +1,4 @@
import { persistNotificationAudit } from '../shared/notificationAudit';
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import { Inject, Service } from 'typedi';
@@ -55,27 +56,33 @@ export default class NotificationService {
content: string,
notificationInfo?: NotificationInfo,
): Promise<boolean | undefined> {
let { type, ...rest } = await this.userService.getNotificationMode();
if (notificationInfo?.type) {
type = notificationInfo?.type;
}
if (type) {
this.title = title;
this.content = content;
let params = rest;
if (notificationInfo) {
const { type: _, ...others } = notificationInfo;
params = { ...rest, ...others };
}
this.params = params;
const notificationModeAction = this.modeMap.get(type);
try {
return await notificationModeAction?.call(this);
} catch (error: any) {
console.error(error);
let type: string | undefined;
let success = false;
try {
const saved = await this.userService.getNotificationMode();
type = notificationInfo?.type || saved.type;
if (type) {
this.title = title;
this.content = content;
const { type: _, ...overrides } = notificationInfo || {};
const { type: _saved, ...rest } = saved;
this.params = { ...rest, ...overrides };
try {
success = !!(await this.modeMap.get(type)?.call(this));
} catch (error) {
console.error(error);
}
}
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
}
return false;
}
public async testNotify(
@@ -84,14 +91,23 @@ export default class NotificationService {
content: string,
) {
const { type, ...rest } = info;
if (type) {
if (!type) return true;
let success = false;
try {
this.title = title;
this.content = content;
this.params = rest;
const notificationModeAction = this.modeMap.get(type);
return await notificationModeAction?.call(this);
success = !!(await this.modeMap.get(type)?.call(this));
return success;
} finally {
await persistNotificationAudit({
title,
content,
notifyType: type,
source: 'system',
status: success ? 0 : 1,
});
}
return true;
}
private parseMailRecipients(value?: string) {
+5 -43
View File
@@ -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<void> {
await persistNotificationAudit(info);
}
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
const docs = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
+121
View File
@@ -0,0 +1,121 @@
import { shellQuote } from './shellQuote';
const repositoryNames =
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
export function normalizeMirror(mirror: string): URL {
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
const url = new URL(mirror);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error(
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
);
}
return url;
}
export function replaceMirrorUrl(current: string, mirror: string): string {
const target = normalizeMirror(mirror);
const previous = new URL(current);
const oldSegments = previous.pathname.split('/').filter(Boolean);
const newSegments = target.pathname.split('/').filter(Boolean);
const repositoryIndex = oldSegments.findIndex((part) =>
repositoryNames.test(part),
);
const targetIndex = newSegments.findIndex((part) =>
repositoryNames.test(part),
);
const prefix =
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
if (repositoryIndex >= 0) {
const repository = oldSegments[repositoryIndex];
let suffix = oldSegments.slice(repositoryIndex + 1);
// Repair paths already corrupted by repeated authority-only replacement.
while (suffix[0] === repository) suffix.shift();
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
}
// Unknown repository paths: a full URL replaces the old root, a hostname
// preserves it. Never append the newly entered root to the existing root.
return `${target.origin}/${(newSegments.length
? newSegments
: oldSegments
).join('/')}`;
}
export function rewriteMirrorSources(content: string, mirror: string): string {
normalizeMirror(mirror);
return content
.split(/(\r?\n[ \t]*\r?\n)/)
.map((stanza) => {
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
return stanza
.split('\n')
.map((line) => {
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
return line;
const legacy = /^\s*deb(?:-src)?\s/.test(line);
const key = legacy
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
: signedBy;
// In .list files a # starts an inline comment; its URLs are metadata.
const comment = legacy ? line.indexOf('#') : -1;
const source = comment < 0 ? line : line.slice(0, comment);
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
);
return rewritten + (comment < 0 ? '' : line.slice(comment));
})
.join('\n');
})
.join('');
}
function isSystemRepository(url: string, signedBy?: string): boolean {
const keys =
signedBy
?.trim()
.split(/[\s,]+/)
.filter(Boolean) || [];
const archiveKeyring =
keys.length > 0 &&
keys.every((key) =>
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
);
// A separate signing key identifies a third-party archive, even if its
// repository happens to be called /debian or /ubuntu.
if (keys.length > 0 && !archiveKeyring) return false;
const segments = new URL(url).pathname.split('/').filter(Boolean);
const repositoryIndex = segments.findIndex((part) =>
repositoryNames.test(part),
);
if (repositoryIndex < 0) return false;
// Without archive metadata, accept only conventional archive roots. A
// vendor path such as Docker's /linux/debian is not the Debian archive.
return (
archiveKeyring ||
repositoryIndex === 0 ||
segments
.slice(0, repositoryIndex)
.every((part) => /^mirrors?$/.test(part)) ||
segments[repositoryIndex] === 'alpine'
);
}
export function mirrorWriteCommand(
file: string,
content: string,
prefix: string,
update: string,
): string {
return `${prefix}mkdir -p ${shellQuote(
file.slice(0, file.lastIndexOf('/')),
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
file,
)} >/dev/null && ${update}`;
}
+48
View File
@@ -0,0 +1,48 @@
import { Op } from 'sequelize';
import {
SystemModel,
AuthDataType,
NotifyStatus,
NotifyLogInfo,
} from '../data/system';
// Both backend sends and script reports use the same bounded, best-effort sink.
export async function persistNotificationAudit(
info: NotifyLogInfo,
): Promise<void> {
try {
await SystemModel.create({
type: AuthDataType.notifyLog,
info: {
timestamp: Date.now(),
title: String(info.title || '').slice(0, 512),
content: String(info.content || '').slice(0, 8192),
status:
info.status === NotifyStatus.success
? NotifyStatus.success
: NotifyStatus.fail,
notifyType: String(info.notifyType || '').slice(0, 64),
source: info.source === 'script' ? 'script' : 'system',
scriptName: String(info.scriptName || '').slice(0, 512),
taskId: /^\d+$/.test(info.taskId || '') ? info.taskId : undefined,
},
});
const oldest = await SystemModel.findAll({
where: { type: AuthDataType.notifyLog },
attributes: ['id'],
order: [['id', 'DESC']],
offset: 200,
limit: 1,
});
if (oldest[0]?.id) {
await SystemModel.destroy({
where: {
type: AuthDataType.notifyLog,
id: { [Op.lte]: oldest[0].id },
},
});
}
} catch (error) {
console.error('Failed to persist notification audit log');
}
}