diff --git a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts index 5a1d2c450..d4b911f0d 100644 --- a/api/src/__test__/core/utils/files/config-file-normalizer.test.ts +++ b/api/src/__test__/core/utils/files/config-file-normalizer.test.ts @@ -10,72 +10,75 @@ test('it creates a FLASH config with NO OPTIONAL values', () => { const basicConfig = initialState; const config = getWriteableConfig(basicConfig, 'flash'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "", - "version": "", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "", + "version": "", + }, + "local": {}, + "notifier": { + "apikey": "", + }, + "remote": { + "accesstoken": "", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "upc": { + "apikey": "", + }, + } + `); }); test('it creates a MEMORY config with NO OPTIONAL values', () => { const basicConfig = initialState; const config = getWriteableConfig(basicConfig, 'memory'); expect(config).toMatchInlineSnapshot(` - { - "api": { - "extraOrigins": "", - "version": "", - }, - "connectionStatus": { - "minigraph": "PRE_INIT", - }, - "local": {}, - "notifier": { - "apikey": "", - }, - "remote": { - "accesstoken": "", - "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", - "apikey": "", - "avatar": "", - "dynamicRemoteAccessType": "DISABLED", - "email": "", - "idtoken": "", - "localApiKey": "", - "refreshtoken": "", - "regWizTime": "", - "username": "", - "wanaccess": "", - "wanport": "", - }, - "upc": { - "apikey": "", - }, - } - `); + { + "api": { + "extraOrigins": "", + "version": "", + }, + "connectionStatus": { + "minigraph": "PRE_INIT", + "upnpStatus": "", + }, + "local": {}, + "notifier": { + "apikey": "", + }, + "remote": { + "accesstoken": "", + "allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000", + "apikey": "", + "avatar": "", + "dynamicRemoteAccessType": "DISABLED", + "email": "", + "idtoken": "", + "localApiKey": "", + "refreshtoken": "", + "regWizTime": "", + "upnpEnabled": "", + "username": "", + "wanaccess": "", + "wanport": "", + }, + "upc": { + "apikey": "", + }, + } + `); }); test('it creates a FLASH config with OPTIONAL values', () => { diff --git a/api/src/consts.ts b/api/src/consts.ts index 96a41c69a..eb79bed01 100644 --- a/api/src/consts.ts +++ b/api/src/consts.ts @@ -68,6 +68,7 @@ export const JWKS_LOCAL_PAYLOAD: JSONWebKeySet = { }, ], }; + export const OAUTH_BASE_URL = 'https://cognito-idp.us-west-2.amazonaws.com/us-west-2_btSkhlsEk'; export const OAUTH_CLIENT_ID = '53ci4o48gac8vq5jepubkjmo36'; diff --git a/api/src/core/utils/files/config-file-normalizer.ts b/api/src/core/utils/files/config-file-normalizer.ts index 3ab27adce..d7f500913 100644 --- a/api/src/core/utils/files/config-file-normalizer.ts +++ b/api/src/core/utils/files/config-file-normalizer.ts @@ -1,95 +1,64 @@ -import { getAllowedOrigins } from '@app/common/allowed-origins'; -import { DynamicRemoteAccessType } from '@app/graphql/generated/api/types'; -import { - type SliceState as ConfigSliceState, - initialState, -} from '@app/store/modules/config'; -import { type RecursivePartial } from '@app/types'; -import type { - MyServersConfig, - MyServersConfigMemory, -} from '@app/types/my-servers-config'; import { isEqual } from 'lodash-es'; -export type ConfigType = 'flash' | 'memory'; -type ConfigObject = T extends 'flash' - ? MyServersConfig - : T extends 'memory' - ? MyServersConfigMemory - : never; -/** - * - * @param config Config to read from to create a new formatted server config to write - * @param mode 'flash' or 'memory', changes what fields are included in the writeable payload - * @returns - */ - -export const getWriteableConfig = ( - config: ConfigSliceState, - mode: T -): ConfigObject => { - // Get current state - const { api, local, notifier, remote, upc, connectionStatus } = config; +import { getAllowedOrigins } from '@app/common/allowed-origins'; +import { initialState } from '@app/store/modules/config'; +import { + MyServersConfig, + MyServersConfigMemory, + MyServersConfigMemorySchema, + MyServersConfigSchema, +} from '@app/types/my-servers-config'; - // Create new state - - const newState: ConfigObject = { - api: { - version: api?.version ?? initialState.api.version, - extraOrigins: api?.extraOrigins ?? initialState.api.extraOrigins, - }, - local: {}, - notifier: { - apikey: notifier.apikey ?? initialState.notifier.apikey, - }, +// Define ConfigType and ConfigObject +export type ConfigType = 'flash' | 'memory'; + +/** + * Get a writeable configuration based on the mode ('flash' or 'memory'). + */ +export const getWriteableConfig = ( + config: T extends 'memory' ? MyServersConfigMemory : MyServersConfig, + mode: T +): T extends 'memory' ? MyServersConfigMemory : MyServersConfig => { + const schema = mode === 'memory' ? MyServersConfigMemorySchema : MyServersConfigSchema; + + const defaultConfig = schema.parse(initialState); + // Use a type assertion for the mergedConfig to include `connectionStatus` only if `mode === 'memory` + const mergedConfig = { + ...defaultConfig, + ...config, remote: { - wanaccess: remote.wanaccess ?? initialState.remote.wanaccess, - wanport: remote.wanport ?? initialState.remote.wanport, - ...(remote.upnpEnabled ? { upnpEnabled: remote.upnpEnabled } : {}), - apikey: remote.apikey ?? initialState.remote.apikey, - localApiKey: remote.localApiKey ?? initialState.remote.localApiKey, - email: remote.email ?? initialState.remote.email, - username: remote.username ?? initialState.remote.username, - avatar: remote.avatar ?? initialState.remote.avatar, - regWizTime: remote.regWizTime ?? initialState.remote.regWizTime, - idtoken: remote.idtoken ?? initialState.remote.idtoken, - accesstoken: remote.accesstoken ?? initialState.remote.accesstoken, - refreshtoken: - remote.refreshtoken ?? initialState.remote.refreshtoken, - ...(mode === 'memory' - ? { - allowedOrigins: - getAllowedOrigins().join(', ') - } - : {}), - dynamicRemoteAccessType: remote.dynamicRemoteAccessType ?? DynamicRemoteAccessType.DISABLED, + ...defaultConfig.remote, + ...config.remote, }, - upc: { - apikey: upc.apikey ?? initialState.upc.apikey, - }, - ...(mode === 'memory' - ? { - connectionStatus: { - minigraph: - connectionStatus.minigraph ?? - initialState.connectionStatus.minigraph, - ...(connectionStatus.upnpStatus - ? { upnpStatus: connectionStatus.upnpStatus } - : {}), - }, - } - : {}), - } as ConfigObject; - return newState; + } as T extends 'memory' ? MyServersConfigMemory : MyServersConfig; + + if (mode === 'memory') { + (mergedConfig as MyServersConfigMemory).remote.allowedOrigins = getAllowedOrigins().join(', '); + (mergedConfig as MyServersConfigMemory).connectionStatus = { + ...(defaultConfig as MyServersConfigMemory).connectionStatus, + ...(config as MyServersConfigMemory).connectionStatus, + }; + } + + return schema.parse(mergedConfig) as any; // Narrowing ensures correct typing }; /** - * Helper function to convert an object into a normalized config file. - * This is used for loading config files and ensure changes have been made before the state is merged. + * Check if two configurations are equivalent by normalizing them through the Zod schema. */ export const areConfigsEquivalent = ( - newConfigFile: RecursivePartial, - currentConfig: ConfigSliceState -): boolean => - // Enable to view config diffs: logger.debug(getDiff(getWriteableConfig(currentConfig, 'flash'), newConfigFile)); - isEqual(newConfigFile, getWriteableConfig(currentConfig, 'flash')); + newConfigFile: Partial, // Use Partial here for flexibility + currentConfig: MyServersConfig +): boolean => { + // Parse and validate the new config file using the schema (with default values applied) + const normalizedNewConfig = MyServersConfigSchema.parse({ + ...currentConfig, // Use currentConfig as a baseline to fill missing fields + ...newConfigFile, + }); + + // Get the writeable configuration for the current config + const normalizedCurrentConfig = getWriteableConfig(currentConfig, 'flash'); + + // Compare the normalized configurations + return isEqual(normalizedNewConfig, normalizedCurrentConfig); +}; diff --git a/api/src/types/my-servers-config.d.ts b/api/src/types/my-servers-config.d.ts deleted file mode 100644 index 488c8392f..000000000 --- a/api/src/types/my-servers-config.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { type DynamicRemoteAccessType, type MinigraphStatus } from '@app/graphql/generated/api/types'; - -interface MyServersConfig extends Record { - api: { - version: string; - extraOrigins: string; - }; - local: {}; - notifier: { - apikey: string; - }; - remote: { - wanaccess: string; - wanport: string; - upnpEnabled?: string; - apikey: string; - localApiKey?: string; - email: string; - username: string; - avatar: string; - regWizTime: string; - accesstoken: string; - idtoken: string; - refreshtoken: string; - allowedOrigins?: string; - dynamicRemoteAccessType?: DynamicRemoteAccessType; - }; - upc: { - apikey: string; - }; -} - -export interface MyServersConfigWithMandatoryHiddenFields extends MyServersConfig { - api: { - extraOrigins: string; - }; - remote: MyServersConfig['remote'] & { - upnpEnabled: string; - dynamicRemoteAccessType: DynamicRemoteAccessType; - }; -} - -export interface MyServersConfigMemory extends MyServersConfig { - connectionStatus: { - minigraph: MinigraphStatus; - upnpStatus?: null | string; - }; - remote: MyServersConfig['remote'] & { - allowedOrigins: string; - }; -} - -export interface MyServersConfigMemoryWithMandatoryHiddenFields extends MyServersConfigMemory { - connectionStatus: { - minigraph: MinigraphStatus; - upnpStatus?: null | string; - }; -} diff --git a/api/src/types/my-servers-config.ts b/api/src/types/my-servers-config.ts new file mode 100644 index 000000000..1ac28c7a9 --- /dev/null +++ b/api/src/types/my-servers-config.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { DynamicRemoteAccessType, MinigraphStatus } from '@app/graphql/generated/api/types'; + +// Define Zod schemas +const ApiConfigSchema = z.object({ + version: z.string(), + extraOrigins: z.string(), +}); + +const NotifierConfigSchema = z.object({ + apikey: z.string(), +}); + +const RemoteConfigSchema = z.object({ + wanaccess: z.string(), + wanport: z.string(), + upnpEnabled: z.string(), + apikey: z.string(), + localApiKey: z.string(), + email: z.string(), + username: z.string(), + avatar: z.string(), + regWizTime: z.string(), + accesstoken: z.string(), + idtoken: z.string(), + refreshtoken: z.string(), + dynamicRemoteAccessType: z.nativeEnum(DynamicRemoteAccessType), +}); + +const UpcConfigSchema = z.object({ + apikey: z.string(), +}); + +// Base config schema +export const MyServersConfigSchema = z.object({ + api: ApiConfigSchema, + local: z.object({}), // Empty object + notifier: NotifierConfigSchema, + remote: RemoteConfigSchema, + upc: UpcConfigSchema, +}); + +// Memory config schema +export const ConnectionStatusSchema = z.object({ + minigraph: z.nativeEnum(MinigraphStatus), + upnpStatus: z.string().nullable().optional(), +}); + +export const MyServersConfigMemorySchema = MyServersConfigSchema.extend({ + connectionStatus: ConnectionStatusSchema, + remote: RemoteConfigSchema.extend({ + allowedOrigins: z.string(), + }), +}); + +// Memory config with mandatory hidden fields schema +export const MyServersConfigMemoryWithMandatoryHiddenFieldsSchema = MyServersConfigMemorySchema.extend({ + connectionStatus: ConnectionStatusSchema, +}); + +// Infer and export types from Zod schemas +export type MyServersConfig = z.infer; +export type MyServersConfigMemory = z.infer; +export type MyServersConfigMemoryWithMandatoryHiddenFields = z.infer< + typeof MyServersConfigMemoryWithMandatoryHiddenFieldsSchema +>; diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index e7fdfbe40..bae742a51 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -28,6 +28,7 @@ export class StartCommand extends CommandRunner { ); if (stdout) { this.logger.log(stdout); + process.exit(0); } if (stderr) { this.logger.error(stderr); diff --git a/api/src/unraid-api/cli/validate-token.command.ts b/api/src/unraid-api/cli/validate-token.command.ts index 6febc5b9d..76ecac4b5 100644 --- a/api/src/unraid-api/cli/validate-token.command.ts +++ b/api/src/unraid-api/cli/validate-token.command.ts @@ -29,6 +29,7 @@ export class ValidateTokenCommand extends CommandRunner { async run(passedParams: string[]): Promise { if (passedParams.length !== 1) { this.logger.error('Please pass token argument only'); + process.exit(1); } const token = passedParams[0];