feat: initial patcher implementation using the diff tool

This commit is contained in:
Eli Bosley
2025-01-31 12:44:43 -05:00
parent 81d33f6b3a
commit 805bc5bfc0
17 changed files with 1782 additions and 328 deletions

17
api/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View 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',
};
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');
});
});
});

View File

@@ -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,

View File

@@ -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, '');
}
}

View File

@@ -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> {

View File

@@ -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, '')
);

View File

@@ -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> {

View 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);
}

View File

@@ -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 = [];
}
}

View File

@@ -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();
});
});
});

View File

@@ -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;
}
};