feat: add script version history (#3085)

* feat: add script version history

* fix: validate canonical save-as paths before backup

* fix: stack script history footer on small screens
This commit is contained in:
whyour
2026-10-06 22:00:11 +08:00
committed by GitHub
parent 2cab112391
commit fa8910b3d6
22 changed files with 2487 additions and 80 deletions
+157 -4
View File
@@ -9,9 +9,14 @@ import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import path, { dirname, join, parse, resolve, sep } from 'path'; import path, { dirname, join, parse, resolve, sep } from 'path';
import ScriptService from '../services/script'; import ScriptService from '../services/script';
import ScriptHistoryService from '../services/scriptHistory';
import { t } from '../shared/i18n'; import { t } from '../shared/i18n';
import multer from 'multer'; import multer from 'multer';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import {
ScriptHistoryError,
HistoryUnavailableError,
} from '../shared/scriptHistory';
const route = Router(); const route = Router();
function isPathAllowed(targetPath: string): boolean { function isPathAllowed(targetPath: string): boolean {
@@ -30,6 +35,34 @@ function isPathAllowed(targetPath: string): boolean {
); );
} }
async function existingWritableRealPath(targetPath: string): Promise<string> {
if (!isPathAllowed(targetPath)) {
throw new ScriptHistoryError(403, '暂无权限');
}
const resolved = resolve(targetPath);
for (const writableRoot of config.writePathList) {
const root = resolve(writableRoot);
const rootPrefix = root.endsWith(sep) ? root : root + sep;
// Keep an explicit normalized boundary check at the filesystem operation.
if (!resolved.startsWith(rootPrefix)) continue;
const realPath = await fs.realpath(resolved);
const realRoot = await fs.realpath(root);
const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
if (
!realPath.startsWith(realRootPrefix) ||
!resolveFileAccess(
realRoot,
[realPath],
root === resolve(config.configPath) ? config.blackFileList : [],
)
) {
throw new ScriptHistoryError(403, '暂无权限');
}
return realPath;
}
throw new ScriptHistoryError(403, '暂无权限');
}
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: function (req, file, cb) { destination: function (req, file, cb) {
cb(null, config.tmpPath); cb(null, config.tmpPath);
@@ -120,6 +153,82 @@ 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) => { route.get('/:file', (req: Request, res: Response) => {
return res.send({ return res.send({
code: 410, code: 410,
@@ -141,6 +250,11 @@ export default (app: Router) => {
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
content: 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(''), originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''), file: Joi.string().optional().allow(''),
@@ -220,6 +334,36 @@ export default (app: Router) => {
return res.send({ code: 403, message: t('暂无权限') }); return res.send({ code: 403, message: t('暂无权限') });
} }
const fileExists = await fileExist(filePath); const fileExists = await fileExist(filePath);
if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) {
let removeSource = filename !== originFilename;
let backupSource = '';
if (removeSource) {
const [originRealPath, targetRealPath] = await Promise.all([
existingWritableRealPath(originFilePath),
existingWritableRealPath(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;
backupSource = originRealPath;
}
// 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(
backupSource,
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) { if (fileExists) {
await fs.copyFile( await fs.copyFile(
originFilePath, originFilePath,
@@ -232,7 +376,7 @@ export default (app: Router) => {
await writeFileWithLock(filePath, content); await writeFileWithLock(filePath, content);
return res.send({ code: 200 }); return res.send({ code: 200 });
} catch (e) { } catch (e) {
return next(e); return historyError(e, res, next);
} }
}, },
); );
@@ -244,6 +388,11 @@ export default (app: Router) => {
filename: Joi.string().required(), filename: Joi.string().required(),
path: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''),
content: Joi.string().required().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) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -261,10 +410,14 @@ export default (app: Router) => {
message: t('暂无权限'), message: t('暂无权限'),
}); });
} }
await writeFileWithLock(filePath, content); const service = Container.get(ScriptHistoryService);
return res.send({ code: 200 }); const data = await service.save(path || '', filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
return res.send({ code: 200, data });
} catch (e) { } catch (e) {
return next(e); return historyError(e, res, next);
} }
}, },
); );
+12
View File
@@ -0,0 +1,12 @@
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'));
}
}
+467
View File
@@ -0,0 +1,467 @@
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);
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import path from 'path';
import { writeFile, open, chmod } from 'fs/promises'; import { writeFile, open, chmod } from 'fs/promises';
import { fileExist } from '../config/util'; import { fileExist } from '../config/util';
function getUniqueLockPath(filePath: string) { export function getUniqueLockPath(filePath: string) {
const sanitizedPath = filePath const sanitizedPath = filePath
.replace(/[<>:"/\\|?*]/g, '_') .replace(/[<>:"/\\|?*]/g, '_')
.replace(/^_/, ''); .replace(/^_/, '');
@@ -1,8 +1,8 @@
# OpenAPI 全量参考 / Complete OpenAPI reference # OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应当前 develop 的 143 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。 CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
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. 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.
## 认证与权限 / Authentication and permissions ## 认证与权限 / Authentication and permissions
@@ -46,6 +46,9 @@ ql env create --data @envs.json --json
ql env update 8 --data '{"name":"EXAMPLE","value":"value"}' --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 create --file ./demo.js --data '{"filename":"demo.js","path":""}' --json
ql script get --query '{"file":"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 save --data '{"name":"example.sh","content":"# example"}' --json
ql config get --query '{"path":"example.sh"}' --json ql config get --query '{"path":"example.sh"}' --json
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
@@ -133,6 +136,9 @@ System/user operations below execute remotely through the panel API. They do not
| `ql config save ` | POST | `configs/save` | --data, --query | | `ql config save ` | POST | `configs/save` | --data, --query |
| `ql script list ` | GET | `scripts` | --query | | `ql script list ` | GET | `scripts` | --query |
| `ql script get ` | GET | `scripts/detail` | --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 create ` | POST | `scripts` | --data, --file (file), --query |
| `ql script update ` | PUT | `scripts` | --data, --file (file), --query | | `ql script update ` | PUT | `scripts` | --data, --file (file), --query |
| `ql script delete ` | DELETE | `scripts` | --data, --query | | `ql script delete ` | DELETE | `scripts` | --data, --query |
+12
View File
@@ -275,6 +275,18 @@ export const openOperations: readonly OpenOperation[] = [
{ name: 'config save', method: 'POST', path: 'configs/save', body: 'json' }, { name: 'config save', method: 'POST', path: 'configs/save', body: 'json' },
{ name: 'script list', method: 'GET', path: 'scripts' }, { name: 'script list', method: 'GET', path: 'scripts' },
{ name: 'script get', method: 'GET', path: 'scripts/detail' }, { 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', name: 'script create',
method: 'POST', method: 'POST',
+25
View File
@@ -58,6 +58,31 @@ test('dashboard record sends execution statistics and rejects a missing body bef
assert.equal(f.requests.length, 1); 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 () => { test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
const root = path.resolve(__dirname, '../../../back/api'); const root = path.resolve(__dirname, '../../../back/api');
const expected = []; const expected = [];
+53
View File
@@ -0,0 +1,53 @@
# 脚本编辑历史
脚本管理工具栏的“历史版本”打开右侧抽屉。选择版本后,对比历史内容与服务器上当前已保存文件;桌面使用双栏对比,手机使用行内对比。恢复需要确认,有未保存修改时必须先保存或取消。恢复前保留当前版本,因此恢复操作也可以撤销。普通保存不再弹出不可恢复确认。
## 记录范围
- 编辑器 `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。历史读写与脚本读取采用相同的路径和符号链接边界检查。面板配置目录中的敏感文件仍受黑名单保护;脚本目录中的 auth.json、token.json 等普通脚本名称可以正常保存和留档。
## 压缩样本
运行 `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。
+39
View File
@@ -0,0 +1,39 @@
// 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),
}),
);
}
}
+29 -1
View File
@@ -665,5 +665,33 @@
"重试": "Retry", "重试": "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", "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", "推送渠道,支持 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" "可选,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"
} }
+29 -1
View File
@@ -665,5 +665,33 @@
"重试": "重试", "重试": "重试",
"WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs", "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", "推送渠道,支持 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" "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs",
"历史版本": "历史版本",
"首次修改前": "首次修改前",
"手动保存": "手动保存",
"从历史版本恢复": "从历史版本恢复",
"面板外修改后": "面板外修改后",
"恢复此版本": "恢复此版本",
"当前已保存的内容会保留在历史版本中。": "当前已保存的内容会保留在历史版本中。",
"请先保存或取消当前修改,再恢复历史版本": "请先保存或取消当前修改,再恢复历史版本",
"已恢复,恢复前的内容已保留": "已恢复,恢复前的内容已保留",
"最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "最多保留 {count} 个历史版本,容量不足时自动清理旧历史。",
"仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。",
"暂无历史版本,下次保存修改时会自动保留当前内容": "暂无历史版本,下次保存修改时会自动保留当前内容",
"选择历史版本": "选择历史版本",
"历史版本加载失败,请刷新重试": "历史版本加载失败,请刷新重试",
"当前已保存文件": "当前已保存文件",
"与当前文件一致": "与当前文件一致",
"历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本",
"历史版本读取失败,请检查历史文件": "历史版本读取失败,请检查历史文件",
"历史版本不存在或已清理": "历史版本不存在或已清理",
"文件已变化,请刷新历史版本后重试": "文件已变化,请刷新历史版本后重试",
"历史版本容量不足,无法安全保存": "历史版本容量不足,无法安全保存",
"脚本不存在": "脚本不存在",
"不记录历史直接保存?": "不记录历史直接保存?",
"当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。",
"仍然保存": "仍然保存",
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
"保存失败": "保存失败"
} }
@@ -0,0 +1,48 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
:global(.ant-typography) {
min-width: 0;
overflow-wrap: anywhere;
}
: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 {
flex-direction: column;
align-items: stretch;
gap: 8px;
:global(.ant-btn) {
width: 100%;
height: auto;
min-height: 32px;
white-space: normal;
overflow-wrap: anywhere;
}
}
.labels {
gap: 8px;
font-size: 12px;
}
.diff {
flex: none;
min-height: 200px;
border: 0;
}
}
+333
View File
@@ -0,0 +1,333 @@
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>
);
}
+90 -43
View File
@@ -10,6 +10,7 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
EllipsisOutlined, EllipsisOutlined,
HistoryOutlined,
PlusOutlined, PlusOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout'; import { PageContainer } from '@ant-design/pro-layout';
@@ -40,6 +41,8 @@ import { useHotkeys } from 'react-hotkeys-hook';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import SplitPane from 'react-split-pane'; import SplitPane from 'react-split-pane';
import EditModal from './editModal'; import EditModal from './editModal';
import HistoryDrawer from './historyDrawer';
import { saveWithHistory } from './saveWithHistory';
import EditScriptNameModal from './editNameModal'; import EditScriptNameModal from './editNameModal';
import styles from './index.module.less'; import styles from './index.module.less';
import RenameModal from './renameModal'; import RenameModal from './renameModal';
@@ -65,6 +68,26 @@ const Script = () => {
const [currentNode, setCurrentNode] = useState<any>(); const [currentNode, setCurrentNode] = useState<any>();
const [expandedKeys, setExpandedKeys] = useState<string[]>([]); const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const [showMonaco, setShowMonaco] = useState(true); 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) => { const handleIsEditing = (filename: string, value: boolean) => {
setIsEditing(value && canPreviewInMonaco(filename)); setIsEditing(value && canPreviewInMonaco(filename));
@@ -89,10 +112,11 @@ const Script = () => {
.get( .get(
`${config.apiPrefix}scripts/detail?file=${encodeURIComponent( `${config.apiPrefix}scripts/detail?file=${encodeURIComponent(
node.title, node.title,
)}&path=${node.parent || ''}`, )}&path=${encodeURIComponent(node.parent || '')}`,
) )
.then(({ code, data }) => { .then(({ code, data }) => {
if (code === 200) { if (code === 200 && currentKeyRef.current === node.key) {
savedContentRef.current = data;
setValue(data); setValue(data);
if (options.callback) { if (options.callback) {
options.callback(); options.callback();
@@ -179,7 +203,7 @@ const Script = () => {
const currentContent = editorRef.current const currentContent = editorRef.current
? editorRef.current.getValue().replace(/\r\n/g, '\n') ? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value; : value;
const originalContent = value.replace(/\r\n/g, '\n'); const originalContent = savedContentRef.current.replace(/\r\n/g, '\n');
if (currentContent !== originalContent && isEditing) { if (currentContent !== originalContent && isEditing) {
Modal.confirm({ Modal.confirm({
@@ -247,42 +271,36 @@ const Script = () => {
getDetail(currentNode); getDetail(currentNode);
}; };
const saveFile = () => { const saveFile = async () => {
Modal.confirm({ if (savingRef.current || !currentNode) return;
title: intl.get('确认保存'), const node = currentNode;
content: ( const content =
<> !isPhone && editorRef.current
{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') ? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value; : value;
return new Promise((resolve, reject) => { savingRef.current = true;
request setSaving(true);
.put(`${config.apiPrefix}scripts`, { try {
filename: currentNode.title, const result = await saveWithHistory(
path: currentNode.parent || '', 'put',
`${config.apiPrefix}scripts`,
{
filename: node.title,
path: node.parent || '',
content, content,
})
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('保存成功'));
setValue(content);
handleIsEditing(currentNode.title, false);
}
resolve(null);
})
.catch((e) => reject(e));
});
}, },
}); );
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 deleteFile = () => { const deleteFile = () => {
@@ -548,8 +566,20 @@ const Script = () => {
</> </>
} }
loading={loading} loading={loading}
extra={ extra={[
isPhone ...(currentNode?.type === 'file' && showMonaco
? [
<Button
key="history"
icon={<HistoryOutlined />}
disabled={saving}
onClick={() => setHistoryVisible(true)}
>
{!isPhone && intl.get('历史版本')}
</Button>,
]
: []),
...(isPhone
? [ ? [
<TreeSelect <TreeSelect
treeExpandAction="click" treeExpandAction="click"
@@ -562,7 +592,7 @@ const Script = () => {
treeNodeFilterProp="title" treeNodeFilterProp="title"
showSearch showSearch
allowClear allowClear
onSelect={onSelect} onSelect={(key, node) => onTreeSelect([key], { node })}
/>, />,
<Dropdown menu={menu} trigger={['click']}> <Dropdown menu={menu} trigger={['click']}>
<Button type="primary" icon={<EllipsisOutlined />} /> <Button type="primary" icon={<EllipsisOutlined />} />
@@ -570,7 +600,7 @@ const Script = () => {
] ]
: isEditing : isEditing
? [ ? [
<Button type="primary" onClick={saveFile}> <Button type="primary" onClick={saveFile} loading={saving}>
{intl.get('保存')} {intl.get('保存')}
</Button>, </Button>,
<Button type="primary" onClick={cancelEdit}> <Button type="primary" onClick={cancelEdit}>
@@ -625,12 +655,29 @@ const Script = () => {
> >
{intl.get('调试')} {intl.get('调试')}
</Button>, </Button>,
] ]),
} ]}
header={{ header={{
style: headerStyle, 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`}> <div className={`${styles['log-container']} log-container`}>
{!isPhone && ( {!isPhone && (
/*// @ts-ignore*/ /*// @ts-ignore*/
@@ -682,7 +729,7 @@ const Script = () => {
value={value} value={value}
theme={theme} theme={theme}
options={{ options={{
readOnly: !isEditing, readOnly: !isEditing || saving,
fontSize: 12, fontSize: 12,
lineNumbersMinChars: 3, lineNumbersMinChars: 3,
glyphMargin: false, glyphMargin: false,
@@ -704,7 +751,7 @@ const Script = () => {
mode ? [langs[mode as keyof typeof langs]()] : undefined mode ? [langs[mode as keyof typeof langs]()] : undefined
} }
theme={theme.includes('dark') ? 'dark' : 'light'} theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing} readOnly={!isEditing || saving}
onChange={(value) => { onChange={(value) => {
setValue(value); setValue(value);
}} }}
+15 -11
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form } from 'antd'; import { Modal, message, Input, Form } from 'antd';
import { request } from '@/utils/http'; import { request } from '@/utils/http';
import config from '@/utils/config'; import config from '@/utils/config';
import { saveWithHistory } from './saveWithHistory';
const SaveModal = ({ const SaveModal = ({
file, file,
@@ -16,18 +17,21 @@ const SaveModal = ({
const handleOk = async (values: any) => { const handleOk = async (values: any) => {
setLoading(true); setLoading(true);
const payload = { ...values, originFilename: file.title, content: file.content }; const payload = {
request ...values,
.post(`${config.apiPrefix}scripts`, payload) originFilename: file.title,
.then(({ code, data }) => { content: file.content,
if (code === 200) { };
message.success(intl.get('保存文件成功')); try {
handleCancel(data); const result = await saveWithHistory(
} 'post',
}) `${config.apiPrefix}scripts`,
.finally(() => { payload,
);
if (result?.code === 200) handleCancel(result.data);
} finally {
setLoading(false); setLoading(false);
}); }
}; };
return ( return (
+74
View File
@@ -0,0 +1,74 @@
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;
}
+1
View File
@@ -42,6 +42,7 @@ function fixture(t, api = 'script') {
'../config': config, '../config': config,
'../config/const': {}, '../config/const': {},
'../services/script': {}, '../services/script': {},
'../services/scriptHistory': {},
'../services/config': {}, '../services/config': {},
'../shared/i18n': { t: (x) => x }, '../shared/i18n': { t: (x) => x },
typedi: { Container: { get: () => service } }, typedi: { Container: { get: () => service } },
+18 -7
View File
@@ -36,6 +36,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
), ),
jwt: { secret }, jwt: { secret },
rootPath: tmp, rootPath: tmp,
dataPath: tmp,
configPath: path.join(tmp, 'config/'), configPath: path.join(tmp, 'config/'),
scriptPath: path.join(tmp, 'scripts/'), scriptPath: path.join(tmp, 'scripts/'),
uploadPath: path.join(tmp, 'upload'), uploadPath: path.join(tmp, 'upload'),
@@ -92,9 +93,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
'../services/script': class {}, '../services/script': class {},
'../data/open': {}, '../data/open': {},
'../data/system': {}, '../data/system': {},
'../shared/utils': { typedi: { Service: () => (x) => x },
writeFileWithLock: (p, content) => fs.promises.writeFile(p, content),
},
}; };
const Config = load(path.join(__dirname, '../../back/services/config.ts'), { const Config = load(path.join(__dirname, '../../back/services/config.ts'), {
...mocks, ...mocks,
@@ -102,8 +101,18 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
}).default; }).default;
const configService = new Config(); const configService = new Config();
mocks['../services/config'] = Config; mocks['../services/config'] = Config;
const historyCache = new Map();
const History = load(
path.join(__dirname, '../../back/services/scriptHistory.ts'),
mocks,
historyCache,
).default;
const history = new History();
mocks.typedi = { mocks.typedi = {
Container: { get: (x) => (x === User ? user : configService) }, ...mocks.typedi,
Container: {
get: (x) => (x === User ? user : x === History ? history : configService),
},
}; };
mocks['../api'] = () => { mocks['../api'] = () => {
const router = express.Router(); const router = express.Router();
@@ -111,9 +120,11 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
load(path.join(__dirname, '../../back/api/config.ts'), mocks).default( load(path.join(__dirname, '../../back/api/config.ts'), mocks).default(
router, router,
); );
load(path.join(__dirname, '../../back/api/script.ts'), mocks).default( load(
router, path.join(__dirname, '../../back/api/script.ts'),
); mocks,
historyCache,
).default(router);
router.get('/envs', (_req, res) => res.json({ code: 200 })); router.get('/envs', (_req, res) => res.json({ code: 200 }));
return router; return router;
}; };
+16 -5
View File
@@ -18,6 +18,7 @@ test('script file operations allow token.json while protecting panel configurati
]) ])
fs.mkdirSync(path.join(root, dir), { recursive: true }); fs.mkdirSync(path.join(root, dir), { recursive: true });
const config = { const config = {
dataPath: root,
scriptPath: path.join(root, 'scripts'), scriptPath: path.join(root, 'scripts'),
configPath: path.join(root, 'config'), configPath: path.join(root, 'config'),
tmpPath: path.join(root, 'tmp'), tmpPath: path.join(root, 'tmp'),
@@ -42,9 +43,6 @@ test('script file operations allow token.json while protecting panel configurati
rmPath: (p) => fs.promises.rm(p, { recursive: true }), rmPath: (p) => fs.promises.rm(p, { recursive: true }),
readDir: async () => [], readDir: async () => [],
}, },
'../shared/utils': {
writeFileWithLock: (p, content) => fs.promises.writeFile(p, content),
},
'../shared/i18n': { t: (x) => x }, '../shared/i18n': { t: (x) => x },
'./sock': {}, './sock': {},
'./cron': {}, './cron': {},
@@ -52,15 +50,28 @@ test('script file operations allow token.json while protecting panel configurati
'../shared/pLimit': {}, '../shared/pLimit': {},
typedi: { Service: () => (x) => x, Inject: () => () => {} }, typedi: { Service: () => (x) => x, Inject: () => () => {} },
}; };
const cache = new Map();
const Script = load( const Script = load(
path.join(__dirname, '../../back/services/script.ts'), path.join(__dirname, '../../back/services/script.ts'),
mocks, mocks,
cache,
).default; ).default;
const service = new Script(); const service = new Script();
mocks['../services/script'] = Script; mocks['../services/script'] = Script;
mocks.typedi = { Container: { get: () => service } }; const History = load(
path.join(__dirname, '../../back/services/scriptHistory.ts'),
mocks,
cache,
).default;
const history = new History();
mocks.typedi = {
...mocks.typedi,
Container: { get: (type) => (type === History ? history : service) },
};
const app = express.Router(); const app = express.Router();
load(path.join(__dirname, '../../back/api/script.ts'), mocks).default(app); load(path.join(__dirname, '../../back/api/script.ts'), mocks, cache).default(
app,
);
const router = app.stack.find((layer) => layer.name === 'router').handle; const router = app.stack.find((layer) => layer.name === 'router').handle;
const invoke = async (method, url, body = {}, query = {}, file) => { const invoke = async (method, url, body = {}, query = {}, file) => {
const route = router.stack.find( const route = router.stack.find(
+551
View File
@@ -0,0 +1,551 @@
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,
configPath: path.join(root, 'config'),
dataPath: root,
bakPath: path.join(root, 'bak'),
blackFileList: ['auth.json'],
writePathList: [scripts, path.join(root, 'config')],
};
await fs.mkdir(config.bakPath);
await fs.mkdir(config.configPath);
let failure = '';
let beforeRealpath;
class ScriptService {
checkFilePath(dir, name) {
return resolveFileAccess(scripts, [dir || '', name]);
}
}
const mocks = {
'../config': { default: config, __esModule: true },
'fs/promises': {
...fs,
realpath: async (file, ...args) => {
if (beforeRealpath) await beforeRealpath(file);
return fs.realpath(file, ...args);
},
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');
// develop allows script names that are protected only in the config root.
await fs.writeFile(path.join(scripts, 'auth.json'), 'script-original');
for (const method of ['PUT', 'POST']) {
const saved = await call(
'/scripts',
{ filename: 'auth.json', path: '', content: `script-${method}` },
method,
);
assert.equal(saved.status, 200);
assert.equal(saved.body.data.historyRecorded, true);
}
const namedHistory = await call('/scripts/history?filename=auth.json');
assert.equal(namedHistory.body.data.versions.length, 2);
const namedPreview = await call(
`/scripts/history/detail?filename=auth.json&id=${namedHistory.body.data.versions[0].id}`,
);
assert.equal(namedPreview.body.data.version.content, 'script-PUT');
assert.equal(namedPreview.body.data.current, 'script-POST');
assert.equal(
(
await call('/scripts/history/restore', {
filename: 'auth.json',
id: namedHistory.body.data.versions[0].id,
expectedHash: namedPreview.body.data.currentHash,
})
).status,
200,
);
assert.equal(
await fs.readFile(path.join(scripts, 'auth.json'), 'utf8'),
'script-PUT',
);
// 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',
);
}
await t.test(
'save-as rejects paths outside writable roots before backup or history changes',
async () => {
const outside = path.join(root, 'outside');
const sibling = `${scripts}-sibling`;
for (const dir of [outside, sibling]) {
await fs.mkdir(dir);
await fs.writeFile(path.join(dir, 'source.js'), 'outside-source');
await fs.writeFile(path.join(dir, 'destination.js'), 'outside-target');
}
await fs.writeFile(
path.join(scripts, 'boundary-source.js'),
'safe-source',
);
await fs.writeFile(
path.join(scripts, 'boundary-target.js'),
'safe-target',
);
await fs.symlink(outside, path.join(scripts, 'outside-link'));
for (const escape of [
'../outside',
'../scripts-sibling',
'outside-link',
]) {
for (const escapedSide of ['source', 'target']) {
const result = await call(
'/scripts',
{
filename:
escapedSide === 'target'
? `${escape}/destination.js`
: 'boundary-target.js',
originFilename:
escapedSide === 'source'
? `${escape}/source.js`
: 'boundary-source.js',
content: 'changed',
},
'POST',
);
assert.equal(result.body.code, 403);
}
}
assert.equal(
await fs.readFile(path.join(scripts, 'boundary-source.js'), 'utf8'),
'safe-source',
);
assert.equal(
await fs.readFile(path.join(scripts, 'boundary-target.js'), 'utf8'),
'safe-target',
);
for (const dir of [outside, sibling]) {
assert.equal(
await fs.readFile(path.join(dir, 'source.js'), 'utf8'),
'outside-source',
);
assert.equal(
await fs.readFile(path.join(dir, 'destination.js'), 'utf8'),
'outside-target',
);
}
assert.deepEqual(await fs.readdir(config.bakPath), []);
assert.equal(
(await call('/scripts/history?filename=boundary-target.js')).body.data
.versions.length,
0,
);
},
);
for (const swappedSide of ['source', 'target']) {
await t.test(
`save-as rejects a ${swappedSide} symlink changed during real-path resolution`,
async () => {
const source = path.join(scripts, `${swappedSide}-race-source.js`);
const destination = path.join(scripts, `${swappedSide}-race-target.js`);
const outside = path.join(root, `${swappedSide}-race-outside.js`);
await fs.writeFile(source, 'safe-source');
await fs.writeFile(destination, 'safe-target');
await fs.writeFile(outside, 'outside-sentinel');
const swapped = swappedSide === 'source' ? source : destination;
let swappedOnce = false;
beforeRealpath = async (file) => {
if (file === swapped && !swappedOnce) {
swappedOnce = true;
await fs.unlink(swapped);
await fs.symlink(outside, swapped);
}
};
try {
const result = await call(
'/scripts',
{
filename: path.basename(destination),
originFilename: path.basename(source),
content: 'changed',
},
'POST',
);
assert.equal(swappedOnce, true);
assert.equal(result.status, 403);
assert.equal(await fs.readFile(outside, 'utf8'), 'outside-sentinel');
assert.equal(
await fs.readFile(
swappedSide === 'source' ? destination : source,
'utf8',
),
swappedSide === 'source' ? 'safe-target' : 'safe-source',
);
assert.deepEqual(await fs.readdir(config.bakPath), []);
await fs.unlink(swapped);
await fs.writeFile(
swapped,
swappedSide === 'source' ? 'safe-source' : 'safe-target',
);
assert.equal(
(
await call(
`/scripts/history?filename=${path.basename(destination)}`,
)
).body.data.versions.length,
0,
);
} finally {
beforeRealpath = undefined;
}
},
);
}
await t.test(
'save-as rechecks configuration secret names after resolving a changed source link',
async () => {
const secret = path.join(config.configPath, 'auth.json');
const source = path.join(config.configPath, 'ordinary.js');
const destination = path.join(scripts, 'config-race-target.js');
await fs.writeFile(secret, 'config-secret');
await fs.writeFile(source, 'ordinary-config');
await fs.writeFile(destination, 'safe-target');
let swapped = false;
beforeRealpath = async (file) => {
if (file === source && !swapped) {
swapped = true;
await fs.unlink(source);
await fs.symlink(secret, source);
}
};
try {
const result = await call(
'/scripts',
{
filename: path.basename(destination),
originFilename: '../config/ordinary.js',
content: 'changed',
},
'POST',
);
assert.equal(swapped, true);
assert.equal(result.status, 403);
assert.equal(await fs.readFile(secret, 'utf8'), 'config-secret');
assert.equal(await fs.readFile(destination, 'utf8'), 'safe-target');
assert.deepEqual(await fs.readdir(config.bakPath), []);
assert.equal(
(await call('/scripts/history?filename=config-race-target.js')).body
.data.versions.length,
0,
);
} finally {
beforeRealpath = undefined;
}
},
);
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 = '';
}
},
);
}
});
+383
View File
@@ -0,0 +1,383 @@
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');
});
+121
View File
@@ -0,0 +1,121 @@
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');
});