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