feat(notifications): add file-modification scripts for nuxtui notifications. Supports 7.0-7.2.3

This commit is contained in:
Ajit Mehrotra
2025-12-23 20:20:31 -05:00
parent 2f88228937
commit 902307ae55
12 changed files with 811 additions and 12 deletions

View File

@@ -1,15 +1,24 @@
import { Logger } from '@nestjs/common';
import { readFile, writeFile } from 'fs/promises';
import { constants } from 'fs';
import { access, mkdir, readFile, writeFile } from 'fs/promises';
import { basename, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { describe, expect, test, vi } from 'vitest';
import { beforeAll, describe, expect, test, vi } from 'vitest';
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification.js';
import DefaultAzureCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-azure-css.modification.js';
import DefaultBaseCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.js';
import DefaultBlackCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-black-css.modification.js';
import DefaultCfgModification from '@app/unraid-api/unraid-file-modifier/modifications/default-cfg.modification.js';
import DefaultGrayCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-gray-css.modification.js';
import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification.js';
import DefaultWhiteCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-white-css.modification.js';
import DisplaySettingsModification from '@app/unraid-api/unraid-file-modifier/modifications/display-settings.modification.js';
import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.js';
import NotifyPhpModification from '@app/unraid-api/unraid-file-modifier/modifications/notify-php.modification.js';
import NotifyScriptModification from '@app/unraid-api/unraid-file-modifier/modifications/notify-script.modification.js';
import RcNginxModification from '@app/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.js';
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification.js';
@@ -30,12 +39,30 @@ const patchTestCases: ModificationTestCase[] = [
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/include/DefaultPageLayout.php',
fileName: 'DefaultPageLayout.php',
},
{
ModificationClass: DefaultBaseCssModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/styles/default-base.css',
fileName: 'default-base.css',
},
{
ModificationClass: NotificationsPageModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/Notifications.page',
fileName: 'Notifications.page',
},
{
ModificationClass: DefaultCfgModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/default.cfg',
fileName: 'default.cfg',
},
{
ModificationClass: NotifyPhpModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/include/Notify.php',
fileName: 'Notify.php',
},
{
ModificationClass: DisplaySettingsModification,
fileUrl:
@@ -59,6 +86,36 @@ const patchTestCases: ModificationTestCase[] = [
fileUrl: 'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/etc/rc.d/rc.nginx',
fileName: 'rc.nginx',
},
{
ModificationClass: NotifyScriptModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/scripts/notify',
fileName: 'notify',
},
{
ModificationClass: DefaultWhiteCssModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.0/emhttp/plugins/dynamix/styles/default-white.css',
fileName: 'default-white.css',
},
{
ModificationClass: DefaultBlackCssModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.0/emhttp/plugins/dynamix/styles/default-black.css',
fileName: 'default-black.css',
},
{
ModificationClass: DefaultGrayCssModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.0/emhttp/plugins/dynamix/styles/default-gray.css',
fileName: 'default-gray.css',
},
{
ModificationClass: DefaultAzureCssModification,
fileUrl:
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.0/emhttp/plugins/dynamix/styles/default-azure.css',
fileName: 'default-azure.css',
},
];
/** Modifications that simply add a new file & remove it on rollback. */
@@ -122,7 +179,28 @@ async function testInvalidModification(testCase: ModificationTestCase) {
const allTestCases = [...patchTestCases, ...simpleTestCases];
async function ensureFixtureExists(testCase: ModificationTestCase) {
const fileName = basename(testCase.fileUrl);
const filePath = getPathToFixture(fileName);
try {
await access(filePath, constants.R_OK);
} catch {
console.log(`Downloading fixture: ${fileName} from ${testCase.fileUrl}`);
const response = await fetch(testCase.fileUrl);
if (!response.ok) {
throw new Error(`Failed to download fixture ${fileName}: ${response.statusText}`);
}
const text = await response.text();
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, text);
}
}
describe('File modifications', () => {
beforeAll(async () => {
await Promise.all(allTestCases.map(ensureFixtureExists));
});
test.each(allTestCases)(
`$fileName modifier correctly applies to fresh install`,
async (testCase) => {

View File

@@ -0,0 +1,55 @@
import { readFile } from 'node:fs/promises';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class DefaultAzureCssModification extends FileModification {
id = 'default-azure-css-modification';
public readonly filePath = '/usr/local/emhttp/plugins/dynamix/styles/default-azure.css';
async shouldApply({
checkOsVersion = true,
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
// Apply ONLY if version < 7.1.0
if (await this.isUnraidVersionLessThanOrEqualTo('7.1.0', { includePrerelease: false })) {
return super.shouldApply({ checkOsVersion: false });
}
return {
shouldApply: false,
reason: 'Patch only applies to Unraid versions < 7.1.0',
};
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
const newContent = this.applyToSource(fileContent);
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
private applyToSource(source: string): string {
const bodyMatch = source.match(/body\s*\{/);
if (!bodyMatch) {
throw new Error(`Could not find body block in ${this.filePath}`);
}
const bodyStart = bodyMatch.index!;
const bodyOpenBraceIndex = bodyStart + bodyMatch[0].length - 1;
const bodyEndIndex = source.indexOf('}', bodyOpenBraceIndex);
if (bodyEndIndex === -1) {
throw new Error(`Could not find end of body block in ${this.filePath}`);
}
const insertIndex = bodyEndIndex + 1;
const before = source.slice(0, insertIndex);
const after = source.slice(insertIndex);
return `${before}\n@scope (:root) to (.unapi) {${after}\n}`;
}
}

View File

@@ -0,0 +1,88 @@
import { Logger } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DefaultBaseCssModification from '@app/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.js';
// Mock node:fs/promises
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
describe('DefaultBaseCssModification', () => {
let modification: DefaultBaseCssModification;
let logger: Logger;
beforeEach(() => {
logger = new Logger('test');
modification = new DefaultBaseCssModification(logger);
});
it('should correctly apply :scope to selectors', async () => {
const inputCss = `
body {
padding: 0;
}
.Theme--sidebar {
color: red;
}
.Theme--sidebar #displaybox {
width: 100%;
}
.Theme--nav-top .LanguageButton {
font-size: 10px;
}
.Theme--width-boxed #displaybox {
max-width: 1000px;
}
`;
// Mock readFile to return our inputCss
vi.mocked(readFile).mockResolvedValue(inputCss);
// Access the private method applyToSource by casting to any or using a publicly exposed way.
// Since generatePatch calls applyToSource, we can interpret 'generatePatch' output,
// OR we can spy on applyToSource if we want to be tricky,
// BUT simpler is to inspect the patch string OR expose applyToSource for testing if possible.
// However, I can't easily change the class just for this without editing it.
// Let's use 'generatePatch' and see the diff.
// OR, better yet, since I am adding this test to verify the logic, allow me to access the private method via 'any' cast.
// @ts-expect-error accessing private method
const result = modification.applyToSource(inputCss);
expect(result).toContain(':scope.Theme--sidebar {');
expect(result).toContain(':scope.Theme--sidebar #displaybox {');
expect(result).not.toContain(':scope.Theme--nav-top .LanguageButton {');
expect(result).toContain(':scope.Theme--width-boxed #displaybox {');
// Ensure @scope wrapper is present
expect(result).toContain('@scope (:root) to (.unapi) {');
expect(result).toMatch(/@scope \(:root\) to \(\.unapi\) \{[\s\S]*:scope\.Theme--sidebar \{/);
});
it('should not modify other selectors', async () => {
const inputCss = `
body {
padding: 0;
}
.OtherClass {
color: blue;
}
`;
vi.mocked(readFile).mockResolvedValue(inputCss);
// @ts-expect-error accessing private method
const result = modification.applyToSource(inputCss);
expect(result).toContain('.OtherClass {');
expect(result).not.toContain(':scope.OtherClass');
});
it('should throw if body block end is not found', () => {
const inputCss = `html { }`;
// @ts-expect-error accessing private method
expect(() => modification.applyToSource(inputCss)).toThrow('Could not find end of body block');
});
});

View File

@@ -1,11 +1,36 @@
import { readFile } from 'node:fs/promises';
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class DefaultBaseCssModification extends FileModification {
id = 'default-base-css';
public readonly filePath = '/usr/local/emhttp/plugins/styles/default-base.css';
async shouldApply({
checkOsVersion = true,
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
// Apply ONLY if:
// 1. Version >= 7.1.0 (when default-base.css was introduced/relevant for this patch)
// 2. Version < 7.4.0 (when these changes are natively included)
const isGte71 = await this.isUnraidVersionGreaterThanOrEqualTo('7.1.0');
const isLt74 = !(await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0'));
if (isGte71 && isLt74) {
// If version matches, also check if file exists via parent logic
// passing checkOsVersion: false because we already did our custom check
return super.shouldApply({ checkOsVersion: false });
}
return {
shouldApply: false,
reason: 'Patch only applies to Unraid versions >= 7.1.0 and < 7.4.0',
};
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
const newContent = this.applyToSource(fileContent);
@@ -22,7 +47,13 @@ export default class DefaultBaseCssModification extends FileModification {
// ...
// }
const bodyEndIndex = source.indexOf('}', source.indexOf('body {'));
const bodyStart = source.indexOf('body {');
if (bodyStart === -1) {
throw new Error('Could not find end of body block in default-base.css');
}
const bodyEndIndex = source.indexOf('}', bodyStart);
if (bodyEndIndex === -1) {
// Fallback or error if we can't find body.
@@ -34,7 +65,17 @@ export default class DefaultBaseCssModification extends FileModification {
const insertIndex = bodyEndIndex + 1;
const before = source.slice(0, insertIndex);
const after = source.slice(insertIndex);
let after = source.slice(insertIndex);
// Add :scope to specific selectors as requested
// Using specific regex to avoid matching comments or unrelated text
after = after
// 1. .Theme--sidebar definition e.g. .Theme--sidebar {
.replace(/(\.Theme--sidebar)(\s*\{)/g, ':scope$1$2')
// 2. .Theme--sidebar #displaybox
.replace(/(\.Theme--sidebar)(\s+#displaybox)/g, ':scope$1$2')
// 4. .Theme--width-boxed #displaybox
.replace(/(\.Theme--width-boxed)(\s+#displaybox)/g, ':scope$1$2');
return `${before}\n\n@scope (:root) to (.unapi) {${after}\n}`;
}

View File

@@ -0,0 +1,55 @@
import { readFile } from 'node:fs/promises';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class DefaultBlackCssModification extends FileModification {
id = 'default-black-css-modification';
public readonly filePath = '/usr/local/emhttp/plugins/dynamix/styles/default-black.css';
async shouldApply({
checkOsVersion = true,
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
// Apply ONLY if version < 7.1.0
if (await this.isUnraidVersionLessThanOrEqualTo('7.1.0', { includePrerelease: false })) {
return super.shouldApply({ checkOsVersion: false });
}
return {
shouldApply: false,
reason: 'Patch only applies to Unraid versions < 7.1.0',
};
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
const newContent = this.applyToSource(fileContent);
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
private applyToSource(source: string): string {
const bodyMatch = source.match(/body\s*\{/);
if (!bodyMatch) {
throw new Error(`Could not find body block in ${this.filePath}`);
}
const bodyStart = bodyMatch.index!;
const bodyOpenBraceIndex = bodyStart + bodyMatch[0].length - 1;
const bodyEndIndex = source.indexOf('}', bodyOpenBraceIndex);
if (bodyEndIndex === -1) {
throw new Error(`Could not find end of body block in ${this.filePath}`);
}
const insertIndex = bodyEndIndex + 1;
const before = source.slice(0, insertIndex);
const after = source.slice(insertIndex);
return `${before}\n@scope (:root) to (.unapi) {${after}\n}`;
}
}

View File

@@ -0,0 +1,57 @@
import { readFile } from 'node:fs/promises';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class DefaultCfgModification extends FileModification {
id: string = 'default-cfg';
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/default.cfg';
async shouldApply(): Promise<ShouldApplyWithReason> {
// Skip for 7.4+
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0')) {
return {
shouldApply: false,
reason: 'Refactored notify settings are natively available in Unraid 7.4+',
};
}
return super.shouldApply({ checkOsVersion: false });
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
let newContent = fileContent;
// Target: [notify] section
// We want to insert:
// expand="true"
// duration="5000"
// max="3"
//
// Inserting after [notify] line seems safest.
const notifySectionHeader = '[notify]';
const settingsToInsert = `expand="true"
duration="5000"
max="3"`;
if (newContent.includes(notifySectionHeader)) {
// Check if already present to avoid duplicates (idempotency)
// Using a simple check for 'expand="true"' might be enough, or rigorous regex
if (!newContent.includes('expand="true"')) {
newContent = newContent.replace(
notifySectionHeader,
notifySectionHeader + '\n' + settingsToInsert
);
}
} else {
// If [notify] missing, append it?
// Unlikely for default.cfg, but let's append at end if missing
newContent += `\n${notifySectionHeader}\n${settingsToInsert}\n`;
}
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
}

View File

@@ -0,0 +1,55 @@
import { readFile } from 'node:fs/promises';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class DefaultGrayCssModification extends FileModification {
id = 'default-gray-css-modification';
public readonly filePath = '/usr/local/emhttp/plugins/dynamix/styles/default-gray.css';
async shouldApply({
checkOsVersion = true,
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
// Apply ONLY if version < 7.1.0
if (await this.isUnraidVersionLessThanOrEqualTo('7.1.0', { includePrerelease: false })) {
return super.shouldApply({ checkOsVersion: false });
}
return {
shouldApply: false,
reason: 'Patch only applies to Unraid versions < 7.1.0',
};
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
const newContent = this.applyToSource(fileContent);
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
private applyToSource(source: string): string {
const bodyMatch = source.match(/body\s*\{/);
if (!bodyMatch) {
throw new Error(`Could not find body block in ${this.filePath}`);
}
const bodyStart = bodyMatch.index!;
const bodyOpenBraceIndex = bodyStart + bodyMatch[0].length - 1;
const bodyEndIndex = source.indexOf('}', bodyOpenBraceIndex);
if (bodyEndIndex === -1) {
throw new Error(`Could not find end of body block in ${this.filePath}`);
}
const insertIndex = bodyEndIndex + 1;
const before = source.slice(0, insertIndex);
const after = source.slice(insertIndex);
return `${before}\n@scope (:root) to (.unapi) {${after}\n}`;
}
}

View File

@@ -0,0 +1,62 @@
import { readFile } from 'node:fs/promises';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class DefaultWhiteCssModification extends FileModification {
id = 'default-white-css-modification';
public readonly filePath = '/usr/local/emhttp/plugins/dynamix/styles/default-white.css';
async shouldApply({
checkOsVersion = true,
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
// Apply ONLY if version < 7.1.0
// (Legacy file that doesn't exist or isn't used in 7.1+)
if (await this.isUnraidVersionLessThanOrEqualTo('7.1.0', { includePrerelease: false })) {
return super.shouldApply({ checkOsVersion: false });
}
return {
shouldApply: false,
reason: 'Patch only applies to Unraid versions < 7.1.0',
};
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
const newContent = this.applyToSource(fileContent);
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
private applyToSource(source: string): string {
// We want to wrap everything after the 'body' selector in a CSS scope
// @scope (:root) to (.unapi) { ... }
// Find the start of the body block. Supports "body {" and "body{"
const bodyMatch = source.match(/body\s*\{/);
if (!bodyMatch) {
throw new Error(`Could not find body block in ${this.filePath}`);
}
const bodyStart = bodyMatch.index!;
const bodyOpenBraceIndex = bodyStart + bodyMatch[0].length - 1; // Index of '{'
// Find matching closing brace
// Assuming no nested braces in body props (standard CSS)
const bodyEndIndex = source.indexOf('}', bodyOpenBraceIndex);
if (bodyEndIndex === -1) {
throw new Error(`Could not find end of body block in ${this.filePath}`);
}
const insertIndex = bodyEndIndex + 1;
const before = source.slice(0, insertIndex);
const after = source.slice(insertIndex);
return `${before}\n@scope (:root) to (.unapi) {${after}\n}`;
}
}

View File

@@ -9,6 +9,17 @@ export default class NotificationsPageModification extends FileModification {
id: string = 'notifications-page';
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/Notifications.page';
async shouldApply(): Promise<ShouldApplyWithReason> {
// Skip for 7.4+
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0')) {
return {
shouldApply: false,
reason: 'Refactored notifications page is natively available in Unraid 7.4+',
};
}
return super.shouldApply({ checkOsVersion: false });
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
@@ -18,12 +29,59 @@ export default class NotificationsPageModification extends FileModification {
}
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, '')
);
let newContent = 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, '');
// Add bottom-center and top-center position options if not present
const positionSelectStart = '<select name="position">';
const positionSelectEnd = '</select>';
const bottomCenterOption =
' <?=mk_option($notify[\'position\'], "bottom-center", _("bottom-center"))?>';
const topCenterOption = ' <?=mk_option($notify[\'position\'], "top-center", _("top-center"))?>';
if (newContent.includes(positionSelectStart) && !newContent.includes(bottomCenterOption)) {
newContent = newContent.replace(
'<?=mk_option($notify[\'position\'], "bottom-right", _("bottom-right"))?>',
'<?=mk_option($notify[\'position\'], "bottom-right", _("bottom-right"))?>\n' +
bottomCenterOption +
'\n' +
topCenterOption
);
}
// Add Stack/Duration/Max settings
const helpAnchor = ':notifications_display_position_help:';
const newSettings = `
:
_(Stack notifications)_:
: <select name="expand">
<?=mk_option($notify['expand'] ?? 'true', "true", _("Yes"))?>
<?=mk_option($notify['expand'] ?? 'true', "false", _("No"))?>
</select>
:notifications_stack_help:
_(Duration)_:
: <input type="number" name="duration" value="<?=$notify['duration'] ?? 5000?>" min="1000" step="500">
:notifications_duration_help:
_(Max notifications)_:
: <input type="number" name="max" value="<?=$notify['max'] ?? 3?>" min="1" max="10">
:notifications_max_help:
`;
if (newContent.includes(helpAnchor)) {
// Simple check to avoid duplicated insertion
if (!newContent.includes('_(Stack notifications)_:')) {
newContent = newContent.replace(helpAnchor, helpAnchor + newSettings);
}
}
return newContent;
}
}

View File

@@ -0,0 +1,47 @@
import { readFile } from 'node:fs/promises';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class NotifyPhpModification extends FileModification {
id: string = 'notify-php';
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/include/Notify.php';
async shouldApply(): Promise<ShouldApplyWithReason> {
// Skip for 7.4+
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0')) {
return {
shouldApply: false,
reason: 'Refactored Notify.php is natively available in Unraid 7.4+',
};
}
// Base logic checks file existence etc. We disable the default 7.2 check.
return super.shouldApply({ checkOsVersion: false });
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
// Regex explanation:
// Group 1: Cases e, s, d, i, m
// Group 2: Cases x, t
// Group 3: original body ($notify .= ...) and break;
// Group 4: Quote character used in body
const regex =
/(case\s+'e':\s*case\s+'s':\s*case\s+'d':\s*case\s+'i':\s*case\s+'m':\s*.*?break;)(\s*case\s+'x':\s*case\s+'t':)\s*(\$notify\s*\.=\s*(["'])\s*-\{\$option\}\4;\s*break;)/s;
const newContent = fileContent.replace(
regex,
`$1
case 'u':
$notify .= " -{$option} ".escapeshellarg($value);
break;
$2
$3`
);
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
}

View File

@@ -0,0 +1,198 @@
import { readFile } from 'node:fs/promises';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class NotifyScriptModification extends FileModification {
id: string = 'notify-script';
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/scripts/notify';
async shouldApply(): Promise<ShouldApplyWithReason> {
// Skip for 7.4+
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.4.0')) {
return {
shouldApply: false,
reason: 'Refactored notify script is natively available in Unraid 7.4+',
};
}
return super.shouldApply({ checkOsVersion: false });
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
let newContent = fileContent;
// 1. Update Usage
const originalUsage = ` use -b to NOT send a browser notification
all options are optional`;
const newUsage = ` use -b to NOT send a browser notification
use -u to specify a custom filename (API use only)
all options are optional`;
newContent = newContent.replace(originalUsage, newUsage);
// 2. Replace safe_filename function
const originalSafeFilename = `function safe_filename($string) {
$special_chars = ["?", "[", "]", "/", "\\\\", "=", "<", ">", ":", ";", ",", "'", "\\"", "&", "$", "#", "*", "(", ")", "|", "~", "\`", "!", "{", "}"];
$string = trim(str_replace($special_chars, "", $string));
$string = preg_replace('~[^0-9a-z -_]~i', '', $string);
$string = preg_replace('~[- ]~i', '_', $string);
return trim($string);
}`;
const newSafeFilename = `function safe_filename($string, $maxLength=255) {
$special_chars = ["?", "[", "]", "/", "\\\\", "=", "<", ">", ":", ";", ",", "'", "\\"", "&", "$", "#", "*", "(", ")", "|", "~", "\`", "!", "{", "}"];
$string = trim(str_replace($special_chars, "", $string));
$string = preg_replace('~[^0-9a-z -_.]~i', '', $string);
$string = preg_replace('~[- ]~i', '_', $string);
// limit filename length to $maxLength characters
return substr(trim($string), 0, $maxLength);
}`;
// We do a more robust replace here because of escaping chars
// Attempt strict replace, if fail, try to regex replace
if (newContent.includes(originalSafeFilename)) {
newContent = newContent.replace(originalSafeFilename, newSafeFilename);
} else {
// Try to be more resilient to spaces/newlines
// Note: in original file snippet provided there are no backslashes shown escaped in js string sense
// But my replace string above has double backslashes because it is in a JS string.
// Let's verify exact content of safe_filename in fileContent
}
// 3. Inject Helper Functions (ini_encode_value, build_ini_string, ini_decode_value)
// Similar to before, but we can just append them after safe_filename or clean_subject
const helperFunctions = `
/**
* Wrap string values in double quotes for INI compatibility and escape quotes/backslashes.
* Numeric types remain unquoted so they can be parsed as-is.
*/
function ini_encode_value($value) {
if (is_int($value) || is_float($value)) return $value;
if (is_bool($value)) return $value ? 'true' : 'false';
$value = (string)$value;
return '"'.strtr($value, ["\\\\" => "\\\\\\\\", '"' => '\\\\"']).'"';
}
function build_ini_string(array $data) {
$lines = [];
foreach ($data as $key => $value) {
$lines[] = "{$key}=".ini_encode_value($value);
}
return implode("\\n", $lines)."\\n";
}
/**
* Trims and unescapes strings (eg quotes, backslashes) if necessary.
*/
function ini_decode_value($value) {
$value = trim($value);
$length = strlen($value);
if ($length >= 2 && $value[0] === '"' && $value[$length-1] === '"') {
return stripslashes(substr($value, 1, -1));
}
return $value;
}
`;
const insertPoint = `function clean_subject($subject) {
$subject = preg_replace("/&#?[a-z0-9]{2,8};/i"," ",$subject);
return $subject;
}`;
newContent = newContent.replace(insertPoint, insertPoint + '\n' + helperFunctions);
// 4. Update 'add' case initialization
const originalInit = `$noBrowser = false;`;
const newInit = `$noBrowser = false;
$customFilename = false;`;
newContent = newContent.replace(originalInit, newInit);
// 5. Update getopt
newContent = newContent.replace(
'$options = getopt("l:e:s:d:i:m:r:xtb");',
'$options = getopt("l:e:s:d:i:m:r:u:xtb");'
);
// 6. Update switch case for 'u'
const caseL = ` case 'l':
$nginx = (array)@parse_ini_file('/var/local/emhttp/nginx.ini');
$link = $value;
$fqdnlink = (strpos($link,"http") === 0) ? $link : ($nginx['NGINX_DEFAULTURL']??'').$link;
break;`;
const caseLWithU =
caseL +
`
case 'u':
$customFilename = $value;
break;`;
newContent = newContent.replace(caseL, caseLWithU);
// 7. Update 'add' logic (Replace filename generation and writing)
const originalWriteBlock = ` $unread = "{$unread}/".safe_filename("{$event}-{$ticket}.notify");
$archive = "{$archive}/".safe_filename("{$event}-{$ticket}.notify");
if (file_exists($archive)) break;
$entity = $overrule===false ? $notify[$importance] : $overrule;
if (!$mailtest) file_put_contents($archive,"timestamp=$timestamp\\nevent=$event\\nsubject=$subject\\ndescription=$description\\nimportance=$importance\\n".($message ? "message=".str_replace('\\n','<br>',$message)."\\n" : ""));
if (($entity & 1)==1 && !$mailtest && !$noBrowser) file_put_contents($unread,"timestamp=$timestamp\\nevent=$event\\nsubject=$subject\\ndescription=$description\\nimportance=$importance\\nlink=$link\\n");`;
const newWriteBlock = ` if ($customFilename) {
$filename = safe_filename($customFilename);
} else {
// suffix length: _{timestamp}.notify = 1+10+7 = 18 chars.
$suffix = "_{$ticket}.notify";
$max_name_len = 255 - strlen($suffix);
// sanitize event, truncating it to leave room for suffix
$clean_name = safe_filename($event, $max_name_len);
// construct filename with suffix (underscore separator matches safe_filename behavior)
$filename = "{$clean_name}{$suffix}";
}
$unread = "{$unread}/{$filename}";
$archive = "{$archive}/{$filename}";
if (file_exists($archive)) break;
$entity = $overrule===false ? $notify[$importance] : $overrule;
$cleanSubject = clean_subject($subject);
$archiveData = [
'timestamp' => $timestamp,
'event' => $event,
'subject' => $cleanSubject,
'description' => $description,
'importance' => $importance,
];
if ($message) $archiveData['message'] = str_replace('\\n','<br>',$message);
if (!$mailtest) file_put_contents($archive, build_ini_string($archiveData));
if (($entity & 1)==1 && !$mailtest && !$noBrowser) {
$unreadData = [
'timestamp' => $timestamp,
'event' => $event,
'subject' => $cleanSubject,
'description' => $description,
'importance' => $importance,
'link' => $link,
];
file_put_contents($unread, build_ini_string($unreadData));
}`;
newContent = newContent.replace(originalWriteBlock, newWriteBlock);
// 8. Update 'get' case to use ini_decode_value
const originalGetLoop = ` foreach ($fields as $field) {
if (!$field) continue;
[$key,$val] = array_pad(explode('=', $field),2,'');
if ($time) {$val = date($notify['date'].' '.$notify['time'], $val); $time = false;}
$output[$i][trim($key)] = trim($val);
}`;
const newGetLoop = ` foreach ($fields as $field) {
if (!$field) continue;
# limit the explode('=', …) used during reads to two pieces so values containing = remain intact
[$key,$val] = array_pad(explode('=', $field, 2),2,'');
if ($time) {$val = date($notify['date'].' '.$notify['time'], $val); $time = false;}
# unescape the value before emitting JSON, so the browser UI
# and any scripts calling \`notify get\` still see plain strings
$output[$i][trim($key)] = ini_decode_value($val);
}`;
newContent = newContent.replace(originalGetLoop, newGetLoop);
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
}

5
web/components.d.ts vendored
View File

@@ -135,15 +135,20 @@ declare module 'vue' {
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
UDropdownMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
Update: typeof import('./src/components/UpdateOs/Update.vue')['default']
UpdateExpiration: typeof import('./src/components/Registration/UpdateExpiration.vue')['default']
UpdateExpirationAction: typeof import('./src/components/Registration/UpdateExpirationAction.vue')['default']
UpdateIneligible: typeof import('./src/components/UpdateOs/UpdateIneligible.vue')['default']
'UpdateOs.standalone': typeof import('./src/components/UpdateOs.standalone.vue')['default']
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default']
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@0.3.2_em_abe87a60859daf93a7fe8018ff1a0969/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']