mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-05 21:01:11 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7603702eca | ||
|
|
f168efcdae |
+4
-130
@@ -9,14 +9,9 @@ import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import path, { join, parse } from 'path';
|
||||
import ScriptService from '../services/script';
|
||||
import ScriptHistoryService from '../services/scriptHistory';
|
||||
import { t } from '../shared/i18n';
|
||||
import multer from 'multer';
|
||||
import { writeFileWithLock } from '../shared/utils';
|
||||
import {
|
||||
ScriptHistoryError,
|
||||
HistoryUnavailableError,
|
||||
} from '../shared/scriptHistory';
|
||||
const route = Router();
|
||||
|
||||
function isPathAllowed(targetPath: string): boolean {
|
||||
@@ -120,82 +115,6 @@ export default (app: Router) => {
|
||||
},
|
||||
);
|
||||
|
||||
const historyError = (error: unknown, res: Response, next: NextFunction) => {
|
||||
if (error instanceof ScriptHistoryError) {
|
||||
return res.status(error.status).send({
|
||||
code: error.status,
|
||||
message: t(error.message),
|
||||
...(error instanceof HistoryUnavailableError
|
||||
? { historyUnavailable: true, currentHash: error.currentHash }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return next(error);
|
||||
};
|
||||
const historyQuery = {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
};
|
||||
|
||||
route.get(
|
||||
'/history',
|
||||
celebrate({ query: Joi.object(historyQuery).unknown(true) }),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.list(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.get(
|
||||
'/history/detail',
|
||||
celebrate({
|
||||
query: Joi.object({
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
}).unknown(true),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.detail(
|
||||
(req.query.path as string) || '',
|
||||
req.query.filename as string,
|
||||
req.query.id as string,
|
||||
);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
route.put(
|
||||
'/history/restore',
|
||||
celebrate({
|
||||
body: Joi.object({
|
||||
...historyQuery,
|
||||
id: Joi.string().uuid().required(),
|
||||
expectedHash: Joi.string().hex().length(64).required(),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { path = '', filename, id, expectedHash } = req.body;
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.restore(path, filename, id, expectedHash);
|
||||
res.send({ code: 200, data });
|
||||
} catch (error) {
|
||||
historyError(error, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
route.get('/:file', (req: Request, res: Response) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
@@ -217,11 +136,6 @@ export default (app: Router) => {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().optional().allow(''),
|
||||
skipHistory: Joi.boolean().optional(),
|
||||
expectedHash: Joi.string()
|
||||
.hex()
|
||||
.length(64)
|
||||
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||
originFilename: Joi.string().optional().allow(''),
|
||||
directory: Joi.string().optional().allow(''),
|
||||
file: Joi.string().optional().allow(''),
|
||||
@@ -283,37 +197,6 @@ export default (app: Router) => {
|
||||
}
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
const fileExists = await fileExist(filePath);
|
||||
if (
|
||||
fileExists &&
|
||||
resolveFileAccess(config.scriptPath, [filePath], config.blackFileList)
|
||||
) {
|
||||
let removeSource = filename !== originFilename;
|
||||
if (removeSource) {
|
||||
const [originRealPath, targetRealPath] = await Promise.all([
|
||||
fs.realpath(originFilePath),
|
||||
fs.realpath(filePath),
|
||||
]);
|
||||
// Aliases of one script are an in-place save. Removing the source
|
||||
// would also remove the destination behind a target symlink.
|
||||
removeSource = originRealPath !== targetRealPath;
|
||||
}
|
||||
// Save-as removes the source after committing the destination. Keep
|
||||
// its original content too: destination history only protects the
|
||||
// file being overwritten, not the source being deleted.
|
||||
if (removeSource) {
|
||||
await fs.copyFile(
|
||||
originFilePath,
|
||||
join(config.bakPath, originFilename.replace(/\//g, '')),
|
||||
);
|
||||
}
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path, filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
if (removeSource) await rmPath(originFilePath);
|
||||
return res.send({ code: 200, data });
|
||||
}
|
||||
if (fileExists) {
|
||||
await fs.copyFile(
|
||||
originFilePath,
|
||||
@@ -326,7 +209,7 @@ export default (app: Router) => {
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return historyError(e, res, next);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -338,11 +221,6 @@ export default (app: Router) => {
|
||||
filename: Joi.string().required(),
|
||||
path: Joi.string().optional().allow(''),
|
||||
content: Joi.string().required().allow(''),
|
||||
skipHistory: Joi.boolean().optional(),
|
||||
expectedHash: Joi.string()
|
||||
.hex()
|
||||
.length(64)
|
||||
.when('skipHistory', { is: true, then: Joi.required() }),
|
||||
}),
|
||||
}),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -360,14 +238,10 @@ export default (app: Router) => {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
const service = Container.get(ScriptHistoryService);
|
||||
const data = await service.save(path || '', filename, content, {
|
||||
skipHistory: req.body.skipHistory,
|
||||
expectedHash: req.body.expectedHash,
|
||||
});
|
||||
return res.send({ code: 200, data });
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return historyError(e, res, next);
|
||||
return next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
|
||||
import { Logger } from 'winston';
|
||||
import SubscriptionService from '../services/subscription';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
@@ -58,10 +58,7 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.create(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
@@ -204,11 +201,7 @@ export default (app: Router) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const logger: Logger = Container.get('logger');
|
||||
try {
|
||||
if (
|
||||
!req.body.schedule ||
|
||||
typeof req.body.schedule === 'object' ||
|
||||
CronExpressionParser.parse(req.body.schedule).hasNext()
|
||||
) {
|
||||
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
|
||||
const subscriptionService = Container.get(SubscriptionService);
|
||||
const data = await subscriptionService.update(req.body);
|
||||
return res.send({ code: 200, data });
|
||||
|
||||
+48
-73
@@ -1,6 +1,6 @@
|
||||
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
||||
import nodeSchedule from 'node-schedule';
|
||||
import { createCronJob, CronJob } from '../shared/cronScheduler';
|
||||
import { isValidCronSchedule } from '../shared/cronSchedule';
|
||||
import { scheduleStacks } from './data';
|
||||
import { runCron } from '../shared/runCron';
|
||||
@@ -24,11 +24,7 @@ const addCron = (
|
||||
|
||||
if (!isValidCronField(schedule)) {
|
||||
validationErrors.push(
|
||||
tf(
|
||||
'任务ID %s: 无效的 cron 表达式 "%s"',
|
||||
String(id),
|
||||
schedule,
|
||||
),
|
||||
tf('任务ID %s: 无效的 cron 表达式 "%s"', String(id), schedule),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,79 +52,58 @@ const addCron = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Recovery replaces the whole snapshot, including deletions and disabled jobs.
|
||||
// Validation above must finish before touching the previous schedule.
|
||||
if (call.request.replace) {
|
||||
for (const jobs of scheduleStacks.values()) {
|
||||
for (const job of jobs) job?.cancel();
|
||||
// Prepare stopped jobs first: construction errors must preserve the old snapshot.
|
||||
const prepared = new Map<string, CronJob[]>();
|
||||
try {
|
||||
for (const item of call.request.crons) {
|
||||
const jobs: CronJob[] = [];
|
||||
prepared.get(item.id)?.forEach((job) => job.cancel());
|
||||
prepared.set(item.id, jobs);
|
||||
for (const schedule of [
|
||||
item.schedule,
|
||||
...(item.extra_schedules || []).map((x) => x.schedule),
|
||||
]) {
|
||||
jobs.push(
|
||||
createCronJob(
|
||||
schedule,
|
||||
async () => {
|
||||
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
|
||||
await runCron(item.command, item);
|
||||
},
|
||||
{
|
||||
name: `${item.id}: ${item.name || ''}`,
|
||||
logger: Logger,
|
||||
start: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
scheduleStacks.clear();
|
||||
} catch (error) {
|
||||
for (const jobs of prepared.values()) jobs.forEach((job) => job.cancel());
|
||||
const err: any = new Error(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
err.code = status.INVALID_ARGUMENT;
|
||||
err.details = err.message;
|
||||
callback(err, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// ===== 第二遍:注册所有任务 =====
|
||||
for (const item of call.request.crons) {
|
||||
const { id, schedule, command, extra_schedules, name } = item;
|
||||
|
||||
// 取消该 id 已有的旧任务
|
||||
if (scheduleStacks.has(id)) {
|
||||
scheduleStacks.get(id)?.forEach((x) => x.cancel());
|
||||
}
|
||||
|
||||
if (call.request.replace) {
|
||||
for (const jobs of scheduleStacks.values())
|
||||
jobs.forEach((job) => job?.cancel());
|
||||
scheduleStacks.clear();
|
||||
}
|
||||
for (const [id, jobs] of prepared) {
|
||||
scheduleStacks.get(id)?.forEach((job) => job.cancel());
|
||||
scheduleStacks.set(id, jobs);
|
||||
jobs.forEach((job) => job.start());
|
||||
Logger.info(
|
||||
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
|
||||
id,
|
||||
name,
|
||||
schedule,
|
||||
command,
|
||||
jobs.length,
|
||||
);
|
||||
|
||||
if (extra_schedules?.length) {
|
||||
extra_schedules.forEach((x) => {
|
||||
Logger.info(
|
||||
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
|
||||
id,
|
||||
name,
|
||||
x.schedule,
|
||||
command,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
|
||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||
runCron(command, item);
|
||||
});
|
||||
|
||||
if (!mainJob) {
|
||||
Logger.warn(
|
||||
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
||||
id,
|
||||
schedule,
|
||||
);
|
||||
}
|
||||
|
||||
const extraJobs = extra_schedules?.length
|
||||
? extra_schedules.map((x) => {
|
||||
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
|
||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||
runCron(command, item);
|
||||
});
|
||||
if (!job) {
|
||||
Logger.warn(
|
||||
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
||||
id,
|
||||
x.schedule,
|
||||
);
|
||||
}
|
||||
return job;
|
||||
})
|
||||
: [];
|
||||
|
||||
// 过滤 null(兜底保护,正常情况下预校验已拦截)
|
||||
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
|
||||
if (jobs.length > 0) {
|
||||
scheduleStacks.set(id, jobs);
|
||||
}
|
||||
}
|
||||
|
||||
callback(null, null);
|
||||
|
||||
@@ -67,11 +67,17 @@ class Client {
|
||||
replace = false
|
||||
): Promise<AddCronResponse> {
|
||||
await this.waitForReady(2000);
|
||||
// Include every rule in a recovery snapshot, but bound a stuck worker.
|
||||
const ruleCount = request.reduce(
|
||||
(count, item) => count + 1 + (item.extra_schedules?.length || 0),
|
||||
0,
|
||||
);
|
||||
const registrationTimeoutMs = Math.min(120000, 5000 + ruleCount * 5);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.addCron(
|
||||
{ crons: request, replace },
|
||||
new Metadata(),
|
||||
{ deadline: Date.now() + 5000 },
|
||||
{ deadline: Date.now() + registrationTimeoutMs },
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import nodeSchedule from 'node-schedule';
|
||||
import { CronJob } from '../shared/cronScheduler';
|
||||
import { ToadScheduler } from 'toad-scheduler';
|
||||
|
||||
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>();
|
||||
export const scheduleStacks = new Map<string, CronJob[]>();
|
||||
|
||||
export const intervalSchedule = new ToadScheduler();
|
||||
|
||||
@@ -13,7 +13,7 @@ const delCron = (
|
||||
'[schedule][取消定时任务] 任务ID: %s',
|
||||
id,
|
||||
);
|
||||
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
|
||||
// 防御性过滤历史调度栈中的空任务,
|
||||
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
|
||||
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
|
||||
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
|
||||
|
||||
+13
-6
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getInvalidCronSchedules } from '../shared/cronSchedule';
|
||||
import { getInvalidCronSchedules, isValidCronSchedule } from '../shared/cronSchedule';
|
||||
import {
|
||||
withSchedulerMutation,
|
||||
schedulerRegistrationError,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from '../data/runningInstance';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import {
|
||||
getFileContentByName,
|
||||
fileExist,
|
||||
@@ -47,12 +46,20 @@ export default class CronService {
|
||||
|
||||
private isNodeCron(cron: Crontab) {
|
||||
const { schedule, extra_schedules } = cron;
|
||||
const fields = schedule?.trim().split(/\s+/) || [];
|
||||
// System crontab only receives portable numeric five-field expressions.
|
||||
// Extended syntax, macros and legacy shorthand belong to node-schedule.
|
||||
// Extended syntax, macros and legacy shorthand use the Node scheduler.
|
||||
return (
|
||||
schedule?.trim().split(/\s+/).length !== 5 ||
|
||||
fields.length !== 5 ||
|
||||
/[^\d\s*,/\-]/.test(schedule || '') ||
|
||||
Boolean(extra_schedules?.length)
|
||||
Boolean(extra_schedules?.length) ||
|
||||
// BusyBox treats N/step as a single value and doesn't support Sunday=7.
|
||||
fields.some((field) => field.split(',').some((part) => /^\d+\//.test(part))) ||
|
||||
fields[4].includes('7') ||
|
||||
// BusyBox steps DOM from zero; its full-range day/week wildcard
|
||||
// handling also differs from the legacy Node parser's OR semantics.
|
||||
fields[2].includes('/') ||
|
||||
(fields[2] !== '*' && fields[4] !== '*')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1057,7 +1064,7 @@ export default class CronService {
|
||||
if (
|
||||
command &&
|
||||
schedule &&
|
||||
CronExpressionParser.parse(schedule).hasNext()
|
||||
isValidCronSchedule(schedule)
|
||||
) {
|
||||
const name = namePrefix + '_' + index;
|
||||
|
||||
|
||||
+33
-14
@@ -1,6 +1,6 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import nodeSchedule from 'node-schedule';
|
||||
import { createCronJob, CronJob } from '../shared/cronScheduler';
|
||||
import { ChildProcessWithoutNullStreams } from 'child_process';
|
||||
import {
|
||||
ToadScheduler,
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { spawn } from 'cross-spawn';
|
||||
import { observeChildProcess, asError, ProcessResult } from '../shared/childProcess';
|
||||
import {
|
||||
observeChildProcess,
|
||||
asError,
|
||||
ProcessResult,
|
||||
} from '../shared/childProcess';
|
||||
|
||||
export interface ScheduleTaskType {
|
||||
id?: number;
|
||||
@@ -38,7 +42,7 @@ export interface TaskCallbacks {
|
||||
|
||||
@Service()
|
||||
export default class ScheduleService {
|
||||
private scheduleStacks = new Map<string, nodeSchedule.Job>();
|
||||
private scheduleStacks = new Map<string, CronJob>();
|
||||
|
||||
private intervalSchedule = new ToadScheduler();
|
||||
|
||||
@@ -159,18 +163,33 @@ export default class ScheduleService {
|
||||
command,
|
||||
);
|
||||
|
||||
this.scheduleStacks.set(
|
||||
_id,
|
||||
nodeSchedule.scheduleJob(_id, schedule, async () => {
|
||||
this.runTask(command, callbacks, {
|
||||
name,
|
||||
if (schedule) {
|
||||
try {
|
||||
const job = createCronJob(
|
||||
schedule,
|
||||
command,
|
||||
id: _id,
|
||||
runOrigin,
|
||||
});
|
||||
}),
|
||||
);
|
||||
() =>
|
||||
this.runTask(command, callbacks, {
|
||||
name,
|
||||
schedule,
|
||||
command,
|
||||
id: _id,
|
||||
runOrigin,
|
||||
}),
|
||||
{ name: `${_id}: ${name || ''}`, logger: this.logger, start: false },
|
||||
);
|
||||
this.scheduleStacks.get(_id)?.cancel();
|
||||
this.scheduleStacks.set(_id, job);
|
||||
job.start();
|
||||
} catch (error) {
|
||||
// A persisted invalid subscription must not become an unhandled startup rejection.
|
||||
this.logger.warn(
|
||||
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
|
||||
_id,
|
||||
schedule,
|
||||
asError(error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (runImmediately) {
|
||||
this.runTask(command, callbacks, {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import path from 'path';
|
||||
import { Service } from 'typedi';
|
||||
import config from '../config';
|
||||
import { ScriptHistory } from '../shared/scriptHistory';
|
||||
|
||||
/** Panel configuration and lifecycle; the storage core stays independently testable. */
|
||||
@Service()
|
||||
export default class ScriptHistoryService extends ScriptHistory {
|
||||
constructor() {
|
||||
super(
|
||||
config.scriptPath,
|
||||
path.join(config.dataPath, 'script-history'),
|
||||
config.blackFileList,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,93 @@
|
||||
import cronParser from 'cron-parser-v4';
|
||||
import CronExpressionParser from 'cron-parser';
|
||||
import { validate } from 'node-cron';
|
||||
import { ScheduleType } from '../interface/schedule';
|
||||
|
||||
// Keep validation aligned with node-schedule's locked cron-parser version.
|
||||
// cron-parser 5 accepts syntax (e.g. H) that the scheduler cannot execute.
|
||||
const aliases: Record<string, string> = {
|
||||
'@yearly': '0 0 0 1 1 *',
|
||||
'@annually': '0 0 0 1 1 *',
|
||||
'@monthly': '0 0 0 1 * *',
|
||||
'@weekly': '0 0 0 * * 0',
|
||||
'@daily': '0 0 0 * * *',
|
||||
'@midnight': '0 0 0 * * *',
|
||||
'@hourly': '0 0 * * * *',
|
||||
'@minutely': '0 * * * * *',
|
||||
};
|
||||
|
||||
export interface CronSchedule {
|
||||
patterns: string[];
|
||||
dayCount: number;
|
||||
nthDay: number;
|
||||
}
|
||||
|
||||
// Validation and registration repeatedly see identical rules in large snapshots.
|
||||
// Cache only immutable syntax, never dates or running tasks, and bound user input.
|
||||
const scheduleCache = new Map<string, CronSchedule>();
|
||||
|
||||
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
|
||||
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
|
||||
export function parseCronSchedule(schedule: unknown): CronSchedule {
|
||||
if (typeof schedule !== 'string' || !schedule.trim()) {
|
||||
throw new Error('Invalid cron schedule');
|
||||
}
|
||||
let source = schedule.trim();
|
||||
const cacheKey = source;
|
||||
const cached = scheduleCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
if (source.startsWith('@')) {
|
||||
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
|
||||
source = aliases[source];
|
||||
}
|
||||
let parts = source.split(/\s+/);
|
||||
if (
|
||||
parts.length > 6 ||
|
||||
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
|
||||
) {
|
||||
throw new Error('Unsupported cron syntax');
|
||||
}
|
||||
parts = [
|
||||
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
|
||||
...parts,
|
||||
];
|
||||
if (
|
||||
parts.some(
|
||||
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
|
||||
)
|
||||
) {
|
||||
throw new Error('Question mark is only valid in day fields');
|
||||
}
|
||||
const expression = CronExpressionParser.parse(parts.join(' '));
|
||||
if (!expression.hasNext())
|
||||
throw new Error('Cron schedule has no next execution');
|
||||
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
|
||||
const dayCount = expression.fields.dayOfMonth.values.length;
|
||||
const weekCount = expression.fields.dayOfWeek.values.length;
|
||||
const patterns =
|
||||
dayCount < 31 && weekCount < 8
|
||||
? [
|
||||
[...normalized.slice(0, 5), '*'].join(' '),
|
||||
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
|
||||
]
|
||||
: [normalized.join(' ')];
|
||||
if (!patterns.every((pattern) => validate(pattern))) {
|
||||
throw new Error('Unsupported cron schedule');
|
||||
}
|
||||
const parsed = {
|
||||
patterns,
|
||||
dayCount,
|
||||
nthDay: expression.fields.dayOfWeek.nthDay,
|
||||
};
|
||||
Object.freeze(patterns);
|
||||
Object.freeze(parsed);
|
||||
if (scheduleCache.size >= 512)
|
||||
scheduleCache.delete(scheduleCache.keys().next().value);
|
||||
scheduleCache.set(cacheKey, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function isValidCronSchedule(schedule: unknown): schedule is string {
|
||||
if (typeof schedule !== 'string' || !schedule.trim()) return false;
|
||||
try {
|
||||
return cronParser.parseExpression(schedule).hasNext();
|
||||
parseCronSchedule(schedule);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -22,6 +103,8 @@ export function getInvalidCronSchedules(cron: {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [cron.schedule, ...(cron.extra_schedules || []).map((x) => x.schedule)]
|
||||
.filter((schedule) => !isValidCronSchedule(schedule));
|
||||
return [
|
||||
cron.schedule,
|
||||
...(cron.extra_schedules || []).map((x) => x.schedule),
|
||||
].filter((schedule) => !isValidCronSchedule(schedule));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createTask, ScheduledTask } from 'node-cron';
|
||||
import { parseCronSchedule } from './cronSchedule';
|
||||
|
||||
interface SchedulerLogger {
|
||||
warn(message: string, ...args: unknown[]): unknown;
|
||||
error(message: string, ...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
start(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
// node-cron owns timers and calendar calculation; Qinglong owns execution
|
||||
// concurrency and process lifetime. Missed in-process slots join that same queue.
|
||||
export function createCronJob(
|
||||
schedule: string,
|
||||
callback: (date: Date) => unknown | Promise<unknown>,
|
||||
options: { name: string; logger: SchedulerLogger; start?: boolean },
|
||||
): CronJob {
|
||||
const parsed = parseCronSchedule(schedule);
|
||||
const tasks: ScheduledTask[] = [];
|
||||
let cancelled = false;
|
||||
let started = false;
|
||||
const accepts = (index: number, date: Date) => {
|
||||
if (tasks.length === 1) return true;
|
||||
// Preserve cron-parser 4's day/weekday OR semantics, including its
|
||||
// month-dependent full-day range and the global nth-week constraint.
|
||||
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
|
||||
return false;
|
||||
if (index === 1) return !tasks[0].match(date);
|
||||
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
|
||||
};
|
||||
const dispatch = async (index: number, date: Date, missed: boolean) => {
|
||||
if (cancelled || !started || !accepts(index, date)) return;
|
||||
if (missed) {
|
||||
options.logger.warn(
|
||||
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
|
||||
options.name,
|
||||
date.toISOString(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await callback(date);
|
||||
} catch (error) {
|
||||
options.logger.error(
|
||||
'[schedule][定时回调失败] 任务: %s, 错误: %s',
|
||||
options.name,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (const [index, pattern] of parsed.patterns.entries()) {
|
||||
const task = createTask(
|
||||
pattern,
|
||||
(context) => dispatch(index, context.date, false),
|
||||
{
|
||||
noOverlap: false,
|
||||
missedExecutionTolerance: 5000,
|
||||
},
|
||||
);
|
||||
tasks.push(task);
|
||||
task.on('execution:missed', (context) =>
|
||||
dispatch(index, context.date, true),
|
||||
);
|
||||
// Fail before an existing schedule is cancelled, including impossible dates.
|
||||
task.getNextRuns(1);
|
||||
}
|
||||
} catch (error) {
|
||||
tasks.forEach((task) => task.destroy());
|
||||
throw error;
|
||||
}
|
||||
const job: CronJob = {
|
||||
start() {
|
||||
if (started || cancelled) return;
|
||||
started = true;
|
||||
tasks.forEach((task) => task.start());
|
||||
},
|
||||
cancel() {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
tasks.forEach((task) => task.destroy());
|
||||
},
|
||||
};
|
||||
if (options.start !== false) job.start();
|
||||
return job;
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import path from 'path';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
import { brotliCompress, brotliDecompress, constants } from 'zlib';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
import { getUniqueLockPath } from './utils';
|
||||
|
||||
const compress = promisify(brotliCompress);
|
||||
const decompress = promisify(brotliDecompress);
|
||||
const hash = (content: string) =>
|
||||
createHash('sha256').update(content).digest('hex');
|
||||
|
||||
export const SCRIPT_HISTORY_LIMITS = {
|
||||
versions: 20,
|
||||
fileBytes: 1024 * 1024,
|
||||
unpackedBytes: 8 * 1024 * 1024,
|
||||
archiveBytes: 2 * 1024 * 1024,
|
||||
totalBytes: 32 * 1024 * 1024,
|
||||
files: 1000,
|
||||
};
|
||||
|
||||
type Source = 'baseline' | 'save' | 'restore' | 'external';
|
||||
interface Version {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
source: Source;
|
||||
hash: string;
|
||||
content: string;
|
||||
}
|
||||
interface Archive {
|
||||
format: 1;
|
||||
path: string;
|
||||
versions: Version[];
|
||||
head?: { hash: string; createdAt: string; source: Source };
|
||||
}
|
||||
|
||||
export class ScriptHistoryError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class HistoryUnavailableError extends ScriptHistoryError {
|
||||
constructor(public currentHash: string) {
|
||||
super(413, '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本');
|
||||
}
|
||||
}
|
||||
interface SaveOptions {
|
||||
skipHistory?: boolean;
|
||||
expectedHash?: string;
|
||||
}
|
||||
|
||||
/** Lazy, bounded storage: no watcher, database, daemon, or in-memory cache.
|
||||
* Full snapshots share a Brotli window, so similar revisions compress together
|
||||
* without a patch chain. All mutations use the existing script lock and a
|
||||
* cross-process archive lock. History is durable before overwriting the script. */
|
||||
export class ScriptHistory {
|
||||
constructor(
|
||||
private scriptRoot: string,
|
||||
private historyRoot: string,
|
||||
private blacklist: string[] = [],
|
||||
private limits = SCRIPT_HISTORY_LIMITS,
|
||||
) {}
|
||||
|
||||
private async target(directory: string, filename: string) {
|
||||
const candidate = resolveFileAccess(
|
||||
this.scriptRoot,
|
||||
[directory, filename],
|
||||
this.blacklist,
|
||||
);
|
||||
if (!candidate) throw new ScriptHistoryError(403, '暂无权限');
|
||||
try {
|
||||
const file = await fs.realpath(candidate);
|
||||
const root = await fs.realpath(this.scriptRoot);
|
||||
const relative = path.relative(root, file);
|
||||
const key = hash(relative);
|
||||
return {
|
||||
file,
|
||||
relative,
|
||||
archive: path.join(this.historyRoot, key + '.br'),
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT')
|
||||
throw new ScriptHistoryError(404, '脚本不存在');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async readText(file: string) {
|
||||
const handle = await fs.open(file, 'r');
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) throw new ScriptHistoryError(400, '请选择脚本文件');
|
||||
if (stat.size > this.limits.fileBytes)
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
// Bounded read even if an external process grows the file after stat.
|
||||
const bytes = Buffer.alloc(this.limits.fileBytes + 1);
|
||||
let size = 0;
|
||||
while (size < bytes.length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
size,
|
||||
bytes.length - size,
|
||||
null,
|
||||
);
|
||||
if (!bytesRead) break;
|
||||
size += bytesRead;
|
||||
}
|
||||
const buffer = bytes.subarray(0, size);
|
||||
const content = buffer.toString('utf8');
|
||||
if (
|
||||
size > this.limits.fileBytes ||
|
||||
content.includes('\0') ||
|
||||
!Buffer.from(content).equals(buffer)
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
return content;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async readArchive(file: string, relative: string): Promise<Archive> {
|
||||
try {
|
||||
const stat = await fs.lstat(file);
|
||||
if (!stat.isFile() || stat.size > this.limits.archiveBytes)
|
||||
throw new Error('Invalid archive');
|
||||
const bytes = await decompress(await fs.readFile(file), {
|
||||
maxOutputLength: this.limits.unpackedBytes,
|
||||
});
|
||||
const archive: Archive = JSON.parse(bytes.toString('utf8'));
|
||||
if (
|
||||
archive.format !== 1 ||
|
||||
archive.path !== relative ||
|
||||
!Array.isArray(archive.versions) ||
|
||||
archive.versions.length > this.limits.versions ||
|
||||
archive.versions.some(
|
||||
(v) =>
|
||||
typeof v.content !== 'string' ||
|
||||
typeof v.id !== 'string' ||
|
||||
typeof v.createdAt !== 'string' ||
|
||||
!['baseline', 'save', 'restore', 'external'].includes(v.source) ||
|
||||
Buffer.byteLength(v.content) > this.limits.fileBytes ||
|
||||
hash(v.content) !== v.hash,
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid archive');
|
||||
}
|
||||
return archive;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT')
|
||||
return { format: 1, path: relative, versions: [] };
|
||||
throw new ScriptHistoryError(500, '历史版本读取失败,请检查历史文件');
|
||||
}
|
||||
}
|
||||
|
||||
async list(directory: string, filename: string) {
|
||||
const target = await this.target(directory, filename);
|
||||
const [current, archive] = await Promise.all([
|
||||
this.readText(target.file),
|
||||
this.readArchive(target.archive, target.relative),
|
||||
]);
|
||||
const currentHash = hash(current);
|
||||
const versions = archive.versions
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(({ content, ...v }) => ({
|
||||
...v,
|
||||
size: Buffer.byteLength(content),
|
||||
identical: v.hash === currentHash,
|
||||
}));
|
||||
return { versions, currentHash, limit: this.limits.versions };
|
||||
}
|
||||
|
||||
async detail(directory: string, filename: string, id: string) {
|
||||
const target = await this.target(directory, filename);
|
||||
const [current, archive] = await Promise.all([
|
||||
this.readText(target.file),
|
||||
this.readArchive(target.archive, target.relative),
|
||||
]);
|
||||
const version = archive.versions.find((v) => v.id === id);
|
||||
if (!version) throw new ScriptHistoryError(404, '历史版本不存在或已清理');
|
||||
return { version, current, currentHash: hash(current) };
|
||||
}
|
||||
|
||||
async save(
|
||||
directory: string,
|
||||
filename: string,
|
||||
content: string,
|
||||
options: SaveOptions = {},
|
||||
) {
|
||||
return this.mutate(directory, filename, { content, ...options });
|
||||
}
|
||||
|
||||
async restore(
|
||||
directory: string,
|
||||
filename: string,
|
||||
id: string,
|
||||
expectedHash: string,
|
||||
) {
|
||||
return this.mutate(directory, filename, { id, expectedHash });
|
||||
}
|
||||
|
||||
private async mutate(
|
||||
directory: string,
|
||||
filename: string,
|
||||
change:
|
||||
| ({ content: string } & SaveOptions)
|
||||
| { id: string; expectedHash: string },
|
||||
) {
|
||||
const target = await this.target(directory, filename);
|
||||
const releaseFile = await lock(target.file, {
|
||||
lockfilePath: getUniqueLockPath(target.file),
|
||||
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||
});
|
||||
try {
|
||||
let current: string;
|
||||
try {
|
||||
current = await this.readText(target.file);
|
||||
if (
|
||||
'content' in change &&
|
||||
(Buffer.byteLength(change.content) > this.limits.fileBytes ||
|
||||
change.content.includes('\0'))
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!('content' in change) ||
|
||||
!(error instanceof ScriptHistoryError) ||
|
||||
error.status !== 413
|
||||
)
|
||||
throw error;
|
||||
// Hash oversized or non-UTF-8 files without reading them all into memory.
|
||||
const digest = createHash('sha256');
|
||||
for await (const chunk of createReadStream(target.file))
|
||||
digest.update(chunk);
|
||||
const currentHash = digest.digest('hex');
|
||||
if (!change.skipHistory) throw new HistoryUnavailableError(currentHash);
|
||||
if (change.expectedHash !== currentHash)
|
||||
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
|
||||
await this.preparedWrite(target.file, change.content, (commit) =>
|
||||
commit(),
|
||||
);
|
||||
return {
|
||||
content: change.content,
|
||||
changed: true,
|
||||
historyRecorded: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
('id' in change || change.skipHistory) &&
|
||||
hash(current) !== change.expectedHash
|
||||
) {
|
||||
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
|
||||
}
|
||||
if ('content' in change && change.content === current)
|
||||
return { content: current, changed: false };
|
||||
await fs.mkdir(this.historyRoot, { recursive: true, mode: 0o700 });
|
||||
const releaseHistory = await lock(this.historyRoot, {
|
||||
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||
});
|
||||
try {
|
||||
const archive = await this.readArchive(target.archive, target.relative);
|
||||
const restored =
|
||||
'id' in change
|
||||
? archive.versions.find((v) => v.id === change.id)
|
||||
: undefined;
|
||||
if ('id' in change && !restored)
|
||||
throw new ScriptHistoryError(404, '历史版本不存在或已清理');
|
||||
const content =
|
||||
'content' in change ? change.content : restored!.content;
|
||||
if (content === current) return { content, changed: false };
|
||||
if (
|
||||
Buffer.byteLength(content) > this.limits.fileBytes ||
|
||||
content.includes('\0')
|
||||
) {
|
||||
throw new ScriptHistoryError(
|
||||
413,
|
||||
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
|
||||
);
|
||||
}
|
||||
const currentHash = hash(current);
|
||||
if (
|
||||
archive.versions[archive.versions.length - 1]?.hash !== currentHash
|
||||
) {
|
||||
const head =
|
||||
archive.head?.hash === currentHash ? archive.head : undefined;
|
||||
archive.versions.push({
|
||||
id: randomUUID(),
|
||||
createdAt: head?.createdAt || new Date().toISOString(),
|
||||
source:
|
||||
head?.source ||
|
||||
(archive.versions.length ? 'external' : 'baseline'),
|
||||
hash: currentHash,
|
||||
content: current,
|
||||
});
|
||||
}
|
||||
// Only prior contents are stored. Head metadata labels the next snapshot,
|
||||
// but is used only if its hash matches the file. A failed file write can
|
||||
// never introduce a phantom content version or lose the old snapshot.
|
||||
archive.head = {
|
||||
hash: hash(content),
|
||||
createdAt: new Date().toISOString(),
|
||||
source: 'id' in change ? 'restore' : 'save',
|
||||
};
|
||||
const packed = await this.pack(archive);
|
||||
const cleanupPending = await this.preparedWrite(
|
||||
target.file,
|
||||
content,
|
||||
(commit) => this.store(target.archive, packed, commit),
|
||||
);
|
||||
return {
|
||||
content,
|
||||
changed: true,
|
||||
historyRecorded: true,
|
||||
cleanupPending,
|
||||
};
|
||||
} finally {
|
||||
await releaseHistory();
|
||||
}
|
||||
} finally {
|
||||
await releaseFile();
|
||||
}
|
||||
}
|
||||
|
||||
private async pack(archive: Archive) {
|
||||
archive.versions = archive.versions.slice(-this.limits.versions);
|
||||
while (true) {
|
||||
const serialized = Buffer.from(JSON.stringify(archive));
|
||||
if (serialized.length <= this.limits.unpackedBytes) {
|
||||
const compressed = await compress(serialized, {
|
||||
params: {
|
||||
[constants.BROTLI_PARAM_QUALITY]: 4,
|
||||
[constants.BROTLI_PARAM_LGWIN]: 22,
|
||||
},
|
||||
});
|
||||
if (compressed.length <= this.limits.archiveBytes) return compressed;
|
||||
}
|
||||
if (archive.versions.length <= 1)
|
||||
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
|
||||
archive.versions.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage the script before publishing history. A failed write/fsync never
|
||||
* truncates the original. Preserve the existing file's ownership and mode. */
|
||||
private async preparedWrite<T>(
|
||||
file: string,
|
||||
content: string,
|
||||
run: (commit: () => Promise<void>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const stat = await fs.stat(file);
|
||||
const temporary = path.join(
|
||||
path.dirname(file),
|
||||
`.ql-save-${randomUUID()}.tmp`,
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(temporary, content, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
if (
|
||||
typeof process.getuid === 'function' &&
|
||||
typeof process.getgid === 'function' &&
|
||||
(stat.uid !== process.getuid() || stat.gid !== process.getgid())
|
||||
) {
|
||||
await fs.chown(temporary, stat.uid, stat.gid);
|
||||
}
|
||||
await fs.chmod(temporary, stat.mode & 0o7777);
|
||||
const handle = await fs.open(temporary, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return await run(() => fs.rename(temporary, file));
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async store(
|
||||
file: string,
|
||||
content: Buffer,
|
||||
commit: () => Promise<void>,
|
||||
) {
|
||||
const entries = [];
|
||||
for (const name of await fs.readdir(this.historyRoot)) {
|
||||
if (!/^[a-f0-9]{64}\.br$/.test(name)) continue;
|
||||
const fullPath = path.join(this.historyRoot, name);
|
||||
if (fullPath === file) continue;
|
||||
const stat = await fs.lstat(fullPath);
|
||||
entries.push({ file: fullPath, size: stat.size, time: stat.mtimeMs });
|
||||
}
|
||||
entries.sort((a, b) => a.time - b.time);
|
||||
let total = entries.reduce((sum, v) => sum + v.size, content.length);
|
||||
const victims: string[] = [];
|
||||
while (
|
||||
entries.length &&
|
||||
(total > this.limits.totalBytes || entries.length >= this.limits.files)
|
||||
) {
|
||||
const oldest = entries.shift()!;
|
||||
victims.push(oldest.file);
|
||||
total -= oldest.size;
|
||||
}
|
||||
if (total > this.limits.totalBytes)
|
||||
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
|
||||
const previous = await fs.readFile(file).catch((error) => {
|
||||
if (error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
const temporary = path.join(this.historyRoot, '.pending.tmp');
|
||||
const publish = async (bytes: Buffer) => {
|
||||
await fs.unlink(temporary).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
});
|
||||
await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
||||
const handle = await fs.open(temporary, 'r');
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fs.rename(temporary, file);
|
||||
};
|
||||
try {
|
||||
await publish(content);
|
||||
try {
|
||||
await commit();
|
||||
} catch (error) {
|
||||
// The original script is still intact. Restore its exact history before
|
||||
// returning an error; no unrelated archive has been removed yet.
|
||||
if (previous) await publish(previous);
|
||||
else await fs.unlink(file);
|
||||
throw error;
|
||||
}
|
||||
// Quota eviction is post-commit maintenance. Report cleanup failures as a
|
||||
// warning, not a failed save (which would encourage a misleading retry).
|
||||
let cleanupPending = false;
|
||||
for (const victim of victims) {
|
||||
try {
|
||||
await fs.unlink(victim);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') cleanupPending = true;
|
||||
}
|
||||
}
|
||||
return cleanupPending;
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import { writeFile, open, chmod } from 'fs/promises';
|
||||
import { fileExist } from '../config/util';
|
||||
|
||||
export function getUniqueLockPath(filePath: string) {
|
||||
function getUniqueLockPath(filePath: string) {
|
||||
const sanitizedPath = filePath
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.replace(/^_/, '');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# OpenAPI 全量参考 / Complete OpenAPI reference
|
||||
|
||||
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
|
||||
CLI 对应当前 develop 的 143 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
|
||||
|
||||
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
|
||||
The catalogue covers 143 active routes in the current develop backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
|
||||
|
||||
## 认证与权限 / Authentication and permissions
|
||||
|
||||
@@ -46,9 +46,6 @@ ql env create --data @envs.json --json
|
||||
ql env update 8 --data '{"name":"EXAMPLE","value":"value"}' --json
|
||||
ql script create --file ./demo.js --data '{"filename":"demo.js","path":""}' --json
|
||||
ql script get --query '{"file":"demo.js","path":""}' --json
|
||||
ql script history-list --query '{"filename":"demo.js","path":""}' --json
|
||||
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
|
||||
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
|
||||
ql config save --data '{"name":"example.sh","content":"# example"}' --json
|
||||
ql config get --query '{"path":"example.sh"}' --json
|
||||
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
|
||||
@@ -136,9 +133,6 @@ System/user operations below execute remotely through the panel API. They do not
|
||||
| `ql config save ` | POST | `configs/save` | --data, --query |
|
||||
| `ql script list ` | GET | `scripts` | --query |
|
||||
| `ql script get ` | GET | `scripts/detail` | --query |
|
||||
| `ql script history-list ` | GET | `scripts/history` | --query |
|
||||
| `ql script history-detail ` | GET | `scripts/history/detail` | --query |
|
||||
| `ql script history-restore ` | PUT | `scripts/history/restore` | --data, --query |
|
||||
| `ql script create ` | POST | `scripts` | --data, --file (file), --query |
|
||||
| `ql script update ` | PUT | `scripts` | --data, --file (file), --query |
|
||||
| `ql script delete ` | DELETE | `scripts` | --data, --query |
|
||||
|
||||
@@ -275,18 +275,6 @@ export const openOperations: readonly OpenOperation[] = [
|
||||
{ name: 'config save', method: 'POST', path: 'configs/save', body: 'json' },
|
||||
{ name: 'script list', method: 'GET', path: 'scripts' },
|
||||
{ name: 'script get', method: 'GET', path: 'scripts/detail' },
|
||||
{ name: 'script history-list', method: 'GET', path: 'scripts/history' },
|
||||
{
|
||||
name: 'script history-detail',
|
||||
method: 'GET',
|
||||
path: 'scripts/history/detail',
|
||||
},
|
||||
{
|
||||
name: 'script history-restore',
|
||||
method: 'PUT',
|
||||
path: 'scripts/history/restore',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'script create',
|
||||
method: 'POST',
|
||||
|
||||
@@ -58,31 +58,6 @@ test('dashboard record sends execution statistics and rejects a missing body bef
|
||||
assert.equal(f.requests.length, 1);
|
||||
});
|
||||
|
||||
test('script history commands send exact query parameters and restore payloads', async t => {
|
||||
const f = await fixture(t);
|
||||
const identity = { filename: '中文 script.js', path: 'nested folder' };
|
||||
const version = { ...identity, id: '32ec8b22-2c48-4bcf-85d4-b394198417ba' };
|
||||
for (const [command, endpoint, query] of [
|
||||
['history-list', 'scripts/history', identity],
|
||||
['history-detail', 'scripts/history/detail', version],
|
||||
]) {
|
||||
success(await f.run(['script', command, '--query', JSON.stringify(query)]));
|
||||
const req = f.requests.at(-1), url = new URL(req.url, 'http://localhost');
|
||||
assert.equal(req.method, 'GET');
|
||||
assert.equal(url.pathname, '/panel/open/' + endpoint);
|
||||
for (const [key, value] of Object.entries(query)) assert.equal(url.searchParams.get(key), value);
|
||||
assert.equal(req.body, '');
|
||||
}
|
||||
const payload = { ...version, expectedHash: 'a'.repeat(64) };
|
||||
success(await f.run(['script', 'history-restore', '--data', '-'], JSON.stringify(payload)));
|
||||
assert.equal(f.requests.at(-1).method, 'PUT');
|
||||
assert.equal(f.requests.at(-1).url, '/panel/open/scripts/history/restore');
|
||||
assert.deepEqual(JSON.parse(f.requests.at(-1).body), payload);
|
||||
const count = f.requests.length;
|
||||
assert.equal((await f.run(['script', 'history-restore'])).code, 2);
|
||||
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
|
||||
});
|
||||
|
||||
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
|
||||
const root = path.resolve(__dirname, '../../../back/api');
|
||||
const expected = [];
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Node cron 调度器迁移
|
||||
|
||||
Node 调度路径使用固定版本 `node-cron@4.6.0`,普通任务(含额外规则)、订阅和系统 cron 共用 `back/shared/cronScheduler.ts`。启动/一次性任务、间隔任务、脚本并发限制和进程执行链路保持原有实现。系统模式只将语义一致的普通五字段规则交给 crond。
|
||||
|
||||
移除 `node-schedule`、其类型包以及兼容别名 `cron-parser-v4`。保留 `cron-parser@5` 做旧表达式的校验与标准化;计时器和日历推进由 node-cron 管理。
|
||||
|
||||
## 兼容行为
|
||||
|
||||
- 保留旧版 1–6 字段补全、五种宏、月份/星期名称、数字起点步长、L 和 #。另外明确新增 `@annually`(等同 `@yearly`)、`@midnight`(等同 `@daily`)、`@minutely`(每分钟第 0 秒)。宏按进程时区解释。`?` 仅允许日期和星期字段;裸 `/N`、H 和其他未列出的宏仍拒绝。
|
||||
- 日期与星期同时指定时保留 OR 语义,不采用 node-cron 默认的 AND。两个原生任务分别处理日期和星期,并按计划时间过滤交集,避免重复执行;保留旧解析器的月份全日期范围及全局 nth-week 限制。
|
||||
- 原生任务先暂停创建并验证,再替换旧任务。构造失败时销毁已准备的实例并保留旧快照。
|
||||
- 进程内迟到的触发通过 `execution:missed` 加入既有执行队列,记录补执行日志。并发和单任务积压上限仍由青龙队列控制;这不提供进程退出期间的持久化补跑或分布式 exactly-once 保证。
|
||||
- 标准化结果使用上限 512 条的只读缓存,仅缓存语法,不缓存运行实例或下次执行时间。避免大批相同表达式重复解析导致恢复 RPC 超时。
|
||||
- 回调异常记录日志,后续调度继续;取消时调用原生 `destroy()` 释放计时器和注册表。
|
||||
- 持久化无效任务在恢复时跳过并记录日志,无效订阅不会产生未处理的启动拒绝。API、导入和执行使用同一套表达式校验。
|
||||
|
||||
## 复审修复
|
||||
|
||||
- 移除没有生产调用的 `nextInvocation()` 接口,避免对 OR/# 等过滤规则报告不会实际执行的原生日期。回归测试直接观察实际执行和原生实例状态。
|
||||
- 批量注册 RPC 时限为 5 秒加每条规则 5 毫秒,计入额外规则并封顶 120 秒;保留超时后的恢复机制,不立即重放结果不确定的写入。
|
||||
- Alpine/系统模式下,数字起点步长(如 `0/5`)、星期包含 `7`、日期步长、日期和星期同时受限的表达式转入 Node 路径。BusyBox 的这些解析或通配语义与旧 Node 解析器不同,不能仅因是纯数字五字段就交给 crond。
|
||||
- 这些规则在 `crontab.list` 中仍保留为注释,避免同时被系统和 Node 执行。其他可移植规则仍使用系统 crond。
|
||||
|
||||
## 夏令时差异
|
||||
|
||||
采用 node-cron 的当地时间语义:春季跳时不存在的时间跳过,秋季重复小时不再执行第二次。旧 cron-parser 对部分春季时间会顺延,并可能重复秋季高频任务。使用有夏令时的时区时需要考虑该行为变化;Asia/Shanghai 不受影响。回归测试固定 America/New_York 的 2026 年转换日期。
|
||||
|
||||
## 验证
|
||||
|
||||
`npm test` 和 `npm run build:back`。
|
||||
|
||||
- `test/fixtures/legacy-cron.json` 是迁移前使用 node-schedule 2.1.1 / cron-parser 4.9.0 保存的快照:269 条语法判定,18 组日历序列,每组 10 次执行。
|
||||
- 真正的 node-cron 实例配合虚拟时钟验证日期/星期 OR、交集去重、闰年、L/#、8 秒迟到补执行、取消释放、回调拒绝和夏令时。
|
||||
- 注册构造失败回滚、无效订阅隔离、空表达式立即运行,以及现有调度恢复/503/子进程测试覆盖接入流程。
|
||||
- 隔离的正式 Debian 镜像使用实际编译产物验证 10,000 条有效任务和 2 条错误任务的恢复、300 个同时到期的 Python 脚本、并发上限 16 及 HTTP 健康检查。具体结果见本次测试报告;这是生产镜像隔离验证,不是线上长期运行证明。
|
||||
|
||||
### 本次验收结果(2026-09-27)
|
||||
|
||||
完整回归 260 通过、3 项原有跳过,后端编译成功。Node 20 生产运行时的 21 项兼容测试通过。隔离镜像中 300/300 脚本完成,无重复或遗漏,最大并发 16,270 个成功及 30 个预设失败均落库;完成耗时 52.36 秒。恢复后健康检查 1,870/1,870 成功,任务列表 38/38 成功。重启恢复阶段另有 50 次健康检查失败。
|
||||
|
||||
初版重复解析曾导致恢复超过 5 秒 RPC 时限,加入有上限的语法缓存后重跑通过。本次 10,000 个任务共享两个表达式,不能视为一万种复杂表达式的容量保证;实际脚本耗时也不代表纯调度性能。
|
||||
|
||||
新增宏在 UTC 和 Asia/Shanghai 下验证连续两次实际触发及不提前触发;原有语法快照仅对这三个新增宏允许预期变化。
|
||||
|
||||
### 复审后验收
|
||||
|
||||
完整回归 267 通过、3 项原有跳过;Node 20 的 10 项修复回归及 Alpine 镜像的 24 项兼容/宏测试通过。
|
||||
|
||||
额外使用 9,700 个不同表达式和 300 个同时到期任务验证恢复(另有 2 个无效任务),不再局限于共享两个表达式。300/300 脚本完成,无重复或遗漏,最大并发 16,270 个退出码 0、30 个预设退出码 3 均落库。恢复后健康检查 1,650/1,650 成功、任务列表 33/33 成功;启动恢复阶段另有 260 次健康检查失败,整批脚本耗时 60.27 秒。较大快照恢复仍需等待,不承诺启动阶段始终可用或任意规模都能在时限内恢复。
|
||||
@@ -0,0 +1,54 @@
|
||||
# Task startup and notification loading
|
||||
|
||||
`task` performs shell configuration, status reporting and dependency-path discovery
|
||||
before invoking the selected runtime. These costs are separate from cron callback
|
||||
latency and time spent waiting for an execution slot.
|
||||
|
||||
## Dependency-path cache
|
||||
|
||||
Node scheduling usually starts in `/ql`, while Alpine crond starts in `/root`.
|
||||
The pnpm discovery key includes the working directory, npm/pnpm configuration,
|
||||
relevant environment and executable metadata. Each key now has its own cached
|
||||
record, so alternating scheduler environments do not evict each other.
|
||||
|
||||
Records expire after 60 seconds. Refreshes share a persistent per-user lock;
|
||||
records older than an hour are removed on refresh. Warm lookups do not scan the
|
||||
cache directory. The lock is never removed while another process might use it.
|
||||
The opt-out `QL_NODE_PATH_CACHE=0`, failed-discovery fallback, and package resolution
|
||||
order remain available. This caches the dependency directory, not its contents.
|
||||
|
||||
## Language startup paths
|
||||
|
||||
| Invocation | Environment and before hooks | Automatic notification loading |
|
||||
| --- | --- | --- |
|
||||
| `task script.py` / `.pyc` | Python preload imports generated environment, runs the shell/command before hooks, imports `task_before.py`, then applies account selection | `QLAPI.notify(...)` imports `__ql_notify__` on first call |
|
||||
| `task script.js` / `.mjs` / `.ts` | Node preload imports generated environment, runs shell/command before hooks, requires `task_before.js`, then applies account selection | `QLAPI.notify(...)` requires `__ql_notify__.js` on first call |
|
||||
| `task script.sh` | Shell sources the generated environment and runs shell before hooks | No automatic Python/Node notification module |
|
||||
| Explicit interpreter or other command, e.g. `task python3 script.py` | Existing generic-command path uses shell environment/before hooks; it does not automatically install the Python/Node preload | No automatic notification module unless configured by the caller |
|
||||
|
||||
TypeScript uses `ts-node-transpile-only`; MJS uses the same Node preload plus the
|
||||
ESM loader. There are no separate built-in Ruby, Go, Java or PHP preload modules.
|
||||
All these task paths still pass through shared dependency-path setup: non-JS
|
||||
scripts and their hooks may themselves call Node or use the QLAPI bridge.
|
||||
|
||||
The before hooks are deliberately still synchronous: they can export environment
|
||||
variables or perform work required by the user's script. Python's preload still
|
||||
starts a child shell/Python to capture that environment; Node's preload starts a
|
||||
child shell/Node. Node also still loads its gRPC client eagerly. These are remaining
|
||||
startup costs, not work performed by the cron library, and are not removed by this
|
||||
change.
|
||||
|
||||
## Lazy notifications
|
||||
|
||||
Scripts keep calling `QLAPI.notify` with the same arguments. When no notification
|
||||
is sent, the notification provider and its channel dependencies are never imported
|
||||
by the preload. First use loads the provider; subsequent uses reuse the standard
|
||||
Python/Node module cache. JS forwards the receiver and return value, including
|
||||
promises, and Python forwards positional and keyword arguments. Import/send errors
|
||||
surface at the notification call rather than preventing unrelated scripts from
|
||||
obtaining QLAPI during startup. A failed import can be retried after its cause is
|
||||
resolved.
|
||||
|
||||
Provider initialization, including provider configuration reads, now happens at
|
||||
first use. A script's explicit `import __ql_notify__`, `require(...)`, or independent
|
||||
notification helper still loads that module immediately.
|
||||
@@ -1,53 +0,0 @@
|
||||
# 脚本编辑历史
|
||||
|
||||
脚本管理工具栏的“历史版本”打开右侧抽屉。选择版本后,对比历史内容与服务器上当前已保存文件;桌面使用双栏对比,手机使用行内对比。恢复需要确认,有未保存修改时必须先保存或取消。恢复前保留当前版本,因此恢复操作也可以撤销。普通保存不再弹出不可恢复确认。
|
||||
|
||||
## 记录范围
|
||||
|
||||
- 编辑器 `PUT /api/scripts` 和调试窗口 `POST /api/scripts` 覆盖已有脚本时,在内容变化时留档,首次修改前的内容也保留;相同内容不写历史。
|
||||
- 历史只包含保存前真实存在的内容;不把失败写入当成一个内容版本。
|
||||
- 上传、新增、重命名、删除、订阅更新和外部编辑不主动留档。若下次面板保存时检测到外部变更,会保留那一刻的内容,标记为“面板外修改后”。这不代表外部修改之前的内容受到保护。
|
||||
- 按脚本规范化后的相对路径隔离历史;不同目录同名文件不会混淆,指向同一脚本的安全符号链接共用历史。重命名后不会自动迁移历史。调试器另存为覆盖已有脚本时,目标旧内容进入历史,删除源文件前将其旧内容保留到现有 bak 目录;备份失败则不覆盖目标、不删除源文件。源和目标实际指向同一脚本时(如安全符号链接或不同路径写法),按原地保存处理,保留两个路径,不删除源文件。历史不能用于回收已删除的文件。
|
||||
- 历史记录支持至多 1 MiB 的 UTF-8 文本文件(含空文件)。当前文件或新内容超出范围时,界面要求明确确认“不记录历史直接保存”;取消则保持文件和草稿不变。确认后以 `skipHistory: true` 和服务端返回的当前哈希重试,锁内检查哈希以防确认期间覆盖他人修改。成功后明确提示本次未留档。大文件可以通过此流程缩小,后续恢复普通留档。未确认的 API 请求返回 413,并包含 `historyUnavailable` 和 `currentHash`;历史读取损坏等其他错误不允许用此选项绕过。
|
||||
|
||||
## 存储与资源边界
|
||||
|
||||
位置:`<QL_DATA_DIR>/script-history/<SHA-256(规范相对路径)>.br`。目录位于脚本目录之外,避免进入脚本树、订阅和任务扫描。目录权限 `0700`,历史文件权限 `0600`。目录随整个数据目录备份。
|
||||
|
||||
每个脚本仅一个 Brotli 压缩 JSON 包,存储完整的旧内容快照以及时间、来源、内容哈希。新文件内容不再重复存储,只保存头版本元数据,供下一次留档标注来源。面板服务通过 TypeDI 注册为单例,路由使用 `Container.get(ScriptHistoryService)` 获取;构造实例只初始化配置,首次发生保存时才创建历史目录。包内重复文本共享压缩窗口,不依赖补丁链,也不需要额外数据库、文件监听、后台任务或常驻缓存。使用 Node 内置 zlib 异步压缩,Brotli quality=4、window=4 MiB,不新增依赖。
|
||||
|
||||
| 限制 | 默认值 |
|
||||
| --- | --- |
|
||||
| 每脚本历史版本 | 最多 20 个 |
|
||||
| 单个脚本内容 | 1 MiB |
|
||||
| 单包解压后的 JSON | 8 MiB |
|
||||
| 单个压缩历史包 | 2 MiB |
|
||||
| 所有压缩历史包合计 | 32 MiB |
|
||||
| 有历史的脚本数 | 最多 1000 个 |
|
||||
| 历史写入暂存文件 | 共享一个,最多 2 MiB |
|
||||
| 脚本写入暂存文件 | 每次保存一个,与本次保存内容等大,结束后清理 |
|
||||
|
||||
达到单文件限制时删除最旧版本,至少保留本次覆盖前的内容;无法保留则拒绝覆盖。达到全局限制时,在脚本成功提交后优先清理最久未更新的脚本历史包(该文件的整份历史),不删除脚本本身。历史暂存、提交或脚本提交失败时不清理任何其他历史;脚本提交失败会回滚本文件历史。如果提交成功但清理失败,返回 `cleanupPending`,界面提示检查存储权限和空间,后续保存重新尝试清理。配额是正常清理成功后的目标,事务期间和清理失败时会暂时超出。32 MiB 是文件内容字节数,不包含文件系统块分配、目录和锁的开销。清理只在发生保存时执行,通常最多扫描 1000 个包的元数据(清理失败期间可能更多),不解压其他脚本的历史。读取仅解压当前脚本,限制解压输出长度。
|
||||
|
||||
文件保存复用现有锁路径,同时以独立跨进程目录锁串行化历史写入和配额清理;`finally` 释放锁。先准备并同步脚本暂存文件,保留文件权限和所有者;随后写并同步历史暂存文件、原子提交历史,再原子替换脚本。任一步准备失败不会截断原脚本;最后替换脚本失败时还原此前历史;成功后才清理配额。超限确认保存不创建新历史包,使用同样的脚本暂存和替换方式,并流式计算旧文件哈希。外部进程不遵守该锁时,不承诺与其并发写入的事务一致性。
|
||||
|
||||
恢复请求携带预览时的当前内容哈希,锁内检查后才写入;文件变化则返回 409。历史读写与脚本读取采用相同的路径、黑名单和符号链接边界检查。
|
||||
|
||||
## 压缩样本
|
||||
|
||||
运行 `node scripts/benchmark-script-history.cjs` 可复现比较。模拟同一脚本连续 20 次小修改,比较独立 gzip、合并 gzip 与合并 Brotli。首次本机测量:
|
||||
|
||||
| 样本 | 独立 gzip | 合并 gzip | 合并 Brotli(quality 4) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| sample/notify.js | 242,430 B | 221,832 B | 13,930 B |
|
||||
| shell/task.sh | 30,159 B | 2,317 B | 1,737 B |
|
||||
|
||||
这是文本重复度较高的模拟样本,不是所有工作负载的压缩保证。Brotli 合包特别适合大小超过 gzip 32 KiB 窗口、但每次只改少量内容的脚本。增量补丁可能在部分样本更小,但增加补丁生成、链式恢复、裁剪和损坏恢复的复杂度;本实现选择压缩合包与硬上限,在磁盘、CPU 和可靠性之间取平衡。
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/scripts/history?filename=...&path=...`:版本元数据、当前哈希、保留数量。
|
||||
- `GET /api/scripts/history/detail?filename=...&path=...&id=...`:历史内容、当前内容及当前哈希。
|
||||
- `PUT /api/scripts/history/restore`:`{filename, path, id, expectedHash}`。
|
||||
|
||||
沿用现有 scripts 路由的认证与权限。历史 ID 使用 UUID;不存在或已清理返回 404,文件已变更返回 409,文本或容量限制返回 413。
|
||||
+1
-3
@@ -90,7 +90,6 @@
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^5.4.0",
|
||||
"cron-parser-v4": "npm:cron-parser@4.9.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.6",
|
||||
@@ -108,7 +107,7 @@
|
||||
"keyv": "^5.2.3",
|
||||
"lodash": "^4.17.21",
|
||||
"multer": "2.1.1",
|
||||
"node-schedule": "^2.1.0",
|
||||
"node-cron": "4.6.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"p-queue-cjs": "7.3.4",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
@@ -145,7 +144,6 @@
|
||||
"@types/lodash": "^4.14.185",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/node-schedule": "^1.3.2",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/ps-tree": "^1.1.6",
|
||||
|
||||
Generated
+8
-44
@@ -42,9 +42,6 @@ dependencies:
|
||||
cron-parser:
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.0
|
||||
cron-parser-v4:
|
||||
specifier: npm:cron-parser@4.9.0
|
||||
version: /cron-parser@4.9.0
|
||||
cross-spawn:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
@@ -96,9 +93,9 @@ dependencies:
|
||||
multer:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
node-schedule:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.1
|
||||
node-cron:
|
||||
specifier: 4.6.0
|
||||
version: 4.6.0
|
||||
nodemailer:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
@@ -203,9 +200,6 @@ devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^17.0.21
|
||||
version: 17.0.45
|
||||
'@types/node-schedule':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2
|
||||
'@types/nodemailer':
|
||||
specifier: ^6.4.4
|
||||
version: 6.4.17
|
||||
@@ -4516,12 +4510,6 @@ packages:
|
||||
'@types/express': 4.17.21
|
||||
dev: true
|
||||
|
||||
/@types/node-schedule@1.3.2:
|
||||
resolution: {integrity: sha512-Y0CqdAr+lCpArT8CJJjJq4U2v8Bb5e7ru2nV/NhDdaptCMCRdOL3Y7tAhen39HluQMaIKWvPbDuiFBUQpg7Srw==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.45
|
||||
dev: true
|
||||
|
||||
/@types/node@17.0.45:
|
||||
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
|
||||
|
||||
@@ -7046,13 +7034,6 @@ packages:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
dev: true
|
||||
|
||||
/cron-parser@4.9.0:
|
||||
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
dependencies:
|
||||
luxon: 3.5.0
|
||||
dev: false
|
||||
|
||||
/cron-parser@5.4.0:
|
||||
resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -10445,10 +10426,6 @@ packages:
|
||||
triple-beam: 1.4.1
|
||||
dev: false
|
||||
|
||||
/long-timeout@0.1.1:
|
||||
resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==}
|
||||
dev: false
|
||||
|
||||
/long@5.2.3:
|
||||
resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==}
|
||||
dev: false
|
||||
@@ -10482,11 +10459,6 @@ packages:
|
||||
yallist: 4.0.0
|
||||
dev: true
|
||||
|
||||
/luxon@3.5.0:
|
||||
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/luxon@3.7.2:
|
||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -10849,6 +10821,11 @@ packages:
|
||||
resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==}
|
||||
dev: false
|
||||
|
||||
/node-cron@4.6.0:
|
||||
resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==}
|
||||
engines: {node: '>=20'}
|
||||
dev: false
|
||||
|
||||
/node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -10963,15 +10940,6 @@ packages:
|
||||
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
|
||||
dev: true
|
||||
|
||||
/node-schedule@2.1.1:
|
||||
resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==}
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
long-timeout: 0.1.1
|
||||
sorted-array-functions: 1.3.0
|
||||
dev: false
|
||||
|
||||
/nodemailer@8.0.1:
|
||||
resolution: {integrity: sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -14180,10 +14148,6 @@ packages:
|
||||
sort-object-keys: 1.1.3
|
||||
dev: true
|
||||
|
||||
/sorted-array-functions@1.3.0:
|
||||
resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==}
|
||||
dev: false
|
||||
|
||||
/source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Reproducible storage comparison, without touching actual script history.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
for (const file of ['sample/notify.js', 'shell/task.sh']) {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
const versions = Array.from({ length: 20 }, (_, i) => ({
|
||||
content: source + '\n// edit ' + i,
|
||||
}));
|
||||
const raw = Buffer.from(JSON.stringify(versions));
|
||||
const strategies = {
|
||||
gzipSeparate: () =>
|
||||
versions.reduce(
|
||||
(sum, version) => sum + zlib.gzipSync(version.content).length,
|
||||
0,
|
||||
),
|
||||
gzipBundle: () => zlib.gzipSync(raw).length,
|
||||
brotliBundle: () =>
|
||||
zlib.brotliCompressSync(raw, {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
||||
[zlib.constants.BROTLI_PARAM_LGWIN]: 22,
|
||||
},
|
||||
}).length,
|
||||
};
|
||||
for (const [strategy, compress] of Object.entries(strategies)) {
|
||||
const start = performance.now();
|
||||
const bytes = compress();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
file,
|
||||
strategy,
|
||||
rawBytes: raw.length,
|
||||
bytes,
|
||||
ms: +(performance.now() - start).toFixed(2),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,9 @@ ql_read_node_path_cache() {
|
||||
# the caller's descriptors. A crashed refresher releases the kernel lock.
|
||||
ql_refresh_node_global_path() (
|
||||
local cache="$1" key="$2" now result previous_umask
|
||||
local lock="${cache}.lock"
|
||||
# Keep one persistent lock per owner. Removing per-key lock files could
|
||||
# split concurrent waiters across different inodes during cache cleanup.
|
||||
local lock="${dir_tmp}/pnpm-root-${EUID}.lock"
|
||||
previous_umask=$(umask)
|
||||
umask 077
|
||||
if type -P flock &>/dev/null && mkdir -p -- "$dir_tmp" 2>/dev/null; then
|
||||
@@ -93,6 +95,10 @@ ql_refresh_node_global_path() (
|
||||
|
||||
# Private lock creation must not change pnpm's inherited creation mask.
|
||||
umask "$previous_umask"
|
||||
# Warm lookups never scan the directory. Retire old environment records on
|
||||
# refresh, while preserving the shared lock and unrelated cache files.
|
||||
find "$dir_tmp" -maxdepth 1 -type f -user "$EUID" \
|
||||
-name "pnpm-root-${EUID}-*.cache" -mmin +60 -delete 2>/dev/null || true
|
||||
now=${EPOCHSECONDS:-$(date +%s)}
|
||||
result=$(pnpm root -g 9>&- 2>/dev/null) || return $?
|
||||
# Never cache failed, empty, multiline or non-absolute answers.
|
||||
@@ -119,8 +125,10 @@ ql_get_node_global_path() {
|
||||
fi
|
||||
|
||||
local key cache
|
||||
cache="${dir_tmp}/pnpm-root-${EUID}.cache"
|
||||
key=$(ql_node_path_cache_key) || { pnpm root -g 2>/dev/null; return $?; }
|
||||
# Node and crond may have different working directories/configuration.
|
||||
# Preserve both discoveries instead of evicting each other on every run.
|
||||
cache="${dir_tmp}/pnpm-root-${EUID}-${key// /-}.cache"
|
||||
if ql_read_node_path_cache "$cache" "$key"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -159,9 +159,13 @@ try {
|
||||
|
||||
run();
|
||||
|
||||
const { sendNotify } = require('./__ql_notify__.js');
|
||||
global.QLAPI = {
|
||||
notify: sendNotify,
|
||||
notify(...args) {
|
||||
// Keep notification dependencies out of ordinary script startup.
|
||||
// require caches the module after the first notification.
|
||||
const { sendNotify } = require('./__ql_notify__.js');
|
||||
return sendNotify.apply(this, args);
|
||||
},
|
||||
...client,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -129,10 +129,12 @@ try:
|
||||
|
||||
run()
|
||||
|
||||
from __ql_notify__ import send
|
||||
|
||||
class BaseApi(Client):
|
||||
def notify(self, *args, **kwargs):
|
||||
# Most scripts never send a notification. Python caches this
|
||||
# module after the first call, including its channel dependencies.
|
||||
from __ql_notify__ import send
|
||||
|
||||
return send(*args, **kwargs)
|
||||
|
||||
QLAPI = BaseApi()
|
||||
|
||||
+1
-29
@@ -665,33 +665,5 @@
|
||||
"重试": "Retry",
|
||||
"WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH API Key from https://wpush.cn/settings, see https://wpush.cn/docs",
|
||||
"推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "Channel: wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot, default wechat",
|
||||
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs",
|
||||
"历史版本": "Version history",
|
||||
"首次修改前": "Before the first edit",
|
||||
"手动保存": "Manual save",
|
||||
"从历史版本恢复": "Restored version",
|
||||
"面板外修改后": "After an external edit",
|
||||
"恢复此版本": "Restore this version",
|
||||
"当前已保存的内容会保留在历史版本中。": "The currently saved content will be kept in version history.",
|
||||
"请先保存或取消当前修改,再恢复历史版本": "Save or discard your unsaved changes before restoring a version",
|
||||
"已恢复,恢复前的内容已保留": "Restored. The previous content is kept in version history",
|
||||
"最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "Keep up to {count} previous versions. Older history is removed when storage is full.",
|
||||
"仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "Only panel saves are recorded. Subscription updates may still overwrite restored files.",
|
||||
"暂无历史版本,下次保存修改时会自动保留当前内容": "No versions yet. Your current content will be kept when you next save changes.",
|
||||
"选择历史版本": "Choose a version",
|
||||
"历史版本加载失败,请刷新重试": "Unable to load this version. Refresh and try again.",
|
||||
"当前已保存文件": "Currently saved file",
|
||||
"与当前文件一致": "Identical to the current file",
|
||||
"历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "Version history supports UTF-8 text scripts up to 1 MiB",
|
||||
"历史版本读取失败,请检查历史文件": "Unable to read version history. Check the history file.",
|
||||
"历史版本不存在或已清理": "This version does not exist or has been removed",
|
||||
"文件已变化,请刷新历史版本后重试": "The file has changed. Refresh version history and try again.",
|
||||
"历史版本容量不足,无法安全保存": "Not enough version history capacity to save safely",
|
||||
"脚本不存在": "The script does not exist",
|
||||
"不记录历史直接保存?": "Save without version history?",
|
||||
"当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "The current file or new content is outside the history limits. The content being replaced will not be kept.",
|
||||
"仍然保存": "Save anyway",
|
||||
"已保存,本次未记录历史版本": "Saved without a history snapshot",
|
||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
||||
"保存失败": "Unable to save"
|
||||
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs"
|
||||
}
|
||||
|
||||
+1
-29
@@ -665,33 +665,5 @@
|
||||
"重试": "重试",
|
||||
"WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs",
|
||||
"推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat",
|
||||
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs",
|
||||
"历史版本": "历史版本",
|
||||
"首次修改前": "首次修改前",
|
||||
"手动保存": "手动保存",
|
||||
"从历史版本恢复": "从历史版本恢复",
|
||||
"面板外修改后": "面板外修改后",
|
||||
"恢复此版本": "恢复此版本",
|
||||
"当前已保存的内容会保留在历史版本中。": "当前已保存的内容会保留在历史版本中。",
|
||||
"请先保存或取消当前修改,再恢复历史版本": "请先保存或取消当前修改,再恢复历史版本",
|
||||
"已恢复,恢复前的内容已保留": "已恢复,恢复前的内容已保留",
|
||||
"最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "最多保留 {count} 个历史版本,容量不足时自动清理旧历史。",
|
||||
"仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。",
|
||||
"暂无历史版本,下次保存修改时会自动保留当前内容": "暂无历史版本,下次保存修改时会自动保留当前内容",
|
||||
"选择历史版本": "选择历史版本",
|
||||
"历史版本加载失败,请刷新重试": "历史版本加载失败,请刷新重试",
|
||||
"当前已保存文件": "当前已保存文件",
|
||||
"与当前文件一致": "与当前文件一致",
|
||||
"历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本",
|
||||
"历史版本读取失败,请检查历史文件": "历史版本读取失败,请检查历史文件",
|
||||
"历史版本不存在或已清理": "历史版本不存在或已清理",
|
||||
"文件已变化,请刷新历史版本后重试": "文件已变化,请刷新历史版本后重试",
|
||||
"历史版本容量不足,无法安全保存": "历史版本容量不足,无法安全保存",
|
||||
"脚本不存在": "脚本不存在",
|
||||
"不记录历史直接保存?": "不记录历史直接保存?",
|
||||
"当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。",
|
||||
"仍然保存": "仍然保存",
|
||||
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
|
||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
|
||||
"保存失败": "保存失败"
|
||||
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs"
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
:global(.ant-btn) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.labels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.diff {
|
||||
flex: 1;
|
||||
min-height: 260px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ant-border-color-split, #d9d9d9);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.footer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.labels {
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.diff {
|
||||
flex: none;
|
||||
min-height: 200px;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Drawer,
|
||||
Empty,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { DiffEditor } from '@monaco-editor/react';
|
||||
import ReactDiffViewer from 'react-diff-viewer';
|
||||
import intl from 'react-intl-universal';
|
||||
import config from '@/utils/config';
|
||||
import { request } from '@/utils/http';
|
||||
import styles from './historyDrawer.module.less';
|
||||
|
||||
interface Version {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
source: 'baseline' | 'save' | 'restore' | 'external';
|
||||
identical: boolean;
|
||||
}
|
||||
interface Detail {
|
||||
version: Version & { content: string };
|
||||
current: string;
|
||||
currentHash: string;
|
||||
}
|
||||
interface Props {
|
||||
filename: string;
|
||||
directory: string;
|
||||
isPhone: boolean;
|
||||
theme: string;
|
||||
language: string;
|
||||
hasUnsavedChanges: () => boolean;
|
||||
onClose: () => void;
|
||||
onRestored: (content: string) => void;
|
||||
}
|
||||
|
||||
const sourceLabel = (source: Version['source']) =>
|
||||
intl.get(
|
||||
{
|
||||
baseline: '首次修改前',
|
||||
save: '手动保存',
|
||||
restore: '从历史版本恢复',
|
||||
external: '面板外修改后',
|
||||
}[source],
|
||||
);
|
||||
const versionLabel = (v: Version) =>
|
||||
`${new Date(v.createdAt).toLocaleString()} · ${sourceLabel(v.source)}`;
|
||||
|
||||
export default function HistoryDrawer(props: Props) {
|
||||
const {
|
||||
filename,
|
||||
directory,
|
||||
isPhone,
|
||||
theme,
|
||||
language,
|
||||
onClose,
|
||||
onRestored,
|
||||
hasUnsavedChanges,
|
||||
} = props;
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const [detail, setDetail] = useState<Detail>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detailError, setDetailError] = useState(false);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const active = useRef(true);
|
||||
const detailRequest = useRef(0);
|
||||
const params = { filename, path: directory };
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setDetail(undefined);
|
||||
setSelected(undefined);
|
||||
detailRequest.current++;
|
||||
try {
|
||||
const result = await request.get(`${config.apiPrefix}scripts/history`, {
|
||||
params,
|
||||
});
|
||||
if (result.code !== 200) throw new Error(result.message);
|
||||
if (!active.current) return;
|
||||
setVersions(result.data.versions);
|
||||
setLimit(result.data.limit);
|
||||
setSelected(result.data.versions[0]?.id);
|
||||
} catch (e: any) {
|
||||
if (active.current)
|
||||
setError(e?.response?.data?.message || intl.get('加载失败'));
|
||||
} finally {
|
||||
if (active.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDetail = async (id: string) => {
|
||||
const sequence = ++detailRequest.current;
|
||||
setDetail(undefined);
|
||||
setDetailLoading(true);
|
||||
setDetailError(false);
|
||||
try {
|
||||
const result = await request.get(
|
||||
`${config.apiPrefix}scripts/history/detail`,
|
||||
{ params: { ...params, id } },
|
||||
);
|
||||
if (result.code !== 200) throw new Error(result.message);
|
||||
if (active.current && sequence === detailRequest.current)
|
||||
setDetail(result.data);
|
||||
} catch {
|
||||
if (active.current && sequence === detailRequest.current)
|
||||
setDetailError(true);
|
||||
} finally {
|
||||
if (active.current && sequence === detailRequest.current)
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
active.current = true;
|
||||
load();
|
||||
return () => {
|
||||
active.current = false;
|
||||
detailRequest.current++;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (selected) loadDetail(selected);
|
||||
}, [selected]);
|
||||
|
||||
const restore = () => {
|
||||
if (!detail) return;
|
||||
if (hasUnsavedChanges()) {
|
||||
message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本'));
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: intl.get('恢复此版本'),
|
||||
content: (
|
||||
<>
|
||||
<p>{versionLabel(detail.version)}</p>
|
||||
<p>{intl.get('当前已保存的内容会保留在历史版本中。')}</p>
|
||||
</>
|
||||
),
|
||||
okText: intl.get('恢复此版本'),
|
||||
onOk: async () => {
|
||||
if (hasUnsavedChanges()) {
|
||||
message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本'));
|
||||
throw new Error('Unsaved changes');
|
||||
}
|
||||
setRestoring(true);
|
||||
try {
|
||||
const result = await request.put(
|
||||
`${config.apiPrefix}scripts/history/restore`,
|
||||
{
|
||||
...params,
|
||||
id: detail.version.id,
|
||||
expectedHash: detail.currentHash,
|
||||
},
|
||||
);
|
||||
if (result.code !== 200) throw new Error(result.message);
|
||||
if (!active.current) return;
|
||||
onRestored(result.data.content);
|
||||
if (result.data.cleanupPending) {
|
||||
message.warning(
|
||||
intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'),
|
||||
);
|
||||
} else {
|
||||
message.success(intl.get('已恢复,恢复前的内容已保留'));
|
||||
}
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
setDetail(undefined);
|
||||
setDetailError(true);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (active.current) setRestoring(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const identical = detail?.version.content === detail?.current;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Space direction="vertical" size={0}>
|
||||
<span>{intl.get('历史版本')}</span>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, wordBreak: 'break-all' }}
|
||||
>
|
||||
{directory ? `${directory}/` : ''}
|
||||
{filename}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
open
|
||||
width={isPhone ? '100%' : 'min(1100px, 90vw)'}
|
||||
onClose={restoring ? undefined : onClose}
|
||||
closable={!restoring}
|
||||
maskClosable={!restoring}
|
||||
keyboard={!restoring}
|
||||
bodyStyle={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: isPhone ? 16 : 24,
|
||||
}}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Typography.Text type="secondary">
|
||||
{intl.get(
|
||||
'最多保留 {count} 个历史版本,容量不足时自动清理旧历史。',
|
||||
{ count: limit },
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={restore}
|
||||
loading={restoring}
|
||||
disabled={!detail || detailLoading || identical}
|
||||
>
|
||||
{intl.get('恢复此版本')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={intl.get(
|
||||
'仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。',
|
||||
)}
|
||||
/>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
action={<Button onClick={load}>{intl.get('重试')}</Button>}
|
||||
/>
|
||||
) : versions.length === 0 ? (
|
||||
<Empty
|
||||
description={intl.get(
|
||||
'暂无历史版本,下次保存修改时会自动保留当前内容',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
aria-label={intl.get('选择历史版本')}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
disabled={restoring}
|
||||
options={versions.map((v) => ({
|
||||
value: v.id,
|
||||
label: versionLabel(v),
|
||||
}))}
|
||||
/>
|
||||
{detailLoading ? (
|
||||
<Spin />
|
||||
) : detailError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={intl.get('历史版本加载失败,请刷新重试')}
|
||||
action={<Button onClick={load}>{intl.get('重试')}</Button>}
|
||||
/>
|
||||
) : (
|
||||
detail && (
|
||||
<>
|
||||
<div className={styles.labels}>
|
||||
<span>
|
||||
{isPhone ? '− ' : ''}
|
||||
{intl.get('历史版本')}
|
||||
</span>
|
||||
<span>
|
||||
{isPhone ? '+ ' : ''}
|
||||
{intl.get('当前已保存文件')}
|
||||
</span>
|
||||
</div>
|
||||
{identical && (
|
||||
<Alert type="success" message={intl.get('与当前文件一致')} />
|
||||
)}
|
||||
<div className={styles.diff}>
|
||||
{isPhone ? (
|
||||
<ReactDiffViewer
|
||||
oldValue={detail.version.content}
|
||||
newValue={detail.current}
|
||||
splitView={false}
|
||||
disableWordDiff
|
||||
useDarkTheme={theme.includes('dark')}
|
||||
styles={{
|
||||
contentText: {
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DiffEditor
|
||||
language={language}
|
||||
original={detail.version.content}
|
||||
modified={detail.current}
|
||||
theme={theme}
|
||||
options={{
|
||||
readOnly: true,
|
||||
originalEditable: false,
|
||||
fontSize: 12,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderSideBySide: true,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
+47
-94
@@ -10,7 +10,6 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EllipsisOutlined,
|
||||
HistoryOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-layout';
|
||||
@@ -41,8 +40,6 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import intl from 'react-intl-universal';
|
||||
import SplitPane from 'react-split-pane';
|
||||
import EditModal from './editModal';
|
||||
import HistoryDrawer from './historyDrawer';
|
||||
import { saveWithHistory } from './saveWithHistory';
|
||||
import EditScriptNameModal from './editNameModal';
|
||||
import styles from './index.module.less';
|
||||
import RenameModal from './renameModal';
|
||||
@@ -68,26 +65,6 @@ const Script = () => {
|
||||
const [currentNode, setCurrentNode] = useState<any>();
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
|
||||
const [showMonaco, setShowMonaco] = useState(true);
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingRef = useRef(false);
|
||||
const savedContentRef = useRef('');
|
||||
const currentKeyRef = useRef(currentNode?.key);
|
||||
currentKeyRef.current = currentNode?.key;
|
||||
|
||||
useEffect(() => {
|
||||
setHistoryVisible(false);
|
||||
}, [currentNode?.key]);
|
||||
|
||||
const hasUnsavedChanges = () => {
|
||||
const content =
|
||||
!isPhone && editorRef.current ? editorRef.current.getValue() : value;
|
||||
return (
|
||||
isEditing &&
|
||||
content.replace(/\r\n/g, '\n') !==
|
||||
savedContentRef.current.replace(/\r\n/g, '\n')
|
||||
);
|
||||
};
|
||||
|
||||
const handleIsEditing = (filename: string, value: boolean) => {
|
||||
setIsEditing(value && canPreviewInMonaco(filename));
|
||||
@@ -112,11 +89,10 @@ const Script = () => {
|
||||
.get(
|
||||
`${config.apiPrefix}scripts/detail?file=${encodeURIComponent(
|
||||
node.title,
|
||||
)}&path=${encodeURIComponent(node.parent || '')}`,
|
||||
)}&path=${node.parent || ''}`,
|
||||
)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200 && currentKeyRef.current === node.key) {
|
||||
savedContentRef.current = data;
|
||||
if (code === 200) {
|
||||
setValue(data);
|
||||
if (options.callback) {
|
||||
options.callback();
|
||||
@@ -203,7 +179,7 @@ const Script = () => {
|
||||
const currentContent = editorRef.current
|
||||
? editorRef.current.getValue().replace(/\r\n/g, '\n')
|
||||
: value;
|
||||
const originalContent = savedContentRef.current.replace(/\r\n/g, '\n');
|
||||
const originalContent = value.replace(/\r\n/g, '\n');
|
||||
|
||||
if (currentContent !== originalContent && isEditing) {
|
||||
Modal.confirm({
|
||||
@@ -271,36 +247,42 @@ const Script = () => {
|
||||
getDetail(currentNode);
|
||||
};
|
||||
|
||||
const saveFile = async () => {
|
||||
if (savingRef.current || !currentNode) return;
|
||||
const node = currentNode;
|
||||
const content =
|
||||
!isPhone && editorRef.current
|
||||
? editorRef.current.getValue().replace(/\r\n/g, '\n')
|
||||
: value;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await saveWithHistory(
|
||||
'put',
|
||||
`${config.apiPrefix}scripts`,
|
||||
{
|
||||
filename: node.title,
|
||||
path: node.parent || '',
|
||||
content,
|
||||
},
|
||||
);
|
||||
if (result?.code === 200 && currentKeyRef.current === node.key) {
|
||||
savedContentRef.current = content;
|
||||
setValue(content);
|
||||
handleIsEditing(node.title, false);
|
||||
}
|
||||
} catch {
|
||||
// The shared HTTP handler presents the server's error; keep the draft.
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
const saveFile = () => {
|
||||
Modal.confirm({
|
||||
title: intl.get('确认保存'),
|
||||
content: (
|
||||
<>
|
||||
{intl.get('确认保存文件')}
|
||||
<Text style={{ wordBreak: 'break-all' }} type="warning">
|
||||
{' '}
|
||||
{currentNode.title}
|
||||
</Text>
|
||||
{intl.get(',保存后不可恢复')}
|
||||
</>
|
||||
),
|
||||
onOk() {
|
||||
const content = editorRef.current
|
||||
? editorRef.current.getValue().replace(/\r\n/g, '\n')
|
||||
: value;
|
||||
return new Promise((resolve, reject) => {
|
||||
request
|
||||
.put(`${config.apiPrefix}scripts`, {
|
||||
filename: currentNode.title,
|
||||
path: currentNode.parent || '',
|
||||
content,
|
||||
})
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('保存成功'));
|
||||
setValue(content);
|
||||
handleIsEditing(currentNode.title, false);
|
||||
}
|
||||
resolve(null);
|
||||
})
|
||||
.catch((e) => reject(e));
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFile = () => {
|
||||
@@ -566,20 +548,8 @@ const Script = () => {
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
extra={[
|
||||
...(currentNode?.type === 'file' && showMonaco
|
||||
? [
|
||||
<Button
|
||||
key="history"
|
||||
icon={<HistoryOutlined />}
|
||||
disabled={saving}
|
||||
onClick={() => setHistoryVisible(true)}
|
||||
>
|
||||
{!isPhone && intl.get('历史版本')}
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
...(isPhone
|
||||
extra={
|
||||
isPhone
|
||||
? [
|
||||
<TreeSelect
|
||||
treeExpandAction="click"
|
||||
@@ -592,7 +562,7 @@ const Script = () => {
|
||||
treeNodeFilterProp="title"
|
||||
showSearch
|
||||
allowClear
|
||||
onSelect={(key, node) => onTreeSelect([key], { node })}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<Button type="primary" icon={<EllipsisOutlined />} />
|
||||
@@ -600,7 +570,7 @@ const Script = () => {
|
||||
]
|
||||
: isEditing
|
||||
? [
|
||||
<Button type="primary" onClick={saveFile} loading={saving}>
|
||||
<Button type="primary" onClick={saveFile}>
|
||||
{intl.get('保存')}
|
||||
</Button>,
|
||||
<Button type="primary" onClick={cancelEdit}>
|
||||
@@ -655,29 +625,12 @@ const Script = () => {
|
||||
>
|
||||
{intl.get('调试')}
|
||||
</Button>,
|
||||
]),
|
||||
]}
|
||||
]
|
||||
}
|
||||
header={{
|
||||
style: headerStyle,
|
||||
}}
|
||||
>
|
||||
{historyVisible && currentNode && (
|
||||
<HistoryDrawer
|
||||
key={currentNode.key}
|
||||
filename={currentNode.title}
|
||||
directory={currentNode.parent || ''}
|
||||
isPhone={isPhone}
|
||||
theme={theme}
|
||||
language={mode}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
onClose={() => setHistoryVisible(false)}
|
||||
onRestored={(content) => {
|
||||
savedContentRef.current = content;
|
||||
setValue(content);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={`${styles['log-container']} log-container`}>
|
||||
{!isPhone && (
|
||||
/*// @ts-ignore*/
|
||||
@@ -729,7 +682,7 @@ const Script = () => {
|
||||
value={value}
|
||||
theme={theme}
|
||||
options={{
|
||||
readOnly: !isEditing || saving,
|
||||
readOnly: !isEditing,
|
||||
fontSize: 12,
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: false,
|
||||
@@ -751,7 +704,7 @@ const Script = () => {
|
||||
mode ? [langs[mode as keyof typeof langs]()] : undefined
|
||||
}
|
||||
theme={theme.includes('dark') ? 'dark' : 'light'}
|
||||
readOnly={!isEditing || saving}
|
||||
readOnly={!isEditing}
|
||||
onChange={(value) => {
|
||||
setValue(value);
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Modal, message, Input, Form } from 'antd';
|
||||
import { request } from '@/utils/http';
|
||||
import config from '@/utils/config';
|
||||
import { saveWithHistory } from './saveWithHistory';
|
||||
|
||||
const SaveModal = ({
|
||||
file,
|
||||
@@ -17,21 +16,18 @@ const SaveModal = ({
|
||||
|
||||
const handleOk = async (values: any) => {
|
||||
setLoading(true);
|
||||
const payload = {
|
||||
...values,
|
||||
originFilename: file.title,
|
||||
content: file.content,
|
||||
};
|
||||
try {
|
||||
const result = await saveWithHistory(
|
||||
'post',
|
||||
`${config.apiPrefix}scripts`,
|
||||
payload,
|
||||
);
|
||||
if (result?.code === 200) handleCancel(result.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const payload = { ...values, originFilename: file.title, content: file.content };
|
||||
request
|
||||
.post(`${config.apiPrefix}scripts`, payload)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
message.success(intl.get('保存文件成功'));
|
||||
handleCancel(data);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Modal, message } from 'antd';
|
||||
import intl from 'react-intl-universal';
|
||||
import { IResponseData, request } from '@/utils/http';
|
||||
|
||||
/** Shared by the editor and debugger, so neither can silently skip history. */
|
||||
export async function saveWithHistory(
|
||||
method: 'put' | 'post',
|
||||
url: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<IResponseData | undefined> {
|
||||
const send = async (options: Record<string, unknown> = {}) => {
|
||||
const result = await request[method](
|
||||
url,
|
||||
{ ...payload, ...options },
|
||||
{
|
||||
onError: (response) => Promise.reject({ response }),
|
||||
},
|
||||
);
|
||||
if (result.code !== 200)
|
||||
throw new Error(result.message || intl.get('保存失败'));
|
||||
return result;
|
||||
};
|
||||
const report = (error: any) =>
|
||||
message.error(
|
||||
error?.response?.data?.message || error?.message || intl.get('保存失败'),
|
||||
);
|
||||
let result: IResponseData | undefined;
|
||||
try {
|
||||
result = await send();
|
||||
} catch (error: any) {
|
||||
const response = error?.response;
|
||||
if (response?.status !== 413 || !response.data?.historyUnavailable) {
|
||||
report(error);
|
||||
return undefined;
|
||||
}
|
||||
result = await new Promise<IResponseData | undefined>((resolve) => {
|
||||
const confirmation = Modal.confirm({
|
||||
title: intl.get('不记录历史直接保存?'),
|
||||
content: intl.get(
|
||||
'当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。',
|
||||
),
|
||||
okText: intl.get('仍然保存'),
|
||||
maskClosable: false,
|
||||
onCancel: () => resolve(undefined),
|
||||
onOk: async () => {
|
||||
confirmation.update({
|
||||
cancelButtonProps: { disabled: true },
|
||||
keyboard: false,
|
||||
});
|
||||
try {
|
||||
const saved = await send({
|
||||
skipHistory: true,
|
||||
expectedHash: response.data.currentHash,
|
||||
});
|
||||
resolve(saved);
|
||||
} catch (failure) {
|
||||
report(failure);
|
||||
// Close the confirmation and release the editor. A conflict needs a
|
||||
// fresh read, not another retry with the stale confirmation hash.
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
if (result?.data?.historyRecorded === false) {
|
||||
message.warning(intl.get('已保存,本次未记录历史版本'));
|
||||
} else if (result?.data?.cleanupPending) {
|
||||
message.warning(intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'));
|
||||
} else if (result) {
|
||||
message.success(intl.get('保存成功'));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
|
||||
// node-cron caches timezone formatters; model each deployment in a fresh process.
|
||||
if (!process.env.QL_MACRO_TEST_TZ) {
|
||||
for (const tz of ['UTC', 'Asia/Shanghai']) {
|
||||
test(`macro execution in ${tz}`, () => {
|
||||
const result = require('node:child_process').spawnSync(process.execPath,
|
||||
['-r', 'ts-node/register/transpile-only', '--test', __filename],
|
||||
{ env: { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz }, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tz of process.env.QL_MACRO_TEST_TZ ? [process.env.QL_MACRO_TEST_TZ] : []) {
|
||||
for (const [macro, start, next] of [
|
||||
['@annually', [2026, 11, 31, 23, 59, 59], [[2027, 0, 1, 0, 0, 0], [2028, 0, 1, 0, 0, 0]]],
|
||||
['@midnight', [2026, 8, 27, 23, 59, 59], [[2026, 8, 28, 0, 0, 0], [2026, 8, 29, 0, 0, 0]]],
|
||||
['@minutely', [2026, 8, 27, 12, 34, 59], [[2026, 8, 27, 12, 35, 0], [2026, 8, 27, 12, 36, 0]]],
|
||||
]) {
|
||||
test(`${macro} fires at the expected local times in ${tz}`, async (t) => {
|
||||
const previousTZ = process.env.TZ;
|
||||
process.env.TZ = tz;
|
||||
t.after(() => { if (previousTZ === undefined) delete process.env.TZ; else process.env.TZ = previousTZ; });
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date(...start) });
|
||||
const expected = next.map((parts) => new Date(...parts).getTime());
|
||||
const actual = [];
|
||||
assert.equal(isValidCronSchedule(` ${macro} `), true);
|
||||
const job = createCronJob(macro, (date) => actual.push(date.getTime()), {
|
||||
name: macro, logger: { warn() {}, error() {} },
|
||||
});
|
||||
t.after(() => job.cancel());
|
||||
for (const time of expected) {
|
||||
t.mock.timers.tick(time - Date.now() - 1);
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(actual.includes(time), false, 'must not fire early');
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(setImmediate);
|
||||
}
|
||||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('new aliases do not enable unknown macros or raw parser extensions', () => {
|
||||
for (const schedule of ['@annuallyx', '@midnightx', '@minutelyx', '@secondly', '@reboot', 'H * * * *', '0 /5 * * * *']) {
|
||||
assert.equal(isValidCronSchedule(schedule), false, schedule);
|
||||
}
|
||||
});
|
||||
@@ -1,41 +1,69 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const nodeSchedule = require('node-schedule');
|
||||
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
const legacy = require('../fixtures/legacy-cron.json');
|
||||
const logger = { warn() {}, error() {} };
|
||||
const flush = () => new Promise(setImmediate);
|
||||
|
||||
test('validation agrees with the actual scheduler across legacy cron syntax', () => {
|
||||
const candidates = new Set([
|
||||
'*', '0', '?', '0 0', '0 0 *', '0 0 * *',
|
||||
'@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight',
|
||||
'@hourly', '@secondly', '@minutely', '@weekdays', '@weekends', '@reboot',
|
||||
'0 0 1 1 * 2027', '0 0 0 L * *', '0 0 0 * * 5L',
|
||||
'0 0 0 * * MON#2', '0 0 12 LW * *', '0 0 12 15W * *',
|
||||
'0 0 0 ? * MON', '0 0/30 * * * ?', '0 /5 * * * *',
|
||||
]);
|
||||
const fields = [
|
||||
['*', '?', '/5', '*/5', '0/5', '0', '01', '1-5', '1,3', '5-1', 'H', 'H/5', 'H(0-10)', 'L'],
|
||||
['*', '?', '/5', '*/5', '0/5', '0', '01', '1-5', '1,3', '5-1', 'H', 'H/5', 'H(0-10)', 'L'],
|
||||
['*', '?', '/2', '*/2', '0/2', '0', '01', '1-5', '1,3', '23-2', 'H', 'L'],
|
||||
['*', '?', '/2', '*/2', '1/2', '1', '01', '1-5', '1,3', 'L', 'L-1', 'LW', '15W', 'H'],
|
||||
['*', '?', '/2', '*/2', '1/2', '1', '01', '1-5', '1,3', 'JAN', 'jan', 'JAN-MAR', 'DEC-FEB', 'H'],
|
||||
['*', '?', '/2', '*/2', '0/2', '0', '7', '01', '1-5', '1,3', 'MON', 'mon', 'MON-FRI', 'FRI-MON', '5L', 'L', 'MON#2', '1#5', 'H'],
|
||||
];
|
||||
fields.forEach((options, index) => options.forEach((field) => {
|
||||
const values = ['0', '0', '0', '*', '*', '*'];
|
||||
values[index] = field;
|
||||
candidates.add(values.join(' '));
|
||||
}));
|
||||
for (const value of [...candidates]) {
|
||||
if (value.split(' ').length === 6) {
|
||||
candidates.add(` ${value} `);
|
||||
candidates.add(value.split(' ').slice(1).join(' '));
|
||||
}
|
||||
test('validation preserves legacy syntax except the explicitly added macros', () => {
|
||||
const addedMacros = new Set(['@annually', '@midnight', '@minutely']);
|
||||
assert.ok(legacy.validation.length > 250);
|
||||
for (const { schedule, accepted } of legacy.validation) {
|
||||
assert.equal(isValidCronSchedule(schedule), accepted || addedMacros.has(schedule), schedule);
|
||||
}
|
||||
for (const value of candidates) {
|
||||
const job = nodeSchedule.scheduleJob(value, () => {});
|
||||
const accepted = Boolean(job);
|
||||
job?.cancel();
|
||||
assert.equal(isValidCronSchedule(value), accepted, value);
|
||||
}
|
||||
assert.ok(candidates.size > 250);
|
||||
});
|
||||
|
||||
for (const sample of legacy.times) {
|
||||
test(`calendar compatibility: ${sample.schedule} from ${sample.currentDate}`, async (t) => {
|
||||
const previousTZ = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
t.after(() => { if (previousTZ === undefined) delete process.env.TZ; else process.env.TZ = previousTZ; });
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date(sample.currentDate) });
|
||||
const actual = [];
|
||||
const job = createCronJob(sample.schedule, (date) => actual.push(date.toISOString()), { name: 'calendar', logger });
|
||||
t.after(() => job.cancel());
|
||||
for (const expected of sample.next) {
|
||||
t.mock.timers.tick(new Date(expected).getTime() - Date.now());
|
||||
await flush();
|
||||
}
|
||||
assert.deepEqual(actual, sample.next);
|
||||
});
|
||||
}
|
||||
|
||||
test('late callbacks catch up exactly once and cancellation destroys native tasks', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-09-27T00:00:00Z') });
|
||||
const native = require('node-cron');
|
||||
const before = native.getTasks().size;
|
||||
const dates = [], warnings = [];
|
||||
const job = createCronJob('* * * * * *', (date) => dates.push(date.getTime()), {
|
||||
name: 'late', logger: { warn: (...args) => warnings.push(args), error() {} },
|
||||
});
|
||||
t.after(() => job.cancel());
|
||||
t.mock.timers.tick(8000);
|
||||
await flush();
|
||||
assert.equal(dates.length, 8);
|
||||
assert.equal(new Set(dates).size, 8);
|
||||
assert.ok(warnings.length > 0);
|
||||
job.cancel();
|
||||
t.mock.timers.tick(5000);
|
||||
await flush();
|
||||
assert.equal(dates.length, 8);
|
||||
assert.equal(native.getTasks().size, before);
|
||||
});
|
||||
|
||||
test('callback rejection is logged and future executions continue', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-09-27T00:00:00Z') });
|
||||
const errors = [];
|
||||
let count = 0;
|
||||
const job = createCronJob('* * * * * *', async () => { count++; throw Error('callback failed'); }, {
|
||||
name: 'failure', logger: { warn() {}, error: (...args) => errors.push(args) },
|
||||
});
|
||||
t.after(() => job.cancel());
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
assert.equal(count, 2);
|
||||
assert.equal(errors.length, 2);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,14 @@ test('system and node modes route extended cron once and keep portable rules in
|
||||
const rows = [
|
||||
{ id: 'plain', schedule: '*/5 0-23 * * 1,3' },
|
||||
{ id: 'spaces', schedule: ' 0\t0 * * * ' },
|
||||
{ id: 'numeric-step', schedule: '0/5 * * * *' },
|
||||
{ id: 'sunday-seven', schedule: '0 0 * * 7' },
|
||||
{ id: 'day-step', schedule: '0 0 */2 * *' },
|
||||
{ id: 'restricted-days', schedule: '0 0 1-31 * 1' },
|
||||
{ id: 'restricted-week', schedule: '0 0 1 * 0-6' },
|
||||
{ id: 'annually', schedule: '@annually' },
|
||||
{ id: 'midnight', schedule: '@midnight' },
|
||||
{ id: 'minutely', schedule: '@minutely' },
|
||||
{ id: 'seconds', schedule: '0 0 * * * *' },
|
||||
{ id: 'question', schedule: '0 0 * * ?' },
|
||||
{ id: 'last', schedule: '0 0 L * *' },
|
||||
@@ -43,7 +51,7 @@ test('system and node modes route extended cron once and keep portable rules in
|
||||
}
|
||||
await service.setCrontab({ data: rows, total: rows.length });
|
||||
assert.equal(installs, mode === 'system' ? 1 : 0);
|
||||
for (const id of ['seconds', 'question', 'last', 'nth', 'named', 'macro', 'short', 'extra', 'once', 'boot']) {
|
||||
for (const id of ['numeric-step', 'sunday-seven', 'day-step', 'restricted-days', 'restricted-week', 'annually', 'midnight', 'minutely', 'seconds', 'question', 'last', 'nth', 'named', 'macro', 'short', 'extra', 'once', 'boot']) {
|
||||
const line = file.split('\n').find((line) => line.endsWith(`task ${id}.js`));
|
||||
assert.ok(line.startsWith('# '), `${mode}: ${id} cannot also run from system crontab`);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ const { format } = require('node:util');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
|
||||
const { getTasks } = require('node-cron');
|
||||
|
||||
const legacySchedules = [
|
||||
'* * * * *',
|
||||
'*/5 * * * *',
|
||||
@@ -54,8 +56,11 @@ test('legacy main and extra schedules restore real jobs and healthy readiness',
|
||||
assert.equal(stacks.size, crons.length);
|
||||
for (const jobs of stacks.values()) {
|
||||
assert.equal(jobs.length, 2);
|
||||
assert.ok(jobs.every((job) => job.nextInvocation()));
|
||||
assert.ok(jobs.every((job) => typeof job.cancel === 'function'));
|
||||
}
|
||||
const nativeJobs = [...getTasks().values()];
|
||||
assert.equal(nativeJobs.length, crons.length * 2);
|
||||
assert.ok(nativeJobs.every((job) => job.getStatus() !== 'stopped'));
|
||||
const previousJobs = [...stacks.values()].flat();
|
||||
for (const schedule of ['@bogus', '0 /5 * * * ?', '0 70 * * * ?']) {
|
||||
for (const invalid of [
|
||||
@@ -64,7 +69,8 @@ test('legacy main and extra schedules restore real jobs and healthy readiness',
|
||||
]) {
|
||||
await assert.rejects(register([invalid]), (error) => error.code === 3);
|
||||
assert.deepEqual([...stacks.values()].flat(), previousJobs);
|
||||
assert.ok(previousJobs.every((job) => job.nextInvocation()));
|
||||
assert.deepEqual([...getTasks().values()], nativeJobs);
|
||||
assert.ok(nativeJobs.every((job) => job.getStatus() !== 'stopped'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
const logger = { info() {}, warn() {}, error() {} };
|
||||
|
||||
test('native calendar skips nonexistent spring time and duplicate fall hour', async (t) => {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = 'America/New_York';
|
||||
t.after(() => { if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous; });
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-03-08T05:00:00Z') });
|
||||
const springRuns = [];
|
||||
const spring = createCronJob('0 30 2 * * *', (date) => springRuns.push(date.toISOString()), { name: 'spring', logger });
|
||||
t.after(() => spring.cancel());
|
||||
t.mock.timers.tick(new Date('2026-03-09T06:30:00Z') - Date.now());
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(springRuns, ['2026-03-09T06:30:00.000Z']);
|
||||
spring.cancel();
|
||||
t.mock.timers.setTime(new Date('2026-11-01T05:30:00Z').getTime());
|
||||
const fallRuns = [];
|
||||
const fall = createCronJob('0 30 1 * * *', (date) => fallRuns.push(date.toISOString()), { name: 'fall', logger });
|
||||
t.after(() => fall.cancel());
|
||||
t.mock.timers.tick(new Date('2026-11-02T06:30:00Z') - Date.now());
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(fallRuns, ['2026-11-02T06:30:00.000Z']);
|
||||
});
|
||||
|
||||
test('construction failure cleans staged jobs and preserves the running snapshot', async () => {
|
||||
const cancelled = [];
|
||||
const stacks = new Map([['old', [{ cancel: () => cancelled.push('old') }]]]);
|
||||
let count = 0;
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/cronScheduler': { createCronJob: () => {
|
||||
if (++count === 2) throw Error('construction failed');
|
||||
return { start() { throw Error('must not start'); }, cancel: () => cancelled.push('staged') };
|
||||
} },
|
||||
'../shared/runCron': {}, '../loaders/logger': logger, '../shared/i18n': { tf: require('node:util').format },
|
||||
});
|
||||
await assert.rejects(new Promise((resolve, reject) => addCron({ request: {
|
||||
replace: true, crons: [{ id: 'new', schedule: '* * * * *', extra_schedules: [{ schedule: '0 * * * *' }] }],
|
||||
} }, (error) => error ? reject(error) : resolve())), /construction failed/);
|
||||
assert.deepEqual([...stacks.keys()], ['old']);
|
||||
assert.deepEqual(cancelled, ['staged']);
|
||||
});
|
||||
|
||||
test('invalid subscription schedules are isolated and immediate runs without a schedule survive', async () => {
|
||||
const warnings = [], runs = [];
|
||||
const Service = load('back/services/schedule.ts', {
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../shared/pLimit': {},
|
||||
}).default;
|
||||
const service = new Service({ ...logger, warn: (...args) => warnings.push(args) });
|
||||
service.runTask = async (...args) => { runs.push(args); };
|
||||
const item = { id: 123, name: 'subscription', command: 'true', runOrigin: 'subscription' };
|
||||
await service.createCronTask({ ...item, schedule: 'not a cron' });
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(service.scheduleStacks.size, 0);
|
||||
await service.createCronTask(item, {}, true);
|
||||
assert.equal(runs.length, 1);
|
||||
await service.createCronTask({ ...item, schedule: '* * * * *' });
|
||||
assert.equal(service.scheduleStacks.size, 1);
|
||||
await service.cancelCronTask(item);
|
||||
assert.equal(service.scheduleStacks.size, 0);
|
||||
});
|
||||
@@ -65,6 +65,35 @@ test('config, environment, cwd, and executable changes invalidate discovery', (t
|
||||
f.run();
|
||||
assert.equal(f.count(), before + 1);
|
||||
});
|
||||
test('alternating Node/crond working directories retain independent cached roots', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(path.join(f.root, 'child'));
|
||||
fs.writeFileSync(path.join(f.root, 'child', '.npmrc'), 'global-dir=/child\n');
|
||||
for (let i = 0; i < 4; i++) {
|
||||
assert.equal(f.run({ ANSWER: '/parent/modules' }).stdout.trim(), '/parent/modules');
|
||||
assert.equal(
|
||||
f.run({ ANSWER: '/child/modules' }, 'cd child; ' + f.command).stdout.trim(),
|
||||
'/child/modules',
|
||||
);
|
||||
}
|
||||
assert.equal(f.count(), 2, 'each working directory should discover only once');
|
||||
assert.equal(fs.readdirSync(f.env.dir_tmp).filter((x) => x.endsWith('.cache')).length, 2);
|
||||
});
|
||||
test('refresh retires old per-environment records without removing locks or unrelated files', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(f.env.dir_tmp);
|
||||
const prefix = `pnpm-root-${process.getuid()}`;
|
||||
const names = [`${prefix}-1-2.cache`, `${prefix}.lock`, 'unrelated.cache'];
|
||||
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
for (const name of names) {
|
||||
const file = path.join(f.env.dir_tmp, name);
|
||||
fs.writeFileSync(file, '');
|
||||
fs.utimesSync(file, old, old);
|
||||
}
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(fs.existsSync(path.join(f.env.dir_tmp, names[0])), false);
|
||||
for (const name of names.slice(1)) assert.ok(fs.existsSync(path.join(f.env.dir_tmp, name)));
|
||||
});
|
||||
test('expired or malformed records refresh and failed lookups are not cached', (t) => {
|
||||
const f = fixture(t);
|
||||
f.run();
|
||||
|
||||
@@ -51,7 +51,11 @@ function fixture(t) {
|
||||
fs.existsSync(env.CALLS)
|
||||
? fs.readFileSync(env.CALLS, 'utf8').trim().split('\n').length
|
||||
: 0;
|
||||
const cache = path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.cache`);
|
||||
const key = run({}, '. "$HELPER"; ql_node_path_cache_key').stdout.trim();
|
||||
const cache = path.join(
|
||||
env.dir_tmp,
|
||||
`pnpm-root-${process.getuid()}-${key.replace(' ', '-')}.cache`,
|
||||
);
|
||||
return {
|
||||
root,
|
||||
bin,
|
||||
@@ -61,7 +65,7 @@ function fixture(t) {
|
||||
asyncRun,
|
||||
count,
|
||||
cache,
|
||||
lock: cache + '.lock',
|
||||
lock: path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.lock`),
|
||||
};
|
||||
}
|
||||
function check(r, output = '/test/global/node_modules') {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
|
||||
function fixture(t, language) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-lazy-notify-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const extension = language === 'python' ? 'py' : 'js';
|
||||
fs.copyFileSync(`shell/preload/sitecustomize.${extension}`, path.join(root, `sitecustomize.${extension}`));
|
||||
fs.writeFileSync(path.join(root, `env.${extension}`), '');
|
||||
fs.writeFileSync(path.join(root, 'before.sh'), 'export FROM_SHELL=before\n');
|
||||
fs.writeFileSync(path.join(root, 'before.js'), 'process.env.FROM_LANGUAGE = "javascript";\n');
|
||||
fs.writeFileSync(path.join(root, 'task_before.py'), 'import os\nos.environ["FROM_LANGUAGE"] = "python"\n');
|
||||
fs.writeFileSync(path.join(root, 'client.js'), 'module.exports = { getEnvs: () => "client-ready" };\n');
|
||||
fs.copyFileSync('shell/preload/client.py', path.join(root, 'client.py'));
|
||||
fs.copyFileSync('shell/preload/esm-loader.mjs', path.join(root, 'esm-loader.mjs'));
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONPATH: root,
|
||||
PREV_PYTHONPATH: '',
|
||||
NODE_OPTIONS: '',
|
||||
PREV_NODE_OPTIONS: '',
|
||||
QL_NODE_GLOBAL_PATH: '',
|
||||
file_task_before: path.join(root, 'before.sh'),
|
||||
file_task_before_js: path.join(root, 'before.js'),
|
||||
dir_scripts: root,
|
||||
task_before: 'export FROM_COMMAND=command',
|
||||
envParam: 'ACCOUNTS',
|
||||
numParam: '2-3',
|
||||
ACCOUNTS: 'first&second&third',
|
||||
};
|
||||
const write = (name, text) => fs.writeFileSync(path.join(root, name), text);
|
||||
const run = (body, extensionOverride = extension) => {
|
||||
const entry = path.join(root, `entry.${extensionOverride}`);
|
||||
write(path.basename(entry), body);
|
||||
const python = language === 'python';
|
||||
const result = spawnSync(python ? 'python3' : process.execPath,
|
||||
python ? [entry] : ['--require', path.join(root, 'sitecustomize.js'), entry],
|
||||
{ cwd: root, env, encoding: 'utf8', timeout: 15000 });
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
return result.stdout;
|
||||
};
|
||||
return { root, env, write, run };
|
||||
}
|
||||
|
||||
for (const extension of ['js', 'mjs']) {
|
||||
test(`${extension} startup preserves hooks, environment selection and client without importing notifications`, (t) => {
|
||||
const f = fixture(t, 'javascript');
|
||||
f.write('__ql_notify__.js', 'throw Error("notification dependencies must not load at startup");');
|
||||
f.run(`
|
||||
const assert = ${extension === 'mjs' ? '(await import("node:assert/strict")).default' : 'require("node:assert/strict")'};
|
||||
assert.equal(process.env.FROM_SHELL, 'before');
|
||||
assert.equal(process.env.FROM_LANGUAGE, 'javascript');
|
||||
assert.equal(process.env.FROM_COMMAND, 'command');
|
||||
assert.equal(process.env.ACCOUNTS, 'second&third');
|
||||
assert.equal(QLAPI.getEnvs(), 'client-ready');
|
||||
assert.equal(typeof QLAPI.notify, 'function');
|
||||
`, extension);
|
||||
});
|
||||
}
|
||||
|
||||
test('JavaScript first notification imports once and preserves arguments, this, promises and errors', (t) => {
|
||||
const f = fixture(t, 'javascript');
|
||||
f.write('__ql_notify__.js', `
|
||||
global.notificationLoads = (global.notificationLoads || 0) + 1;
|
||||
exports.sendNotify = function (...args) {
|
||||
if (args[0] === 'fail') throw new Error('send failed');
|
||||
global.notificationResult = Promise.resolve({ args, receiver: this });
|
||||
return global.notificationResult;
|
||||
};
|
||||
`);
|
||||
f.run(`
|
||||
const assert = require('node:assert/strict');
|
||||
assert.equal(global.notificationLoads, undefined);
|
||||
(async () => {
|
||||
const options = { channel: 'fixture' };
|
||||
const promise = QLAPI.notify('title', 'content', options);
|
||||
assert.equal(promise, global.notificationResult);
|
||||
const result = await promise;
|
||||
assert.deepEqual(result.args, ['title', 'content', options]);
|
||||
assert.equal(result.receiver, QLAPI);
|
||||
await QLAPI.notify('second');
|
||||
assert.equal(global.notificationLoads, 1);
|
||||
assert.throws(() => QLAPI.notify('fail'), /send failed/);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
`);
|
||||
});
|
||||
|
||||
test('JavaScript reports import failures on notify and permits a later retry', (t) => {
|
||||
const f = fixture(t, 'javascript');
|
||||
f.write('__ql_notify__.js', `
|
||||
if (!process.env.NOTIFY_READY) throw Error('notification dependency unavailable');
|
||||
exports.sendNotify = (...args) => args;
|
||||
`);
|
||||
f.run(`
|
||||
const assert = require('node:assert/strict');
|
||||
assert.equal(QLAPI.getEnvs(), 'client-ready');
|
||||
assert.throws(() => QLAPI.notify('first'), /notification dependency unavailable/);
|
||||
process.env.NOTIFY_READY = '1';
|
||||
assert.deepEqual(QLAPI.notify('retry'), ['retry']);
|
||||
`);
|
||||
});
|
||||
|
||||
test('Python startup preserves hooks, environment selection and client without importing notifications', (t) => {
|
||||
const f = fixture(t, 'python');
|
||||
f.write('__ql_notify__.py', 'raise RuntimeError("notification dependencies must not load at startup")\n');
|
||||
f.run(`
|
||||
import os, sys
|
||||
assert os.environ['FROM_SHELL'] == 'before'
|
||||
assert os.environ['FROM_LANGUAGE'] == 'python'
|
||||
assert os.environ['FROM_COMMAND'] == 'command'
|
||||
assert os.environ['ACCOUNTS'] == 'second&third'
|
||||
assert '__ql_notify__' not in sys.modules
|
||||
assert callable(QLAPI.notify)
|
||||
assert callable(QLAPI.getEnvs)
|
||||
`);
|
||||
});
|
||||
|
||||
test('Python first notification imports once and preserves args, kwargs, return values and errors', (t) => {
|
||||
const f = fixture(t, 'python');
|
||||
f.write('__ql_notify__.py', `
|
||||
import builtins
|
||||
builtins.notification_loads = getattr(builtins, 'notification_loads', 0) + 1
|
||||
def send(*args, **kwargs):
|
||||
if args[0] == 'fail':
|
||||
raise ValueError('send failed')
|
||||
return args, kwargs
|
||||
`);
|
||||
f.run(`
|
||||
import builtins, sys
|
||||
assert '__ql_notify__' not in sys.modules
|
||||
assert QLAPI.notify('title', 'content', channel='fixture') == (('title', 'content'), {'channel': 'fixture'})
|
||||
assert QLAPI.notify('second') == (('second',), {})
|
||||
assert builtins.notification_loads == 1
|
||||
try:
|
||||
QLAPI.notify('fail')
|
||||
except ValueError as error:
|
||||
assert str(error) == 'send failed'
|
||||
else:
|
||||
raise AssertionError('notification exception was swallowed')
|
||||
`);
|
||||
});
|
||||
|
||||
test('Python reports import failures on notify and permits a later retry', (t) => {
|
||||
const f = fixture(t, 'python');
|
||||
f.write('__ql_notify__.py', `
|
||||
import os
|
||||
if not os.environ.get('NOTIFY_READY'):
|
||||
raise RuntimeError('notification dependency unavailable')
|
||||
def send(*args, **kwargs):
|
||||
return args
|
||||
`);
|
||||
f.run(`
|
||||
import os, sys
|
||||
assert '__ql_notify__' not in sys.modules
|
||||
try:
|
||||
QLAPI.notify('first')
|
||||
except RuntimeError as error:
|
||||
assert str(error) == 'notification dependency unavailable'
|
||||
else:
|
||||
raise AssertionError('import exception was swallowed')
|
||||
os.environ['NOTIFY_READY'] = '1'
|
||||
assert QLAPI.notify('retry') == ('retry',)
|
||||
`);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const grpc = require('@grpc/grpc-js');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
|
||||
for (const [name, rows, delay, succeeds] of [
|
||||
['large distinct snapshot', Array.from({length:10000}, (_, i) => ({id:String(i), schedule:`${i%60} ${Math.floor(i/60)%60} ${Math.floor(i/3600)} * * *`})), 10000, true],
|
||||
['extra schedules', [{id:'extra', schedule:'* * * * *', extra_schedules:Array.from({length:2000},()=>({schedule:'0 * * * *'}))}], 10000, true],
|
||||
['unresponsive small write', [{id:'small', schedule:'* * * * *'}], 30000, false],
|
||||
['unresponsive large write remains bounded', Array.from({length:30000},()=>({schedule:'* * * * *'})), 180000, false],
|
||||
]) {
|
||||
test(`recovery deadline: ${name}`, async (t) => {
|
||||
t.mock.timers.enable({apis:['Date','setTimeout'], now:0});
|
||||
let calls=0, deadline;
|
||||
const fake={waitForReady:(_,cb)=>cb(), addCron:(_req,_meta,options,cb)=>{
|
||||
calls++; deadline=options.deadline;
|
||||
const budget=deadline-Date.now();
|
||||
setTimeout(()=>cb(delay<=budget ? null : Object.assign(Error('deadline'),{code:grpc.status.DEADLINE_EXCEEDED}), {}), Math.min(delay,budget));
|
||||
}};
|
||||
const client=load('back/schedule/client.ts', {
|
||||
'../protos/cron':{CronClient:class{constructor(){return fake}}},
|
||||
'../config':{grpcPort:5500},
|
||||
'../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})},
|
||||
'@grpc/grpc-js':{...grpc,credentials:{createSsl:()=>({})}},
|
||||
}).default;
|
||||
const readiness=new SchedulerReadiness(async()=>{}, 60000);
|
||||
t.after(()=>clearTimeout(readiness.retry));
|
||||
client.readiness=readiness;
|
||||
readiness.configure(()=>client.addCron(rows,true));
|
||||
const recovering=readiness.recover();
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(calls,1);
|
||||
assert.ok(deadline<=120000);
|
||||
t.mock.timers.tick(Math.min(delay,deadline));
|
||||
assert.equal(await recovering,succeeds);
|
||||
assert.equal(calls,1,'uncertain writes are not replayed inline');
|
||||
assert.equal(await readiness.check(),succeeds);
|
||||
});
|
||||
}
|
||||
@@ -41,8 +41,10 @@ test('recovery reconciles a surviving scheduler after missed deletes and disable
|
||||
);
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'node-schedule': {
|
||||
scheduleJob: (id) => ({ cancel: () => cancelled.push(id) }),
|
||||
'../shared/cronScheduler': {
|
||||
createCronJob: (_schedule, _callback, options) => ({
|
||||
start() {}, cancel: () => cancelled.push(options.name.split(':')[0]),
|
||||
}),
|
||||
},
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': { info() {}, warn() {} },
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
require('reflect-metadata');
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const { errors } = require('celebrate');
|
||||
const { Container } = require('typedi');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { resolveFileAccess } = load(
|
||||
path.join(__dirname, '../../back/shared/fileAccess.ts'),
|
||||
);
|
||||
|
||||
test('script routes save, list, preview and restore, with validation and conflict responses', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-api-'));
|
||||
const scripts = path.join(root, 'scripts');
|
||||
await fs.mkdir(scripts);
|
||||
await fs.writeFile(path.join(scripts, 'test.js'), 'before');
|
||||
const config = {
|
||||
scriptPath: scripts,
|
||||
dataPath: root,
|
||||
bakPath: path.join(root, 'bak'),
|
||||
blackFileList: ['auth.json'],
|
||||
writePathList: [scripts],
|
||||
};
|
||||
await fs.mkdir(config.bakPath);
|
||||
let failure = '';
|
||||
class ScriptService {
|
||||
checkFilePath(dir, name) {
|
||||
return resolveFileAccess(scripts, [dir || '', name]);
|
||||
}
|
||||
}
|
||||
const mocks = {
|
||||
'../config': { default: config, __esModule: true },
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
copyFile: async (from, to) => {
|
||||
if (failure === 'backup' && path.dirname(to) === config.bakPath)
|
||||
throw Object.assign(new Error('backup denied'), { code: 'EACCES' });
|
||||
return fs.copyFile(from, to);
|
||||
},
|
||||
writeFile: async (file, ...args) => {
|
||||
if (failure === 'history' && String(file).endsWith('.pending.tmp'))
|
||||
throw Object.assign(new Error('history full'), { code: 'ENOSPC' });
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
},
|
||||
'../config/util': {
|
||||
rmPath: (file) => fs.rm(file, { force: true, recursive: true }),
|
||||
fileExist: async (file) =>
|
||||
fs.access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
},
|
||||
'../services/script': { default: ScriptService, __esModule: true },
|
||||
'../shared/i18n': { t: (value) => value },
|
||||
};
|
||||
const cache = new Map();
|
||||
const api = load(
|
||||
path.join(__dirname, '../../back/api/script.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
);
|
||||
const { default: ScriptHistoryService } = load(
|
||||
path.join(__dirname, '../../back/services/scriptHistory.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
);
|
||||
Container.set(ScriptService, new ScriptService());
|
||||
t.after(() => {
|
||||
Container.remove(ScriptHistoryService);
|
||||
Container.remove(ScriptService);
|
||||
});
|
||||
assert.ok(Container.has(ScriptHistoryService));
|
||||
const historyService = Container.get(ScriptHistoryService);
|
||||
assert.strictEqual(Container.get(ScriptHistoryService), historyService);
|
||||
// An empty history read must not create storage or other persistent resources.
|
||||
assert.deepEqual((await historyService.list('', 'test.js')).versions, []);
|
||||
await assert.rejects(fs.stat(path.join(root, 'script-history')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
api.default(app);
|
||||
app.use(errors());
|
||||
app.use((error, _req, res, _next) =>
|
||||
res.status(500).json({ error: error.message }),
|
||||
);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
t.after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const call = async (url, body, method = 'PUT') => {
|
||||
const res = await fetch(
|
||||
base + url,
|
||||
body && {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
assert.equal(
|
||||
(await call('/scripts', { filename: 'test.js', content: 'after' })).body
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
const list = await call('/scripts/history?filename=test.js');
|
||||
assert.equal(list.status, 200);
|
||||
assert.equal(list.body.data.versions.length, 1);
|
||||
const id = list.body.data.versions[0].id;
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=test.js&id=${id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'before');
|
||||
assert.equal(preview.body.data.current, 'after');
|
||||
assert.equal(
|
||||
(await call('/scripts/history/restore', { filename: 'test.js', id }))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/scripts/history/restore', {
|
||||
filename: 'test.js',
|
||||
id,
|
||||
expectedHash: '0'.repeat(64),
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/scripts/history/restore', {
|
||||
filename: 'test.js',
|
||||
id,
|
||||
expectedHash: preview.body.data.currentHash,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'test.js'), 'utf8'),
|
||||
'before',
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/scripts/history?filename=..%2Foutside')).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await call('/scripts/history?filename=missing')).status, 404);
|
||||
for (const content of ['debug-C', 'debug-D']) {
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{ filename: 'test.js', originFilename: 'test.js', path: '', content },
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.data.historyRecorded, true);
|
||||
}
|
||||
const debugList = await call('/scripts/history?filename=test.js');
|
||||
const newest = await call(
|
||||
`/scripts/history/detail?filename=test.js&id=${debugList.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(newest.body.data.version.content, 'debug-C');
|
||||
assert.equal(newest.body.data.current, 'debug-D');
|
||||
|
||||
// Both editor PUT and debugger POST support the same explicit opt-out.
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'large.js'),
|
||||
'x'.repeat(1024 * 1024 + 1),
|
||||
);
|
||||
const payload = { filename: 'large.js', path: '', content: 'small' };
|
||||
const rejected = await call('/scripts', payload, method);
|
||||
assert.equal(rejected.status, 413);
|
||||
assert.equal(rejected.body.historyUnavailable, true);
|
||||
assert.equal(
|
||||
(await call('/scripts', { ...payload, skipHistory: true }, method))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
...payload,
|
||||
skipHistory: true,
|
||||
expectedHash: rejected.body.currentHash,
|
||||
},
|
||||
method,
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.data.historyRecorded, false);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'large.js'), 'utf8'),
|
||||
'small',
|
||||
);
|
||||
}
|
||||
for (const scenario of ['target-link', 'source-link', 'normalized-path']) {
|
||||
await t.test(
|
||||
`save-as preserves aliases of the same file: ${scenario}`,
|
||||
async () => {
|
||||
const sourceName = `${scenario}-source.js`;
|
||||
const aliasName = `${scenario}-alias.js`;
|
||||
const sourcePath = path.join(scripts, sourceName);
|
||||
await fs.writeFile(sourcePath, 'source-original');
|
||||
if (scenario !== 'normalized-path')
|
||||
await fs.symlink(sourceName, path.join(scripts, aliasName));
|
||||
const filename =
|
||||
scenario === 'target-link'
|
||||
? aliasName
|
||||
: scenario === 'normalized-path'
|
||||
? `./${sourceName}`
|
||||
: sourceName;
|
||||
const originFilename =
|
||||
scenario === 'source-link' ? aliasName : sourceName;
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{ filename, originFilename, path: '', content: 'source-edited' },
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.code, 200);
|
||||
assert.equal(await fs.readFile(sourcePath, 'utf8'), 'source-edited');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, filename), 'utf8'),
|
||||
'source-edited',
|
||||
);
|
||||
if (scenario !== 'normalized-path') {
|
||||
assert.equal(
|
||||
await fs.readlink(path.join(scripts, aliasName)),
|
||||
sourceName,
|
||||
);
|
||||
const sourceHistory = await call(
|
||||
`/scripts/history?filename=${sourceName}`,
|
||||
);
|
||||
const aliasHistory = await call(
|
||||
`/scripts/history?filename=${aliasName}`,
|
||||
);
|
||||
assert.deepEqual(aliasHistory.body.data, sourceHistory.body.data);
|
||||
}
|
||||
const list = await call(
|
||||
`/scripts/history?filename=${encodeURIComponent(filename)}`,
|
||||
);
|
||||
assert.equal(list.body.data.versions.length, 1);
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=${encodeURIComponent(
|
||||
filename,
|
||||
)}&id=${list.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'source-original');
|
||||
assert.equal(preview.body.data.current, 'source-edited');
|
||||
},
|
||||
);
|
||||
}
|
||||
await t.test(
|
||||
'save-as backs up the source and retains destination history before removing the source',
|
||||
async () => {
|
||||
await fs.writeFile(path.join(scripts, 'source.js'), 'source-original');
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'destination.js'),
|
||||
'destination-original',
|
||||
);
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: 'destination.js',
|
||||
originFilename: 'source.js',
|
||||
path: '',
|
||||
content: 'source-edited',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
await assert.rejects(fs.access(path.join(scripts, 'source.js')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(config.bakPath, 'source.js'), 'utf8'),
|
||||
'source-original',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'destination.js'), 'utf8'),
|
||||
'source-edited',
|
||||
);
|
||||
const list = await call('/scripts/history?filename=destination.js');
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=destination.js&id=${list.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'destination-original');
|
||||
},
|
||||
);
|
||||
for (const step of ['backup', 'history']) {
|
||||
await t.test(
|
||||
`save-as ${step} failure preserves both original scripts`,
|
||||
async () => {
|
||||
const sourceName = `${step}-source.js`,
|
||||
destinationName = `${step}-destination.js`;
|
||||
await fs.writeFile(path.join(scripts, sourceName), 'source-original');
|
||||
await fs.writeFile(
|
||||
path.join(scripts, destinationName),
|
||||
'destination-original',
|
||||
);
|
||||
failure = step;
|
||||
try {
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: destinationName,
|
||||
originFilename: sourceName,
|
||||
path: '',
|
||||
content: 'source-edited',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 500);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, sourceName), 'utf8'),
|
||||
'source-original',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, destinationName), 'utf8'),
|
||||
'destination-original',
|
||||
);
|
||||
assert.equal(
|
||||
(await call(`/scripts/history?filename=${destinationName}`)).body
|
||||
.data.versions.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
failure = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,383 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const source = path.join(__dirname, '../../back/shared/scriptHistory.ts');
|
||||
const mocks = {
|
||||
'../config/util': {
|
||||
fileExist: async (file) =>
|
||||
fs.access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
},
|
||||
};
|
||||
const { ScriptHistory, SCRIPT_HISTORY_LIMITS } = load(source, mocks);
|
||||
|
||||
async function fixture(t, limits = {}, History = ScriptHistory) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-'));
|
||||
t.after(() => fs.rm(root, { force: true, recursive: true }));
|
||||
const scripts = path.join(root, 'scripts');
|
||||
const history = path.join(root, 'history');
|
||||
await fs.mkdir(scripts);
|
||||
const file = path.join(scripts, 'test.js');
|
||||
await fs.writeFile(file, 'original\n');
|
||||
const service = new History(scripts, history, ['auth.json'], {
|
||||
...SCRIPT_HISTORY_LIMITS,
|
||||
...limits,
|
||||
});
|
||||
return { root, scripts, history, file, service };
|
||||
}
|
||||
|
||||
test('first save, unchanged save, restore and undo restore preserve exact content', async (t) => {
|
||||
const { service, file, history } = await fixture(t);
|
||||
assert.deepEqual((await service.list('', 'test.js')).versions, []);
|
||||
await assert.rejects(fs.stat(history), { code: 'ENOENT' });
|
||||
await service.save('', 'test.js', 'second\r\n中文');
|
||||
let list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions.length, 1);
|
||||
assert.equal(list.versions[0].source, 'baseline');
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', list.versions[0].id)).version.content,
|
||||
'original\n',
|
||||
);
|
||||
const archive = path.join(history, (await fs.readdir(history))[0]);
|
||||
const before = await fs.readFile(archive);
|
||||
await service.save('', 'test.js', 'second\r\n中文');
|
||||
assert.deepEqual(
|
||||
await fs.readFile(archive),
|
||||
before,
|
||||
'unchanged save performs no archive write',
|
||||
);
|
||||
await service.restore('', 'test.js', list.versions[0].id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions[0].source, 'save');
|
||||
await service.restore('', 'test.js', list.versions[0].id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'second\r\n中文');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions[0].source,
|
||||
'restore',
|
||||
);
|
||||
});
|
||||
|
||||
test('conflict check refuses to overwrite a file changed after preview, including external edits', async (t) => {
|
||||
const { service, file } = await fixture(t);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
const list = await service.list('', 'test.js');
|
||||
await fs.writeFile(file, 'external');
|
||||
await assert.rejects(
|
||||
service.restore('', 'test.js', list.versions[0].id, list.currentHash),
|
||||
{ status: 409 },
|
||||
);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'external');
|
||||
await service.save('', 'test.js', 'after external');
|
||||
const current = await service.list('', 'test.js');
|
||||
assert.equal(current.versions[0].source, 'external');
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', current.versions[0].id)).version
|
||||
.content,
|
||||
'external',
|
||||
);
|
||||
});
|
||||
|
||||
test('same-name files are isolated, traversal, blacklist and escaping symlinks are forbidden', async (t) => {
|
||||
const { service, scripts, root } = await fixture(t);
|
||||
await fs.mkdir(path.join(scripts, 'nested'));
|
||||
await fs.writeFile(path.join(scripts, 'nested', 'test.js'), 'nested');
|
||||
await service.save('nested', 'test.js', 'changed');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
await fs.writeFile(path.join(root, 'outside.js'), 'secret');
|
||||
await fs.symlink(
|
||||
path.join(root, 'outside.js'),
|
||||
path.join(scripts, 'escape.js'),
|
||||
);
|
||||
for (const filename of ['../outside.js', 'auth.json', 'escape.js']) {
|
||||
await assert.rejects(service.save('', filename, 'bad'), { status: 403 });
|
||||
await assert.rejects(service.list('', filename), { status: 403 });
|
||||
}
|
||||
await fs.symlink(
|
||||
path.join(scripts, 'test.js'),
|
||||
path.join(scripts, 'alias.js'),
|
||||
);
|
||||
await service.save('', 'alias.js', 'via alias');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions.length,
|
||||
1,
|
||||
'safe aliases share one canonical history',
|
||||
);
|
||||
});
|
||||
|
||||
test('concurrent saves across service instances serialize snapshots without losing content', async (t) => {
|
||||
const { service, scripts, history, file } = await fixture(t);
|
||||
const other = new ScriptHistory(scripts, history);
|
||||
await Promise.all([
|
||||
service.save('', 'test.js', 'A'),
|
||||
other.save('', 'test.js', 'B'),
|
||||
]);
|
||||
const list = await service.list('', 'test.js');
|
||||
const contents = await Promise.all(
|
||||
list.versions.map((v) =>
|
||||
service.detail('', 'test.js', v.id).then((d) => d.version.content),
|
||||
),
|
||||
);
|
||||
contents.push(await fs.readFile(file, 'utf8'));
|
||||
assert.deepEqual(contents.sort(), ['A', 'B', 'original\n'].sort());
|
||||
});
|
||||
|
||||
test('retention trims old revisions and bounds file count and global compressed storage', async (t) => {
|
||||
const { service, scripts, history } = await fixture(t, {
|
||||
versions: 3,
|
||||
files: 2,
|
||||
totalBytes: 1800,
|
||||
});
|
||||
for (let i = 0; i < 6; i++) await service.save('', 'test.js', `version ${i}`);
|
||||
const list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions.length, 3);
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', list.versions.at(-1).id)).version
|
||||
.content,
|
||||
'version 2',
|
||||
);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const name = `${i}.js`;
|
||||
await fs.writeFile(
|
||||
path.join(scripts, name),
|
||||
randomBytes(300).toString('hex'),
|
||||
);
|
||||
await service.save('', name, 'new');
|
||||
}
|
||||
const files = await fs.readdir(history);
|
||||
assert.ok(files.length <= 2);
|
||||
let total = 0;
|
||||
for (const name of files)
|
||||
total += (await fs.stat(path.join(history, name))).size;
|
||||
assert.ok(total <= 1800);
|
||||
assert.equal((await service.list('', '4.js')).versions.length, 1);
|
||||
});
|
||||
|
||||
test('invalid text, oversized files and corrupted archives fail without overwriting scripts', async (t) => {
|
||||
const { service, file, history } = await fixture(t, { fileBytes: 100 });
|
||||
await assert.rejects(service.save('', 'test.js', 'a'.repeat(101)), {
|
||||
status: 413,
|
||||
});
|
||||
await fs.writeFile(file, Buffer.from([0xff, 0xfe]));
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { status: 413 });
|
||||
await fs.writeFile(file, 'before');
|
||||
await service.save('', 'test.js', 'after');
|
||||
await fs.writeFile(
|
||||
path.join(history, (await fs.readdir(history))[0]),
|
||||
'corrupt',
|
||||
);
|
||||
await assert.rejects(service.save('', 'test.js', 'replacement'), {
|
||||
status: 500,
|
||||
});
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'after');
|
||||
});
|
||||
|
||||
test('archive failure prevents overwrite; file-write failure retains snapshot and releases locks', async (t) => {
|
||||
let fail = 'archive';
|
||||
const { ScriptHistory: FailingHistory } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
writeFile: async (file, ...args) => {
|
||||
if (fail === 'archive' && String(file).endsWith('.pending.tmp')) {
|
||||
throw Object.assign(new Error('disk full'), { code: 'ENOSPC' });
|
||||
}
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
if (fail === 'script' && String(to).endsWith('test.js'))
|
||||
throw Object.assign(new Error('commit failed'), { code: 'ENOSPC' });
|
||||
return fs.rename(from, to);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, file } = await fixture(t, {}, FailingHistory);
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' });
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
fail = 'script';
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' });
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
fail = '';
|
||||
await service.save('', 'test.js', 'success');
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'success');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 1);
|
||||
});
|
||||
|
||||
test('empty content is a valid recoverable version and missing versions return 404', async (t) => {
|
||||
const { service, file } = await fixture(t);
|
||||
await service.save('', 'test.js', '');
|
||||
await service.save('', 'test.js', 'again');
|
||||
const list = await service.list('', 'test.js');
|
||||
const empty = list.versions[0];
|
||||
assert.equal(empty.size, 0);
|
||||
await service.restore('', 'test.js', empty.id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), '');
|
||||
await assert.rejects(service.detail('', 'test.js', 'unknown'), {
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
test('quota eviction never happens on failed preparation or failed commit', async (t) => {
|
||||
let failure = '';
|
||||
const { ScriptHistory: Faulty } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
writeFile: async (file, ...args) => {
|
||||
if (
|
||||
failure === 'archive-write' &&
|
||||
String(file).endsWith('.pending.tmp')
|
||||
)
|
||||
throw Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
if (
|
||||
failure === 'script-write' &&
|
||||
path.basename(file).startsWith('.ql-save-')
|
||||
)
|
||||
throw Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
if (failure === 'archive-commit' && String(to).endsWith('.br'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
if (failure === 'script-commit' && String(to).endsWith('.js'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return fs.rename(from, to);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
const archive = path.join(history, (await fs.readdir(history))[0]);
|
||||
const original = await fs.readFile(archive);
|
||||
await fs.writeFile(path.join(scripts, 'other.js'), 'other-original');
|
||||
for (const step of [
|
||||
'archive-write',
|
||||
'script-write',
|
||||
'archive-commit',
|
||||
'script-commit',
|
||||
]) {
|
||||
failure = step;
|
||||
await assert.rejects(service.save('', 'other.js', 'other-changed'), {
|
||||
code: step.endsWith('write') ? 'ENOSPC' : 'EACCES',
|
||||
});
|
||||
assert.deepEqual(await fs.readFile(archive), original, step);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'other.js'), 'utf8'),
|
||||
'other-original',
|
||||
);
|
||||
assert.equal((await fs.readdir(history)).length, 1);
|
||||
assert.equal(
|
||||
(await fs.readdir(scripts)).length,
|
||||
2,
|
||||
'no script staging leftovers',
|
||||
);
|
||||
}
|
||||
// Updating an existing history also restores its exact prior bytes if the
|
||||
// script rename fails, including revisions that retention would have pruned.
|
||||
failure = 'script-commit';
|
||||
await assert.rejects(service.save('', 'test.js', 'changed'), {
|
||||
code: 'EACCES',
|
||||
});
|
||||
assert.deepEqual(await fs.readFile(archive), original);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'test.js'), 'utf8'),
|
||||
'saved',
|
||||
);
|
||||
failure = '';
|
||||
await service.save('', 'other.js', 'other-changed');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
});
|
||||
|
||||
test('cleanup errors report a successful save with a warning and are retried on the next save', async (t) => {
|
||||
let failure = false;
|
||||
const { ScriptHistory: Faulty } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
unlink: async (file) => {
|
||||
if (failure && String(file).endsWith('.br'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return fs.unlink(file);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
await fs.writeFile(path.join(scripts, 'other.js'), 'old');
|
||||
failure = true;
|
||||
const result = await service.save('', 'other.js', 'new');
|
||||
assert.equal(result.cleanupPending, true);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'other.js'), 'utf8'),
|
||||
'new',
|
||||
);
|
||||
failure = false;
|
||||
await service.save('', 'other.js', 'next');
|
||||
assert.equal((await fs.readdir(history)).length, 1);
|
||||
});
|
||||
|
||||
test('large files can be reduced only after explicit historyless confirmation with a matching hash', async (t) => {
|
||||
const { service, file } = await fixture(t, { fileBytes: 100 });
|
||||
await fs.writeFile(file, 'x'.repeat(101));
|
||||
let confirmation;
|
||||
try {
|
||||
await service.save('', 'test.js', 'small');
|
||||
} catch (error) {
|
||||
confirmation = error;
|
||||
}
|
||||
assert.equal(confirmation.status, 413);
|
||||
assert.match(confirmation.currentHash, /^[a-f0-9]{64}$/);
|
||||
assert.equal((await fs.stat(file)).size, 101);
|
||||
await assert.rejects(
|
||||
service.save('', 'test.js', 'small', {
|
||||
skipHistory: true,
|
||||
expectedHash: '0'.repeat(64),
|
||||
}),
|
||||
{ status: 409 },
|
||||
);
|
||||
const result = await service.save('', 'test.js', 'small', {
|
||||
skipHistory: true,
|
||||
expectedHash: confirmation.currentHash,
|
||||
});
|
||||
assert.equal(result.historyRecorded, false);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'small');
|
||||
await service.save('', 'test.js', 'normal');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions.length,
|
||||
1,
|
||||
'normal history resumes',
|
||||
);
|
||||
try {
|
||||
await service.save('', 'test.js', 'x'.repeat(101));
|
||||
} catch (error) {
|
||||
confirmation = error;
|
||||
}
|
||||
await fs.writeFile(file, 'someone else changed it');
|
||||
await assert.rejects(
|
||||
service.save('', 'test.js', 'x'.repeat(101), {
|
||||
skipHistory: true,
|
||||
expectedHash: confirmation.currentHash,
|
||||
}),
|
||||
{ status: 409 },
|
||||
);
|
||||
});
|
||||
|
||||
test('atomic saves preserve executable mode and canonical symlink targets', async (t) => {
|
||||
const { service, scripts, file } = await fixture(t);
|
||||
await fs.chmod(file, 0o755);
|
||||
await fs.symlink(file, path.join(scripts, 'alias.js'));
|
||||
await service.save('', 'alias.js', 'new');
|
||||
assert.equal((await fs.stat(file)).mode & 0o777, 0o755);
|
||||
assert.equal(
|
||||
(await fs.lstat(path.join(scripts, 'alias.js'))).isSymbolicLink(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'new');
|
||||
});
|
||||
Vendored
+1371
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function fixture(send) {
|
||||
const dialogs = [],
|
||||
notices = [],
|
||||
calls = [];
|
||||
const request = {};
|
||||
for (const method of ['put', 'post'])
|
||||
request[method] = async (url, body, options) => {
|
||||
calls.push({ method, body });
|
||||
const result = await send(body);
|
||||
if (result.status) return options.onError(result);
|
||||
return result;
|
||||
};
|
||||
const { saveWithHistory } = load(
|
||||
path.resolve(__dirname, '../../src/pages/script/saveWithHistory.ts'),
|
||||
{
|
||||
'@/utils/http': { request },
|
||||
antd: {
|
||||
Modal: {
|
||||
confirm: (dialog) => {
|
||||
dialogs.push(dialog);
|
||||
return { update: (options) => Object.assign(dialog, options) };
|
||||
},
|
||||
},
|
||||
message: Object.fromEntries(
|
||||
['error', 'warning', 'success'].map((type) => [
|
||||
type,
|
||||
(text) => notices.push({ type, text }),
|
||||
]),
|
||||
),
|
||||
},
|
||||
'react-intl-universal': {
|
||||
__esModule: true,
|
||||
default: { get: (key) => key },
|
||||
},
|
||||
},
|
||||
);
|
||||
return { saveWithHistory, dialogs, notices, calls };
|
||||
}
|
||||
const unavailable = {
|
||||
status: 413,
|
||||
data: { historyUnavailable: true, currentHash: 'a'.repeat(64) },
|
||||
};
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test('oversized editor save requires explicit confirmation and cancellation keeps the draft unsaved', async () => {
|
||||
const f = fixture(() => unavailable);
|
||||
const pending = f.saveWithHistory('put', '/api/scripts', {
|
||||
filename: 'large.js',
|
||||
content: 'small',
|
||||
});
|
||||
await tick();
|
||||
assert.equal(f.calls.length, 1);
|
||||
assert.equal(f.dialogs.length, 1);
|
||||
assert.match(f.dialogs[0].content, /不会保留/);
|
||||
f.dialogs[0].onCancel();
|
||||
assert.equal(await pending, undefined);
|
||||
assert.equal(f.calls.length, 1);
|
||||
assert.equal(f.notices.length, 0);
|
||||
});
|
||||
|
||||
test('debugger confirmation retries POST with the exact preview hash and displays a warning', async () => {
|
||||
const f = fixture((body) =>
|
||||
body.skipHistory
|
||||
? { code: 200, data: { historyRecorded: false } }
|
||||
: unavailable,
|
||||
);
|
||||
const pending = f.saveWithHistory('post', '/api/scripts', {
|
||||
content: 'small',
|
||||
});
|
||||
await tick();
|
||||
await f.dialogs[0].onOk();
|
||||
assert.equal((await pending).code, 200);
|
||||
assert.equal(f.dialogs[0].cancelButtonProps.disabled, true);
|
||||
assert.equal(f.dialogs[0].keyboard, false);
|
||||
assert.equal(f.calls[1].method, 'post');
|
||||
assert.equal(f.calls[1].body.expectedHash, unavailable.data.currentHash);
|
||||
assert.equal(f.calls[1].body.skipHistory, true);
|
||||
assert.deepEqual(
|
||||
f.notices.map((v) => v.type),
|
||||
['warning'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale confirmation reports the conflict without marking the editor saved', async () => {
|
||||
const f = fixture((body) =>
|
||||
body.skipHistory
|
||||
? { status: 409, data: { message: 'changed' } }
|
||||
: unavailable,
|
||||
);
|
||||
const pending = f.saveWithHistory('put', '/api/scripts', {});
|
||||
await tick();
|
||||
await f.dialogs[0].onOk();
|
||||
assert.equal(await pending, undefined);
|
||||
assert.deepEqual(f.notices, [{ type: 'error', text: 'changed' }]);
|
||||
});
|
||||
|
||||
test('unrelated errors never offer history bypass, and post-commit cleanup failures remain successful saves', async () => {
|
||||
const failed = fixture(() => ({
|
||||
status: 500,
|
||||
data: { message: 'storage failed' },
|
||||
}));
|
||||
assert.equal(
|
||||
await failed.saveWithHistory('put', '/api/scripts', {}),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(failed.dialogs.length, 0);
|
||||
const committed = fixture(() => ({
|
||||
code: 200,
|
||||
data: { cleanupPending: true },
|
||||
}));
|
||||
assert.equal(
|
||||
(await committed.saveWithHistory('put', '/api/scripts', {})).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(committed.notices[0].type, 'warning');
|
||||
});
|
||||
Reference in New Issue
Block a user