mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 17:32:56 +08:00
Compare commits
17
Commits
@@ -21,6 +21,3 @@ JWT_EXPIRES_IN=
|
||||
QINIU_AK=
|
||||
QINIU_SK=
|
||||
QINIU_SCOPE=
|
||||
|
||||
# Python 虚拟环境路径,留空则使用系统 Python
|
||||
# PYTHON_VENV_DIR=./.venv
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
run: |
|
||||
docker run --rm --network none --entrypoint node \
|
||||
qinglong-readiness:${{ matrix.image }} \
|
||||
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.3.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||
- name: Export native Debian image for isolated dev validation
|
||||
if: matrix.image == 'debian'
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Assign existing resources to accounts
|
||||
|
||||
The panel adds ownership columns automatically while preserving existing data. Unassigned historical data is visible only to administrators. Ordinary users can read their assigned environment variables, task metadata and subscription metadata; dependencies and execution remain administrative operations.
|
||||
|
||||
Prefer the authenticated administrator endpoint `PUT /api/user-management/:id/resources` with a body such as `{"resource":"envs","ids":[1,2]}`. Supported resources are `envs`, `crons` and `subscriptions`. Assignment does not run tasks or move files.
|
||||
|
||||
The optional local migration helper assigns every unassigned row to one existing account. Stop the panel and back up `data/db/database.sqlite` before using it; set `QL_DIR` or `QL_DATA_DIR` when the data directory differs from the repository default. The helper refuses to create a new empty database.
|
||||
|
||||
```bash
|
||||
node migrate-to-multiuser.js --list-users
|
||||
node migrate-to-multiuser.js --username=alice --dry-run
|
||||
node migrate-to-multiuser.js --username=alice
|
||||
```
|
||||
|
||||
Already assigned rows are preserved and writes run in a transaction. Dependencies may receive an owner for bookkeeping, but remain inaccessible to ordinary users. To allocate only selected rows, use the administrator resource endpoint instead of the bulk helper.
|
||||
|
||||
See [the permission contract](docs/development/multiuser-permissions.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Multi-user accounts
|
||||
|
||||
Administrators can create, disable and delete accounts in Settings → User Management. New administrator accounts have full access to the shared instance. Ordinary accounts can only read their assigned environment variables, task metadata and subscription metadata. They cannot execute tasks, read shared files or logs, install dependencies, or modify system settings.
|
||||
|
||||
Existing data with no owner remains visible only to administrators. The original administrator and its password migration, MFA and sessions are preserved. Accounts from older drafts that used bcrypt require an administrator password reset.
|
||||
|
||||
See [the permission contract](docs/development/multiuser-permissions.md) and [resource migration](MIGRATION_GUIDE.md). This draft does not provide isolated execution for untrusted users.
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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 中重新安装。
|
||||
@@ -12,6 +12,7 @@ import subscription from './subscription';
|
||||
import update from './update';
|
||||
import dashboard from './dashboard';
|
||||
import health from './health';
|
||||
import userManagement from './userManagement';
|
||||
import clientIp from './clientIp';
|
||||
import retention from './retention';
|
||||
|
||||
@@ -30,6 +31,7 @@ export default () => {
|
||||
update(app);
|
||||
dashboard(app);
|
||||
health(app);
|
||||
userManagement(app);
|
||||
clientIp(app);
|
||||
retention(app);
|
||||
|
||||
|
||||
@@ -383,19 +383,6 @@ 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) => {
|
||||
|
||||
+39
-1
@@ -76,7 +76,17 @@ export default (app: Router) => {
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
const token = getToken(req);
|
||||
await userService.logout(req.platform, token);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
await Container.get(UserManagementService).revokeToken(
|
||||
req.user.userId,
|
||||
token,
|
||||
);
|
||||
} else {
|
||||
await userService.logout(req.platform, token);
|
||||
}
|
||||
res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
@@ -98,6 +108,16 @@ export default (app: Router) => {
|
||||
return res.send({ code: 450, message: t('未知错误') });
|
||||
}
|
||||
const userService = Container.get(UserService);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
await Container.get(UserManagementService).update({
|
||||
id: req.user.userId,
|
||||
...req.body,
|
||||
});
|
||||
return res.send({ code: 200 });
|
||||
}
|
||||
const result = await userService.updateUsernameAndPassword(req.body);
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
@@ -110,10 +130,28 @@ export default (app: Router) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
const userService = Container.get(UserService);
|
||||
if (req.user?.userId) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
const account = await Container.get(UserManagementService).get(
|
||||
req.user.userId,
|
||||
);
|
||||
return res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
username: account.username,
|
||||
role: account.role,
|
||||
id: account.id,
|
||||
readOnly: account.role === 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
const authInfo = await userService.getAuthInfo();
|
||||
res.send({
|
||||
code: 200,
|
||||
data: {
|
||||
role: 0,
|
||||
username: authInfo.username,
|
||||
avatar: authInfo.avatar,
|
||||
twoFactorActivated: authInfo.twoFactorActivated,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import UserManagementService from '../services/userManagement';
|
||||
import { UserRole, UserStatus } from '../data/user';
|
||||
import { EnvModel } from '../data/env';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
|
||||
const route = Router();
|
||||
|
||||
// Middleware to check if user is admin
|
||||
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.user && req.user.role === UserRole.admin) {
|
||||
return next();
|
||||
}
|
||||
return res.status(403).send({ code: 403, message: '需要管理员权限' });
|
||||
};
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/user-management', route);
|
||||
|
||||
// List all users (admin only)
|
||||
route.get(
|
||||
'/',
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const data = await userManagementService.list(
|
||||
req.query.searchValue as string,
|
||||
);
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.put(
|
||||
'/:id/resources',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
params: Joi.object({ id: Joi.number().integer().positive().required() }),
|
||||
body: Joi.object({
|
||||
resource: Joi.string()
|
||||
.valid('envs', 'crons', 'subscriptions')
|
||||
.required(),
|
||||
ids: Joi.array()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.unique()
|
||||
.items(Joi.number().integer().positive())
|
||||
.required(),
|
||||
}),
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
await Container.get(UserManagementService).get(id);
|
||||
const models: Record<string, any> = {
|
||||
envs: EnvModel,
|
||||
crons: CrontabModel,
|
||||
subscriptions: SubscriptionModel,
|
||||
};
|
||||
const [count] = await models[req.body.resource].update(
|
||||
{ userId: id },
|
||||
{ where: { id: req.body.ids } },
|
||||
);
|
||||
return res.json({ code: 200, data: { assigned: count } });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get a specific user (admin only)
|
||||
route.get(
|
||||
'/:id',
|
||||
requireAdmin,
|
||||
celebrate({ params: Joi.object({ id: Joi.number().integer().positive().required() }) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const data = await userManagementService.get(Number(req.params.id));
|
||||
res.send({ code: 200, data });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new user (admin only)
|
||||
route.post(
|
||||
'/',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
username: Joi.string().trim().max(255).required(),
|
||||
password: Joi.string().min(6).max(1024).required(),
|
||||
role: Joi.number()
|
||||
.valid(UserRole.admin, UserRole.user)
|
||||
.default(UserRole.user),
|
||||
status: Joi.number()
|
||||
.valid(UserStatus.active, UserStatus.disabled)
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const data = await userManagementService.create(req.body);
|
||||
res.send({ code: 200, data, message: '创建用户成功' });
|
||||
} catch (e: any) {
|
||||
return res.send({ code: 400, message: e.message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update a user (admin only)
|
||||
route.put(
|
||||
'/',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
id: Joi.number().integer().positive().required(),
|
||||
username: Joi.string().trim().max(255).required(),
|
||||
password: Joi.string().min(6).max(1024).optional().allow(''),
|
||||
role: Joi.number().valid(UserRole.admin, UserRole.user),
|
||||
status: Joi.number()
|
||||
.valid(UserStatus.active, UserStatus.disabled)
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const data = await userManagementService.update(req.body);
|
||||
res.send({ code: 200, data, message: '更新用户成功' });
|
||||
} catch (e: any) {
|
||||
return res.send({ code: 400, message: e.message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete users (admin only)
|
||||
route.delete(
|
||||
'/',
|
||||
requireAdmin,
|
||||
celebrate({
|
||||
body: Joi.array()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.unique()
|
||||
.items(Joi.number().integer().positive())
|
||||
.required(),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const userManagementService = Container.get(UserManagementService);
|
||||
const count = await userManagementService.delete(req.body);
|
||||
res.send({ code: 200, data: count, message: '删除用户成功' });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,6 @@ 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',
|
||||
|
||||
+102
-223
@@ -1,25 +1,18 @@
|
||||
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, PYTHON_VENV_DIR, TASK_COMMAND } from './const';
|
||||
import { PYTHON_INSTALL_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';
|
||||
|
||||
export * from './share';
|
||||
|
||||
@@ -677,9 +670,7 @@ 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;
|
||||
@@ -689,177 +680,61 @@ 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 "${trimmedName}" | head -1`,
|
||||
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
|
||||
`from importlib.metadata import version, PackageNotFoundError
|
||||
import importlib.util
|
||||
import sys
|
||||
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
|
||||
[DependenceTypes.python3]: `
|
||||
python3 -c "exec('''
|
||||
name='${name}'
|
||||
try:
|
||||
try:
|
||||
from packaging.requirements import Requirement
|
||||
except ImportError:
|
||||
from pip._vendor.packaging.requirements import Requirement
|
||||
requirement = Requirement(sys.argv[1])
|
||||
if requirement.marker and not requirement.marker.evaluate():
|
||||
print('not applicable')
|
||||
else:
|
||||
try:
|
||||
installed = version(requirement.name)
|
||||
except PackageNotFoundError:
|
||||
if not requirement.specifier and not requirement.extras and not requirement.url:
|
||||
if importlib.util.find_spec(requirement.name):
|
||||
print(requirement.name)
|
||||
else:
|
||||
if requirement.specifier.contains(installed, prereleases=True):
|
||||
print(installed)
|
||||
except Exception:
|
||||
pass`,
|
||||
)} ${shellQuote(trimmedName)}`,
|
||||
[DependenceTypes.linux]:
|
||||
getOsTypeSync() === 'Alpine'
|
||||
? `apk info -es ${trimmedName}`
|
||||
: maybeSudo(`dpkg-query -s ${trimmedName}`),
|
||||
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}`),
|
||||
};
|
||||
|
||||
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.linux]:
|
||||
getOsTypeSync() === 'Alpine'
|
||||
? 'apk add --no-check-certificate'
|
||||
: maybeSudo('apt-get install -y'),
|
||||
[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'),
|
||||
};
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
|
||||
let command = baseCommands[type];
|
||||
|
||||
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
|
||||
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
|
||||
}
|
||||
|
||||
return `${command} ${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.linux]:
|
||||
getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
|
||||
[DependenceTypes.python3]:
|
||||
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
|
||||
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
|
||||
? 'apk del'
|
||||
: maybeSudo('apt-get remove -y'),
|
||||
};
|
||||
|
||||
return `${baseCommands[type]} ${name.trim()}`;
|
||||
}
|
||||
|
||||
@@ -931,73 +806,77 @@ export async function detectOS(): Promise<
|
||||
return osType;
|
||||
}
|
||||
|
||||
async function getCurrentMirrorDomain(
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||
const lines = fileContent.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/https?:\/\/[^\/]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function replaceDomainInFile(
|
||||
filePath: string,
|
||||
oldDomainWithScheme: string,
|
||||
newDomainWithScheme: string,
|
||||
): Promise<void> {
|
||||
let fileContent = await fs.readFile(filePath, 'utf8');
|
||||
let updatedContent = fileContent.replace(
|
||||
new RegExp(oldDomainWithScheme, 'g'),
|
||||
newDomainWithScheme,
|
||||
);
|
||||
|
||||
if (!newDomainWithScheme.endsWith('/')) {
|
||||
newDomainWithScheme += '/';
|
||||
}
|
||||
|
||||
await writeFileWithLock(filePath, updatedContent);
|
||||
}
|
||||
|
||||
async function _updateLinuxMirror(
|
||||
osType: string,
|
||||
mirror: string,
|
||||
mirrorDomainWithScheme: string,
|
||||
): Promise<string> {
|
||||
const defaults: Record<string, string> = {
|
||||
Debian: 'http://deb.debian.org/debian',
|
||||
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
|
||||
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
|
||||
};
|
||||
if (!defaults[osType])
|
||||
throw new Error('Unsupported OS type for updating mirrors.');
|
||||
const target = mirror || defaults[osType];
|
||||
normalizeMirror(target);
|
||||
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
|
||||
const update =
|
||||
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
|
||||
let file =
|
||||
osType === 'Alpine'
|
||||
? '/etc/apk/repositories'
|
||||
: `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(file, 'utf8');
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
if (osType !== 'Alpine') {
|
||||
try {
|
||||
file = '/etc/apt/sources.list';
|
||||
content = await fs.readFile(file, 'utf8');
|
||||
} catch (legacyError: any) {
|
||||
if (legacyError.code !== 'ENOENT') throw legacyError;
|
||||
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||
const S = isInContainer() ? 'sudo ' : '';
|
||||
// APT otherwise returns zero when index downloads fail with transient errors.
|
||||
const aptUpdate = `${S}apt-get update --error-on=any`;
|
||||
let filePath: string, currentDomainWithScheme: string | null;
|
||||
switch (osType) {
|
||||
case 'Debian':
|
||||
filePath = '/etc/apt/sources.list.d/debian.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
|
||||
}
|
||||
}
|
||||
case 'Ubuntu':
|
||||
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
|
||||
} else {
|
||||
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
|
||||
}
|
||||
case 'Alpine':
|
||||
filePath = '/etc/apk/repositories';
|
||||
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
|
||||
if (currentDomainWithScheme) {
|
||||
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
|
||||
} else {
|
||||
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
|
||||
}
|
||||
default:
|
||||
throw Error('Unsupported OS type for updating mirrors.');
|
||||
}
|
||||
if (!content.trim()) {
|
||||
const release = await fs.readFile('/etc/os-release', 'utf8');
|
||||
if (osType === 'Alpine') {
|
||||
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
|
||||
if (!version)
|
||||
throw new Error('Cannot determine Alpine repository version');
|
||||
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
|
||||
} else {
|
||||
// The fallback may have read an empty legacy .list. Deb822 stanzas must
|
||||
// be written to a .sources file, leaving the legacy file untouched.
|
||||
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
|
||||
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
|
||||
if (!codename)
|
||||
throw new Error('Cannot determine APT repository codename');
|
||||
const ubuntu = osType === 'Ubuntu';
|
||||
content = `Types: deb\nURIs: ${
|
||||
defaults[osType]
|
||||
}\nSuites: ${codename} ${codename}-updates${
|
||||
ubuntu ? ` ${codename}-backports` : ''
|
||||
}\nComponents: ${
|
||||
ubuntu ? 'main restricted universe multiverse' : 'main'
|
||||
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
|
||||
if (!ubuntu)
|
||||
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
|
||||
}
|
||||
}
|
||||
return mirrorWriteCommand(
|
||||
file,
|
||||
rewriteMirrorSources(content, target),
|
||||
S,
|
||||
update,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
|
||||
|
||||
@@ -25,6 +25,7 @@ export class Crontab {
|
||||
log_name?: string;
|
||||
allow_multiple_instances?: 1 | 0;
|
||||
work_dir?: string;
|
||||
userId?: number;
|
||||
|
||||
constructor(options: Crontab) {
|
||||
this.name = options.name;
|
||||
@@ -52,6 +53,7 @@ export class Crontab {
|
||||
this.log_name = options.log_name;
|
||||
this.allow_multiple_instances = options.allow_multiple_instances || 0;
|
||||
this.work_dir = options.work_dir;
|
||||
this.userId = options.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,4 +97,5 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
log_name: DataTypes.STRING,
|
||||
allow_multiple_instances: DataTypes.NUMBER,
|
||||
work_dir: DataTypes.STRING,
|
||||
userId: { type: DataTypes.INTEGER, allowNull: true },
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export class Dependence {
|
||||
name: string;
|
||||
log?: string[];
|
||||
remark?: string;
|
||||
userId?: number;
|
||||
|
||||
constructor(options: Dependence) {
|
||||
this.id = options.id;
|
||||
@@ -21,6 +22,7 @@ export class Dependence {
|
||||
this.name = options.name.trim();
|
||||
this.log = options.log || [];
|
||||
this.remark = options.remark || '';
|
||||
this.userId = options.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +61,6 @@ export const DependenceModel = sequelize.define<DependenceInstance>(
|
||||
status: DataTypes.NUMBER,
|
||||
log: DataTypes.JSON,
|
||||
remark: DataTypes.STRING,
|
||||
userId: { type: DataTypes.NUMBER, allowNull: true },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export class Env {
|
||||
position?: number;
|
||||
name?: string;
|
||||
remarks?: string;
|
||||
userId?: number;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
|
||||
@@ -23,6 +24,7 @@ export class Env {
|
||||
this.position = options.position;
|
||||
this.name = options.name;
|
||||
this.remarks = options.remarks || '';
|
||||
this.userId = options.userId;
|
||||
this.isPinned = options.isPinned || 0;
|
||||
this.labels = options.labels || [];
|
||||
}
|
||||
@@ -46,6 +48,7 @@ export const EnvModel = sequelize.define<EnvInstance>('Env', {
|
||||
position: DataTypes.NUMBER,
|
||||
name: { type: DataTypes.STRING, unique: 'compositeIndex' },
|
||||
remarks: DataTypes.STRING,
|
||||
userId: { type: DataTypes.NUMBER, allowNull: true },
|
||||
isPinned: DataTypes.NUMBER,
|
||||
labels: DataTypes.JSON,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export class Subscription {
|
||||
proxy?: string;
|
||||
autoAddCron?: 1 | 0;
|
||||
autoDelCron?: 1 | 0;
|
||||
userId?: number;
|
||||
|
||||
constructor(options: Subscription) {
|
||||
this.id = options.id;
|
||||
@@ -60,6 +61,7 @@ export class Subscription {
|
||||
this.proxy = options.proxy;
|
||||
this.autoAddCron = options.autoAddCron ? 1 : 0;
|
||||
this.autoDelCron = options.autoDelCron ? 1 : 0;
|
||||
this.userId = options.userId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,5 +113,6 @@ export const SubscriptionModel = sequelize.define<SubscriptionInstance>(
|
||||
proxy: { type: DataTypes.STRING, allowNull: true },
|
||||
autoAddCron: { type: DataTypes.NUMBER, allowNull: true },
|
||||
autoDelCron: { type: DataTypes.NUMBER, allowNull: true },
|
||||
userId: { type: DataTypes.NUMBER, allowNull: true },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -28,12 +28,8 @@ 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;
|
||||
@@ -57,17 +53,6 @@ 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;
|
||||
@@ -103,7 +88,6 @@ export interface AuthInfo {
|
||||
export type SystemModelInfo = SystemConfigInfo &
|
||||
Partial<NotificationInfo> &
|
||||
LoginLogInfo &
|
||||
Partial<NotifyLogInfo> &
|
||||
Partial<AuthInfo>;
|
||||
|
||||
export interface SystemInstance
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { sequelize } from '.';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
|
||||
export class User {
|
||||
id?: number;
|
||||
username: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
sessions?: { digest: string; platform: string }[];
|
||||
|
||||
constructor(options: User) {
|
||||
this.id = options.id;
|
||||
this.username = options.username;
|
||||
this.password = options.password;
|
||||
this.role = options.role ?? UserRole.user;
|
||||
this.status =
|
||||
typeof options.status === 'number' && UserStatus[options.status]
|
||||
? options.status
|
||||
: UserStatus.active;
|
||||
this.createdAt = options.createdAt;
|
||||
this.updatedAt = options.updatedAt;
|
||||
this.sessions = options.sessions || [];
|
||||
}
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
'admin' = 0,
|
||||
'user' = 1,
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
'active' = 0,
|
||||
'disabled' = 1,
|
||||
}
|
||||
|
||||
export interface UserInstance extends Model<User, User>, User {}
|
||||
export const UserModel = sequelize.define<UserInstance>('User', {
|
||||
sessions: { type: DataTypes.JSON, allowNull: false, defaultValue: [] },
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
unique: true,
|
||||
allowNull: false,
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: UserRole.user,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: UserStatus.active,
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { CrontabViewModel } from '../data/cronView';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import { RunningInstanceModel } from '../data/runningInstance';
|
||||
import { sequelize } from '../data';
|
||||
import { UserModel } from '../data/user';
|
||||
import { migrateUserSchema } from '../shared/userSchemaMigrations';
|
||||
import { migrateSchema } from '../shared/schemaMigrations';
|
||||
|
||||
export default async () => {
|
||||
@@ -23,7 +25,9 @@ export default async () => {
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
|
||||
await UserModel.sync();
|
||||
await migrateSchema(sequelize);
|
||||
await migrateUserSchema(sequelize);
|
||||
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
|
||||
+32
-9
@@ -13,6 +13,8 @@ import { isValidToken, isDefaultAuthInfo } from '../shared/auth';
|
||||
import { AuthInfo } from '../data/system';
|
||||
import path from 'path';
|
||||
import { t } from '../shared/i18n';
|
||||
import { Container } from 'typedi';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AppScope } from '../data/open';
|
||||
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
|
||||
import protectedPathCase from '../middlewares/protectedPathCase';
|
||||
@@ -144,6 +146,26 @@ export default ({ app }: { app: Application }) => {
|
||||
|
||||
const authInfo = await shareStore.getAuthInfo();
|
||||
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
|
||||
req.user = { role: 0 };
|
||||
return next();
|
||||
}
|
||||
const claims = jwt.decode(headerToken);
|
||||
let account = null;
|
||||
if (
|
||||
typeof claims === 'object' &&
|
||||
Number.isSafeInteger(claims?.userId) &&
|
||||
claims!.userId > 0
|
||||
) {
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
account = await Container.get(UserManagementService).validateToken(
|
||||
headerToken,
|
||||
req.platform,
|
||||
);
|
||||
}
|
||||
if (account) {
|
||||
req.user = { userId: account.id, role: account.role };
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -177,16 +199,17 @@ export default ({ app }: { app: Application }) => {
|
||||
});
|
||||
|
||||
app.use(rewrite('/open/*', '/api/$1'));
|
||||
app.use(config.api.prefix, routes());
|
||||
app.use(
|
||||
config.api.prefix,
|
||||
async (req, res, next) => {
|
||||
if (!req.user || req.user.role === 0) return next();
|
||||
const { userAccess } = await import('../middlewares/userAccess');
|
||||
return userAccess(req, res, next);
|
||||
},
|
||||
routes(),
|
||||
);
|
||||
|
||||
app.get('*', (req, res, next) => {
|
||||
// Don't serve index.html for API routes
|
||||
if (
|
||||
req.path === config.api.prefix ||
|
||||
req.path.startsWith(`${config.api.prefix}/`)
|
||||
) {
|
||||
return res.status(404).json({ code: 404, message: 'Not Found' });
|
||||
}
|
||||
app.get('*', (_, res, next) => {
|
||||
const indexPath = path.join(frontendPath, 'index.html');
|
||||
res.sendFile(indexPath, (err) => {
|
||||
if (err) {
|
||||
|
||||
+28
-2
@@ -6,6 +6,7 @@ import { getPlatform } from '../config/util';
|
||||
import { shareStore } from '../shared/store';
|
||||
import { isValidToken } from '../shared/auth';
|
||||
import config from '../config';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export default async ({ server }: { server: Server }) => {
|
||||
const echo = sockJs.createServer({
|
||||
@@ -17,6 +18,30 @@ export default async ({ server }: { server: Server }) => {
|
||||
let sessionTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let checking = false;
|
||||
|
||||
const validateSession = async (
|
||||
authInfo: any,
|
||||
token: string,
|
||||
platform: string,
|
||||
) => {
|
||||
if (isValidToken(authInfo, token, platform, config.jwt.secret)) return true;
|
||||
const claims = jwt.decode(token);
|
||||
if (
|
||||
!claims ||
|
||||
typeof claims === 'string' ||
|
||||
!Number.isSafeInteger(claims.userId) ||
|
||||
claims.userId <= 0
|
||||
)
|
||||
return false;
|
||||
const { default: UserManagementService } = await import(
|
||||
'../services/userManagement'
|
||||
);
|
||||
const account = await Container.get(UserManagementService).validateToken(
|
||||
token,
|
||||
platform,
|
||||
);
|
||||
return account?.role === 0;
|
||||
};
|
||||
|
||||
const checkSessions = async () => {
|
||||
if (checking || sessions.size === 0) return;
|
||||
checking = true;
|
||||
@@ -35,7 +60,7 @@ export default async ({ server }: { server: Server }) => {
|
||||
}
|
||||
let valid = platforms.get(platform);
|
||||
if (valid === undefined) {
|
||||
valid = isValidToken(current, token, platform, config.jwt.secret);
|
||||
valid = await validateSession(current, token, platform);
|
||||
platforms.set(platform, valid);
|
||||
}
|
||||
if (!valid) conn.close('401');
|
||||
@@ -77,7 +102,8 @@ export default async ({ server }: { server: Server }) => {
|
||||
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
|
||||
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
|
||||
|
||||
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
|
||||
if (await validateSession(authInfo, headerToken, platform)) {
|
||||
if (closed) return;
|
||||
sockService.addClient(conn);
|
||||
sessions.set(conn, { token: headerToken, platform });
|
||||
if (!sessionTimer) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Op } from 'sequelize';
|
||||
import { EnvModel } from '../data/env';
|
||||
import { CrontabModel } from '../data/cron';
|
||||
import { SubscriptionModel } from '../data/subscription';
|
||||
|
||||
// Ordinary users are read-only until task workers and shared files are isolated.
|
||||
export async function userAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
if (!req.user || req.user.role === 0) return next();
|
||||
const userId = req.user.userId;
|
||||
if (!Number.isSafeInteger(userId) || userId! <= 0)
|
||||
return res.status(403).json({ code: 403, message: '需要有效用户会话' });
|
||||
const pathname = req.path.replace(/\/$/, '') || '/';
|
||||
if (
|
||||
(req.method === 'GET' &&
|
||||
['/user', '/system', '/health'].includes(pathname)) ||
|
||||
(req.method === 'POST' && pathname === '/user/logout')
|
||||
)
|
||||
return next();
|
||||
if (req.method === 'GET' && pathname === '/crons/views')
|
||||
return res.json({ code: 200, data: [] });
|
||||
const match = pathname.match(
|
||||
/^\/(envs|crons|subscriptions)(?:\/([1-9][0-9]*))?$/,
|
||||
);
|
||||
if (req.method !== 'GET' || !match)
|
||||
return res
|
||||
.status(403)
|
||||
.json({
|
||||
code: 403,
|
||||
message: '普通用户只读自己的数据,执行和系统修改由管理员负责',
|
||||
});
|
||||
try {
|
||||
const models: Record<string, any> = {
|
||||
envs: EnvModel,
|
||||
crons: CrontabModel,
|
||||
subscriptions: SubscriptionModel,
|
||||
};
|
||||
const model = models[match[1]];
|
||||
const where: any = { userId };
|
||||
if (match[2]) {
|
||||
const id = Number(match[2]);
|
||||
if (!Number.isSafeInteger(id))
|
||||
return res.status(400).json({ code: 400, message: '无效 ID' });
|
||||
where.id = id;
|
||||
const doc = await model.findOne({ where });
|
||||
return doc
|
||||
? res.json({ code: 200, data: doc.get({ plain: true }) })
|
||||
: res.status(404).json({ code: 404, message: '数据不存在' });
|
||||
}
|
||||
const search =
|
||||
typeof req.query.searchValue === 'string'
|
||||
? req.query.searchValue.slice(0, 1024)
|
||||
: '';
|
||||
if (search) where.name = { [Op.like]: `%${search}%` };
|
||||
const docs = await model.findAll({ where, order: [['createdAt', 'DESC']] });
|
||||
const data = docs.map((doc: any) => doc.get({ plain: true }));
|
||||
return res.json({
|
||||
code: 200,
|
||||
data: match[1] === 'crons' ? { data, total: data.length } : data,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
@@ -260,15 +260,6 @@ 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) {}
|
||||
@@ -280,7 +271,6 @@ 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) {}
|
||||
|
||||
+1
-175
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v6.31.1
|
||||
// protoc v3.17.3
|
||||
// source: back/protos/api.proto
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -417,15 +417,6 @@ 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,
|
||||
@@ -4305,146 +4296,6 @@ 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: {
|
||||
@@ -4537,15 +4388,6 @@ 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,
|
||||
@@ -4640,7 +4482,6 @@ 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>;
|
||||
@@ -4803,21 +4644,6 @@ 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,
|
||||
|
||||
+2
-25
@@ -16,7 +16,6 @@ import {
|
||||
MoveEnvRequest,
|
||||
Response,
|
||||
SystemNotifyRequest,
|
||||
RecordNotifyLogRequest,
|
||||
UpdateEnvNamesRequest,
|
||||
UpdateEnvRequest,
|
||||
} from '../protos/api';
|
||||
@@ -239,8 +238,7 @@ 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) {
|
||||
@@ -248,31 +246,10 @@ 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 ?? [],
|
||||
|
||||
+24
-69
@@ -20,7 +20,6 @@ import {
|
||||
getInstallCommand,
|
||||
getUninstallCommand,
|
||||
getGetCommand,
|
||||
isPythonSourceDependency,
|
||||
} from '../config/util';
|
||||
import dayjs from 'dayjs';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
@@ -33,11 +32,10 @@ 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;
|
||||
});
|
||||
@@ -61,7 +59,6 @@ export default class DependenceService {
|
||||
...other,
|
||||
status: DependenceStatus.queued,
|
||||
});
|
||||
getInstallCommand(tab.type, tab.name);
|
||||
const newDoc = await this.updateDb(tab);
|
||||
this.installDependenceOneByOne([newDoc]);
|
||||
return newDoc;
|
||||
@@ -77,25 +74,21 @@ 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 ||
|
||||
// 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)),
|
||||
(x) => x.status === DependenceStatus.installed,
|
||||
);
|
||||
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: installedDeps.map((x) => x.id!) } },
|
||||
{ where: { id: ids } },
|
||||
);
|
||||
|
||||
this.installDependenceOneByOne(installedDeps, false, force);
|
||||
this.installDependenceOneByOne(docs, false, force);
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
@@ -161,15 +154,9 @@ export default class DependenceService {
|
||||
},
|
||||
});
|
||||
const checks = await concurrentRun(
|
||||
docs
|
||||
.filter(
|
||||
(doc) =>
|
||||
doc.type !== DependenceTypes.python3 ||
|
||||
!isPythonSourceDependency(doc.name),
|
||||
)
|
||||
.map((doc) => async () => {
|
||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||
}),
|
||||
docs.map((doc) => async () => {
|
||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||
}),
|
||||
5,
|
||||
);
|
||||
const missingIds = (checks || []).filter(
|
||||
@@ -188,10 +175,7 @@ export default class DependenceService {
|
||||
let depName = dependency.name.trim();
|
||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||
let depVersion = '';
|
||||
if (
|
||||
dependency.type !== DependenceTypes.python3 &&
|
||||
depName.includes(depVersionStr)
|
||||
) {
|
||||
if (depName.includes(depVersionStr)) {
|
||||
const symbolRegx = new RegExp(
|
||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||
);
|
||||
@@ -312,26 +296,6 @@ 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);
|
||||
@@ -381,7 +345,9 @@ export default class DependenceService {
|
||||
: DependenceStatus.removing;
|
||||
await DependenceModel.update({ status }, { where: { id: depIds } });
|
||||
|
||||
let command = validatedCommand;
|
||||
let command = isInstall
|
||||
? getInstallCommand(dependency.type, depName)
|
||||
: getUninstallCommand(dependency.type, depName);
|
||||
if (isLinuxDependence) {
|
||||
command = isInstall
|
||||
? `${linuxCommand.install} ${depName.trim()}`
|
||||
@@ -404,13 +370,7 @@ export default class DependenceService {
|
||||
this.updateLog(depIds, message);
|
||||
|
||||
// 判断是否已经安装过依赖
|
||||
// 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('['))
|
||||
) {
|
||||
if (isInstall && !force) {
|
||||
let getCommand = getGetCommand(dependency.type, depName);
|
||||
const depVersionStr = versionDependenceCommandTypes[dependency.type];
|
||||
if (isLinuxDependence) {
|
||||
@@ -418,7 +378,7 @@ export default class DependenceService {
|
||||
}
|
||||
|
||||
let depVersion = '';
|
||||
if (!isPythonDependence && depName.includes(depVersionStr)) {
|
||||
if (depName.includes(depVersionStr)) {
|
||||
const symbolRegx = new RegExp(
|
||||
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
|
||||
);
|
||||
@@ -510,20 +470,15 @@ 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,
|
||||
|
||||
+26
-42
@@ -1,4 +1,3 @@
|
||||
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||
import crypto from 'crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { Inject, Service } from 'typedi';
|
||||
@@ -56,33 +55,27 @@ export default class NotificationService {
|
||||
content: string,
|
||||
notificationInfo?: NotificationInfo,
|
||||
): Promise<boolean | undefined> {
|
||||
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,
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async testNotify(
|
||||
@@ -91,23 +84,14 @@ export default class NotificationService {
|
||||
content: string,
|
||||
) {
|
||||
const { type, ...rest } = info;
|
||||
if (!type) return true;
|
||||
let success = false;
|
||||
try {
|
||||
if (type) {
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.params = rest;
|
||||
success = !!(await this.modeMap.get(type)?.call(this));
|
||||
return success;
|
||||
} finally {
|
||||
await persistNotificationAudit({
|
||||
title,
|
||||
content,
|
||||
notifyType: type,
|
||||
source: 'system',
|
||||
status: success ? 0 : 1,
|
||||
});
|
||||
const notificationModeAction = this.modeMap.get(type);
|
||||
return await notificationModeAction?.call(this);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private parseMailRecipients(value?: string) {
|
||||
@@ -937,8 +921,7 @@ 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',
|
||||
@@ -991,4 +974,5 @@ export default class NotificationService {
|
||||
throw new Error(error.response ? error.response.body : error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
-58
@@ -1,4 +1,3 @@
|
||||
import { persistNotificationAudit } from '../shared/notificationAudit';
|
||||
import { spawn } from 'cross-spawn';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
@@ -33,8 +32,6 @@ import {
|
||||
SystemInstance,
|
||||
SystemModel,
|
||||
SystemModelInfo,
|
||||
NotifyStatus,
|
||||
NotifyLogInfo,
|
||||
} from '../data/system';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import cronClient from '../schedule/client';
|
||||
@@ -56,7 +53,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 });
|
||||
@@ -161,9 +158,7 @@ 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);
|
||||
@@ -298,7 +293,7 @@ export default class SystemService {
|
||||
);
|
||||
const text = await body.text();
|
||||
lastVersionContent = parseContentVersion(text);
|
||||
} catch (error) {}
|
||||
} catch (error) { }
|
||||
|
||||
if (!lastVersionContent) {
|
||||
lastVersionContent = currentVersionContent;
|
||||
@@ -412,13 +407,11 @@ 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 {
|
||||
@@ -426,36 +419,16 @@ export default class SystemService {
|
||||
}
|
||||
}
|
||||
|
||||
public async recordNotifyLog(info: NotifyLogInfo): Promise<void> {
|
||||
await persistNotificationAudit(info);
|
||||
}
|
||||
|
||||
public async getNotifyLog(): Promise<Array<NotifyLogInfo>> {
|
||||
const docs = await SystemModel.findAll({
|
||||
where: { type: AuthDataType.notifyLog },
|
||||
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,
|
||||
) {
|
||||
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 }) {
|
||||
@@ -486,20 +459,8 @@ 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('参数错误') });
|
||||
}
|
||||
@@ -509,13 +470,9 @@ 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 });
|
||||
|
||||
@@ -34,10 +34,13 @@ import {
|
||||
} from '../shared/password';
|
||||
import { serializeAuthMutation } from '../shared/authMutation';
|
||||
import { LoginThrottle } from '../shared/loginThrottle';
|
||||
import UserManagementService from './userManagement';
|
||||
|
||||
@Service()
|
||||
export default class UserService {
|
||||
private loginThrottle = new LoginThrottle();
|
||||
@Inject((type) => UserManagementService)
|
||||
private userManagementService!: UserManagementService;
|
||||
@Inject((type) => NotificationService)
|
||||
private notificationService!: NotificationService;
|
||||
|
||||
@@ -117,6 +120,24 @@ export default class UserService {
|
||||
};
|
||||
}
|
||||
|
||||
if (username !== cUsername) {
|
||||
const account = await this.userManagementService.authenticate(
|
||||
username,
|
||||
password,
|
||||
);
|
||||
if (account) {
|
||||
this.loginThrottle.reset(ip);
|
||||
const token = await this.userManagementService.issueToken(
|
||||
account,
|
||||
req.platform,
|
||||
);
|
||||
return {
|
||||
code: 200,
|
||||
data: { token, username: account.username, role: account.role },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMatches =
|
||||
username === cUsername && (await verifyPassword(password, cPassword));
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import { User, UserModel, UserStatus } from '../data/user';
|
||||
import { Op } from 'sequelize';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from '../config';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
isPasswordHash,
|
||||
} from '../shared/password';
|
||||
import { serializeAuthMutation } from '../shared/authMutation';
|
||||
import { shareStore } from '../shared/store';
|
||||
|
||||
type PublicUser = Omit<User, 'password' | 'sessions'>;
|
||||
function publicUser(user: User): PublicUser {
|
||||
const { password, sessions, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
function digest(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
@Service()
|
||||
export default class UserManagementService {
|
||||
constructor(@Inject('logger') private logger: winston.Logger) {}
|
||||
|
||||
public async list(searchText?: string): Promise<PublicUser[]> {
|
||||
const where = searchText
|
||||
? { username: { [Op.like]: `%${searchText}%` } }
|
||||
: {};
|
||||
return (await UserModel.findAll({ where })).map((x) =>
|
||||
publicUser(x.get({ plain: true })),
|
||||
);
|
||||
}
|
||||
public async get(id: number): Promise<PublicUser> {
|
||||
const doc = await UserModel.findByPk(id);
|
||||
if (!doc) throw Object.assign(new Error('用户不存在'), { status: 404 });
|
||||
return publicUser(doc.get({ plain: true }));
|
||||
}
|
||||
public async getByUsername(username: string): Promise<User | null> {
|
||||
return (
|
||||
(await UserModel.findOne({ where: { username } }))?.get({
|
||||
plain: true,
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async create(payload: User): Promise<PublicUser> {
|
||||
const auth = await shareStore.getAuthInfo();
|
||||
if (
|
||||
auth?.username === payload.username ||
|
||||
(await this.getByUsername(payload.username))
|
||||
)
|
||||
throw Object.assign(new Error('用户名已存在'), { status: 400 });
|
||||
if (!payload.password || payload.password.length < 6)
|
||||
throw Object.assign(new Error('密码长度至少为6位'), { status: 400 });
|
||||
const user = new User({
|
||||
...payload,
|
||||
password: await hashPassword(payload.password),
|
||||
sessions: [],
|
||||
});
|
||||
return publicUser((await UserModel.create(user)).get({ plain: true }));
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async update(
|
||||
payload: Partial<User> & { id: number },
|
||||
): Promise<PublicUser> {
|
||||
const existing = await this.get(payload.id);
|
||||
if (payload.username && payload.username !== existing.username) {
|
||||
const auth = await shareStore.getAuthInfo();
|
||||
if (
|
||||
auth?.username === payload.username ||
|
||||
(await this.getByUsername(payload.username))
|
||||
)
|
||||
throw Object.assign(new Error('用户名已存在'), { status: 400 });
|
||||
}
|
||||
const changes: Partial<User> = {};
|
||||
for (const key of ['username', 'role', 'status'] as const)
|
||||
if (payload[key] !== undefined) (changes as any)[key] = payload[key];
|
||||
if (payload.password) {
|
||||
if (payload.password.length < 6)
|
||||
throw Object.assign(new Error('密码长度至少为6位'), { status: 400 });
|
||||
changes.password = await hashPassword(payload.password);
|
||||
}
|
||||
if (Object.keys(changes).length) changes.sessions = [];
|
||||
await UserModel.update(changes, { where: { id: payload.id } });
|
||||
return this.get(payload.id);
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async delete(ids: number[]): Promise<number> {
|
||||
return UserModel.destroy({ where: { id: ids } });
|
||||
}
|
||||
public async authenticate(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<User | null> {
|
||||
const user = await this.getByUsername(username);
|
||||
if (
|
||||
!user ||
|
||||
user.status !== UserStatus.active ||
|
||||
!isPasswordHash(user.password)
|
||||
)
|
||||
return null;
|
||||
return (await verifyPassword(password, user.password)) ? user : null;
|
||||
}
|
||||
// Caller holds the shared account mutation queue during authentication and issuance.
|
||||
public async issueToken(user: User, platform: string): Promise<string> {
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, nonce: randomBytes(32).toString('hex') },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.expiresIn || '20d', algorithm: 'HS384' },
|
||||
);
|
||||
const sessions = [
|
||||
...(user.sessions || []).filter((x) => x.platform !== platform),
|
||||
{ digest: digest(token), platform },
|
||||
].slice(-10);
|
||||
await UserModel.update({ sessions }, { where: { id: user.id } });
|
||||
return token;
|
||||
}
|
||||
public async validateToken(
|
||||
token: string,
|
||||
platform: string,
|
||||
): Promise<PublicUser | null> {
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwt.secret, {
|
||||
algorithms: ['HS384'],
|
||||
});
|
||||
if (
|
||||
typeof payload === 'string' ||
|
||||
!Number.isSafeInteger(payload.userId) ||
|
||||
payload.userId <= 0
|
||||
)
|
||||
return null;
|
||||
const user = (await UserModel.findByPk(payload.userId))?.get({
|
||||
plain: true,
|
||||
});
|
||||
if (
|
||||
!user ||
|
||||
user.status !== UserStatus.active ||
|
||||
!user.sessions?.some(
|
||||
(x) => x.digest === digest(token) && x.platform === platform,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return publicUser(user);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@serializeAuthMutation
|
||||
public async revokeToken(id: number, token: string): Promise<void> {
|
||||
const user = await UserModel.findByPk(id);
|
||||
if (!user) return;
|
||||
await user.update({
|
||||
sessions: (user.sessions || []).filter((x) => x.digest !== digest(token)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { shellQuote } from './shellQuote';
|
||||
|
||||
const repositoryNames =
|
||||
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
|
||||
|
||||
export function normalizeMirror(mirror: string): URL {
|
||||
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
|
||||
const url = new URL(mirror);
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
throw new Error(
|
||||
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function replaceMirrorUrl(current: string, mirror: string): string {
|
||||
const target = normalizeMirror(mirror);
|
||||
const previous = new URL(current);
|
||||
const oldSegments = previous.pathname.split('/').filter(Boolean);
|
||||
const newSegments = target.pathname.split('/').filter(Boolean);
|
||||
const repositoryIndex = oldSegments.findIndex((part) =>
|
||||
repositoryNames.test(part),
|
||||
);
|
||||
const targetIndex = newSegments.findIndex((part) =>
|
||||
repositoryNames.test(part),
|
||||
);
|
||||
const prefix =
|
||||
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
|
||||
if (repositoryIndex >= 0) {
|
||||
const repository = oldSegments[repositoryIndex];
|
||||
let suffix = oldSegments.slice(repositoryIndex + 1);
|
||||
// Repair paths already corrupted by repeated authority-only replacement.
|
||||
while (suffix[0] === repository) suffix.shift();
|
||||
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
|
||||
}
|
||||
// Unknown repository paths: a full URL replaces the old root, a hostname
|
||||
// preserves it. Never append the newly entered root to the existing root.
|
||||
return `${target.origin}/${(newSegments.length
|
||||
? newSegments
|
||||
: oldSegments
|
||||
).join('/')}`;
|
||||
}
|
||||
|
||||
export function rewriteMirrorSources(content: string, mirror: string): string {
|
||||
normalizeMirror(mirror);
|
||||
return content
|
||||
.split(/(\r?\n[ \t]*\r?\n)/)
|
||||
.map((stanza) => {
|
||||
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
|
||||
return stanza
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
|
||||
return line;
|
||||
const legacy = /^\s*deb(?:-src)?\s/.test(line);
|
||||
const key = legacy
|
||||
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
|
||||
: signedBy;
|
||||
// In .list files a # starts an inline comment; its URLs are metadata.
|
||||
const comment = legacy ? line.indexOf('#') : -1;
|
||||
const source = comment < 0 ? line : line.slice(0, comment);
|
||||
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
|
||||
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
|
||||
);
|
||||
return rewritten + (comment < 0 ? '' : line.slice(comment));
|
||||
})
|
||||
.join('\n');
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isSystemRepository(url: string, signedBy?: string): boolean {
|
||||
const keys =
|
||||
signedBy
|
||||
?.trim()
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean) || [];
|
||||
const archiveKeyring =
|
||||
keys.length > 0 &&
|
||||
keys.every((key) =>
|
||||
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
|
||||
);
|
||||
// A separate signing key identifies a third-party archive, even if its
|
||||
// repository happens to be called /debian or /ubuntu.
|
||||
if (keys.length > 0 && !archiveKeyring) return false;
|
||||
const segments = new URL(url).pathname.split('/').filter(Boolean);
|
||||
const repositoryIndex = segments.findIndex((part) =>
|
||||
repositoryNames.test(part),
|
||||
);
|
||||
if (repositoryIndex < 0) return false;
|
||||
// Without archive metadata, accept only conventional archive roots. A
|
||||
// vendor path such as Docker's /linux/debian is not the Debian archive.
|
||||
return (
|
||||
archiveKeyring ||
|
||||
repositoryIndex === 0 ||
|
||||
segments
|
||||
.slice(0, repositoryIndex)
|
||||
.every((part) => /^mirrors?$/.test(part)) ||
|
||||
segments[repositoryIndex] === 'alpine'
|
||||
);
|
||||
}
|
||||
|
||||
export function mirrorWriteCommand(
|
||||
file: string,
|
||||
content: string,
|
||||
prefix: string,
|
||||
update: string,
|
||||
): string {
|
||||
return `${prefix}mkdir -p ${shellQuote(
|
||||
file.slice(0, file.lastIndexOf('/')),
|
||||
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
|
||||
file,
|
||||
)} >/dev/null && ${update}`;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { QueryTypes, Sequelize } from 'sequelize';
|
||||
export async function migrateUserSchema(database: Sequelize): Promise<void> {
|
||||
const columns = [
|
||||
{ table: 'Users', column: 'sessions', type: "JSON NOT NULL DEFAULT '[]'" },
|
||||
...['Envs', 'Crontabs', 'Subscriptions', 'Dependences'].map((table) => ({
|
||||
table,
|
||||
column: 'userId',
|
||||
type: 'INTEGER',
|
||||
})),
|
||||
];
|
||||
await database.transaction(async (transaction) => {
|
||||
for (const { table, column, type } of columns) {
|
||||
const fields = await database.query<{ name: string }>(
|
||||
`PRAGMA table_info("${table}")`,
|
||||
{ type: QueryTypes.SELECT, transaction },
|
||||
);
|
||||
if (!fields.length)
|
||||
throw new Error(`User migration table is missing: ${table}`);
|
||||
if (!fields.some((field) => field.name === column))
|
||||
await database.query(
|
||||
`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`,
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Vendored
+5
@@ -6,6 +6,11 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
platform: 'desktop' | 'mobile';
|
||||
user?: {
|
||||
userId?: number;
|
||||
role?: number;
|
||||
};
|
||||
auth?: any;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,37 @@ export interface OpenOperation {
|
||||
anonymous?: boolean;
|
||||
}
|
||||
export const openOperations: readonly OpenOperation[] = [
|
||||
{ name: 'account list', method: 'GET', path: 'user-management' },
|
||||
{
|
||||
name: 'system notification-logs',
|
||||
name: 'account get',
|
||||
method: 'GET',
|
||||
path: 'system/notify-log',
|
||||
path: 'user-management/:id',
|
||||
params: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'account create',
|
||||
method: 'POST',
|
||||
path: 'user-management',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'account update',
|
||||
method: 'PUT',
|
||||
path: 'user-management',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'account delete',
|
||||
method: 'DELETE',
|
||||
path: 'user-management',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'account assign',
|
||||
method: 'PUT',
|
||||
path: 'user-management/:id/resources',
|
||||
params: ['id'],
|
||||
body: 'json',
|
||||
},
|
||||
|
||||
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
|
||||
|
||||
@@ -409,7 +409,7 @@ test('cron metadata precedence agrees with unchanged legacy add_cron', (t) => {
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
`${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s:%s:%s:%s' "$1" "$2" "$3" "$4"; }\nadd_cron "$dir_scripts/list" repo`,
|
||||
`${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s' "$1"; }\nadd_cron "$dir_scripts/list" repo`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
|
||||
@@ -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,13 @@
|
||||
# 多用户权限范围
|
||||
|
||||
系统原有管理员仍使用现有初始化、密码迁移、双重验证与会话校验。新增账号使用 scrypt 密码哈希;用户接口不返回密码哈希和会话记录。密码、角色、状态或用户名修改会撤销该账号的已有会话;退出、禁用和删除账号也会使令牌失效。仅签名有效而未登记的 JWT 不能作为用户会话。
|
||||
|
||||
普通用户只读自己名下的环境变量、任务元数据和订阅元数据,可查看自己的账号信息并退出。无归属的历史数据属于管理员,普通用户不能访问。共享脚本、配置、日志、依赖安装、任务执行、终端命令、系统设置及账号管理均需管理员权限。前端显示只读提示,并仅显示任务、环境变量和订阅菜单。
|
||||
|
||||
管理员可在系统设置中创建、修改、禁用、删除账号。新增管理员角色属于完全可信的系统管理员,具有全局操作权限。原有管理员账号保留在原有认证配置中,不复制到新用户表。
|
||||
|
||||
管理员可通过 `PUT /api/user-management/:id/resources` 分配资源,请求体例如 `{"resource":"envs","ids":[1,2]}`。`resource` 支持 `envs`、`crons`、`subscriptions`;用户 ID 和资源 ID 必须为正整数。分配接口不会移动文件或执行任务。
|
||||
|
||||
脚本目录、环境变量导出文件和系统执行身份仍为实例共用。当前草稿不向普通用户开放执行;若要向不可信用户开放执行,必须增加独立执行环境、独立凭据和工作目录,以及相应的越权测试。
|
||||
|
||||
旧草稿曾使用 bcrypt 的账号需要由管理员重置密码后登录;bcrypt 哈希不会按明文密码比较。正式合并前应在 Linux 容器中验收账号管理与资源分配界面。
|
||||
@@ -1,11 +0,0 @@
|
||||
# 通知日志
|
||||
|
||||
通知日志记录系统统一通知入口(包括登录通知、配置测试和系统通知 API),以及任务使用内置 `notify.js`、`notify.py` 或 `QLAPI.notify` 发出的通知。脚本通知按实际配置的渠道各记录一条结果,未配置渠道和跳过的标题不记账。
|
||||
|
||||
日志包含时间、标题、正文、渠道、结果和来源;脚本来源另包含文件名与任务 ID。成功表示通知渠道返回成功或接受请求,不表示最终收件人已经收到。多目标渠道只在所有目标成功时记为成功。
|
||||
|
||||
所有入口共用数据库中的最近 200 条日志。标题最多 512 字符、正文最多 8192 字符;上报字段不包含通知密码、令牌或渠道配置。日志页受系统设置的管理员权限保护。
|
||||
|
||||
脚本通过现有的本机 mTLS gRPC 服务上报,独立客户端最多等待 1.5 秒。旧面板、服务不可用或数据库写入失败不会改变通知发送结果。Python 使用 Node 上报子进程,并限制在 3 秒内。
|
||||
|
||||
自行保存的旧版通知库需要更新为面板内置版本才能上报。脚本自行调用 HTTP、SMTP 或第三方 SDK 时不经过这些入口,无法自动纳入通知日志。
|
||||
@@ -1,7 +0,0 @@
|
||||
# Python 依赖来源
|
||||
|
||||
依赖管理中可手动添加 Python 包名、`git+https://` 等 pip 支持的源 URL,或脚本/订阅仓库目录内的 requirements 文件、pyproject.toml 路径。文件路径支持目录空格,必须位于持久化 scripts 或 repo 根目录内;越界与指向根目录外的符号链接会被拒绝。
|
||||
|
||||
requirements 使用 `pip install -r`,pyproject 使用完整项目目录安装,保留相对依赖文件和项目源码。拉取订阅不会自动安装仓库声明的依赖。
|
||||
|
||||
删除 requirements 条目会调用 `pip uninstall -r`。源 URL 或 pyproject 条目无法可靠逆向推断其安装的所有分发包,因此删除该条目只移除面板记录;需另外按包名卸载已安装的包,日志会明确提示。
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Multi-User Data Migration Script
|
||||
*
|
||||
* This script migrates existing data (Cron, Env, Subscription, Dependence)
|
||||
* to be associated with specific users.
|
||||
*
|
||||
* Usage:
|
||||
* node migrate-to-multiuser.js --userId=1
|
||||
* node migrate-to-multiuser.js --username=admin
|
||||
* node migrate-to-multiuser.js --list-users
|
||||
*
|
||||
* Options:
|
||||
* --userId=<id> Assign all legacy data to user with this ID
|
||||
* --username=<name> Assign all legacy data to user with this username
|
||||
* --list-users List all users in the system
|
||||
* --dry-run Show what would be changed without making changes
|
||||
* --help Show this help message
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
// Load environment variables
|
||||
require('dotenv').config();
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
dbPath: path.join(
|
||||
process.env.QL_DATA_DIR ||
|
||||
path.join(process.env.QL_DIR || __dirname, 'data'),
|
||||
'db',
|
||||
),
|
||||
rootPath: __dirname,
|
||||
};
|
||||
|
||||
if (!fs.existsSync(path.join(config.dbPath, 'database.sqlite'))) {
|
||||
throw new Error(
|
||||
'Existing database not found; start the panel and verify QL_DATA_DIR before migration.',
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Sequelize
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(config.dbPath, 'database.sqlite'),
|
||||
logging: false,
|
||||
});
|
||||
|
||||
// Define models
|
||||
const UserModel = sequelize.define('User', {
|
||||
username: Sequelize.STRING,
|
||||
password: Sequelize.STRING,
|
||||
role: Sequelize.INTEGER,
|
||||
status: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const CrontabModel = sequelize.define('Crontab', {
|
||||
name: Sequelize.STRING,
|
||||
command: Sequelize.STRING,
|
||||
schedule: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const EnvModel = sequelize.define('Env', {
|
||||
name: Sequelize.STRING,
|
||||
value: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const SubscriptionModel = sequelize.define('Subscription', {
|
||||
name: Sequelize.STRING,
|
||||
url: Sequelize.STRING,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
const DependenceModel = sequelize.define('Dependence', {
|
||||
name: Sequelize.STRING,
|
||||
type: Sequelize.INTEGER,
|
||||
userId: Sequelize.INTEGER,
|
||||
});
|
||||
|
||||
// Parse command line arguments
|
||||
function parseArgs() {
|
||||
const args = {
|
||||
userId: null,
|
||||
username: null,
|
||||
listUsers: false,
|
||||
dryRun: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
process.argv.slice(2).forEach((arg) => {
|
||||
if (arg.startsWith('--userId=')) {
|
||||
args.userId = parseInt(arg.split('=')[1]);
|
||||
} else if (arg.startsWith('--username=')) {
|
||||
args.username = arg.split('=')[1];
|
||||
} else if (arg === '--list-users') {
|
||||
args.listUsers = true;
|
||||
} else if (arg === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
args.help = true;
|
||||
}
|
||||
});
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// Show help
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Multi-User Data Migration Script
|
||||
|
||||
This script migrates existing data (Cron, Env, Subscription, Dependence)
|
||||
to be associated with specific users.
|
||||
|
||||
Usage:
|
||||
node migrate-to-multiuser.js --userId=1
|
||||
node migrate-to-multiuser.js --username=admin
|
||||
node migrate-to-multiuser.js --list-users
|
||||
|
||||
Options:
|
||||
--userId=<id> Assign all legacy data to user with this ID
|
||||
--username=<name> Assign all legacy data to user with this username
|
||||
--list-users List all users in the system
|
||||
--dry-run Show what would be changed without making changes
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
# List all users
|
||||
node migrate-to-multiuser.js --list-users
|
||||
|
||||
# Migrate all data to user ID 1 (dry run)
|
||||
node migrate-to-multiuser.js --userId=1 --dry-run
|
||||
|
||||
# Migrate all data to user 'admin'
|
||||
node migrate-to-multiuser.js --username=admin
|
||||
|
||||
Note: This script will only migrate data where userId is NULL or undefined.
|
||||
Data already assigned to users will not be changed.
|
||||
`);
|
||||
}
|
||||
|
||||
// List all users
|
||||
async function listUsers() {
|
||||
const users = await UserModel.findAll();
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('\nNo users found in the database.');
|
||||
console.log(
|
||||
'Please create users first using the User Management interface.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\nUsers in the system:');
|
||||
console.log('ID\tUsername\tRole\t\tStatus');
|
||||
console.log('--\t--------\t----\t\t------');
|
||||
|
||||
users.forEach((user) => {
|
||||
const role = user.role === 0 ? 'Admin' : 'User';
|
||||
const status = user.status === 0 ? 'Enabled' : 'Disabled';
|
||||
console.log(`${user.id}\t${user.username}\t\t${role}\t\t${status}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Get statistics of legacy data
|
||||
async function getStatistics() {
|
||||
const stats = {
|
||||
crons: await CrontabModel.count({ where: { userId: null } }),
|
||||
envs: await EnvModel.count({ where: { userId: null } }),
|
||||
subscriptions: await SubscriptionModel.count({ where: { userId: null } }),
|
||||
dependences: await DependenceModel.count({ where: { userId: null } }),
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Migrate data to a specific user
|
||||
async function migrateData(userId, dryRun = false) {
|
||||
const stats = await getStatistics();
|
||||
|
||||
console.log('\nLegacy Data Statistics:');
|
||||
console.log(` Cron tasks: ${stats.crons}`);
|
||||
console.log(` Environment variables: ${stats.envs}`);
|
||||
console.log(` Subscriptions: ${stats.subscriptions}`);
|
||||
console.log(` Dependencies: ${stats.dependences}`);
|
||||
console.log('');
|
||||
|
||||
if (
|
||||
stats.crons + stats.envs + stats.subscriptions + stats.dependences ===
|
||||
0
|
||||
) {
|
||||
console.log('No legacy data found. All data is already assigned to users.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('DRY RUN: No changes will be made.\n');
|
||||
console.log(`Would assign all legacy data to user ID ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Migrating data to user ID ${userId}...`);
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Migrate crons
|
||||
if (stats.crons > 0) {
|
||||
await CrontabModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.crons} cron tasks`);
|
||||
}
|
||||
|
||||
// Migrate envs
|
||||
if (stats.envs > 0) {
|
||||
await EnvModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.envs} environment variables`);
|
||||
}
|
||||
|
||||
// Migrate subscriptions
|
||||
if (stats.subscriptions > 0) {
|
||||
await SubscriptionModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.subscriptions} subscriptions`);
|
||||
}
|
||||
|
||||
// Migrate dependences
|
||||
if (stats.dependences > 0) {
|
||||
await DependenceModel.update(
|
||||
{ userId },
|
||||
{ where: { userId: null }, transaction },
|
||||
);
|
||||
console.log(`✓ Migrated ${stats.dependences} dependencies`);
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
console.log('\n✓ Migration completed successfully!');
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('\n✗ Migration failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
if (args.help) {
|
||||
showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Test database connection
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connection established.');
|
||||
|
||||
if (args.listUsers) {
|
||||
await listUsers();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate arguments
|
||||
if (!args.userId && !args.username) {
|
||||
console.error('\nError: You must specify either --userId or --username');
|
||||
console.log('Use --help for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
let userId = args.userId;
|
||||
|
||||
if (args.username) {
|
||||
const user = await UserModel.findOne({
|
||||
where: { username: args.username },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error(`\nError: User '${args.username}' not found.`);
|
||||
console.log('Use --list-users to see available users.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
userId = user.id;
|
||||
console.log(`Found user '${args.username}' with ID ${userId}`);
|
||||
} else {
|
||||
// Verify user exists
|
||||
const user = await UserModel.findByPk(userId);
|
||||
|
||||
if (!user) {
|
||||
console.error(`\nError: User with ID ${userId} not found.`);
|
||||
console.log('Use --list-users to see available users.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Found user '${user.username}' with ID ${userId}`);
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
await migrateData(userId, args.dryRun);
|
||||
} catch (error) {
|
||||
console.error('\nError:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { main, listUsers, migrateData, getStatistics };
|
||||
+5
-8
@@ -28,8 +28,7 @@
|
||||
"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",
|
||||
"start:simple": "bash shell/start-simplify.sh"
|
||||
"postinstall": "max setup 2>/dev/null || true"
|
||||
},
|
||||
"gitHooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
@@ -77,9 +76,7 @@
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@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"
|
||||
"websocket-driver": "0.7.5"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -111,9 +108,9 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"keyv": "^5.2.3",
|
||||
"lodash": "^4.17.21",
|
||||
"multer": "2.4.0",
|
||||
"multer": "2.3.0",
|
||||
"node-cron": "4.6.0",
|
||||
"nodemailer": "^10.0.9",
|
||||
"nodemailer": "^8.0.1",
|
||||
"p-queue-cjs": "7.3.4",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"ps-tree": "^1.2.0",
|
||||
@@ -174,7 +171,7 @@
|
||||
"concurrently": "^7.0.0",
|
||||
"file-saver": "2.0.2",
|
||||
"lint-staged": "^13.0.3",
|
||||
"moment": "2.31.0",
|
||||
"moment": "2.30.1",
|
||||
"monaco-editor": "0.33.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
|
||||
Generated
+43
-368
@@ -6,8 +6,6 @@ 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':
|
||||
@@ -30,7 +28,7 @@ dependencies:
|
||||
version: 12.0.1
|
||||
body-parser:
|
||||
specifier: ^1.20.3
|
||||
version: 1.20.6
|
||||
version: 1.20.3
|
||||
celebrate:
|
||||
specifier: ^15.0.3
|
||||
version: 15.0.3
|
||||
@@ -75,7 +73,7 @@ dependencies:
|
||||
version: 1.2.0
|
||||
http-proxy-middleware:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.7
|
||||
version: 3.0.3
|
||||
iconv-lite:
|
||||
specifier: ^0.6.3
|
||||
version: 0.6.3
|
||||
@@ -84,7 +82,7 @@ dependencies:
|
||||
version: 2.3.0(@types/node@17.0.45)
|
||||
js-yaml:
|
||||
specifier: ^4.1.0
|
||||
version: 4.3.2
|
||||
version: 4.1.0
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
@@ -95,14 +93,14 @@ dependencies:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
multer:
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
node-cron:
|
||||
specifier: 4.6.0
|
||||
version: 4.6.0
|
||||
nodemailer:
|
||||
specifier: ^10.0.9
|
||||
version: 10.0.9
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
p-queue-cjs:
|
||||
specifier: 7.3.4
|
||||
version: 7.3.4
|
||||
@@ -135,7 +133,7 @@ dependencies:
|
||||
version: 0.10.0
|
||||
undici:
|
||||
specifier: ^7.9.0
|
||||
version: 7.29.1
|
||||
version: 7.9.0
|
||||
uuid:
|
||||
specifier: ^11.0.3
|
||||
version: 11.0.3
|
||||
@@ -266,7 +264,7 @@ devDependencies:
|
||||
version: 4.23.0(antd@4.24.16)(react-dom@18.3.1)(react@18.3.1)
|
||||
axios:
|
||||
specifier: ^1.4.0
|
||||
version: 1.20.0
|
||||
version: 1.7.8
|
||||
compression-webpack-plugin:
|
||||
specifier: 9.2.0
|
||||
version: 9.2.0
|
||||
@@ -280,8 +278,8 @@ devDependencies:
|
||||
specifier: ^13.0.3
|
||||
version: 13.3.0
|
||||
moment:
|
||||
specifier: 2.31.0
|
||||
version: 2.31.0
|
||||
specifier: 2.30.1
|
||||
version: 2.30.1
|
||||
monaco-editor:
|
||||
specifier: 0.33.0
|
||||
version: 0.33.0
|
||||
@@ -878,7 +876,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.31.0
|
||||
moment: 2.30.1
|
||||
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)
|
||||
@@ -2881,7 +2879,7 @@ packages:
|
||||
globals: 13.24.0
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.3.2
|
||||
js-yaml: 4.1.0
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
@@ -3062,7 +3060,7 @@ packages:
|
||||
camelcase: 5.3.1
|
||||
find-up: 4.1.0
|
||||
get-package-type: 0.1.0
|
||||
js-yaml: 3.15.2
|
||||
js-yaml: 3.14.1
|
||||
resolve-from: 5.0.0
|
||||
dev: true
|
||||
|
||||
@@ -5371,7 +5369,7 @@ packages:
|
||||
fast-deep-equal: 3.1.3
|
||||
intl: 1.2.5
|
||||
lodash: 4.17.21
|
||||
moment: 2.31.0
|
||||
moment: 2.30.1
|
||||
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)
|
||||
@@ -5679,6 +5677,7 @@ packages:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/agentkeepalive@4.5.0:
|
||||
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
|
||||
@@ -5868,7 +5867,7 @@ packages:
|
||||
classnames: 2.5.1
|
||||
copy-to-clipboard: 3.3.3
|
||||
lodash: 4.17.21
|
||||
moment: 2.31.0
|
||||
moment: 2.30.1
|
||||
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)
|
||||
@@ -6359,6 +6358,18 @@ 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'}
|
||||
@@ -8291,7 +8302,7 @@ packages:
|
||||
is-glob: 4.0.3
|
||||
is-path-inside: 3.0.3
|
||||
js-sdsl: 4.4.2
|
||||
js-yaml: 4.3.2
|
||||
js-yaml: 4.1.0
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
levn: 0.4.1
|
||||
lodash.merge: 4.6.2
|
||||
@@ -8502,7 +8513,7 @@ packages:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
array-flatten: 1.1.1
|
||||
body-parser: 1.20.6
|
||||
body-parser: 1.20.3
|
||||
content-disposition: 0.5.4
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.1
|
||||
@@ -8652,7 +8663,7 @@ packages:
|
||||
/file-stream-rotator@0.6.1:
|
||||
resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==}
|
||||
dependencies:
|
||||
moment: 2.31.0
|
||||
moment: 2.30.1
|
||||
dev: false
|
||||
|
||||
/fill-range@7.1.1:
|
||||
@@ -9323,6 +9334,7 @@ packages:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
@@ -10575,7 +10587,7 @@ packages:
|
||||
engines: {node: '>=8.6'}
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.2
|
||||
picomatch: 2.3.1
|
||||
|
||||
/miller-rabin@4.0.1:
|
||||
resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
|
||||
@@ -10642,19 +10654,19 @@ packages:
|
||||
/minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
dependencies:
|
||||
brace-expansion: 1.1.21
|
||||
brace-expansion: 1.1.12
|
||||
dev: true
|
||||
|
||||
/minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
dependencies:
|
||||
brace-expansion: 1.1.21
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
/minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
dependencies:
|
||||
brace-expansion: 2.1.7
|
||||
brace-expansion: 2.0.2
|
||||
dev: true
|
||||
|
||||
/minimist-options@4.1.0:
|
||||
@@ -10723,12 +10735,11 @@ packages:
|
||||
/moment-timezone@0.5.46:
|
||||
resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==}
|
||||
dependencies:
|
||||
moment: 2.31.0
|
||||
moment: 2.30.1
|
||||
dev: false
|
||||
|
||||
/moment@2.31.0:
|
||||
resolution:
|
||||
integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==
|
||||
/moment@2.30.1:
|
||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||
|
||||
/monaco-editor@0.33.0:
|
||||
resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==}
|
||||
@@ -10859,7 +10870,7 @@ packages:
|
||||
semver: 7.7.4
|
||||
tar: 7.5.20
|
||||
tinyglobby: 0.2.17
|
||||
undici: 6.29.0
|
||||
undici: 6.27.0
|
||||
which: 6.0.1
|
||||
dev: false
|
||||
optional: true
|
||||
@@ -12715,7 +12726,7 @@ packages:
|
||||
classnames: 2.5.1
|
||||
date-fns: 2.30.0
|
||||
dayjs: 1.11.13
|
||||
moment: 2.31.0
|
||||
moment: 2.30.1
|
||||
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
|
||||
@@ -13942,7 +13953,7 @@ packages:
|
||||
dottie: 2.0.6
|
||||
inflection: 1.13.4
|
||||
lodash: 4.17.21
|
||||
moment: 2.31.0
|
||||
moment: 2.30.1
|
||||
moment-timezone: 0.5.46
|
||||
pg-connection-string: 2.7.0
|
||||
retry-as-promised: 7.0.4
|
||||
@@ -15783,342 +15794,6 @@ packages:
|
||||
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||
dev: true
|
||||
|
||||
/brace-expansion@2.1.7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
dev: true
|
||||
|
||||
|
||||
/brace-expansion@1.1.21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
|
||||
/axios@1.20.0:
|
||||
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||
dependencies:
|
||||
follow-redirects: 1.16.1(debug@4.4.3)
|
||||
form-data: 4.0.6
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
|
||||
/proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
|
||||
/form-data@4.0.6:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
dev: true
|
||||
|
||||
|
||||
/hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
|
||||
/es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.4
|
||||
dev: true
|
||||
|
||||
|
||||
/get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
|
||||
/math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
|
||||
/gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
|
||||
/get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
|
||||
/es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
|
||||
/dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
|
||||
/call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
|
||||
/es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
|
||||
/follow-redirects@1.16.1(debug@4.4.3):
|
||||
resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
|
||||
/undici@7.29.1:
|
||||
resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
dev: false
|
||||
|
||||
|
||||
/undici@6.29.0:
|
||||
resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==}
|
||||
engines: {node: '>=18.17'}
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
|
||||
/nodemailer@10.0.9:
|
||||
resolution: {integrity: sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
dev: false
|
||||
|
||||
|
||||
/multer@2.4.0:
|
||||
resolution: {integrity: sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
dependencies:
|
||||
append-field: 1.0.0
|
||||
busboy: 1.6.0
|
||||
type-is: 1.6.18
|
||||
dev: false
|
||||
|
||||
|
||||
/js-yaml@4.3.2:
|
||||
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
|
||||
/js-yaml@3.15.2:
|
||||
resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
dev: true
|
||||
|
||||
|
||||
/http-proxy-middleware@3.0.7:
|
||||
resolution: {integrity: sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==}
|
||||
engines: {node: ^14.18.0 || ^16.10.0 || >=18.0.0}
|
||||
dependencies:
|
||||
'@types/http-proxy': 1.17.17
|
||||
debug: 4.4.3
|
||||
http-proxy: 1.18.1(debug@4.4.3)
|
||||
is-glob: 4.0.3
|
||||
is-plain-object: 5.1.0
|
||||
micromatch: 4.0.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
|
||||
/picomatch@2.3.2:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
|
||||
/is-plain-object@5.1.0:
|
||||
resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
|
||||
/http-proxy@1.18.1(debug@4.4.3):
|
||||
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
dependencies:
|
||||
eventemitter3: 4.0.7
|
||||
follow-redirects: 1.16.1(debug@4.4.3)
|
||||
requires-port: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
dev: false
|
||||
|
||||
|
||||
/@types/http-proxy@1.17.17:
|
||||
resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.45
|
||||
dev: false
|
||||
|
||||
|
||||
/body-parser@1.20.6:
|
||||
resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
destroy: 1.2.0
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.4.24
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.3
|
||||
raw-body: 2.5.3
|
||||
type-is: 1.6.18
|
||||
unpipe: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
|
||||
/raw-body@2.5.3:
|
||||
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.4.24
|
||||
unpipe: 1.0.0
|
||||
|
||||
|
||||
/http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
|
||||
/statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
|
||||
/qs@6.15.3:
|
||||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||
engines: {node: '>=0.6'}
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
dev: false
|
||||
|
||||
|
||||
/side-channel@1.1.1:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
|
||||
/side-channel-weakmap@1.0.2:
|
||||
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-map: 1.0.1
|
||||
|
||||
|
||||
/side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
|
||||
/object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
|
||||
/call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
|
||||
/side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
+136
-309
File diff suppressed because it is too large
Load Diff
+27
-162
@@ -10,8 +10,6 @@ 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
|
||||
@@ -163,11 +161,10 @@ for k in push_config:
|
||||
push_config[k] = v
|
||||
|
||||
|
||||
def bark(title: str, content: str) -> Optional[bool]:
|
||||
def bark(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 bark 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("BARK_PUSH"):
|
||||
return
|
||||
print("bark 服务启动")
|
||||
@@ -203,28 +200,22 @@ def bark(title: str, content: str) -> Optional[bool]:
|
||||
).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) -> Optional[bool]:
|
||||
def console(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 控制台 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
print(f"{title}\n\n{content}")
|
||||
return True
|
||||
|
||||
|
||||
def dingding_bot(title: str, content: str) -> Optional[bool]:
|
||||
def dingding_bot(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 钉钉机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"):
|
||||
return
|
||||
print("钉钉机器人 服务启动")
|
||||
@@ -245,19 +236,15 @@ def dingding_bot(title: str, content: str) -> Optional[bool]:
|
||||
).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) -> Optional[bool]:
|
||||
def feishu_bot(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 飞书机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("FSKEY"):
|
||||
return
|
||||
print("飞书 服务启动")
|
||||
@@ -281,19 +268,15 @@ def feishu_bot(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def go_cqhttp(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 go_cqhttp 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"):
|
||||
return
|
||||
print("go-cqhttp 服务启动")
|
||||
@@ -302,19 +285,15 @@ def go_cqhttp(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def gotify(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 gotify 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"):
|
||||
return
|
||||
print("gotify 服务启动")
|
||||
@@ -328,19 +307,15 @@ def gotify(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def iGot(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 iGot 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("IGOT_PUSH_KEY"):
|
||||
return
|
||||
print("iGot 服务启动")
|
||||
@@ -351,19 +326,15 @@ def iGot(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def serverJ(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 serverJ 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("PUSH_KEY"):
|
||||
return
|
||||
print("serverJ 服务启动")
|
||||
@@ -380,19 +351,15 @@ def serverJ(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def pushdeer(title: str, content: str) -> None:
|
||||
"""
|
||||
通过PushDeer 推送消息
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("DEER_KEY"):
|
||||
return
|
||||
print("PushDeer 服务启动")
|
||||
@@ -409,19 +376,15 @@ def pushdeer(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def chat(title: str, content: str) -> None:
|
||||
"""
|
||||
通过Chat 推送消息
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"):
|
||||
return
|
||||
print("chat 服务启动")
|
||||
@@ -430,19 +393,15 @@ def chat(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def pushplus_bot(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 pushplus 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("PUSH_PLUS_TOKEN"):
|
||||
return
|
||||
print("PUSHPLUS 服务启动")
|
||||
@@ -465,9 +424,7 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
|
||||
|
||||
code = response["code"]
|
||||
if code == 200:
|
||||
audit_results.append(True)
|
||||
print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"])
|
||||
audit_results.append(True)
|
||||
print(
|
||||
"注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果"
|
||||
)
|
||||
@@ -480,20 +437,16 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def weplus_bot(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 微加机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WE_PLUS_BOT_TOKEN"):
|
||||
return
|
||||
print("微加机器人 服务启动")
|
||||
@@ -516,19 +469,15 @@ def weplus_bot(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def qmsg_bot(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 qmsg 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"):
|
||||
return
|
||||
print("qmsg 服务启动")
|
||||
@@ -538,19 +487,15 @@ def qmsg_bot(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def wecom_app(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 企业微信 APP 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("QYWX_AM"):
|
||||
return
|
||||
QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM"))
|
||||
@@ -576,12 +521,9 @@ def wecom_app(title: str, content: str) -> Optional[bool]:
|
||||
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:
|
||||
@@ -646,11 +588,10 @@ class WeCom:
|
||||
return respone["errmsg"]
|
||||
|
||||
|
||||
def wecom_bot(title: str, content: str) -> Optional[bool]:
|
||||
def wecom_bot(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 企业微信机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("QYWX_KEY"):
|
||||
return
|
||||
print("企业微信机器人服务启动")
|
||||
@@ -667,19 +608,15 @@ def wecom_bot(title: str, content: str) -> Optional[bool]:
|
||||
).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) -> Optional[bool]:
|
||||
def telegram_bot(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 telegram 机器人 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"):
|
||||
return
|
||||
print("tg 服务启动")
|
||||
@@ -715,19 +652,15 @@ def telegram_bot(title: str, content: str) -> Optional[bool]:
|
||||
).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) -> Optional[bool]:
|
||||
def aibotk(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 智能微秘书 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if (
|
||||
not push_config.get("AIBOTK_KEY")
|
||||
or not push_config.get("AIBOTK_TYPE")
|
||||
@@ -755,19 +688,15 @@ def aibotk(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def smtp(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 SMTP 邮件 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if (
|
||||
not push_config.get("SMTP_SERVER")
|
||||
or not push_config.get("SMTP_SSL")
|
||||
@@ -807,19 +736,15 @@ def smtp(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def pushme(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 PushMe 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("PUSHME_KEY"):
|
||||
return
|
||||
print("PushMe 服务启动")
|
||||
@@ -839,19 +764,15 @@ def pushme(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def chronocat(title: str, content: str) -> None:
|
||||
"""
|
||||
使用 CHRONOCAT 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if (
|
||||
not push_config.get("CHRONOCAT_URL")
|
||||
or not push_config.get("CHRONOCAT_QQ")
|
||||
@@ -886,27 +807,21 @@ def chronocat(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def ntfy(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 Ntfy 推送消息
|
||||
"""
|
||||
|
||||
audit_results = []
|
||||
def encode_rfc2047(text: str) -> str:
|
||||
"""将文本编码为符合 RFC 2047 标准的格式"""
|
||||
encoded_bytes = base64.b64encode(text.encode("utf-8"))
|
||||
@@ -938,15 +853,12 @@ def ntfy(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def wxpusher_bot(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 wxpusher 推送消息。
|
||||
支持的环境变量:
|
||||
@@ -954,7 +866,6 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
|
||||
- WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔
|
||||
- WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WXPUSHER_APP_TOKEN"):
|
||||
return
|
||||
|
||||
@@ -998,21 +909,17 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def wxpusher_spt(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 wxpusher 极简推送(SPT)推送消息。
|
||||
支持的环境变量:
|
||||
- WXPUSHER_SPT_LIST: SPT, 多个用英文逗号,分隔, 最多10个
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WXPUSHER_SPT_LIST"):
|
||||
return
|
||||
|
||||
@@ -1049,21 +956,17 @@ def wxpusher_spt(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def wpush(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 WPUSH 推送消息。
|
||||
官方文档: https://wpush.cn/docs
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WPUSH_APIKEY"):
|
||||
return
|
||||
|
||||
@@ -1083,15 +986,12 @@ def wpush(title: str, content: str) -> Optional[bool]:
|
||||
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) -> Optional[bool]:
|
||||
def openilink(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 OpeniLink 推送消息。
|
||||
支持的环境变量:
|
||||
@@ -1099,7 +999,6 @@ def openilink(title: str, content: str) -> Optional[bool]:
|
||||
- OPENILINK_HUB_URL: OpeniLink Hub 地址,默认为 https://hub.openilink.com
|
||||
- OPENILINK_CONTEXT_TOKEN: 消息会话上下文 token,可从消息事件中获取
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("OPENILINK_APP_TOKEN"):
|
||||
return
|
||||
|
||||
@@ -1124,12 +1023,9 @@ def openilink(title: str, content: str) -> Optional[bool]:
|
||||
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):
|
||||
@@ -1183,11 +1079,10 @@ def parse_body(body, content_type, value_format_fn=None):
|
||||
return parsed
|
||||
|
||||
|
||||
def custom_notify(title: str, content: str) -> Optional[bool]:
|
||||
def custom_notify(title: str, content: str) -> None:
|
||||
"""
|
||||
通过 自定义通知 推送消息。
|
||||
"""
|
||||
audit_results = []
|
||||
if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"):
|
||||
return
|
||||
|
||||
@@ -1219,12 +1114,9 @@ def custom_notify(title: str, content: str) -> Optional[bool]:
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -1312,33 +1204,6 @@ 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
|
||||
@@ -1363,7 +1228,7 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
|
||||
|
||||
notify_function = add_notify_function()
|
||||
ts = [
|
||||
threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__)
|
||||
threading.Thread(target=mode, args=(title, content), name=mode.__name__)
|
||||
for mode in notify_function
|
||||
]
|
||||
[t.start() for t in ts]
|
||||
|
||||
+6
-17
@@ -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
|
||||
__ql_token__=$(node "$token_file")
|
||||
else
|
||||
__ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts")
|
||||
token_command="node ${token_file}"
|
||||
fi
|
||||
__ql_token__=$(eval "$token_command")
|
||||
}
|
||||
|
||||
get_token() {
|
||||
@@ -85,13 +85,9 @@ add_cron_api() {
|
||||
sub_id="null"
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(jq -cn --arg name "$name" --arg command "$command" \
|
||||
--arg schedule "$schedule" --argjson sub_id "$sub_id" \
|
||||
'{name: $name, command: $command, schedule: $schedule, sub_id: $sub_id}') || return $?
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
--data-raw "$payload" \
|
||||
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
|
||||
-X POST)
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
@@ -116,14 +112,10 @@ update_cron_api() {
|
||||
local id="$4"
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(jq -cn --arg name "$name" --arg command "$command" \
|
||||
--arg schedule "$schedule" --arg id "$id" \
|
||||
'{name: $name, command: $command, schedule: $schedule, id: $id}') || return $?
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-X PUT \
|
||||
--data-raw "$payload")
|
||||
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}")
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code == 200 ]]; then
|
||||
@@ -143,13 +135,10 @@ update_cron_command_api() {
|
||||
local id="$2"
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(jq -cn --arg command "$command" --arg id "$id" \
|
||||
'{command: $command, id: $id}') || return $?
|
||||
local api=$(ql_api_request \
|
||||
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
|
||||
-X PUT \
|
||||
--data-raw "$payload")
|
||||
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}")
|
||||
code=$(echo "$api" | jq -r .code)
|
||||
message=$(echo "$api" | jq -r .message)
|
||||
if [[ $code == 200 ]]; then
|
||||
|
||||
+28
-30
@@ -39,16 +39,16 @@ random_delay() {
|
||||
gen_array_scripts() {
|
||||
local dir_current=$(pwd)
|
||||
local i="-1"
|
||||
cd "$dir_scripts" || return $?
|
||||
for file in *; do
|
||||
cd $dir_scripts
|
||||
for file in $(ls); do
|
||||
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
|
||||
let i++
|
||||
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)
|
||||
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)
|
||||
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
|
||||
fi
|
||||
done
|
||||
cd "$dir_current" || return $?
|
||||
cd $dir_current
|
||||
}
|
||||
|
||||
## 使用说明
|
||||
@@ -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}" || return $?
|
||||
cd "${_target_dir}"
|
||||
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" || return $?
|
||||
cd $dir_scripts
|
||||
if [[ ${file_param} =~ "/" ]]; then
|
||||
local script_dir="${file_param%/*}"
|
||||
local script_name="${file_param##*/}"
|
||||
|
||||
if [[ -d ${script_dir} ]]; then
|
||||
cd "$script_dir" || return $?
|
||||
cd ${script_dir}
|
||||
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 || return $?
|
||||
enter_script_workdir
|
||||
|
||||
if [[ $isJsOrPythonFile == 'false' ]]; then
|
||||
clear_non_sh_env
|
||||
fi
|
||||
run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
|
||||
run_task_command $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 || return $?
|
||||
enter_script_workdir
|
||||
|
||||
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
|
||||
envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" &
|
||||
eval envParam="${env_param}" numParam="${i}" run_task_command $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 || return $?
|
||||
enter_script_workdir
|
||||
|
||||
envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
|
||||
envParam="${env_param}" numParam="${num_param}" run_task_command $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 || return $?
|
||||
enter_script_workdir true
|
||||
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 || return $?
|
||||
enter_script_workdir true
|
||||
shift
|
||||
fi
|
||||
else
|
||||
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
|
||||
enter_script_workdir true || return $?
|
||||
enter_script_workdir true
|
||||
shift
|
||||
fi
|
||||
|
||||
clear_non_sh_env
|
||||
run_task_command ${which_program:+"$which_program"} "$file_param" "$@"
|
||||
run_task_command $which_program $file_param "$@"
|
||||
}
|
||||
|
||||
check_file() {
|
||||
@@ -313,14 +313,12 @@ check_file() {
|
||||
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
|
||||
export PREV_PYTHONPATH="${PYTHONPATH:=}"
|
||||
if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then
|
||||
local preload_path=${file_preload_js//\\/\\\\}
|
||||
preload_path=${preload_path//\"/\\\"}
|
||||
export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}"
|
||||
export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}"
|
||||
else
|
||||
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
|
||||
fi
|
||||
else
|
||||
. "$file_env"
|
||||
. $file_env
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ class GrpcClient {
|
||||
'updateEnvNames',
|
||||
'getEnvById',
|
||||
'systemNotify',
|
||||
'recordNotifyLog',
|
||||
'getCronDetail',
|
||||
'createCron',
|
||||
'updateCron',
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
+2
-8
@@ -1,12 +1,4 @@
|
||||
#!/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分支"
|
||||
@@ -17,6 +9,8 @@ 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"
|
||||
|
||||
+16
-21
@@ -3,47 +3,42 @@
|
||||
days=$1
|
||||
|
||||
remove_js_log() {
|
||||
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}
|
||||
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)
|
||||
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") || continue
|
||||
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log")
|
||||
else
|
||||
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
|
||||
fi
|
||||
fi
|
||||
if [[ $is_macos -eq 1 ]]; then
|
||||
# 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
|
||||
diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s)))
|
||||
else
|
||||
log_timestamp=$(date +%s -d "$log_date") || continue
|
||||
diff_time=$(($(date +%s) - $(date +%s -d "$log_date")))
|
||||
fi
|
||||
diff_time=$((now - log_timestamp))
|
||||
if [[ $diff_time -gt $((${days} * 86400)) ]]; then
|
||||
log_path=${log#"$dir_log"/}
|
||||
encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue
|
||||
result=$(find_cron_api "log_path=$encoded_path")
|
||||
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g")
|
||||
local result=$(find_cron_api "log_path=$log_path")
|
||||
t '查询文件 %s' "$log_path"
|
||||
if [[ -z $result ]]; then
|
||||
t '删除中~'
|
||||
rm -vf -- "$log"
|
||||
rm -vf $log
|
||||
else
|
||||
t '正在被 %s 使用,跳过~' "$result"
|
||||
fi
|
||||
fi
|
||||
done < <(find "$dir_log" -type f -name "*.log" -print0)
|
||||
done
|
||||
}
|
||||
|
||||
remove_empty_dir() {
|
||||
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
|
||||
cd $dir_log
|
||||
for dir in $(ls); do
|
||||
if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then
|
||||
rm -rf $dir
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
+41
-45
@@ -77,19 +77,16 @@ 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
|
||||
|
||||
# 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.
|
||||
# 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key)
|
||||
local lang=${QL_LANG:-zh}
|
||||
local lang_file="$dir_shell/lang/${lang}.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
|
||||
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then
|
||||
. $lang_file
|
||||
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then
|
||||
. "$dir_shell/lang/zh.sh"
|
||||
fi
|
||||
|
||||
load_ql_envs
|
||||
@@ -135,7 +132,7 @@ unset_proxy() {
|
||||
make_dir() {
|
||||
local dir=$1
|
||||
if [[ ! -d $dir ]]; then
|
||||
mkdir -p -- "$dir"
|
||||
mkdir -p $dir
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -179,57 +176,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
|
||||
|
||||
}
|
||||
@@ -249,17 +246,17 @@ npm_install_2() {
|
||||
local dir_current=$(pwd)
|
||||
local dir_work=$1
|
||||
|
||||
cd "$dir_work" || return $?
|
||||
cd $dir_work
|
||||
t '安装 %s 依赖包...\n' "$dir_work"
|
||||
npm_install_sub
|
||||
cd "$dir_current" || return $?
|
||||
cd $dir_current
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -268,13 +265,12 @@ git_clone_scripts() {
|
||||
local dir="$2"
|
||||
local branch="$3"
|
||||
local proxy="$4"
|
||||
local branch_args=()
|
||||
[[ $branch ]] && branch_args=(-b "$branch")
|
||||
[[ $branch ]] && local part_cmd="-b $branch "
|
||||
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
|
||||
|
||||
set_proxy "$proxy"
|
||||
|
||||
git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir"
|
||||
git clone -q --depth=1 $part_cmd $url $dir
|
||||
exit_status=$?
|
||||
|
||||
unset_proxy
|
||||
@@ -287,7 +283,7 @@ random_range() {
|
||||
}
|
||||
|
||||
delete_pm2() {
|
||||
cd "$dir_root" || return $?
|
||||
cd $dir_root
|
||||
# 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
|
||||
@@ -295,7 +291,7 @@ delete_pm2() {
|
||||
}
|
||||
|
||||
reload_pm2() {
|
||||
cd "$dir_root" || return $?
|
||||
cd $dir_root
|
||||
restore_env_vars
|
||||
|
||||
# Try to start PM2, but handle failures gracefully
|
||||
@@ -314,7 +310,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=$!
|
||||
|
||||
@@ -402,7 +398,7 @@ handle_task_start() {
|
||||
}
|
||||
|
||||
run_task_before() {
|
||||
. "$file_task_before" "$@"
|
||||
. $file_task_before "$@"
|
||||
|
||||
if [[ ${task_before:=} ]]; then
|
||||
t '执行前置命令\n'
|
||||
@@ -412,7 +408,7 @@ run_task_before() {
|
||||
}
|
||||
|
||||
run_task_after() {
|
||||
. "$file_task_after" "$@"
|
||||
. $file_task_after "$@"
|
||||
|
||||
if [[ ${task_after:=} ]]; then
|
||||
t '\n执行后置命令\n'
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/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" "🎉 启动成功!"
|
||||
+72
-39
@@ -4,46 +4,31 @@
|
||||
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 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
|
||||
npm_dir=$(npm root -g)
|
||||
pnpm_dir=$(pnpm root -g)
|
||||
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then
|
||||
QL_DIR="$npm_dir/@whyour/qinglong"
|
||||
elif [[ -n "$pnpm_dir" && -d "$pnpm_dir/@whyour/qinglong" ]]; then
|
||||
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then
|
||||
QL_DIR="$pnpm_dir/@whyour/qinglong"
|
||||
else
|
||||
startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
|
||||
t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
|
||||
fi
|
||||
|
||||
if [[ $QL_DIR ]]; then
|
||||
startup_message '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
|
||||
t '请先手动设置 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
|
||||
startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
|
||||
t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $QL_DATA_DIR != /*/data ]]; then
|
||||
startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
|
||||
if [[ $QL_DATA_DIR != */data ]]; then
|
||||
t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -53,11 +38,7 @@ if [[ $command != "reload" ]]; then
|
||||
# 安装依赖
|
||||
os_name="${QL_OS_TYPE:-}"
|
||||
if [ -z "$os_name" ]; then
|
||||
if [[ -r /etc/os-release ]]; then
|
||||
os_name=$(source /etc/os-release && echo "$ID")
|
||||
else
|
||||
os_name=$(uname -s)
|
||||
fi
|
||||
os_name=$(source /etc/os-release && echo "$ID")
|
||||
fi
|
||||
|
||||
# 非 root 用户使用 sudo
|
||||
@@ -88,7 +69,7 @@ if [[ $command != "reload" ]]; then
|
||||
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
|
||||
;;
|
||||
*)
|
||||
startup_message '暂不支持此系统部署 %s' "$os_name"
|
||||
t '暂不支持此系统部署 %s' "$os_name"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -96,13 +77,65 @@ if [[ $command != "reload" ]]; then
|
||||
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5
|
||||
fi
|
||||
|
||||
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
|
||||
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
|
||||
exec /bin/bash "${QL_DIR}/shell/start-simplify.sh" "$@"
|
||||
|
||||
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 &
|
||||
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" "🎉 启动成功!"
|
||||
|
||||
+10
-22
@@ -36,31 +36,19 @@ run_task_command() {
|
||||
) &
|
||||
timeout_watcher=$!
|
||||
set +m
|
||||
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
|
||||
trap 'kill -KILL -- "-$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 || :
|
||||
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
|
||||
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
|
||||
# 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 || finish_task_timeout 125
|
||||
kill -0 "$timeout_watcher" 2>/dev/null || exit 125
|
||||
sleep 0.001
|
||||
done
|
||||
wait "$timeout_worker"
|
||||
@@ -68,8 +56,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 || :
|
||||
finish_task_timeout 124
|
||||
exit 124
|
||||
fi
|
||||
finish_task_timeout "$timeout_code"
|
||||
exit "$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
|
||||
|
||||
printf -v cmd '2>&1 | tee -a %q' "$dir_log/$log_path"
|
||||
cmd="2>&1 | tee -a $dir_log/$log_path"
|
||||
make_dir "$dir_log/$log_dir"
|
||||
if [[ "${no_tee:=}" == "true" ]]; then
|
||||
printf -v cmd '>> %q 2>&1' "$dir_log/$log_path"
|
||||
cmd=">> $dir_log/$log_path 2>&1"
|
||||
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
|
||||
|
||||
+19
-29
@@ -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
|
||||
|
||||
@@ -99,9 +99,7 @@ add_cron() {
|
||||
cron_name=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
|
||||
[[ -z $cron_name ]] && cron_name=$(grep "name:" $file | awk -F ":" '{print $2}' | head -1 | xargs)
|
||||
[[ -z $cron_name ]] && cron_name=$(basename "$file_name")
|
||||
cron_name=${cron_name//$'\r'/}
|
||||
cron_line=${cron_line//$'\r'/}
|
||||
result=$(add_cron_api "$cron_line" "${cmd_task} ${file}" "$cron_name" "$SUB_ID")
|
||||
result=$(add_cron_api "${cron_line}:${cmd_task} ${file}:${cron_name}:${SUB_ID}")
|
||||
echo -e "$result"
|
||||
if [[ $detail ]]; then
|
||||
detail="${detail}${result}\n"
|
||||
@@ -165,20 +163,17 @@ update_raw() {
|
||||
t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
|
||||
|
||||
set_proxy "$proxy"
|
||||
if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then
|
||||
exit_status=0
|
||||
else
|
||||
exit_status=$?
|
||||
fi
|
||||
wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url}
|
||||
exit_status=$?
|
||||
unset_proxy
|
||||
|
||||
if [[ $exit_status -eq 0 ]]; then
|
||||
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $?
|
||||
if [[ $? -eq 0 ]]; then
|
||||
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}"
|
||||
t '下载 %s 成功...\n' "${raw_file_name}"
|
||||
cd "$dir_raw" || return $?
|
||||
cd $dir_raw
|
||||
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}" || return $?
|
||||
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}
|
||||
if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then
|
||||
cron_line=$(
|
||||
perl -ne "{
|
||||
@@ -195,9 +190,7 @@ update_raw() {
|
||||
[[ -z $cron_line ]] && cron_line=$(grep "cron:" $raw_file_name | awk -F ":" '{print $2}' | head -1 | xargs)
|
||||
[[ -z $cron_line ]] && cron_line=$(grep "cron " $raw_file_name | awk -F "cron \"" '{print $2}' | awk -F "\" " '{print $1}' | head -1 | xargs)
|
||||
[[ -z $cron_line ]] && cron_line="$default_cron"
|
||||
cron_name=${cron_name//$'\r'/}
|
||||
cron_line=${cron_line//$'\r'/}
|
||||
result=$(add_cron_api "$cron_line" "${cmd_task} ${filename}" "$cron_name" "$SUB_ID")
|
||||
result=$(add_cron_api "${cron_line}:${cmd_task} ${filename}:${cron_name}:${SUB_ID}")
|
||||
echo -e "$result\n"
|
||||
notify_api "新增任务通知" "\n$result"
|
||||
# update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id"
|
||||
@@ -205,7 +198,6 @@ 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
|
||||
|
||||
}
|
||||
@@ -213,7 +205,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
|
||||
@@ -270,8 +262,7 @@ 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"
|
||||
# GNU and BSD tail both support -F; --retry is GNU-only and implied by -F.
|
||||
tail -n 0 -F "$current_system_log"
|
||||
tail -n 0 --retry -F "$current_system_log"
|
||||
}
|
||||
|
||||
## 脚本用法
|
||||
@@ -489,9 +480,9 @@ main() {
|
||||
local log_path="${log_dir}/${log_time}.log"
|
||||
local file_path="$dir_log/$log_path"
|
||||
|
||||
printf -v cmd '2>&1 | tee -a %q' "$file_path"
|
||||
cmd="2>&1 | tee -a $file_path"
|
||||
if [[ "$no_tee" == "true" ]]; then
|
||||
printf -v cmd '>> %q 2>&1' "$file_path"
|
||||
cmd=">> $file_path 2>&1"
|
||||
fi
|
||||
if [[ "$real_time" == "true" ]]; then
|
||||
cmd=""
|
||||
@@ -537,20 +528,19 @@ 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
|
||||
|
||||
+33
-4
@@ -10,7 +10,15 @@ import {
|
||||
import ProLayout, { PageLoading } from '@ant-design/pro-layout';
|
||||
import { history, Link, Outlet, useLocation } from '@umijs/max';
|
||||
import * as DarkReader from '@umijs/ssr-darkreader';
|
||||
import { Avatar, Badge, Dropdown, Image, MenuProps, Tooltip } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Dropdown,
|
||||
Image,
|
||||
MenuProps,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import vhCheck from 'vh-check';
|
||||
@@ -48,7 +56,8 @@ export default function () {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
|
||||
const [siteTitle, setSiteTitle] = useState(
|
||||
() => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
||||
() =>
|
||||
localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [initLoading, setInitLoading] = useState<boolean>(true);
|
||||
@@ -94,7 +103,7 @@ export default function () {
|
||||
if (code === 200 && data.username) {
|
||||
setUser(data);
|
||||
if (location.pathname === '/') {
|
||||
history.push('/dashboard');
|
||||
history.push(data.role === 1 ? '/env' : '/dashboard');
|
||||
}
|
||||
}
|
||||
needLoading && setLoading(false);
|
||||
@@ -206,7 +215,7 @@ export default function () {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !user.username) return;
|
||||
if (!user || !user.username || user.role === 1) return;
|
||||
const ws = WebSocketManager.getInstance(
|
||||
`${window.location.origin}${
|
||||
config.apiPrefix
|
||||
@@ -390,7 +399,27 @@ export default function () {
|
||||
</span>
|
||||
)}
|
||||
{...defaultProps}
|
||||
{...(user.role === 1
|
||||
? {
|
||||
route: {
|
||||
routes: defaultProps.route!.routes!.filter((route) =>
|
||||
['/crontab', '/env', '/subscription'].includes(
|
||||
route.path || '',
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{user.role === 1 && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={intl.get(
|
||||
'普通用户只读自己的数据,执行和系统修改由管理员负责',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Outlet
|
||||
context={{
|
||||
...ctx,
|
||||
|
||||
+11
-11
@@ -699,15 +699,15 @@
|
||||
"已保存,本次未记录历史版本": "Saved without a history snapshot",
|
||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
||||
"保存失败": "Unable to save",
|
||||
"通知日志": "Notification log",
|
||||
"标题": "Title",
|
||||
"内容": "Content",
|
||||
"发送时间": "Sent at",
|
||||
"推送渠道": "Channel",
|
||||
"发送状态": "Delivery status",
|
||||
"通知来源": "Notification source",
|
||||
"脚本通知": "Script notification",
|
||||
"系统通知": "System notification",
|
||||
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.",
|
||||
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path"
|
||||
"用户管理": "User Management",
|
||||
"角色": "Role",
|
||||
"管理员": "Admin",
|
||||
"普通用户": "User",
|
||||
"确认删除选中的用户吗": "Are you sure to delete selected users?",
|
||||
"请输入用户名": "Please enter username",
|
||||
"请输入密码": "Please enter password",
|
||||
"密码长度至少为6位": "Password must be at least 6 characters",
|
||||
"新增用户": "Add User",
|
||||
"编辑用户": "Edit User",
|
||||
"普通用户只读自己的数据,执行和系统修改由管理员负责": "You can view your own data. Administrators run tasks and change system settings."
|
||||
}
|
||||
|
||||
+11
-11
@@ -699,15 +699,15 @@
|
||||
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
|
||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
|
||||
"保存失败": "保存失败",
|
||||
"通知日志": "通知日志",
|
||||
"标题": "标题",
|
||||
"内容": "内容",
|
||||
"发送时间": "发送时间",
|
||||
"推送渠道": "推送渠道",
|
||||
"发送状态": "发送状态",
|
||||
"通知来源": "通知来源",
|
||||
"脚本通知": "脚本通知",
|
||||
"系统通知": "系统通知",
|
||||
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径",
|
||||
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径"
|
||||
"用户管理": "用户管理",
|
||||
"角色": "角色",
|
||||
"管理员": "管理员",
|
||||
"普通用户": "普通用户",
|
||||
"确认删除选中的用户吗": "确认删除选中的用户吗",
|
||||
"请输入用户名": "请输入用户名",
|
||||
"请输入密码": "请输入密码",
|
||||
"密码长度至少为6位": "密码长度至少为6位",
|
||||
"新增用户": "新增用户",
|
||||
"编辑用户": "编辑用户",
|
||||
"普通用户只读自己的数据,执行和系统修改由管理员负责": "普通用户只读自己的数据,执行和系统修改由管理员负责"
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@ 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);
|
||||
@@ -93,7 +90,7 @@ const DependenceModal = ({
|
||||
label={intl.get('依赖类型')}
|
||||
initialValue={DependenceTypes[defaultType as any]}
|
||||
>
|
||||
<Select onChange={(value) => setSelectedType(value)}>
|
||||
<Select>
|
||||
{config.dependenceTypes.map((x, i) => (
|
||||
<Option key={i} value={i}>
|
||||
{x}
|
||||
@@ -124,24 +121,11 @@ 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={
|
||||
selectedType === DependenceTypes.python3
|
||||
? intl.get(
|
||||
'支持包名、GitHub链接、requirements.txt或pyproject.toml路径',
|
||||
)
|
||||
: intl.get('请输入依赖名称')
|
||||
}
|
||||
placeholder={intl.get('请输入依赖名称')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label={intl.get('备注')}>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,
|
||||
@@ -16,6 +15,7 @@ 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,7 +747,9 @@ const Script = () => {
|
||||
{isPhone && (
|
||||
<CodeMirror
|
||||
value={value}
|
||||
extensions={getCodeMirrorExtensions(mode)}
|
||||
extensions={
|
||||
mode ? [langs[mode as keyof typeof langs]()] : undefined
|
||||
}
|
||||
theme={theme.includes('dark') ? 'dark' : 'light'}
|
||||
readOnly={!isEditing || saving}
|
||||
onChange={(value) => {
|
||||
|
||||
@@ -26,7 +26,6 @@ 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';
|
||||
@@ -37,6 +36,7 @@ import useResizeObserver from '@react-hook/resize-observer';
|
||||
import SystemLog from './systemLog';
|
||||
import Dependence from './dependence';
|
||||
import ClientIp from './clientIp';
|
||||
import UserManagement from './userManagement';
|
||||
|
||||
const { Text } = Typography;
|
||||
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
|
||||
@@ -127,7 +127,6 @@ 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);
|
||||
@@ -256,8 +255,6 @@ const Setting = () => {
|
||||
getApps();
|
||||
} else if (activeKey === 'login') {
|
||||
getLoginLog();
|
||||
} else if (activeKey === 'notifylog') {
|
||||
getNotifyLog();
|
||||
} else if (activeKey === 'notification') {
|
||||
getNotification();
|
||||
}
|
||||
@@ -276,19 +273,6 @@ 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();
|
||||
@@ -363,9 +347,9 @@ const Setting = () => {
|
||||
children: <LoginLog height={height} data={loginLogData} />,
|
||||
},
|
||||
{
|
||||
key: 'notifylog',
|
||||
label: intl.get('通知日志'),
|
||||
children: <NotifyLog height={height} data={notifyLogData} />,
|
||||
key: 'user-management',
|
||||
label: intl.get('用户管理'),
|
||||
children: <UserManagement height={height} />,
|
||||
},
|
||||
{
|
||||
key: 'client-ip',
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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,257 @@
|
||||
import intl from 'react-intl-universal';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
role: number;
|
||||
status: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const UserManagement: React.FC<{ height: number }> = ({ height }) => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: intl.get('用户名'),
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: intl.get('角色'),
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: number) => (
|
||||
<Tag color={role === 0 ? 'red' : 'blue'}>
|
||||
{role === 0 ? intl.get('管理员') : intl.get('普通用户')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: intl.get('状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: number) => (
|
||||
<Tag color={status === 0 ? 'green' : 'default'}>
|
||||
{status === 0 ? intl.get('启用') : intl.get('禁用')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: intl.get('创建时间'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (text: string) => (text ? new Date(text).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: intl.get('操作'),
|
||||
key: 'action',
|
||||
render: (_: any, record: User) => (
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
{intl.get('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete([record.id])}
|
||||
>
|
||||
{intl.get('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { code, data } = await request.get(
|
||||
`${config.apiPrefix}user-management`,
|
||||
);
|
||||
if (code === 200) {
|
||||
setUsers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Failed to fetch users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: User) => {
|
||||
setEditingUser(record);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
username: record.username,
|
||||
role: record.role,
|
||||
status: record.status,
|
||||
});
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (ids: number[]) => {
|
||||
Modal.confirm({
|
||||
title: intl.get('确认删除'),
|
||||
content: intl.get('确认删除选中的用户吗'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const { code, message: msg } = await request.delete(
|
||||
`${config.apiPrefix}user-management`,
|
||||
{ data: ids },
|
||||
);
|
||||
if (code === 200) {
|
||||
message.success(msg || intl.get('删除成功'));
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(msg || intl.get('删除失败'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || intl.get('删除失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingUser) {
|
||||
// Update user
|
||||
const { code, message: msg } = await request.put(
|
||||
`${config.apiPrefix}user-management`,
|
||||
{ ...values, id: editingUser.id },
|
||||
);
|
||||
if (code === 200) {
|
||||
message.success(msg || intl.get('更新成功'));
|
||||
setIsModalVisible(false);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(msg || intl.get('更新失败'));
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
const { code, message: msg } = await request.post(
|
||||
`${config.apiPrefix}user-management`,
|
||||
values,
|
||||
);
|
||||
if (code === 200) {
|
||||
message.success(msg || intl.get('创建成功'));
|
||||
setIsModalVisible(false);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(msg || intl.get('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || intl.get('操作失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
{intl.get('新增用户')}
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ y: height - 120 }}
|
||||
pagination={false}
|
||||
/>
|
||||
<Modal
|
||||
title={editingUser ? intl.get('编辑用户') : intl.get('新增用户')}
|
||||
open={isModalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={intl.get('用户名')}
|
||||
rules={[{ required: true, message: intl.get('请输入用户名') }]}
|
||||
>
|
||||
<Input placeholder={intl.get('请输入用户名')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={intl.get('密码')}
|
||||
rules={[
|
||||
{ required: !editingUser, message: intl.get('请输入密码') },
|
||||
{ min: 6, message: intl.get('密码长度至少为6位') },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder={intl.get('请输入密码')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="role"
|
||||
label={intl.get('角色')}
|
||||
rules={[{ required: true, message: intl.get('请选择角色') }]}
|
||||
initialValue={1}
|
||||
>
|
||||
<Select>
|
||||
<Option value={0}>{intl.get('管理员')}</Option>
|
||||
<Option value={1}>{intl.get('普通用户')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label={intl.get('状态')}
|
||||
rules={[{ required: true, message: intl.get('请选择状态') }]}
|
||||
initialValue={0}
|
||||
>
|
||||
<Select>
|
||||
<Option value={0}>{intl.get('启用')}</Option>
|
||||
<Option value={1}>{intl.get('禁用')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
@@ -1,11 +0,0 @@
|
||||
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()] : [];
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
const test = require('node:test'),
|
||||
assert = require('node:assert/strict'),
|
||||
fs = require('node:fs'),
|
||||
os = require('node:os'),
|
||||
path = require('node:path'),
|
||||
express = require('express');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
test('unknown API requests return JSON 404 while env.js and SPA deep links work', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-fallback-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(dir, 'static/dist'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'static/dist/index.html'), 'SPA');
|
||||
const app = express();
|
||||
load('back/loaders/express.ts', {
|
||||
'../api': () => express.Router(),
|
||||
'../config': {
|
||||
rootPath: dir,
|
||||
uploadPath: dir,
|
||||
api: { prefix: '/api' },
|
||||
apiWhiteList: ['/api/env.js'],
|
||||
jwt: { secret: 'test' },
|
||||
},
|
||||
'express-jwt': {
|
||||
expressjwt: () => ({ unless: () => (_q, _r, next) => next() }),
|
||||
UnauthorizedError: class extends Error {},
|
||||
},
|
||||
'../config/util': {
|
||||
getPlatform: () => 'desktop',
|
||||
getToken: () => 'stored',
|
||||
},
|
||||
'../config/serverEnv': {
|
||||
serveEnv: (_q, r) => r.type('js').send('window.QL_CONFIG={};'),
|
||||
},
|
||||
'../shared/store': {
|
||||
shareStore: { getAuthInfo: async () => ({}), getApps: async () => [] },
|
||||
},
|
||||
'../shared/auth': {
|
||||
isValidToken: () => true,
|
||||
isDefaultAuthInfo: () => false,
|
||||
},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../middlewares/uniqueConstraintError': (_q, _r, next) => next(),
|
||||
'../middlewares/protectedPathCase': (_q, _r, next) => next(),
|
||||
'../data/system': {},
|
||||
'../data/open': {},
|
||||
}).default({ app });
|
||||
const server = await new Promise((r) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => r(s));
|
||||
});
|
||||
t.after(() => new Promise((r) => server.close(r)));
|
||||
const url = `http://127.0.0.1:${server.address().port}`;
|
||||
for (const p of ['/api', '/api/missing', '/open/missing']) {
|
||||
const res = await fetch(url + p);
|
||||
assert.equal(res.status, 404, p);
|
||||
assert.match(res.headers.get('content-type'), /json/);
|
||||
assert.equal((await res.json()).code, 404);
|
||||
}
|
||||
const env = await fetch(url + '/api/env.js');
|
||||
assert.match(await env.text(), /QL_CONFIG/);
|
||||
assert.equal(await (await fetch(url + '/script')).text(), 'SPA');
|
||||
});
|
||||
@@ -61,7 +61,6 @@ 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 () => {},
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const hasApt = spawnSync('apt-get', ['--version']).status === 0;
|
||||
const {
|
||||
rewriteMirrorSources,
|
||||
replaceMirrorUrl,
|
||||
normalizeMirror,
|
||||
mirrorWriteCommand,
|
||||
} = require('../../back/shared/linuxMirror');
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-issue-fixes-'));
|
||||
t.after(() => fs.rm(root, { force: true, recursive: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test('#2902/#2871 mirror writes are idempotent, preserve suites, security/ports/version paths and quote URLs', async (t) => {
|
||||
const original = `# http://leave.example/debian\nTypes: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm bookworm-updates\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: bookworm-security\n`;
|
||||
const url = 'https://mirror.example/mirrors/debian/';
|
||||
const changed = rewriteMirrorSources(original, url);
|
||||
assert.equal(rewriteMirrorSources(changed, url), changed);
|
||||
assert.match(
|
||||
changed,
|
||||
/URIs: https:\/\/mirror.example\/mirrors\/debian-security/,
|
||||
);
|
||||
assert.match(changed, /Suites: bookworm-security/);
|
||||
assert.match(changed, /# http:\/\/leave.example\/debian/);
|
||||
assert.equal(
|
||||
replaceMirrorUrl('http://old/debian/debian/debian', url),
|
||||
'https://mirror.example/mirrors/debian',
|
||||
);
|
||||
assert.equal(
|
||||
replaceMirrorUrl('http://old/armbian/armbian', 'https://new/armbian'),
|
||||
'https://new/armbian',
|
||||
);
|
||||
assert.equal(
|
||||
replaceMirrorUrl('http://old/ubuntu-ports', 'https://new/ubuntu'),
|
||||
'https://new/ubuntu-ports',
|
||||
);
|
||||
assert.equal(
|
||||
replaceMirrorUrl('http://old/alpine/v3.22/main', 'https://new/alpine'),
|
||||
'https://new/alpine/v3.22/main',
|
||||
);
|
||||
assert.equal(
|
||||
replaceMirrorUrl('http://old/debian', 'https://new'),
|
||||
'https://new/debian',
|
||||
);
|
||||
for (const bad of [
|
||||
'file:///tmp/a',
|
||||
'http://u:p@host',
|
||||
'https://host?x=1',
|
||||
'https://host/\nanything',
|
||||
])
|
||||
assert.throws(() => normalizeMirror(bad));
|
||||
const root = await fixture(t);
|
||||
const file = path.join(root, "quote's", 'sources');
|
||||
const command = mirrorWriteCommand(file, changed, '', 'true');
|
||||
const result = spawnSync('bash', ['-c', command], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), changed);
|
||||
});
|
||||
|
||||
test('#2701 shell API serializes CR/LF, quotes, backslashes and colons without corrupting JSON', async () => {
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[
|
||||
'-c',
|
||||
`source shell/api.sh
|
||||
ql_api_request() { while [[ $# -gt 0 ]]; do if [[ "$1" == --data-raw ]]; then printf '%s\\n' "$2" >&3; break; fi; shift; done; printf '{"code":200}'; }
|
||||
t() { :; }
|
||||
add_cron_api '0 0 1 1 *' 'task a:b.js' $'quote"slash\\\\colon:\\r\\n\\t中文' 23
|
||||
update_cron_api '0 0 1 1 *' 'task a:b.js' $'quote"slash\\\\colon:\\r\\n\\t中文' 27
|
||||
update_cron_command_api $'task path\\\\with"quotes.js' 27
|
||||
`,
|
||||
],
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payloads = result.output[3].trim().split('\n').map(JSON.parse);
|
||||
assert.equal(payloads[0].name, 'quote"slash\\colon:\r\n\t中文');
|
||||
assert.equal(payloads[0].sub_id, 23);
|
||||
assert.equal(payloads[1].id, '27');
|
||||
assert.equal(payloads[2].command, 'task path\\with"quotes.js');
|
||||
});
|
||||
|
||||
for (const distribution of ['Debian', 'Ubuntu']) {
|
||||
for (const legacyContent of ['', ' \t\r\n']) {
|
||||
test(`${distribution}: empty legacy sources.list falls back to deb822 .sources`, async (t) => {
|
||||
const root = await fixture(t);
|
||||
const legacy = path.join(root, 'sources.list');
|
||||
const sources = path.join(
|
||||
root,
|
||||
'sources.list.d',
|
||||
`${distribution.toLowerCase()}.sources`,
|
||||
);
|
||||
await fs.writeFile(legacy, legacyContent);
|
||||
const util = load('back/config/util.ts', {
|
||||
'fs/promises': {
|
||||
readFile: async (file) => {
|
||||
if (file === '/etc/os-release')
|
||||
return `ID=${distribution.toLowerCase()}\nVERSION_CODENAME=${
|
||||
distribution === 'Debian' ? 'bookworm' : 'noble'
|
||||
}\n`;
|
||||
if (file === '/etc/apt/sources.list')
|
||||
return fs.readFile(legacy, 'utf8');
|
||||
throw Object.assign(new Error('missing fixture source'), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
},
|
||||
},
|
||||
os: { platform: () => 'linux' },
|
||||
'./index': {},
|
||||
'./const': {},
|
||||
'./share': {},
|
||||
'./container': { isInContainer: () => false },
|
||||
'../loaders/logger': {},
|
||||
'../shared/utils': {},
|
||||
'../shared/fileAccess': {},
|
||||
'../data/dependence': {},
|
||||
});
|
||||
const previousOS = process.env.QL_OS_TYPE;
|
||||
let command;
|
||||
try {
|
||||
process.env.QL_OS_TYPE = distribution.toLowerCase();
|
||||
command = await util.updateLinuxMirrorFile('https://mirror.example');
|
||||
} finally {
|
||||
if (previousOS === undefined) delete process.env.QL_OS_TYPE;
|
||||
else process.env.QL_OS_TYPE = previousOS;
|
||||
}
|
||||
// Execute the entire generated writer, redirecting its APT paths into
|
||||
// the fixture and replacing only the package-index refresh.
|
||||
const isolated = command
|
||||
.replaceAll('/etc/apt', root)
|
||||
.replace('apt-get update --error-on=any', 'true');
|
||||
const result = spawnSync('bash', ['-c', isolated], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(await fs.readFile(legacy, 'utf8'), legacyContent);
|
||||
const generated = await fs.readFile(sources, 'utf8');
|
||||
assert.match(generated, /^Types: deb\nURIs: https:\/\/mirror\.example\//);
|
||||
assert.match(generated, /Signed-By: \/usr\/share\/keyrings\//);
|
||||
assert.match(
|
||||
generated,
|
||||
distribution === 'Debian' ? /bookworm-security/ : /noble-backports/,
|
||||
);
|
||||
if (hasApt) {
|
||||
// Parse both source locations with real APT, without refreshing indexes
|
||||
// or touching the host's configuration/cache.
|
||||
const parsed = spawnSync(
|
||||
'apt-get',
|
||||
[
|
||||
'indextargets',
|
||||
'-o',
|
||||
`Dir::Etc::sourcelist=${legacy}`,
|
||||
'-o',
|
||||
`Dir::Etc::sourceparts=${path.dirname(sources)}`,
|
||||
'-o',
|
||||
`Dir::State::lists=${path.join(root, 'lists')}`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(parsed.status, 0, parsed.stderr);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('deb822 mirror changes preserve vendor archives, signing keys and comments across repeated saves', () => {
|
||||
const system =
|
||||
'Types: deb\nURIs: https://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n';
|
||||
const vendors = [
|
||||
'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: bookworm\nComponents: stable\nSigned-By: /etc/apt/keyrings/docker.asc\n',
|
||||
'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: bookworm\nComponents: stable\n',
|
||||
'Types: deb\nURIs: https://packages.microsoft.com/repos/code\nSuites: stable\nComponents: main\n',
|
||||
'Types: deb\nURIs: https://vendor.example/debian\nSuites: bookworm\nComponents: main\nSigned-By: /etc/apt/keyrings/vendor.gpg\n',
|
||||
];
|
||||
const comment = '# Keep https://vendor.example/debian unchanged\n';
|
||||
const original = [system, ...vendors, comment].join('\n');
|
||||
const first = rewriteMirrorSources(
|
||||
original,
|
||||
'https://mirror.example/mirrors/debian',
|
||||
);
|
||||
assert.ok(first.includes('URIs: https://mirror.example/mirrors/debian\n'));
|
||||
for (const unchanged of [...vendors, comment])
|
||||
assert.ok(first.includes(unchanged));
|
||||
assert.equal(
|
||||
rewriteMirrorSources(first, 'https://mirror.example/mirrors/debian'),
|
||||
first,
|
||||
);
|
||||
const second = rewriteMirrorSources(first, 'https://second.example/debian');
|
||||
assert.ok(second.includes('URIs: https://second.example/debian\n'));
|
||||
for (const unchanged of [...vendors, comment])
|
||||
assert.ok(second.includes(unchanged));
|
||||
});
|
||||
|
||||
test('legacy mirror changes preserve third-party lines, inline comments and source options', () => {
|
||||
const system =
|
||||
'deb [arch=amd64 signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://deb.debian.org/debian bookworm main # https://leave.example/debian\n';
|
||||
const security =
|
||||
'deb-src https://security.debian.org/debian-security bookworm-security main\n';
|
||||
const vendors = [
|
||||
'deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable\n',
|
||||
'deb https://download.docker.com/linux/debian bookworm stable\n',
|
||||
'deb [signed-by=/etc/apt/keyrings/vendor.gpg] https://vendor.example/debian bookworm main\n',
|
||||
'deb https://packages.microsoft.com/repos/code stable main\n',
|
||||
'# deb https://deb.debian.org/debian bookworm main\n',
|
||||
];
|
||||
const original = system + security + vendors.join('');
|
||||
const changed = rewriteMirrorSources(
|
||||
original,
|
||||
'https://mirror.example/debian',
|
||||
);
|
||||
assert.ok(
|
||||
changed.includes(
|
||||
system.replace(
|
||||
'https://deb.debian.org/debian',
|
||||
'https://mirror.example/debian',
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
changed.includes(
|
||||
security.replace(
|
||||
'https://security.debian.org/debian-security',
|
||||
'https://mirror.example/debian-security',
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const unchanged of vendors) assert.ok(changed.includes(unchanged));
|
||||
assert.equal(
|
||||
rewriteMirrorSources(changed, 'https://mirror.example/debian'),
|
||||
changed,
|
||||
);
|
||||
});
|
||||
|
||||
test('Ubuntu ports, Armbian and Alpine roots still switch mirrors without altering repository metadata', () => {
|
||||
for (const url of [
|
||||
'https://ports.ubuntu.com/ubuntu-ports',
|
||||
'https://apt.armbian.com/armbian',
|
||||
'https://old.example/alpine/v3.22/main',
|
||||
'https://old.example/alpine/v3.22/community',
|
||||
]) {
|
||||
const original = `URIs: ${url}\nSuites: noble\nComponents: main\n`;
|
||||
assert.equal(
|
||||
rewriteMirrorSources(original, 'https://mirror.example'),
|
||||
original.replace(url, replaceMirrorUrl(url, 'https://mirror.example')),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -129,7 +129,7 @@ Acquire::http::Timeout "1";
|
||||
'fs/promises': {
|
||||
readFile: async (file) =>
|
||||
file === '/etc/os-release'
|
||||
? `${distribution}\nVERSION_CODENAME=bookworm\n`
|
||||
? distribution
|
||||
: existingMirror
|
||||
? 'URIs: http://old.example/debian\n'
|
||||
: '',
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
const test = require('node:test'),
|
||||
assert = require('node:assert/strict');
|
||||
const { Sequelize } = require('sequelize');
|
||||
const jwt = require('jsonwebtoken'),
|
||||
express = require('express');
|
||||
const fs = require('node:fs'),
|
||||
os = require('node:os'),
|
||||
path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('user accounts keep credentials private, update on SQLite and revoke changed sessions', async (t) => {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => database.close());
|
||||
const cache = new Map();
|
||||
const config = {
|
||||
jwt: { secret: 'synthetic-multiuser-test' },
|
||||
api: { prefix: '/api' },
|
||||
};
|
||||
const mocks = {
|
||||
'.': { sequelize: database },
|
||||
'../config': config,
|
||||
'../shared/store': {
|
||||
shareStore: { getAuthInfo: async () => ({ username: 'owner' }) },
|
||||
},
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
};
|
||||
const { UserModel } = load('back/data/user.ts', mocks, cache);
|
||||
await UserModel.sync();
|
||||
const Account = load('back/services/userManagement.ts', mocks, cache).default;
|
||||
const account = new Account({});
|
||||
const alice = await account.create({
|
||||
username: 'alice',
|
||||
password: 'password1',
|
||||
role: 1,
|
||||
status: 0,
|
||||
});
|
||||
assert.ok(!Object.hasOwn(alice, 'password'));
|
||||
assert.ok(!Object.hasOwn(alice, 'sessions'));
|
||||
const admin = await account.create({
|
||||
username: 'manager',
|
||||
password: 'password2',
|
||||
role: 0,
|
||||
status: 0,
|
||||
});
|
||||
assert.equal(admin.role, 0);
|
||||
const raw = await UserModel.findByPk(alice.id);
|
||||
assert.match(raw.password, /^scrypt\$/);
|
||||
assert.notEqual(raw.password, 'password1');
|
||||
assert.equal(await account.authenticate('alice', 'wrong'), null);
|
||||
let token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
assert.equal((await account.validateToken(token, 'desktop')).id, alice.id);
|
||||
assert.equal(await account.validateToken(token, 'mobile'), null);
|
||||
const forged = jwt.sign({ userId: alice.id, role: 0 }, config.jwt.secret, {
|
||||
algorithm: 'HS384',
|
||||
expiresIn: '1h',
|
||||
});
|
||||
assert.equal(await account.validateToken(forged, 'desktop'), null);
|
||||
const updated = await account.update({
|
||||
id: alice.id,
|
||||
username: 'alice',
|
||||
password: '',
|
||||
});
|
||||
assert.equal(updated.username, 'alice');
|
||||
assert.ok(!Object.hasOwn(updated, 'password'));
|
||||
assert.equal(await account.validateToken(token, 'desktop'), null);
|
||||
token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
await account.revokeToken(alice.id, token);
|
||||
assert.equal(await account.validateToken(token, 'desktop'), null);
|
||||
token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
await account.update({ id: alice.id, status: 1 });
|
||||
assert.equal(await account.validateToken(token, 'desktop'), null);
|
||||
assert.equal(await account.authenticate('alice', 'password1'), null);
|
||||
assert.ok(
|
||||
(await account.list()).every(
|
||||
(x) => !Object.hasOwn(x, 'password') && !Object.hasOwn(x, 'sessions'),
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
account.create({
|
||||
username: 'owner',
|
||||
password: 'password3',
|
||||
role: 1,
|
||||
status: 0,
|
||||
}),
|
||||
);
|
||||
await account.update({ id: alice.id, status: 0, password: 'changed123' });
|
||||
assert.equal(await account.authenticate('alice', 'password1'), null);
|
||||
await account.delete([alice.id]);
|
||||
assert.equal(await account.validateToken(token, 'desktop'), null);
|
||||
});
|
||||
|
||||
test('HTTP ordinary users see only their own data and cannot invoke global or executable APIs', async (t) => {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => database.close());
|
||||
const cache = new Map();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-user-http-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const secret = 'synthetic-http-multiuser',
|
||||
adminToken = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
||||
const config = {
|
||||
jwt: { secret },
|
||||
api: { prefix: '/api' },
|
||||
apiWhiteList: ['/api/user/login', '/api/health', '/api/system'],
|
||||
baseUrl: '',
|
||||
rootPath: root,
|
||||
uploadPath: root,
|
||||
};
|
||||
const mocks = {
|
||||
'.': { sequelize: database },
|
||||
'../config': config,
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'../data/open': {},
|
||||
'../config/serverEnv': { serveEnv: (req, res) => res.end() },
|
||||
'../config/util': {
|
||||
getToken: (req) =>
|
||||
(req.headers.authorization || '').replace(/^Bearer /, ''),
|
||||
getPlatform: () => 'desktop',
|
||||
},
|
||||
'../shared/store': {
|
||||
shareStore: {
|
||||
getAuthInfo: async () => ({
|
||||
username: 'owner',
|
||||
password: 'configured',
|
||||
token: adminToken,
|
||||
}),
|
||||
getApps: async () => [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const userData = load('back/data/user.ts', mocks, cache),
|
||||
envData = load('back/data/env.ts', mocks, cache),
|
||||
cronData = load('back/data/cron.ts', mocks, cache),
|
||||
subData = load('back/data/subscription.ts', mocks, cache);
|
||||
for (const model of [
|
||||
userData.UserModel,
|
||||
envData.EnvModel,
|
||||
cronData.CrontabModel,
|
||||
subData.SubscriptionModel,
|
||||
])
|
||||
await model.sync();
|
||||
mocks['../data/env'] = envData;
|
||||
mocks['../data/cron'] = cronData;
|
||||
mocks['../data/subscription'] = subData;
|
||||
const Account = load('back/services/userManagement.ts', mocks, cache).default;
|
||||
const account = new Account({});
|
||||
mocks['../services/userManagement'] = { default: Account };
|
||||
mocks.typedi.Container = { get: () => account };
|
||||
const alice = await account.create({
|
||||
username: 'alice',
|
||||
password: 'password1',
|
||||
role: 1,
|
||||
status: 0,
|
||||
}),
|
||||
bob = await account.create({
|
||||
username: 'bob',
|
||||
password: 'password2',
|
||||
role: 1,
|
||||
status: 0,
|
||||
});
|
||||
await envData.EnvModel.bulkCreate([
|
||||
{ name: 'mine', value: 'alice-only', userId: alice.id },
|
||||
{ name: 'other', value: 'bob-secret', userId: bob.id },
|
||||
{ name: 'legacy', value: 'owner-secret', userId: null },
|
||||
]);
|
||||
let commands = 0;
|
||||
mocks['../api'] = () => {
|
||||
const router = express.Router();
|
||||
router.get('/system', (req, res) => res.json({ code: 200 }));
|
||||
router.get('/user', async (req, res) =>
|
||||
res.json({ code: 200, data: await account.get(req.user.userId) }),
|
||||
);
|
||||
router.post('/user/logout', async (req, res) => {
|
||||
await account.revokeToken(
|
||||
req.user.userId,
|
||||
mocks['../config/util'].getToken(req),
|
||||
);
|
||||
res.json({ code: 200 });
|
||||
});
|
||||
router.post('/system/command', (req, res) => {
|
||||
commands++;
|
||||
res.json({ code: 200 });
|
||||
});
|
||||
router.get('/envs', (req, res) =>
|
||||
res.json({ code: 200, data: 'admin-all' }),
|
||||
);
|
||||
return router;
|
||||
};
|
||||
const app = express();
|
||||
load('back/loaders/express.ts', mocks, cache).default({ app });
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((r) => server.once('listening', r));
|
||||
t.after(() => new Promise((r) => server.close(r)));
|
||||
let token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
async function request(url, method = 'GET', current = token) {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}${url}`,
|
||||
{ method, headers: { Authorization: 'Bearer ' + current } },
|
||||
);
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
assert.equal((await request('/api/system')).status, 200);
|
||||
assert.equal((await request('/api/user')).body.data.username, 'alice');
|
||||
const mine = await request(`/api/envs?userId=${bob.id}`);
|
||||
assert.deepEqual(
|
||||
mine.body.data.map((x) => x.value),
|
||||
['alice-only'],
|
||||
);
|
||||
assert.equal((await request('/api/envs/2')).status, 404);
|
||||
assert.equal((await request('/api/envs/3')).status, 404);
|
||||
for (const [url, method] of [
|
||||
['/api/system/command', 'POST'],
|
||||
['/api/crons/run', 'PUT'],
|
||||
['/api/envs', 'PUT'],
|
||||
['/api/configs', 'GET'],
|
||||
['/api/logs', 'GET'],
|
||||
['/api/scripts', 'GET'],
|
||||
['/api/dependencies', 'POST'],
|
||||
['/api/user-management', 'GET'],
|
||||
['/api/system/config', 'GET'],
|
||||
['/api/user/two-factor/init', 'GET'],
|
||||
])
|
||||
assert.equal((await request(url, method)).status, 403, url);
|
||||
assert.equal(commands, 0);
|
||||
assert.equal(
|
||||
(await request('/api/envs', 'GET', adminToken)).body.data,
|
||||
'admin-all',
|
||||
);
|
||||
const forged = jwt.sign({ userId: alice.id, role: 0 }, secret, {
|
||||
algorithm: 'HS384',
|
||||
expiresIn: '1h',
|
||||
});
|
||||
assert.equal((await request('/api/envs', 'GET', forged)).status, 401);
|
||||
await request('/api/user/logout', 'POST');
|
||||
assert.equal((await request('/api/envs')).status, 401);
|
||||
token = await account.issueToken(
|
||||
await account.authenticate('alice', 'password1'),
|
||||
'desktop',
|
||||
);
|
||||
await account.update({ id: alice.id, status: 1 });
|
||||
assert.equal((await request('/api/envs')).status, 401);
|
||||
});
|
||||
|
||||
test('user ownership migration preserves existing records and is idempotent', async (t) => {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => database.close());
|
||||
for (const table of [
|
||||
'Users',
|
||||
'Envs',
|
||||
'Crontabs',
|
||||
'Subscriptions',
|
||||
'Dependences',
|
||||
])
|
||||
await database.query(
|
||||
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
);
|
||||
await database.query(`INSERT INTO Envs (id,name) VALUES (1,'preserved')`);
|
||||
const {
|
||||
migrateUserSchema,
|
||||
} = require('../../back/shared/userSchemaMigrations');
|
||||
await migrateUserSchema(database);
|
||||
await migrateUserSchema(database);
|
||||
const [rows] = await database.query('SELECT * FROM Envs');
|
||||
assert.equal(rows[0].name, 'preserved');
|
||||
assert.equal(rows[0].userId, null);
|
||||
const columns = await database.getQueryInterface().describeTable('Users');
|
||||
assert.ok(columns.sessions);
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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'`),
|
||||
);
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('#3097 Python requirement probes preserve version constraints, extras and installed distributions', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-specifiers-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const venv = path.join(root, 'virtual env');
|
||||
execFileSync('python3', ['-m', 'venv', venv]);
|
||||
execFileSync('python3', [
|
||||
'-c',
|
||||
`import zipfile,sys,os
|
||||
for version in ['1.2.3','2.1.0']:
|
||||
name='ql_specifier_fixture'
|
||||
with zipfile.ZipFile(os.path.join(sys.argv[1],name+'-'+version+'-py3-none-any.whl'),'w') as z:
|
||||
z.writestr(name+'.py','value=42\\n')
|
||||
info=name+'-'+version+'.dist-info/'
|
||||
z.writestr(info+'METADATA','Metadata-Version: 2.1\\nName: ql-specifier-fixture\\nVersion: '+version+'\\nProvides-Extra: optional\\n')
|
||||
z.writestr(info+'WHEEL','Wheel-Version: 1.0\\nGenerator: fixture\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n')
|
||||
z.writestr(info+'RECORD','')`,
|
||||
root,
|
||||
]);
|
||||
const api = load('back/config/util.ts', {
|
||||
'./index': { rootPath: root },
|
||||
'./const': { PYTHON_VENV_DIR: venv, PYTHON_INSTALL_DIR: '/unused-prefix' },
|
||||
'../loaders/logger': {},
|
||||
'../shared/utils': {},
|
||||
'../data/dependence': {
|
||||
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
|
||||
},
|
||||
});
|
||||
const env = { ...process.env, PIP_NO_INDEX: '1', PIP_FIND_LINKS: root };
|
||||
for (const key of ['PYTHONPATH', 'PYTHONUSERBASE', 'PYTHON_HOME'])
|
||||
delete env[key];
|
||||
const run = (command) =>
|
||||
execFileSync('/bin/bash', ['-c', command], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
run(api.getInstallCommand(1, 'ql-specifier-fixture==1.2.3'));
|
||||
const Service = load('back/services/dependence.ts', {
|
||||
'../config': {},
|
||||
'../config/util': {
|
||||
...api,
|
||||
promiseExecSuccess: async (command) => run(command),
|
||||
},
|
||||
'../data/dependence': {
|
||||
Dependence: class {},
|
||||
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
|
||||
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
|
||||
},
|
||||
'../shared/pLimit': {},
|
||||
'./sock': class {},
|
||||
'../shared/i18n': {},
|
||||
}).default;
|
||||
const service = new Service({}, {});
|
||||
assert.equal(
|
||||
await service.isDependenceInstalled({
|
||||
type: 1,
|
||||
name: 'ql-specifier-fixture==1.2.3.0',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await service.isDependenceInstalled({
|
||||
type: 1,
|
||||
name: 'ql-specifier-fixture>=2',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
for (const suffix of [
|
||||
'',
|
||||
'>=1.0',
|
||||
'<2',
|
||||
'>=1,<2',
|
||||
'~=1.2',
|
||||
'==1.*',
|
||||
'!=2.0',
|
||||
'[optional]>=1',
|
||||
'==1.2.3.0',
|
||||
]) {
|
||||
assert.equal(
|
||||
run(api.getGetCommand(1, 'ql-specifier-fixture' + suffix)),
|
||||
'1.2.3',
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
for (const suffix of ['>=2', '<1', '!=1.2.3', '==1.2.30']) {
|
||||
assert.equal(
|
||||
run(api.getGetCommand(1, 'ql-specifier-fixture' + suffix)),
|
||||
'',
|
||||
suffix,
|
||||
);
|
||||
}
|
||||
assert.equal(run(api.getGetCommand(1, 'ql_specifier_fixture')), '1.2.3');
|
||||
assert.equal(run(api.getGetCommand(1, 'json')), 'json');
|
||||
assert.equal(
|
||||
run(api.getGetCommand(1, 'ql-missing-specifier-package>=1')),
|
||||
'',
|
||||
);
|
||||
assert.equal(
|
||||
run(api.getGetCommand(1, 'ql-specifier-fixture; python_version < "0"')),
|
||||
'not applicable',
|
||||
);
|
||||
run(api.getInstallCommand(1, 'ql-specifier-fixture>=2'));
|
||||
assert.equal(
|
||||
run(api.getGetCommand(1, 'ql-specifier-fixture>=2,<3')),
|
||||
'2.1.0',
|
||||
);
|
||||
run(api.getUninstallCommand(1, 'ql-specifier-fixture[optional]>=2,<3'));
|
||||
assert.equal(run(api.getGetCommand(1, 'ql-specifier-fixture')), '');
|
||||
assert.equal(fs.existsSync(path.join(root, 'INJECTED')), false);
|
||||
assert.equal(
|
||||
run(
|
||||
api.getGetCommand(
|
||||
1,
|
||||
`ql-specifier-fixture; __import__('os').system('touch ${root}/INJECTED')`,
|
||||
),
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
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> <-F> <${currentLog}>\n`);
|
||||
assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`);
|
||||
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ test('database loader rejects initialization failure rather than allowing worker
|
||||
const mocks = { './logger': { error() {}, info() {} } };
|
||||
for (const [file, model] of [
|
||||
['env', 'EnvModel'],
|
||||
['user', 'UserModel'],
|
||||
['cron', 'CrontabModel'],
|
||||
['dependence', 'DependenceModel'],
|
||||
['open', 'AppModel'],
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,560 +0,0 @@
|
||||
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}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ async function setup() {
|
||||
const clients = new Set();
|
||||
const timers = new Set();
|
||||
const mocks = {
|
||||
jsonwebtoken: jwt,
|
||||
sockjs: {
|
||||
createServer: () => ({
|
||||
on: (_, fn) => {
|
||||
|
||||
@@ -70,3 +70,75 @@ test('WebSocket connections reject expired tokens and close when sessions are re
|
||||
});
|
||||
assert.equal(clients.size, 0);
|
||||
});
|
||||
|
||||
test('database administrators receive live updates while read-only users are refused and revoked admins disconnect', async () => {
|
||||
const secret = 'account-sock-test';
|
||||
const adminToken = jwt.sign({ userId: 2 }, secret, { algorithm: 'HS384' });
|
||||
const userToken = jwt.sign({ userId: 3 }, secret, { algorithm: 'HS384' });
|
||||
let revoked = false,
|
||||
onConnection;
|
||||
const clients = new Set();
|
||||
const Sock = class {},
|
||||
Account = class {};
|
||||
const loader = load(path.join(__dirname, '../../back/loaders/sock.ts'), {
|
||||
sockjs: {
|
||||
createServer: () => ({
|
||||
on: (_event, fn) => {
|
||||
onConnection = fn;
|
||||
},
|
||||
installHandlers() {},
|
||||
}),
|
||||
},
|
||||
typedi: {
|
||||
Container: {
|
||||
get: (kind) =>
|
||||
kind === Account
|
||||
? {
|
||||
validateToken: async (token) =>
|
||||
revoked ? null : { role: token === adminToken ? 0 : 1 },
|
||||
}
|
||||
: {
|
||||
addClient: (c) => clients.add(c),
|
||||
removeClient: (c) => clients.delete(c),
|
||||
},
|
||||
},
|
||||
},
|
||||
'../services/sock': Sock,
|
||||
'../services/userManagement': Account,
|
||||
'../config': { jwt: { secret }, baseUrl: '' },
|
||||
'../config/util': { getPlatform: () => 'desktop' },
|
||||
'../shared/store': {
|
||||
shareStore: { getAuthInfo: async () => ({ token: '', tokens: {} }) },
|
||||
},
|
||||
}).default;
|
||||
await loader({ server: {} });
|
||||
const connection = (token) =>
|
||||
Object.assign(new EventEmitter(), {
|
||||
headers: {},
|
||||
pathname: '/api/ws/a/b/websocket',
|
||||
url: `/api/ws/a/b/websocket?token=${token}`,
|
||||
close() {
|
||||
this.closed = true;
|
||||
this.emit('close');
|
||||
},
|
||||
write() {},
|
||||
});
|
||||
const ordinary = connection(userToken);
|
||||
await onConnection(ordinary);
|
||||
assert.equal(ordinary.closed, true);
|
||||
const admin = connection(adminToken);
|
||||
await onConnection(admin);
|
||||
assert.equal(clients.has(admin), true);
|
||||
revoked = true;
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
admin.close();
|
||||
reject(new Error('revoked administrator remained connected'));
|
||||
}, 3000);
|
||||
admin.once('close', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
assert.equal(clients.size, 0);
|
||||
});
|
||||
|
||||
@@ -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-simplify.sh', 'utf8')
|
||||
.split('. "${QL_DIR}/shell/share.sh"')[0];
|
||||
.readFileSync('shell/start.sh', 'utf8')
|
||||
.split('. ${QL_DIR}/shell/share.sh')[0];
|
||||
for (const command of ['start', 'reload']) {
|
||||
for (const existing of [false, true]) {
|
||||
test(`${command} ${
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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));
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ function fixture(initial) {
|
||||
require('node:crypto').randomBytes(32).toString('hex'),
|
||||
},
|
||||
'./notify': class {},
|
||||
'./userManagement': class {},
|
||||
'./schedule': class {},
|
||||
'./sock': class {},
|
||||
'../shared/store': {
|
||||
@@ -67,6 +68,7 @@ function fixture(initial) {
|
||||
{ getClients: () => [{ close: () => closed++ }] },
|
||||
);
|
||||
user.notificationService = { notify() {} };
|
||||
user.userManagementService = { authenticate: async () => null };
|
||||
return {
|
||||
user,
|
||||
get auth() {
|
||||
|
||||
@@ -64,7 +64,7 @@ test('decoded SystemNotify requests reach WPUSH with explicit or saved configura
|
||||
'../config/const': constants,
|
||||
'../config/util': {},
|
||||
'../data/dependence': {},
|
||||
'../data/system': { AuthDataType: { notifyLog: 'notifyLog' }, NotifyStatus: { success: 0, fail: 1 }, SystemModel: { create: async () => {}, findAll: async () => [] } },
|
||||
'../data/system': {},
|
||||
'../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({ error() {} });
|
||||
const system = new SystemService();
|
||||
system.notificationService = notification;
|
||||
|
||||
for (const info of [
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
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