diff --git a/api/src/core/utils/__test__/safe-mode.test.ts b/api/src/core/utils/__test__/safe-mode.test.ts new file mode 100644 index 0000000000..c19b3290a4 --- /dev/null +++ b/api/src/core/utils/__test__/safe-mode.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { isSafeModeEnabled } from '@app/core/utils/safe-mode.js'; +import { store } from '@app/store/index.js'; +import * as stateFileLoader from '@app/store/services/state-file-loader.js'; + +describe('isSafeModeEnabled', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the safe mode flag already present in the store', () => { + const baseState = store.getState(); + vi.spyOn(store, 'getState').mockReturnValue({ + ...baseState, + emhttp: { + ...baseState.emhttp, + var: { + ...(baseState.emhttp?.var ?? {}), + safeMode: true, + }, + }, + }); + const loaderSpy = vi.spyOn(stateFileLoader, 'loadStateFileSync'); + + expect(isSafeModeEnabled()).toBe(true); + expect(loaderSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the synchronous loader when store state is missing', () => { + const baseState = store.getState(); + vi.spyOn(store, 'getState').mockReturnValue({ + ...baseState, + emhttp: { + ...baseState.emhttp, + var: { + ...(baseState.emhttp?.var ?? {}), + safeMode: undefined as unknown as boolean, + } as typeof baseState.emhttp.var, + } as typeof baseState.emhttp, + } as typeof baseState); + vi.spyOn(stateFileLoader, 'loadStateFileSync').mockReturnValue({ + ...(baseState.emhttp?.var ?? {}), + safeMode: true, + } as any); + + expect(isSafeModeEnabled()).toBe(true); + }); + + it('defaults to false when loader cannot provide state', () => { + const baseState = store.getState(); + vi.spyOn(store, 'getState').mockReturnValue({ + ...baseState, + emhttp: { + ...baseState.emhttp, + var: { + ...(baseState.emhttp?.var ?? {}), + safeMode: undefined as unknown as boolean, + } as typeof baseState.emhttp.var, + } as typeof baseState.emhttp, + } as typeof baseState); + vi.spyOn(stateFileLoader, 'loadStateFileSync').mockReturnValue(null); + + expect(isSafeModeEnabled()).toBe(false); + }); +}); diff --git a/api/src/core/utils/safe-mode.ts b/api/src/core/utils/safe-mode.ts new file mode 100644 index 0000000000..914dc7eded --- /dev/null +++ b/api/src/core/utils/safe-mode.ts @@ -0,0 +1,17 @@ +import { store } from '@app/store/index.js'; +import { loadStateFileSync } from '@app/store/services/state-file-loader.js'; +import { StateFileKey } from '@app/store/types.js'; + +export const isSafeModeEnabled = (): boolean => { + const safeModeFromStore = store.getState().emhttp?.var?.safeMode; + if (typeof safeModeFromStore === 'boolean') { + return safeModeFromStore; + } + + const varState = loadStateFileSync(StateFileKey.var); + if (varState) { + return Boolean(varState.safeMode); + } + + return false; +}; diff --git a/api/src/store/modules/emhttp.ts b/api/src/store/modules/emhttp.ts index 4b2905d6e3..d7f4830f62 100644 --- a/api/src/store/modules/emhttp.ts +++ b/api/src/store/modules/emhttp.ts @@ -163,6 +163,18 @@ export const loadStateFiles = createAsyncThunk< return state; }); +const stateFieldKeyMap: Record = { + [StateFileKey.var]: 'var', + [StateFileKey.devs]: 'devices', + [StateFileKey.network]: 'networks', + [StateFileKey.nginx]: 'nginx', + [StateFileKey.shares]: 'shares', + [StateFileKey.disks]: 'disks', + [StateFileKey.users]: 'users', + [StateFileKey.sec]: 'smbShares', + [StateFileKey.sec_nfs]: 'nfsShares', +}; + export const emhttp = createSlice({ name: 'emhttp', initialState, @@ -175,7 +187,8 @@ export const emhttp = createSlice({ }> ) { const { field } = action.payload; - return Object.assign(state, { [field]: action.payload.state }); + const targetField = stateFieldKeyMap[field] ?? (field as keyof SliceState); + return Object.assign(state, { [targetField]: action.payload.state }); }, }, extraReducers(builder) { diff --git a/api/src/store/services/__test__/state-file-loader.test.ts b/api/src/store/services/__test__/state-file-loader.test.ts new file mode 100644 index 0000000000..d699cdcab3 --- /dev/null +++ b/api/src/store/services/__test__/state-file-loader.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { store } from '@app/store/index.js'; +import { loadStateFileSync } from '@app/store/services/state-file-loader.js'; +import { StateFileKey } from '@app/store/types.js'; + +const VAR_FIXTURE = readFileSync(new URL('../../../../dev/states/var.ini', import.meta.url), 'utf-8'); + +const writeVarFixture = (dir: string, safeMode: 'yes' | 'no') => { + const content = VAR_FIXTURE.replace(/safeMode="(yes|no)"/, `safeMode="${safeMode}"`); + writeFileSync(join(dir, `${StateFileKey.var}.ini`), content); +}; + +describe('loadStateFileSync', () => { + let tempDir: string; + let baseState: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'state-file-')); + baseState = store.getState(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('loads var.ini, updates the store, and returns the parsed state', () => { + writeVarFixture(tempDir, 'yes'); + vi.spyOn(store, 'getState').mockReturnValue({ + ...baseState, + paths: { + ...baseState.paths, + states: tempDir, + }, + }); + const dispatchSpy = vi.spyOn(store, 'dispatch').mockImplementation((action) => action as any); + + const result = loadStateFileSync(StateFileKey.var); + + expect(result?.safeMode).toBe(true); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emhttp/updateEmhttpState', + payload: { + field: StateFileKey.var, + state: expect.objectContaining({ safeMode: true }), + }, + }) + ); + }); + + it('returns null when the states path is missing', () => { + vi.spyOn(store, 'getState').mockReturnValue({ + ...baseState, + paths: undefined, + } as any); + const dispatchSpy = vi.spyOn(store, 'dispatch'); + + expect(loadStateFileSync(StateFileKey.var)).toBeNull(); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('returns null when the requested state file cannot be found', () => { + vi.spyOn(store, 'getState').mockReturnValue({ + ...baseState, + paths: { + ...baseState.paths, + states: tempDir, + }, + }); + const dispatchSpy = vi.spyOn(store, 'dispatch'); + + expect(loadStateFileSync(StateFileKey.var)).toBeNull(); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/store/services/state-file-loader.ts b/api/src/store/services/state-file-loader.ts new file mode 100644 index 0000000000..3f68f86184 --- /dev/null +++ b/api/src/store/services/state-file-loader.ts @@ -0,0 +1,81 @@ +import { join } from 'node:path'; + +import type { SliceState } from '@app/store/modules/emhttp.js'; +import type { StateFileToIniParserMap } from '@app/store/types.js'; +import { parseConfig } from '@app/core/utils/misc/parse-config.js'; +import { store } from '@app/store/index.js'; +import { updateEmhttpState } from '@app/store/modules/emhttp.js'; +import { parse as parseDevices } from '@app/store/state-parsers/devices.js'; +import { parse as parseNetwork } from '@app/store/state-parsers/network.js'; +import { parse as parseNfs } from '@app/store/state-parsers/nfs.js'; +import { parse as parseNginx } from '@app/store/state-parsers/nginx.js'; +import { parse as parseShares } from '@app/store/state-parsers/shares.js'; +import { parse as parseSlots } from '@app/store/state-parsers/slots.js'; +import { parse as parseSmb } from '@app/store/state-parsers/smb.js'; +import { parse as parseUsers } from '@app/store/state-parsers/users.js'; +import { parse as parseVar } from '@app/store/state-parsers/var.js'; +import { StateFileKey } from '@app/store/types.js'; + +type ParserReturnMap = { + [StateFileKey.var]: ReturnType; + [StateFileKey.devs]: ReturnType; + [StateFileKey.network]: ReturnType; + [StateFileKey.nginx]: ReturnType; + [StateFileKey.shares]: ReturnType; + [StateFileKey.disks]: ReturnType; + [StateFileKey.users]: ReturnType; + [StateFileKey.sec]: ReturnType; + [StateFileKey.sec_nfs]: ReturnType; +}; + +const PARSER_MAP: { [K in StateFileKey]: StateFileToIniParserMap[K] } = { + [StateFileKey.var]: parseVar, + [StateFileKey.devs]: parseDevices, + [StateFileKey.network]: parseNetwork, + [StateFileKey.nginx]: parseNginx, + [StateFileKey.shares]: parseShares, + [StateFileKey.disks]: parseSlots, + [StateFileKey.users]: parseUsers, + [StateFileKey.sec]: parseSmb, + [StateFileKey.sec_nfs]: parseNfs, +}; + +/** + * Synchronously loads an emhttp state file, updates the Redux store slice, and returns the parsed state. + * + * Designed for bootstrap contexts (CLI, plugin loading, etc.) where dispatching the async thunks is + * impractical but we still need authoritative emhttp state from disk. + */ +export const loadStateFileSync = ( + stateFileKey: K +): ParserReturnMap[K] | null => { + const state = store.getState(); + const statesDirectory = state.paths?.states; + + if (!statesDirectory) { + return null; + } + + const filePath = join(statesDirectory, `${stateFileKey}.ini`); + + try { + const parser = PARSER_MAP[stateFileKey] as StateFileToIniParserMap[K]; + const rawConfig = parseConfig>({ + filePath, + type: 'ini', + }); + const config = rawConfig as Parameters[0]; + const parsed = (parser as (input: any) => ParserReturnMap[K])(config); + + store.dispatch( + updateEmhttpState({ + field: stateFileKey, + state: parsed as Partial, + }) + ); + + return parsed; + } catch (error) { + return null; + } +}; diff --git a/api/src/unraid-api/plugin/__test__/plugin.service.test.ts b/api/src/unraid-api/plugin/__test__/plugin.service.test.ts new file mode 100644 index 0000000000..e6783ec5c2 --- /dev/null +++ b/api/src/unraid-api/plugin/__test__/plugin.service.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as safeModeUtils from '@app/core/utils/safe-mode.js'; +import { PluginService } from '@app/unraid-api/plugin/plugin.service.js'; + +type PluginServicePrivateApi = { + plugins?: Promise; + importPlugins(): Promise; +}; + +const PrivatePluginService = PluginService as unknown as PluginServicePrivateApi; + +describe('PluginService.getPlugins safe mode handling', () => { + beforeEach(() => { + PrivatePluginService.plugins = undefined; + }); + + afterEach(() => { + PrivatePluginService.plugins = undefined; + vi.restoreAllMocks(); + }); + + it('returns an empty array and skips imports when safe mode is enabled', async () => { + const safeModeSpy = vi.spyOn(safeModeUtils, 'isSafeModeEnabled').mockReturnValue(true); + const importSpy = vi + .spyOn(PrivatePluginService, 'importPlugins') + .mockResolvedValue([{ name: 'example', version: '1.0.0' }]); + + const plugins = await PluginService.getPlugins(); + + expect(plugins).toEqual([]); + expect(safeModeSpy).toHaveBeenCalledTimes(1); + expect(importSpy).not.toHaveBeenCalled(); + }); + + it('loads plugins when safe mode is disabled', async () => { + const expected = [{ name: 'example', version: '1.0.0' }]; + vi.spyOn(safeModeUtils, 'isSafeModeEnabled').mockReturnValue(false); + const importSpy = vi.spyOn(PrivatePluginService, 'importPlugins').mockResolvedValue(expected); + + const plugins = await PluginService.getPlugins(); + + expect(plugins).toBe(expected); + expect(importSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/api/src/unraid-api/plugin/plugin.service.ts b/api/src/unraid-api/plugin/plugin.service.ts index 0e54734399..4e9ff7108b 100644 --- a/api/src/unraid-api/plugin/plugin.service.ts +++ b/api/src/unraid-api/plugin/plugin.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js'; import { pluginLogger } from '@app/core/log.js'; +import { isSafeModeEnabled } from '@app/core/utils/safe-mode.js'; import { getPackageJson } from '@app/environment.js'; import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js'; import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js'; @@ -20,7 +21,16 @@ export class PluginService { private static plugins: Promise | undefined; static async getPlugins() { - PluginService.plugins ??= PluginService.importPlugins(); + if (!PluginService.plugins) { + if (isSafeModeEnabled()) { + PluginService.logger.warn( + 'Safe mode enabled (vars.ini); skipping API plugin discovery and load.' + ); + PluginService.plugins = Promise.resolve([]); + } else { + PluginService.plugins = PluginService.importPlugins(); + } + } return PluginService.plugins; }