mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
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:
+157
-4
@@ -9,9 +9,14 @@ import * as fs from 'fs/promises';
|
||||
import { celebrate, Joi } from 'celebrate';
|
||||
import path, { dirname, join, parse, resolve, sep } 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 {
|
||||
@@ -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({
|
||||
destination: function (req, file, cb) {
|
||||
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) => {
|
||||
return res.send({
|
||||
code: 410,
|
||||
@@ -141,6 +250,11 @@ 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(''),
|
||||
@@ -220,6 +334,36 @@ export default (app: Router) => {
|
||||
return res.send({ code: 403, message: t('暂无权限') });
|
||||
}
|
||||
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) {
|
||||
await fs.copyFile(
|
||||
originFilePath,
|
||||
@@ -232,7 +376,7 @@ export default (app: Router) => {
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -244,6 +388,11 @@ 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) => {
|
||||
@@ -261,10 +410,14 @@ export default (app: Router) => {
|
||||
message: t('暂无权限'),
|
||||
});
|
||||
}
|
||||
await writeFileWithLock(filePath, content);
|
||||
return res.send({ code: 200 });
|
||||
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 });
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
return historyError(e, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import { writeFile, open, chmod } from 'fs/promises';
|
||||
import { fileExist } from '../config/util';
|
||||
|
||||
function getUniqueLockPath(filePath: string) {
|
||||
export function getUniqueLockPath(filePath: string) {
|
||||
const sanitizedPath = filePath
|
||||
.replace(/[<>:"/\\|?*]/g, '_')
|
||||
.replace(/^_/, '');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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
|
||||
|
||||
@@ -46,6 +46,9 @@ 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
|
||||
@@ -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 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,6 +275,18 @@ 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,6 +58,31 @@ 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,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。
|
||||
@@ -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
@@ -665,5 +665,33 @@
|
||||
"重试": "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"
|
||||
"可选,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
@@ -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",
|
||||
"推送渠道,支持 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+94
-47
@@ -10,6 +10,7 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EllipsisOutlined,
|
||||
HistoryOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-layout';
|
||||
@@ -40,6 +41,8 @@ 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';
|
||||
@@ -65,6 +68,26 @@ 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));
|
||||
@@ -89,10 +112,11 @@ const Script = () => {
|
||||
.get(
|
||||
`${config.apiPrefix}scripts/detail?file=${encodeURIComponent(
|
||||
node.title,
|
||||
)}&path=${node.parent || ''}`,
|
||||
)}&path=${encodeURIComponent(node.parent || '')}`,
|
||||
)
|
||||
.then(({ code, data }) => {
|
||||
if (code === 200) {
|
||||
if (code === 200 && currentKeyRef.current === node.key) {
|
||||
savedContentRef.current = data;
|
||||
setValue(data);
|
||||
if (options.callback) {
|
||||
options.callback();
|
||||
@@ -179,7 +203,7 @@ const Script = () => {
|
||||
const currentContent = editorRef.current
|
||||
? editorRef.current.getValue().replace(/\r\n/g, '\n')
|
||||
: value;
|
||||
const originalContent = value.replace(/\r\n/g, '\n');
|
||||
const originalContent = savedContentRef.current.replace(/\r\n/g, '\n');
|
||||
|
||||
if (currentContent !== originalContent && isEditing) {
|
||||
Modal.confirm({
|
||||
@@ -247,42 +271,36 @@ const Script = () => {
|
||||
getDetail(currentNode);
|
||||
};
|
||||
|
||||
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 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 deleteFile = () => {
|
||||
@@ -548,8 +566,20 @@ const Script = () => {
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
extra={
|
||||
isPhone
|
||||
extra={[
|
||||
...(currentNode?.type === 'file' && showMonaco
|
||||
? [
|
||||
<Button
|
||||
key="history"
|
||||
icon={<HistoryOutlined />}
|
||||
disabled={saving}
|
||||
onClick={() => setHistoryVisible(true)}
|
||||
>
|
||||
{!isPhone && intl.get('历史版本')}
|
||||
</Button>,
|
||||
]
|
||||
: []),
|
||||
...(isPhone
|
||||
? [
|
||||
<TreeSelect
|
||||
treeExpandAction="click"
|
||||
@@ -562,7 +592,7 @@ const Script = () => {
|
||||
treeNodeFilterProp="title"
|
||||
showSearch
|
||||
allowClear
|
||||
onSelect={onSelect}
|
||||
onSelect={(key, node) => onTreeSelect([key], { node })}
|
||||
/>,
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<Button type="primary" icon={<EllipsisOutlined />} />
|
||||
@@ -570,7 +600,7 @@ const Script = () => {
|
||||
]
|
||||
: isEditing
|
||||
? [
|
||||
<Button type="primary" onClick={saveFile}>
|
||||
<Button type="primary" onClick={saveFile} loading={saving}>
|
||||
{intl.get('保存')}
|
||||
</Button>,
|
||||
<Button type="primary" onClick={cancelEdit}>
|
||||
@@ -625,12 +655,29 @@ 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*/
|
||||
@@ -682,7 +729,7 @@ const Script = () => {
|
||||
value={value}
|
||||
theme={theme}
|
||||
options={{
|
||||
readOnly: !isEditing,
|
||||
readOnly: !isEditing || saving,
|
||||
fontSize: 12,
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: false,
|
||||
@@ -704,7 +751,7 @@ const Script = () => {
|
||||
mode ? [langs[mode as keyof typeof langs]()] : undefined
|
||||
}
|
||||
theme={theme.includes('dark') ? 'dark' : 'light'}
|
||||
readOnly={!isEditing}
|
||||
readOnly={!isEditing || saving}
|
||||
onChange={(value) => {
|
||||
setValue(value);
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -16,18 +17,21 @@ const SaveModal = ({
|
||||
|
||||
const handleOk = async (values: any) => {
|
||||
setLoading(true);
|
||||
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);
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -42,6 +42,7 @@ function fixture(t, api = 'script') {
|
||||
'../config': config,
|
||||
'../config/const': {},
|
||||
'../services/script': {},
|
||||
'../services/scriptHistory': {},
|
||||
'../services/config': {},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
typedi: { Container: { get: () => service } },
|
||||
|
||||
@@ -36,6 +36,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
),
|
||||
jwt: { secret },
|
||||
rootPath: tmp,
|
||||
dataPath: tmp,
|
||||
configPath: path.join(tmp, 'config/'),
|
||||
scriptPath: path.join(tmp, 'scripts/'),
|
||||
uploadPath: path.join(tmp, 'upload'),
|
||||
@@ -92,9 +93,7 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
'../services/script': class {},
|
||||
'../data/open': {},
|
||||
'../data/system': {},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, content) => fs.promises.writeFile(p, content),
|
||||
},
|
||||
typedi: { Service: () => (x) => x },
|
||||
};
|
||||
const Config = load(path.join(__dirname, '../../back/services/config.ts'), {
|
||||
...mocks,
|
||||
@@ -102,8 +101,18 @@ test('HTTP authentication protects init, scopes, expired sessions and config sec
|
||||
}).default;
|
||||
const configService = new 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 = {
|
||||
Container: { get: (x) => (x === User ? user : configService) },
|
||||
...mocks.typedi,
|
||||
Container: {
|
||||
get: (x) => (x === User ? user : x === History ? history : configService),
|
||||
},
|
||||
};
|
||||
mocks['../api'] = () => {
|
||||
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(
|
||||
router,
|
||||
);
|
||||
load(path.join(__dirname, '../../back/api/script.ts'), mocks).default(
|
||||
router,
|
||||
);
|
||||
load(
|
||||
path.join(__dirname, '../../back/api/script.ts'),
|
||||
mocks,
|
||||
historyCache,
|
||||
).default(router);
|
||||
router.get('/envs', (_req, res) => res.json({ code: 200 }));
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ test('script file operations allow token.json while protecting panel configurati
|
||||
])
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
const config = {
|
||||
dataPath: root,
|
||||
scriptPath: path.join(root, 'scripts'),
|
||||
configPath: path.join(root, 'config'),
|
||||
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 }),
|
||||
readDir: async () => [],
|
||||
},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, content) => fs.promises.writeFile(p, content),
|
||||
},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'./sock': {},
|
||||
'./cron': {},
|
||||
@@ -52,15 +50,28 @@ test('script file operations allow token.json while protecting panel configurati
|
||||
'../shared/pLimit': {},
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
};
|
||||
const cache = new Map();
|
||||
const Script = load(
|
||||
path.join(__dirname, '../../back/services/script.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
).default;
|
||||
const service = new 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();
|
||||
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 invoke = async (method, url, body = {}, query = {}, file) => {
|
||||
const route = router.stack.find(
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user