diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/generic-modification.spec.ts b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/generic-modification.spec.ts index 3ec940480..7716b2e96 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/generic-modification.spec.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/generic-modification.spec.ts @@ -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) => { diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/default-azure-css.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/default-azure-css.modification.ts new file mode 100644 index 000000000..53f22bca6 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/default-azure-css.modification.ts @@ -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 { + // 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 { + 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}`; + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.spec.ts b/api/src/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.spec.ts new file mode 100644 index 000000000..ca4fa0148 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.spec.ts @@ -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'); + }); +}); diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.ts index 65bdfebc5..82eaadbfe 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/default-base-css.modification.ts @@ -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 { + // 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 { 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}`; } diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/default-black-css.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/default-black-css.modification.ts new file mode 100644 index 000000000..47e01b7ee --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/default-black-css.modification.ts @@ -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 { + // 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 { + 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}`; + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/default-cfg.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/default-cfg.modification.ts new file mode 100644 index 000000000..a48a0f0ba --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/default-cfg.modification.ts @@ -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 { + // 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 { + 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); + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/default-gray-css.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/default-gray-css.modification.ts new file mode 100644 index 000000000..036d3e401 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/default-gray-css.modification.ts @@ -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 { + // 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 { + 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}`; + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/default-white-css.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/default-white-css.modification.ts new file mode 100644 index 000000000..528375457 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/default-white-css.modification.ts @@ -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 { + // 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 { + 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}`; + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.ts index 98f16fa61..c09f2bdfe 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.ts @@ -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 { + // 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 { 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 = ''; + const bottomCenterOption = + ' '; + const topCenterOption = ' '; + + if (newContent.includes(positionSelectStart) && !newContent.includes(bottomCenterOption)) { + newContent = newContent.replace( + '', + '\n' + + bottomCenterOption + + '\n' + + topCenterOption + ); + } + + // Add Stack/Duration/Max settings + const helpAnchor = ':notifications_display_position_help:'; + const newSettings = ` +: + _(Stack notifications)_: +: + +:notifications_stack_help: + +_(Duration)_: +: + +:notifications_duration_help: + +_(Max notifications)_: +: + +: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; } } diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/notify-php.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/notify-php.modification.ts new file mode 100644 index 000000000..f916730ce --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/notify-php.modification.ts @@ -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 { + // 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 { + 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); + } +} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/notify-script.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/notify-script.modification.ts new file mode 100644 index 000000000..5f4bb45d9 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/notify-script.modification.ts @@ -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 { + // 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 { + 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','
',$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','
',$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); + } +} diff --git a/web/components.d.ts b/web/components.d.ts index ab060b8f7..c37fc1dc6 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -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']