mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: initial patcher implementation using the diff tool
This commit is contained in:
17
api/package-lock.json
generated
17
api/package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@reflet/cron": "^1.3.1",
|
||||
"@runonflux/nat-upnp": "^1.0.2",
|
||||
"@types/diff": "^7.0.1",
|
||||
"accesscontrol": "^2.2.1",
|
||||
"bycontract": "^2.0.11",
|
||||
"bytes": "^3.1.2",
|
||||
@@ -40,6 +41,7 @@
|
||||
"convert": "^5.5.1",
|
||||
"cookie": "^1.0.2",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docker-event-emitter": "^0.3.0",
|
||||
"dockerode": "^3.3.5",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -5489,6 +5491,12 @@
|
||||
"moment": ">=2.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/diff": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz",
|
||||
"integrity": "sha512-R/BHQFripuhW6XPXy05hIvXJQdQ4540KnTvEFHSLjXfHYM41liOLKgIJEyYYiQe796xpaMHfe4Uj/p7Uvng2vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/docker-modem": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz",
|
||||
@@ -8738,6 +8746,15 @@
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
|
||||
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@reflet/cron": "^1.3.1",
|
||||
"@runonflux/nat-upnp": "^1.0.2",
|
||||
"@types/diff": "^7.0.1",
|
||||
"accesscontrol": "^2.2.1",
|
||||
"bycontract": "^2.0.11",
|
||||
"bytes": "^3.1.2",
|
||||
@@ -73,6 +74,7 @@
|
||||
"convert": "^5.5.1",
|
||||
"cookie": "^1.0.2",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"diff": "^7.0.0",
|
||||
"docker-event-emitter": "^0.3.0",
|
||||
"dockerode": "^3.3.5",
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
112
api/src/unraid-api/unraid-file-modifier/file-modification.ts
Normal file
112
api/src/unraid-api/unraid-file-modifier/file-modification.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { readFile, writeFile, access } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { applyPatch, parsePatch, reversePatch } from 'diff';
|
||||
|
||||
export interface PatchResult {
|
||||
targetFile: string;
|
||||
patch: string;
|
||||
}
|
||||
|
||||
export interface ShouldApplyWithReason {
|
||||
shouldApply: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Convert interface to abstract class with default implementations
|
||||
export abstract class FileModification {
|
||||
abstract id: string;
|
||||
|
||||
protected constructor(protected readonly logger: Logger) {}
|
||||
|
||||
// This is the main method that child classes need to implement
|
||||
protected abstract generatePatch(): Promise<PatchResult>;
|
||||
|
||||
private getPatchFilePath(targetFile: string): string {
|
||||
const dir = dirname(targetFile);
|
||||
const filename = `${this.id}.patch`;
|
||||
return join(dir, filename);
|
||||
}
|
||||
|
||||
private async savePatch(patchResult: PatchResult): Promise<void> {
|
||||
const patchFile = this.getPatchFilePath(patchResult.targetFile);
|
||||
await writeFile(patchFile, patchResult.patch, 'utf8');
|
||||
}
|
||||
|
||||
private async loadSavedPatch(targetFile: string): Promise<string | null> {
|
||||
const patchFile = this.getPatchFilePath(targetFile);
|
||||
try {
|
||||
await access(patchFile, constants.R_OK);
|
||||
return await readFile(patchFile, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Default implementation of apply that uses the patch
|
||||
async apply(): Promise<void> {
|
||||
const patchResult = await this.generatePatch();
|
||||
const { targetFile, patch } = patchResult;
|
||||
const currentContent = await readFile(targetFile, 'utf8');
|
||||
const parsedPatch = parsePatch(patch)[0];
|
||||
|
||||
const results = applyPatch(currentContent, parsedPatch);
|
||||
if (results === false) {
|
||||
throw new Error(`Failed to apply patch to ${targetFile}`);
|
||||
}
|
||||
|
||||
await writeFile(targetFile, results);
|
||||
await this.savePatch(patchResult);
|
||||
}
|
||||
|
||||
// Update rollback to use the shared utility
|
||||
async rollback(): Promise<void> {
|
||||
const { targetFile } = await this.generatePatch();
|
||||
let patch: string;
|
||||
|
||||
// Try to load saved patch first
|
||||
const savedPatch = await this.loadSavedPatch(targetFile);
|
||||
if (savedPatch) {
|
||||
this.logger.debug(`Using saved patch file for ${this.id}`);
|
||||
patch = savedPatch;
|
||||
} else {
|
||||
this.logger.debug(`No saved patch found for ${this.id}, generating new patch`);
|
||||
const patchResult = await this.generatePatch();
|
||||
patch = patchResult.patch;
|
||||
}
|
||||
|
||||
const currentContent = await readFile(targetFile, 'utf8');
|
||||
const parsedPatch = parsePatch(patch)[0];
|
||||
|
||||
if (!parsedPatch || !parsedPatch.hunks || parsedPatch.hunks.length === 0) {
|
||||
throw new Error('Invalid or empty patch content');
|
||||
}
|
||||
|
||||
const reversedPatch = reversePatch(parsedPatch);
|
||||
const results = applyPatch(currentContent, reversedPatch);
|
||||
|
||||
if (results === false) {
|
||||
throw new Error(`Failed to rollback patch from ${targetFile}`);
|
||||
}
|
||||
|
||||
await writeFile(targetFile, results);
|
||||
|
||||
// Clean up the patch file after successful rollback
|
||||
try {
|
||||
const patchFile = this.getPatchFilePath(targetFile);
|
||||
await access(patchFile, constants.W_OK);
|
||||
await unlink(patchFile);
|
||||
} catch {
|
||||
// Ignore errors when trying to delete the patch file
|
||||
}
|
||||
}
|
||||
|
||||
// Default implementation that can be overridden if needed
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
return {
|
||||
shouldApply: true,
|
||||
reason: 'Default behavior is to always apply modifications',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
Index: /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
|
||||
===================================================================
|
||||
--- /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
|
||||
+++ /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
|
||||
@@ -557,14 +557,5 @@
|
||||
$.post('/webGui/include/Notify.php',{cmd:'get',csrf_token:csrf_token},function(msg) {
|
||||
$.each($.parseJSON(msg), function(i, notify){
|
||||
- $.jGrowl(notify.subject+'<br>'+notify.description,{
|
||||
- group: notify.importance,
|
||||
- header: notify.event+': '+notify.timestamp,
|
||||
- theme: notify.file,
|
||||
- sticky: true,
|
||||
- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);},
|
||||
- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');},
|
||||
- click: function(e,m,o){if (notify.link) location.replace(notify.link);},
|
||||
- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});}
|
||||
- });
|
||||
+
|
||||
});
|
||||
});
|
||||
@@ -680,6 +671,6 @@
|
||||
}
|
||||
|
||||
-echo "<div class='nav-user show'><a id='board' href='#' class='hand'><b id='bell' class='icon-u-bell system'></b></a></div>";
|
||||
|
||||
+
|
||||
if ($themes2) echo "</div>";
|
||||
echo "</div></div>";
|
||||
@@ -886,20 +877,12 @@
|
||||
<?if ($notify['display']==0):?>
|
||||
if (notify.show) {
|
||||
- $.jGrowl(notify.subject+'<br>'+notify.description,{
|
||||
- group: notify.importance,
|
||||
- header: notify.event+': '+notify.timestamp,
|
||||
- theme: notify.file,
|
||||
- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);},
|
||||
- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');},
|
||||
- click: function(e,m,o){if (notify.link) location.replace(notify.link);},
|
||||
- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'hide',file:"<?=$notify['path'].'/unread/'?>"+notify.file,csrf_token:csrf_token}<?if ($notify['life']==0):?>,function(){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});}<?endif;?>);}
|
||||
- });
|
||||
+
|
||||
}
|
||||
<?endif;?>
|
||||
});
|
||||
- $('#bell').removeClass('red-orb yellow-orb green-orb').prop('title',"<?=_('Alerts')?> ["+bell1+']\n'+"<?=_('Warnings')?> ["+bell2+']\n'+"<?=_('Notices')?> ["+bell3+']');
|
||||
- if (bell1) $('#bell').addClass('red-orb'); else
|
||||
- if (bell2) $('#bell').addClass('yellow-orb'); else
|
||||
- if (bell3) $('#bell').addClass('green-orb');
|
||||
+
|
||||
+
|
||||
+
|
||||
+
|
||||
break;
|
||||
}
|
||||
@@ -1204,4 +1187,5 @@
|
||||
});
|
||||
</script>
|
||||
+<unraid-toaster rich-colors close-button position="<?= ($notify['position'] === 'center') ? 'top-center' : $notify['position'] ?>"></unraid-toaster>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
Index: /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
|
||||
===================================================================
|
||||
--- /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
|
||||
+++ /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
|
||||
@@ -557,14 +557,5 @@
|
||||
$.post('/webGui/include/Notify.php',{cmd:'get',csrf_token:csrf_token},function(msg) {
|
||||
$.each($.parseJSON(msg), function(i, notify){
|
||||
- $.jGrowl(notify.subject+'<br>'+notify.description,{
|
||||
- group: notify.importance,
|
||||
- header: notify.event+': '+notify.timestamp,
|
||||
- theme: notify.file,
|
||||
- sticky: true,
|
||||
- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);},
|
||||
- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');},
|
||||
- click: function(e,m,o){if (notify.link) location.replace(notify.link);},
|
||||
- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});}
|
||||
- });
|
||||
+
|
||||
});
|
||||
});
|
||||
@@ -680,6 +671,6 @@
|
||||
}
|
||||
|
||||
-echo "<div class='nav-user show'><a id='board' href='#' class='hand'><b id='bell' class='icon-u-bell system'></b></a></div>";
|
||||
|
||||
+
|
||||
if ($themes2) echo "</div>";
|
||||
echo "</div></div>";
|
||||
@@ -886,20 +877,12 @@
|
||||
<?if ($notify['display']==0):?>
|
||||
if (notify.show) {
|
||||
- $.jGrowl(notify.subject+'<br>'+notify.description,{
|
||||
- group: notify.importance,
|
||||
- header: notify.event+': '+notify.timestamp,
|
||||
- theme: notify.file,
|
||||
- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);},
|
||||
- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');},
|
||||
- click: function(e,m,o){if (notify.link) location.replace(notify.link);},
|
||||
- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'hide',file:"<?=$notify['path'].'/unread/'?>"+notify.file,csrf_token:csrf_token}<?if ($notify['life']==0):?>,function(){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});}<?endif;?>);}
|
||||
- });
|
||||
+
|
||||
}
|
||||
<?endif;?>
|
||||
});
|
||||
- $('#bell').removeClass('red-orb yellow-orb green-orb').prop('title',"<?=_('Alerts')?> ["+bell1+']\n'+"<?=_('Warnings')?> ["+bell2+']\n'+"<?=_('Notices')?> ["+bell3+']');
|
||||
- if (bell1) $('#bell').addClass('red-orb'); else
|
||||
- if (bell2) $('#bell').addClass('yellow-orb'); else
|
||||
- if (bell3) $('#bell').addClass('green-orb');
|
||||
+
|
||||
+
|
||||
+
|
||||
+
|
||||
break;
|
||||
}
|
||||
@@ -1204,4 +1187,5 @@
|
||||
});
|
||||
</script>
|
||||
+<unraid-toaster rich-colors close-button position="<?= ($notify['position'] === 'center') ? 'top-center' : $notify['position'] ?>"></unraid-toaster>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,19 +1,47 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
|
||||
|
||||
|
||||
import { applyPatch } from 'diff';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
|
||||
|
||||
import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
describe('DefaultPageLayout.php modifier', () => {
|
||||
test('correctly applies to fresh install', async () => {
|
||||
const fileContent = await readFile(
|
||||
resolve(__dirname, '../__fixtures__/DefaultPageLayout.php'),
|
||||
'utf-8'
|
||||
);
|
||||
const path = resolve(__dirname, '../__fixtures__/DefaultPageLayout.php');
|
||||
expect(fileContent.length).toBeGreaterThan(0);
|
||||
await expect(DefaultPageLayoutModification.applyToSource(fileContent)).toMatchFileSnapshot(
|
||||
'DefaultPageLayout.modified.php'
|
||||
);
|
||||
const logger = new Logger();
|
||||
const patcher = await new DefaultPageLayoutModification(logger);
|
||||
patcher.filePath = path;
|
||||
const patch = await patcher.generatePatch();
|
||||
|
||||
await expect(patch.patch).toMatchFileSnapshot('DefaultPageLayout.patch.php');
|
||||
// Now we need to apply the patch
|
||||
|
||||
const newContent = applyPatch(fileContent, patch.patch, {
|
||||
fuzzFactor: 1,
|
||||
});
|
||||
await expect(newContent).toMatchFileSnapshot('DefaultPageLayout.modified.php');
|
||||
|
||||
// Now apply the patch
|
||||
await patcher.apply();
|
||||
|
||||
// Now rollback the patch and check that the file is back to the original
|
||||
await patcher.rollback();
|
||||
const revertedContent = await readFile(path, 'utf-8');
|
||||
await expect(revertedContent).toMatchFileSnapshot('DefaultPageLayout.original.php');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { existsSync } from 'fs';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
PatchResult,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service';
|
||||
import { backupFile } from '@app/utils';
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification';
|
||||
|
||||
const AUTH_REQUEST_FILE = '/usr/local/emhttp/auth-request.php' as const;
|
||||
const WEB_COMPS_DIR = '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/' as const;
|
||||
@@ -17,47 +19,47 @@ const getJsFiles = async (dir: string) => {
|
||||
return files.map((file) => file.replace('/usr/local/emhttp', ''));
|
||||
};
|
||||
|
||||
export default class AuthRequestModification implements FileModification {
|
||||
export default class AuthRequestModification extends FileModification {
|
||||
id: string = 'auth-request';
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger = logger;
|
||||
constructor(logger: Logger) {
|
||||
super(logger);
|
||||
}
|
||||
|
||||
async apply(): Promise<void> {
|
||||
protected async generatePatch(): Promise<PatchResult> {
|
||||
const JS_FILES = await getJsFiles(WEB_COMPS_DIR);
|
||||
this.logger.debug(`Found ${JS_FILES.length} .js files in ${WEB_COMPS_DIR}`);
|
||||
|
||||
const FILES_TO_ADD = ['/webGui/images/partner-logo.svg', ...JS_FILES];
|
||||
|
||||
if (existsSync(AUTH_REQUEST_FILE)) {
|
||||
const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8');
|
||||
|
||||
if (fileContent.includes('$arrWhitelist')) {
|
||||
backupFile(AUTH_REQUEST_FILE, true);
|
||||
this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`);
|
||||
|
||||
const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n');
|
||||
|
||||
const updatedContent = fileContent.replace(
|
||||
/(\$arrWhitelist\s*=\s*\[)/,
|
||||
`$1\n${filesToAddString}`
|
||||
);
|
||||
|
||||
await writeFile(AUTH_REQUEST_FILE, updatedContent);
|
||||
this.logger.debug(
|
||||
`Default values and .js files from ${WEB_COMPS_DIR} added to $arrWhitelist.`
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`$arrWhitelist array not found in the file.`);
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(`File ${AUTH_REQUEST_FILE} not found.`);
|
||||
if (!existsSync(AUTH_REQUEST_FILE)) {
|
||||
throw new Error(`File ${AUTH_REQUEST_FILE} not found.`);
|
||||
}
|
||||
|
||||
const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8');
|
||||
|
||||
if (!fileContent.includes('$arrWhitelist')) {
|
||||
throw new Error(`$arrWhitelist array not found in the file.`);
|
||||
}
|
||||
|
||||
this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`);
|
||||
|
||||
const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n');
|
||||
|
||||
// Create new content by finding the array declaration and adding our files after it
|
||||
const newContent = fileContent.replace(/(\$arrWhitelist\s*=\s*\[)/, `$1\n${filesToAddString}`);
|
||||
|
||||
// Generate and return patch
|
||||
const patch = createPatch(AUTH_REQUEST_FILE, fileContent, newContent, undefined, undefined, {
|
||||
context: 3,
|
||||
});
|
||||
|
||||
return {
|
||||
targetFile: AUTH_REQUEST_FILE,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
async rollback(): Promise<void> {
|
||||
// No rollback needed, this is safe to preserve
|
||||
}
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
return {
|
||||
shouldApply: true,
|
||||
|
||||
@@ -1,34 +1,66 @@
|
||||
import type { Logger } from '@nestjs/common';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
PatchResult,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service';
|
||||
import { backupFile, restoreFile } from '@app/utils';
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification';
|
||||
|
||||
export default class DefaultPageLayoutModification extends FileModification {
|
||||
id: string = 'default-page-layout';
|
||||
private readonly filePath: string =
|
||||
'/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php';
|
||||
|
||||
export default class DefaultPageLayoutModification implements FileModification {
|
||||
id: string = 'DefaultPageLayout.php';
|
||||
logger: Logger;
|
||||
filePath: string = '/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php';
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
super(logger);
|
||||
}
|
||||
|
||||
async apply(): Promise<void> {
|
||||
await backupFile(this.filePath, true);
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
await writeFile(this.filePath, DefaultPageLayoutModification.applyToSource(fileContent));
|
||||
this.logger.log(`${this.id} replaced successfully.`);
|
||||
}
|
||||
|
||||
async rollback(): Promise<void> {
|
||||
const restored = await restoreFile(this.filePath, false);
|
||||
if (restored) {
|
||||
this.logger.debug(`${this.id} restored.`);
|
||||
} else {
|
||||
this.logger.warn(`Could not restore ${this.id}`);
|
||||
private addToaster(source: string): string {
|
||||
if (source.includes('unraid-toaster')) {
|
||||
return source;
|
||||
}
|
||||
const insertion = `<unraid-toaster rich-colors close-button position="<?= ($notify['position'] === 'center') ? 'top-center' : $notify['position'] ?>"></unraid-toaster>`;
|
||||
return source.replace(/<\/body>/, `${insertion}\n</body>`);
|
||||
}
|
||||
|
||||
private removeNotificationBell(source: string): string {
|
||||
return source.replace(/^.*(id='bell'|#bell).*$/gm, '');
|
||||
}
|
||||
|
||||
private replaceToasts(source: string): string {
|
||||
// matches jgrowl calls up to the second `)};`
|
||||
const jGrowlPattern =
|
||||
/\$\.jGrowl\(notify\.subject\+'<br>'\+notify\.description,\s*\{(?:[\s\S]*?\}\);[\s\S]*?)}\);/g;
|
||||
|
||||
return source.replace(jGrowlPattern, '');
|
||||
}
|
||||
|
||||
private applyToSource(fileContent: string): string {
|
||||
const transformers = [
|
||||
this.removeNotificationBell.bind(this),
|
||||
this.replaceToasts.bind(this),
|
||||
this.addToaster.bind(this),
|
||||
];
|
||||
return transformers.reduce((content, fn) => fn(content), fileContent);
|
||||
}
|
||||
|
||||
protected async generatePatch(): Promise<PatchResult> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
|
||||
|
||||
const newContent = this.applyToSource(fileContent);
|
||||
|
||||
const patch = createPatch(this.filePath, fileContent, newContent, undefined, undefined, {
|
||||
context: 2,
|
||||
});
|
||||
|
||||
return {
|
||||
targetFile: this.filePath,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
@@ -37,33 +69,4 @@ export default class DefaultPageLayoutModification implements FileModification {
|
||||
reason: 'Always apply the allowed file changes to ensure compatibility.',
|
||||
};
|
||||
}
|
||||
|
||||
static applyToSource(fileContent: string): string {
|
||||
const transformers = [
|
||||
DefaultPageLayoutModification.removeNotificationBell,
|
||||
DefaultPageLayoutModification.replaceToasts,
|
||||
DefaultPageLayoutModification.addToaster,
|
||||
];
|
||||
return transformers.reduce((content, fn) => fn(content), fileContent);
|
||||
}
|
||||
|
||||
static addToaster(source: string): string {
|
||||
if (source.includes('unraid-toaster')) {
|
||||
return source;
|
||||
}
|
||||
const insertion = `<unraid-toaster rich-colors close-button position="<?= ($notify['position'] === 'center') ? 'top-center' : $notify['position'] ?>"></unraid-toaster>`;
|
||||
return source.replace(/<\/body>/, `${insertion}\n</body>`);
|
||||
}
|
||||
|
||||
static removeNotificationBell(source: string): string {
|
||||
return source.replace(/^.*(id='bell'|#bell).*$/gm, '');
|
||||
}
|
||||
|
||||
static replaceToasts(source: string): string {
|
||||
// matches jgrowl calls up to the second `)};`
|
||||
const jGrowlPattern =
|
||||
/\$\.jGrowl\(notify\.subject\+'<br>'\+notify\.description,\s*\{(?:[\s\S]*?\}\);[\s\S]*?)}\);/g;
|
||||
|
||||
return source.replace(jGrowlPattern, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { fileExists } from '@app/core/utils/files/file-exists';
|
||||
import {
|
||||
FileModification,
|
||||
PatchResult,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service';
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification';
|
||||
|
||||
export class LogRotateModification implements FileModification {
|
||||
export class LogRotateModification extends FileModification {
|
||||
id: string = 'log-rotate';
|
||||
filePath: string = '/etc/logrotate.d/unraid-api' as const;
|
||||
logger: Logger;
|
||||
logRotateConfig: string = `
|
||||
private readonly filePath: string = '/etc/logrotate.d/unraid-api' as const;
|
||||
private readonly logRotateConfig: string = `
|
||||
/var/log/unraid-api/*.log {
|
||||
rotate 1
|
||||
missingok
|
||||
@@ -27,17 +28,32 @@ export class LogRotateModification implements FileModification {
|
||||
`;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
super(logger);
|
||||
}
|
||||
|
||||
async apply(): Promise<void> {
|
||||
await writeFile(this.filePath, this.logRotateConfig, { mode: '644' });
|
||||
// Ensure file is owned by root:root
|
||||
protected async generatePatch(): Promise<PatchResult> {
|
||||
const currentContent = (await fileExists(this.filePath))
|
||||
? await readFile(this.filePath, 'utf8')
|
||||
: '';
|
||||
|
||||
const patch = createPatch(
|
||||
this.filePath,
|
||||
currentContent,
|
||||
this.logRotateConfig,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
context: 3,
|
||||
}
|
||||
);
|
||||
|
||||
// After applying patch, ensure file permissions are correct
|
||||
await execa('chown', ['root:root', this.filePath]).catch((err) => this.logger.error(err));
|
||||
}
|
||||
|
||||
async rollback(): Promise<void> {
|
||||
await rm(this.filePath);
|
||||
return {
|
||||
targetFile: this.filePath,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
import type { Logger } from '@nestjs/common';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { createPatch } from 'diff';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
PatchResult,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service';
|
||||
import { backupFile, restoreFile } from '@app/utils';
|
||||
} from '@app/unraid-api/unraid-file-modifier/file-modification';
|
||||
|
||||
export default class NotificationsPageModification implements FileModification {
|
||||
export default class NotificationsPageModification extends FileModification {
|
||||
id: string = 'Notifications.page';
|
||||
logger: Logger;
|
||||
filePath: string = '/usr/local/emhttp/plugins/dynamix/Notifications.page';
|
||||
private readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/Notifications.page';
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
super(logger);
|
||||
}
|
||||
|
||||
async apply(): Promise<void> {
|
||||
await backupFile(this.filePath, true);
|
||||
protected async generatePatch(): Promise<PatchResult> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
await writeFile(this.filePath, NotificationsPageModification.applyToSource(fileContent));
|
||||
this.logger.log(`${this.id} replaced successfully.`);
|
||||
}
|
||||
|
||||
async rollback(): Promise<void> {
|
||||
const restored = await restoreFile(this.filePath, false);
|
||||
if (restored) {
|
||||
this.logger.debug(`${this.id} restored.`);
|
||||
} else {
|
||||
this.logger.warn(`Could not restore ${this.id}`);
|
||||
}
|
||||
const newContent = NotificationsPageModification.applyToSource(fileContent);
|
||||
|
||||
const patch = createPatch(this.filePath, fileContent, newContent, undefined, undefined, {
|
||||
context: 3,
|
||||
});
|
||||
|
||||
return {
|
||||
targetFile: this.filePath,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
@@ -38,12 +39,11 @@ export default class NotificationsPageModification implements FileModification {
|
||||
};
|
||||
}
|
||||
|
||||
static applyToSource(fileContent: string): string {
|
||||
private static applyToSource(fileContent: string): string {
|
||||
return (
|
||||
fileContent
|
||||
// Remove lines between _(Date format)_: and :notifications_date_format_help:
|
||||
.replace(/^\s*_\(Date format\)_:(?:[^\n]*\n)*?\s*:notifications_date_format_help:/gm, '')
|
||||
|
||||
// Remove lines between _(Time format)_: and :notifications_time_format_help:
|
||||
.replace(/^\s*_\(Time format\)_:(?:[^\n]*\n)*?\s*:notifications_time_format_help:/gm, '')
|
||||
);
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import type { Logger } from '@nestjs/common';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createPatch } from 'diff';
|
||||
import { FileModification, PatchResult, ShouldApplyWithReason } from '@app/unraid-api/unraid-file-modifier/file-modification';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
ShouldApplyWithReason,
|
||||
} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service';
|
||||
import { backupFile, restoreFile } from '@app/utils';
|
||||
|
||||
export default class SSOFileModification implements FileModification {
|
||||
export default class SSOFileModification extends FileModification {
|
||||
id: string = 'sso';
|
||||
logger: Logger;
|
||||
loginFilePath: string = '/usr/local/emhttp/plugins/dynamix/include/.login.php';
|
||||
private loginFilePath: string = '/usr/local/emhttp/plugins/dynamix/include/.login.php';
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
super(logger);
|
||||
}
|
||||
|
||||
async apply(): Promise<void> {
|
||||
protected async generatePatch(): Promise<PatchResult> {
|
||||
// Define the new PHP function to insert
|
||||
/* eslint-disable no-useless-escape */
|
||||
const newFunction = `
|
||||
@@ -49,39 +45,31 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool
|
||||
const tagToInject =
|
||||
'<?php include "$docroot/plugins/dynamix.my.servers/include/sso-login.php"; ?>';
|
||||
|
||||
// Restore the original file if exists
|
||||
await restoreFile(this.loginFilePath, false);
|
||||
// Backup the original content
|
||||
await backupFile(this.loginFilePath, true);
|
||||
|
||||
// Read the file content
|
||||
let fileContent = await readFile(this.loginFilePath, 'utf-8');
|
||||
const originalContent = await readFile(this.loginFilePath, 'utf-8');
|
||||
|
||||
// Create modified content
|
||||
let newContent = originalContent;
|
||||
|
||||
// Add new function after the opening PHP tag (<?php)
|
||||
fileContent = fileContent.replace(/<\?php\s*(\r?\n|\r)*/, `<?php\n\n${newFunction}\n`);
|
||||
// Add new function after the opening PHP tag
|
||||
newContent = newContent.replace(/<\?php\s*(\r?\n|\r)*/, `<?php\n\n${newFunction}\n`);
|
||||
|
||||
// Replace the old function call with the new function name
|
||||
const functionCallPattern = /!verifyUsernamePassword\(\$username, \$password\)/g;
|
||||
fileContent = fileContent.replace(
|
||||
functionCallPattern,
|
||||
// Replace the old function call
|
||||
newContent = newContent.replace(
|
||||
/!verifyUsernamePassword\(\$username, \$password\)/g,
|
||||
'!verifyUsernamePasswordAndSSO($username, $password)'
|
||||
);
|
||||
|
||||
// Inject the PHP include tag after the closing </form> tag
|
||||
fileContent = fileContent.replace(/<\/form>/i, `</form>\n${tagToInject}`);
|
||||
// Inject the PHP include tag
|
||||
newContent = newContent.replace(/<\/form>/i, `</form>\n${tagToInject}`);
|
||||
|
||||
// Write the updated content back to the file
|
||||
await writeFile(this.loginFilePath, fileContent);
|
||||
|
||||
this.logger.log('Login Function replaced successfully.');
|
||||
}
|
||||
async rollback(): Promise<void> {
|
||||
const restored = await restoreFile(this.loginFilePath, false);
|
||||
if (restored) {
|
||||
this.logger.debug('SSO login file restored.');
|
||||
} else {
|
||||
this.logger.debug('No SSO login file backup found.');
|
||||
}
|
||||
// Create and return the patch
|
||||
const patch = createPatch(this.loginFilePath, originalContent, newContent, 'original', 'modified');
|
||||
|
||||
return {
|
||||
targetFile: this.loginFilePath,
|
||||
patch
|
||||
};
|
||||
}
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
|
||||
20
api/src/unraid-api/unraid-file-modifier/patch-utils.ts
Normal file
20
api/src/unraid-api/unraid-file-modifier/patch-utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { applyPatch, parsePatch, reversePatch } from 'diff';
|
||||
|
||||
export async function rollbackPatch(targetFile: string, patch: string): Promise<void> {
|
||||
const currentContent = await readFile(targetFile, 'utf8');
|
||||
const parsedPatch = parsePatch(patch)[0];
|
||||
|
||||
if (!parsedPatch || !parsedPatch.hunks || parsedPatch.hunks.length === 0) {
|
||||
throw new Error('Invalid or empty patch content');
|
||||
}
|
||||
|
||||
const reversedPatch = reversePatch(parsedPatch);
|
||||
const results = applyPatch(currentContent, reversedPatch);
|
||||
|
||||
if (results === false) {
|
||||
throw new Error(`Failed to rollback patch from ${targetFile}`);
|
||||
}
|
||||
|
||||
await writeFile(targetFile, results);
|
||||
}
|
||||
@@ -1,29 +1,15 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification';
|
||||
import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification';
|
||||
import { LogRotateModification } from '@app/unraid-api/unraid-file-modifier/modifications/log-rotate.modification';
|
||||
import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification';
|
||||
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification';
|
||||
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification';
|
||||
|
||||
export interface ShouldApplyWithReason {
|
||||
shouldApply: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Step 1: Define the interface
|
||||
export interface FileModification {
|
||||
id: string; // Unique identifier for the operation
|
||||
apply(): Promise<void>; // Method to apply the modification
|
||||
rollback(): Promise<void>; // Method to roll back the modification
|
||||
shouldApply(): Promise<ShouldApplyWithReason>; // Method to determine if the modification should be applied
|
||||
}
|
||||
|
||||
// Step 2: Create a FileModificationService
|
||||
@Injectable()
|
||||
export class UnraidFileModificationService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(UnraidFileModificationService.name);
|
||||
private history: FileModification[] = []; // Keeps track of applied modifications
|
||||
private appliedModifications: FileModification[] = [];
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
@@ -31,17 +17,19 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest
|
||||
const mods = await this.loadModifications();
|
||||
await this.applyModifications(mods);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to apply modifications: ${err}`);
|
||||
if (err instanceof Error) {
|
||||
this.logger.error(`Failed to apply modifications: ${err.message}`);
|
||||
} else {
|
||||
this.logger.error('Failed to apply modifications: Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Rolling back all modifications...');
|
||||
await this.rollbackAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically load all file modifications from the specified folder.
|
||||
*/
|
||||
async loadModifications(): Promise<FileModification[]> {
|
||||
const modifications: FileModification[] = [];
|
||||
const modificationClasses: Array<new (logger: Logger) => FileModification> = [
|
||||
@@ -64,10 +52,6 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a file modification.
|
||||
* @param modification - The file modification to apply
|
||||
*/
|
||||
async applyModification(modification: FileModification): Promise<void> {
|
||||
try {
|
||||
const shouldApplyWithReason = await modification.shouldApply();
|
||||
@@ -76,7 +60,7 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest
|
||||
`Applying modification: ${modification.id} - ${shouldApplyWithReason.reason}`
|
||||
);
|
||||
await modification.apply();
|
||||
this.history.push(modification); // Store modification in history
|
||||
this.appliedModifications.push(modification);
|
||||
this.logger.log(`Modification applied successfully: ${modification.id}`);
|
||||
} else {
|
||||
this.logger.log(
|
||||
@@ -90,35 +74,26 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest
|
||||
error.stack
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Failed to apply modification: ${modification.id}: ${error}`);
|
||||
this.logger.error(`Failed to apply modification: ${modification.id}: Unknown error`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back all applied modifications in reverse order.
|
||||
*/
|
||||
async rollbackAll(): Promise<void> {
|
||||
while (this.history.length > 0) {
|
||||
const modification = this.history.pop(); // Get the last applied modification
|
||||
if (modification) {
|
||||
try {
|
||||
this.logger.log(`Rolling back modification: ${modification.id}`);
|
||||
await modification.rollback();
|
||||
this.logger.log(`Modification rolled back successfully: ${modification.id}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
this.logger.error(
|
||||
`Failed to roll back modification: ${modification.id}: ${error.message}`,
|
||||
error.stack
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to roll back modification: ${modification.id}: ${error}`
|
||||
);
|
||||
}
|
||||
// Process modifications in reverse order
|
||||
for (const modification of [...this.appliedModifications].reverse()) {
|
||||
try {
|
||||
this.logger.log(`Rolling back modification: ${modification.id}`);
|
||||
await modification.rollback();
|
||||
this.logger.log(`Successfully rolled back modification: ${modification.id}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
this.logger.error(`Failed to roll back modification: ${error.message}`);
|
||||
} else {
|
||||
this.logger.error('Failed to roll back modification: Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
this.appliedModifications = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { join } from 'path';
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
FileModification,
|
||||
UnraidFileModificationService,
|
||||
} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service';
|
||||
|
||||
class TestFileModification implements FileModification {
|
||||
constructor(
|
||||
public applyImplementation?: () => Promise<void>,
|
||||
public rollbackImplementation?: () => Promise<void>
|
||||
) {}
|
||||
import { createPatch } from 'diff';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
||||
|
||||
import { FileModification, PatchResult, ShouldApplyWithReason } from '@app/unraid-api/unraid-file-modifier/file-modification';
|
||||
import { UnraidFileModificationService } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TestFileModification extends FileModification {
|
||||
id = 'test';
|
||||
async apply() {
|
||||
if (this.applyImplementation) {
|
||||
return this.applyImplementation();
|
||||
}
|
||||
throw new Error('Application not implemented.');
|
||||
|
||||
constructor(logger: Logger) {
|
||||
super(logger);
|
||||
}
|
||||
async rollback() {
|
||||
if (this.rollbackImplementation) {
|
||||
return this.rollbackImplementation();
|
||||
}
|
||||
throw new Error('Rollback not implemented.');
|
||||
|
||||
protected async generatePatch(): Promise<PatchResult> {
|
||||
return {
|
||||
targetFile: join(__dirname, '__fixtures__/text-patch-file.txt'),
|
||||
patch: createPatch(
|
||||
'__fixtures__/text-patch-file.txt',
|
||||
'original',
|
||||
'modified',
|
||||
'original',
|
||||
'modified'
|
||||
),
|
||||
};
|
||||
}
|
||||
async shouldApply() {
|
||||
|
||||
apply = vi.fn();
|
||||
|
||||
rollback = vi.fn();
|
||||
|
||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||
return { shouldApply: true, reason: 'Always Apply this mod' };
|
||||
}
|
||||
}
|
||||
@@ -40,6 +54,8 @@ describe('FileModificationService', () => {
|
||||
verbose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let service: UnraidFileModificationService;
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
@@ -54,6 +70,9 @@ describe('FileModificationService', () => {
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(mockLogger.warn);
|
||||
vi.spyOn(Logger.prototype, 'debug').mockImplementation(mockLogger.debug);
|
||||
vi.spyOn(Logger.prototype, 'verbose').mockImplementation(mockLogger.verbose);
|
||||
|
||||
logger = new Logger('test');
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UnraidFileModificationService],
|
||||
}).compile();
|
||||
@@ -71,7 +90,8 @@ describe('FileModificationService', () => {
|
||||
});
|
||||
|
||||
it('should apply modifications', async () => {
|
||||
await expect(service.applyModification(new TestFileModification())).resolves.toBe(undefined);
|
||||
const mod = new TestFileModification(logger);
|
||||
await expect(service.applyModification(mod)).resolves.toBe(undefined);
|
||||
});
|
||||
|
||||
it('should not rollback any mods without loaded', async () => {
|
||||
@@ -80,47 +100,62 @@ describe('FileModificationService', () => {
|
||||
|
||||
it('should rollback all mods', async () => {
|
||||
await service.loadModifications();
|
||||
const applyFn = vi.fn();
|
||||
const rollbackFn = vi.fn();
|
||||
await service.applyModification(new TestFileModification(applyFn, rollbackFn));
|
||||
await expect(service.rollbackAll()).resolves.toBe(undefined);
|
||||
const mod = new TestFileModification(logger);
|
||||
|
||||
await service.applyModification(mod);
|
||||
await service.rollbackAll();
|
||||
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockLogger.log).toHaveBeenCalledTimes(5);
|
||||
expect(applyFn).toHaveBeenCalled();
|
||||
expect(rollbackFn).toHaveBeenCalled();
|
||||
expect(mockLogger.log).toHaveBeenNthCalledWith(1, 'RootTestModule dependencies initialized');
|
||||
expect(mockLogger.log).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Applying modification: test - Always Apply this mod'
|
||||
);
|
||||
expect(mockLogger.log).toHaveBeenNthCalledWith(3, 'Modification applied successfully: test');
|
||||
expect(mockLogger.log).toHaveBeenNthCalledWith(4, 'Rolling back modification: test');
|
||||
expect(mockLogger.log).toHaveBeenNthCalledWith(5, 'Modification rolled back successfully: test');
|
||||
expect(mockLogger.log.mock.calls).toEqual([
|
||||
['RootTestModule dependencies initialized'],
|
||||
['Applying modification: test - Always Apply this mod'],
|
||||
['Modification applied successfully: test'],
|
||||
['Rolling back modification: test'],
|
||||
['Successfully rolled back modification: test'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle errors during rollback', async () => {
|
||||
const errorMod = new TestFileModification(vi.fn(), () =>
|
||||
Promise.reject(new Error('Rollback failed'))
|
||||
);
|
||||
await service.applyModification(errorMod);
|
||||
// Mock the logger to track error calls
|
||||
|
||||
const mod = new TestFileModification(logger);
|
||||
await service.applyModification(mod);
|
||||
console.log(service.appliedModifications);
|
||||
expect(mockLogger.log.mock.calls).toEqual([
|
||||
['RootTestModule dependencies initialized'],
|
||||
['Applying modification: test - Always Apply this mod'],
|
||||
['Modification applied successfully: test'],
|
||||
]);
|
||||
|
||||
service.appliedModifications[0].appliedPatch = null;
|
||||
console.log(service.appliedModifications);
|
||||
// Now break the appliedModifications array so that the rollbackAll method fails
|
||||
await service.rollbackAll();
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to roll back modification')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle concurrent modifications', async () => {
|
||||
const mods = [
|
||||
new TestFileModification(vi.fn(), vi.fn()),
|
||||
new TestFileModification(vi.fn(), vi.fn()),
|
||||
];
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn().mockResolvedValue('modified'),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
const mods = [new TestFileModification(logger), new TestFileModification(logger)];
|
||||
|
||||
await Promise.all(mods.map((mod) => service.applyModification(mod)));
|
||||
await service.rollbackAll();
|
||||
mods.forEach((mod) => {
|
||||
expect(mod.rollbackImplementation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Successfully rolled back modification')
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await service.rollbackAll();
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -245,75 +245,3 @@ export function handleAuthError(
|
||||
|
||||
throw new UnauthorizedException(`${operation}: ${errorMessage}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to allow backing up a single file to a .bak file.
|
||||
* @param path the file to backup, creates a .bak file in the same directory
|
||||
* @throws Error if the file cannot be copied
|
||||
*/
|
||||
export const backupFile = async (path: string, throwOnMissing = true): Promise<void> => {
|
||||
try {
|
||||
// Validate path
|
||||
if (!path) {
|
||||
throw new Error('File path cannot be empty');
|
||||
}
|
||||
|
||||
// Check if source file exists and is readable
|
||||
await access(path, constants.R_OK);
|
||||
|
||||
// Check if backup directory is writable
|
||||
await access(dirname(path), constants.W_OK);
|
||||
|
||||
const backupPath = path + '.bak';
|
||||
await copyFile(path, backupPath);
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (throwOnMissing) {
|
||||
throw new Error(
|
||||
error.code === 'ENOENT'
|
||||
? `File does not exist: ${path}`
|
||||
: error.code === 'EACCES'
|
||||
? `Permission denied: ${path}`
|
||||
: `Failed to backup file: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path Path to original (not .bak) file
|
||||
* @param throwOnMissing Whether to throw an error if the backup file does not exist
|
||||
* @throws Error if the backup file does not exist and throwOnMissing is true
|
||||
* @returns boolean indicating whether the restore was successful
|
||||
*/
|
||||
export const restoreFile = async (path: string, throwOnMissing = true): Promise<boolean> => {
|
||||
if (!path) {
|
||||
throw new Error('File path cannot be empty');
|
||||
}
|
||||
|
||||
const backupPath = path + '.bak';
|
||||
try {
|
||||
// Check if backup file exists and is readable
|
||||
await access(backupPath, constants.R_OK);
|
||||
|
||||
// Check if target directory is writable
|
||||
await access(dirname(path), constants.W_OK);
|
||||
|
||||
await copyFile(backupPath, path);
|
||||
await unlink(backupPath);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (throwOnMissing) {
|
||||
throw new Error(
|
||||
error.code === 'ENOENT'
|
||||
? `Backup file does not exist: ${backupPath}`
|
||||
: error.code === 'EACCES'
|
||||
? `Permission denied: ${path}`
|
||||
: `Failed to restore file: ${error.message}`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user