Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 0 additions & 137 deletions api/src/__test__/config/api-config.test.ts

This file was deleted.

170 changes: 55 additions & 115 deletions api/src/unraid-api/config/api-config.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { Injectable, Logger, Module } from '@nestjs/common';
import { ConfigService, registerAs } from '@nestjs/config';
import path from 'path';

import type { ApiConfig } from '@unraid/shared/services/api-config.js';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { csvStringToArray } from '@unraid/shared/util/data.js';
import { fileExists } from '@unraid/shared/util/file.js';
import { bufferTime } from 'rxjs/operators';

import { API_VERSION } from '@app/environment.js';
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js';

export { type ApiConfig };

Expand All @@ -22,123 +20,72 @@ const createDefaultConfig = (): ApiConfig => ({
plugins: [],
});

export const persistApiConfig = async (config: ApiConfig) => {
const apiConfig = new ApiStateConfig<ApiConfig>(
{
name: 'api',
defaultConfig: config,
parse: (data) => data as ApiConfig,
},
new ConfigPersistenceHelper()
);
return await apiConfig.persist(config);
};

/**
* Simple file-based config loading for plugin discovery (outside of nestjs DI container).
* This avoids complex DI container instantiation during module loading.
*/
export const loadApiConfig = async () => {
try {
const defaultConfig = createDefaultConfig();
const apiConfig = new ApiStateConfig<ApiConfig>(
{
name: 'api',
defaultConfig,
parse: (data) => data as ApiConfig,
},
new ConfigPersistenceHelper()
);

let diskConfig: ApiConfig | undefined;
try {
diskConfig = await apiConfig.parseConfig();
} catch (error) {
logger.error('Failed to load API config from disk, using defaults:', error);
diskConfig = undefined;

// Try to overwrite the invalid config with defaults to fix the issue
try {
const configToWrite = {
...defaultConfig,
version: API_VERSION,
};

const writeSuccess = await apiConfig.persist(configToWrite);
if (writeSuccess) {
logger.log('Successfully overwrote invalid config file with defaults.');
} else {
logger.error(
'Failed to overwrite invalid config file. Continuing with defaults in memory only.'
);
}
} catch (persistError) {
logger.error('Error during config file repair:', persistError);
}
}
const defaultConfig = createDefaultConfig();
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();

return {
...defaultConfig,
...diskConfig,
version: API_VERSION,
};
} catch (outerError) {
// This should never happen, but ensures the config factory never throws
logger.error('Critical error in loadApiConfig, using minimal defaults:', outerError);
return createDefaultConfig();
let diskConfig: Partial<ApiConfig> = {};
try {
diskConfig = await apiHandler.loadConfig();
} catch (error) {
logger.warn('Failed to load API config from disk:', error);
}

return {
...defaultConfig,
...diskConfig,
// diskConfig's version may be older, but we still want to use the correct version
version: API_VERSION,
};
};

/**
* Loads the API config from disk. If not found, returns the default config, but does not persist it.
* This is used in the root config module to register the api config.
*/
export const apiConfig = registerAs<ApiConfig>('api', loadApiConfig);

@Injectable()
export class ApiConfigPersistence {
private configModel: ApiStateConfig<ApiConfig>;
private logger = new Logger(ApiConfigPersistence.name);
get filePath() {
return this.configModel.filePath;
export class ApiConfigPersistence extends ConfigFilePersister<ApiConfig> {
constructor(configService: ConfigService) {
super(configService);
}
get config() {
return this.configService.getOrThrow('api');

fileName(): string {
return 'api.json';
}

constructor(
private readonly configService: ConfigService,
private readonly persistenceHelper: ConfigPersistenceHelper
) {
this.configModel = new ApiStateConfig<ApiConfig>(
{
name: 'api',
defaultConfig: createDefaultConfig(),
parse: (data) => data as ApiConfig,
},
this.persistenceHelper
);
configKey(): string {
return 'api';
}

async onModuleInit() {
try {
if (!(await fileExists(this.filePath))) {
this.migrateFromMyServersConfig();
}
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async (changes) => {
if (changes.some((change) => change.path.startsWith('api'))) {
this.logger.verbose(`API Config changed ${JSON.stringify(changes)}`);
try {
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
} catch (persistError) {
this.logger.error('Error persisting config changes:', persistError);
}
}
},
error: (err) => {
this.logger.error('Error receiving config changes:', err);
},
});
} catch (error) {
this.logger.error('Error during API config module initialization:', error);
}
/**
* @override
* Since the api config is read outside of the nestjs DI container,
* we need to provide an explicit path instead of relying on the
* default prefix from the configService.
*
* @returns The path to the api config file
*/
configPath(): string {
return path.join(PATHS_CONFIG_MODULES, this.fileName());
}

defaultConfig(): ApiConfig {
return createDefaultConfig();
}

async migrateConfig(): Promise<ApiConfig> {
const legacyConfig = this.configService.get('store.config', {});
const migrated = this.convertLegacyConfig(legacyConfig);
return {
...this.defaultConfig(),
...migrated,
};
}

convertLegacyConfig(
Expand All @@ -156,18 +103,11 @@ export class ApiConfigPersistence {
ssoSubIds: csvStringToArray(config?.remote?.ssoSubIds ?? ''),
};
}

migrateFromMyServersConfig() {
const legacyConfig = this.configService.get('store.config', {});
const { sandbox, extraOrigins, ssoSubIds } = this.convertLegacyConfig(legacyConfig);
this.configService.set('api.sandbox', sandbox);
this.configService.set('api.extraOrigins', extraOrigins);
this.configService.set('api.ssoSubIds', ssoSubIds);
}
}

// apiConfig should be registered in root config in app.module.ts, not here.
@Module({
providers: [ApiConfigPersistence, ConfigPersistenceHelper],
providers: [ApiConfigPersistence],
exports: [ApiConfigPersistence],
})
export class ApiConfigModule {}
Loading