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
39 changes: 25 additions & 14 deletions api/src/unraid-api/config/api-config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ConfigService, registerAs } from '@nestjs/config';
import type { ApiConfig } from '@unraid/shared/services/api-config.js';
import { csvStringToArray } from '@unraid/shared/util/data.js';
import { fileExists } from '@unraid/shared/util/file.js';
import { debounceTime } from 'rxjs/operators';
import { bufferTime, debounceTime } from 'rxjs/operators';

import { API_VERSION } from '@app/environment.js';
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
Expand Down Expand Up @@ -56,7 +56,7 @@ export const loadApiConfig = async () => {
export const apiConfig = registerAs<ApiConfig>('api', loadApiConfig);

@Injectable()
class ApiConfigPersistence {
export class ApiConfigPersistence {
private configModel: ApiStateConfig<ApiConfig>;
private logger = new Logger(ApiConfigPersistence.name);
get filePath() {
Expand Down Expand Up @@ -85,10 +85,10 @@ class ApiConfigPersistence {
this.migrateFromMyServersConfig();
}
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
this.configService.changes$.pipe(debounceTime(25)).subscribe({
next: async ({ newValue, oldValue, path }) => {
if (path.startsWith('api')) {
this.logger.verbose(`Config changed: ${path} from ${oldValue} to ${newValue}`);
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)}`);
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
}
},
Expand All @@ -98,15 +98,26 @@ class ApiConfigPersistence {
});
}

private migrateFromMyServersConfig() {
const { local, api, remote } = this.configService.get('store.config', {});
const sandbox = local?.sandbox;
const extraOrigins = csvStringToArray(api?.extraOrigins ?? '').filter(
(origin) => origin.startsWith('http://') || origin.startsWith('https://')
);
const ssoSubIds = csvStringToArray(remote?.ssoSubIds ?? '');
convertLegacyConfig(
config?: Partial<{
local: { sandbox?: string };
api: { extraOrigins?: string };
remote: { ssoSubIds?: string };
}>
) {
return {
sandbox: config?.local?.sandbox === 'yes',
extraOrigins: csvStringToArray(config?.api?.extraOrigins ?? '').filter(
(origin) => origin.startsWith('http://') || origin.startsWith('https://')
),
ssoSubIds: csvStringToArray(config?.remote?.ssoSubIds ?? ''),
};
}

this.configService.set('api.sandbox', sandbox === 'yes');
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);
}
Expand Down
137 changes: 137 additions & 0 deletions api/src/unraid-api/config/api-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { ConfigService } from '@nestjs/config';

import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';

describe('ApiConfigPersistence', () => {
let service: ApiConfigPersistence;
let configService: ConfigService;
let persistenceHelper: ConfigPersistenceHelper;

beforeEach(() => {
configService = {
get: vi.fn(),
set: vi.fn(),
} as any;

persistenceHelper = {} as ConfigPersistenceHelper;
service = new ApiConfigPersistence(configService, persistenceHelper);
});

describe('convertLegacyConfig', () => {
it('should migrate sandbox from string "yes" to boolean true', () => {
const legacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.sandbox).toBe(true);
});

it('should migrate sandbox from string "no" to boolean false', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.sandbox).toBe(false);
});

it('should migrate extraOrigins from comma-separated string to array', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: 'https://example.com,https://test.com' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.extraOrigins).toEqual(['https://example.com', 'https://test.com']);
});

it('should filter out non-HTTP origins from extraOrigins', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: {
extraOrigins: 'https://example.com,invalid-origin,http://test.com,ftp://bad.com',
},
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.extraOrigins).toEqual(['https://example.com', 'http://test.com']);
});

it('should handle empty extraOrigins string', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.extraOrigins).toEqual([]);
});

it('should migrate ssoSubIds from comma-separated string to array', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: 'user1,user2,user3' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.ssoSubIds).toEqual(['user1', 'user2', 'user3']);
});

it('should handle empty ssoSubIds string', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.ssoSubIds).toEqual([]);
});

it('should handle undefined config sections', () => {
const legacyConfig = {};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.sandbox).toBe(false);
expect(result.extraOrigins).toEqual([]);
expect(result.ssoSubIds).toEqual([]);
});

it('should handle complete migration with all fields', () => {
const legacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: 'https://app1.example.com,https://app2.example.com' },
remote: { ssoSubIds: 'sub1,sub2,sub3' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.sandbox).toBe(true);
expect(result.extraOrigins).toEqual([
'https://app1.example.com',
'https://app2.example.com',
]);
expect(result.ssoSubIds).toEqual(['sub1', 'sub2', 'sub3']);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,6 @@ export class MyServersConfig {
@IsEnum(DynamicRemoteAccessType)
dynamicRemoteAccessType!: DynamicRemoteAccessType;

@Field(() => [String])
@IsArray()
@Matches(/^[a-zA-Z0-9-]+$/, {
each: true,
message: 'Each SSO ID must be alphanumeric with dashes',
})
ssoSubIds!: string[];

// Connection Status
// @Field(() => MinigraphStatus)
// @IsEnum(MinigraphStatus)
Expand Down Expand Up @@ -223,7 +215,6 @@ export const emptyMyServersConfig = (): MyServersConfig => ({
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
ssoSubIds: [],
});

export const configFeature = registerAs<ConnectConfig>('connect', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { existsSync, readFileSync } from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';

import { csvStringToArray } from '@unraid/shared/util/data.js';
import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { parse as parseIni } from 'ini';
Expand Down Expand Up @@ -137,25 +136,30 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async migrateLegacyConfig() {
const legacyConfig = await this.parseLegacyConfig();
this.configService.set('connect.config', legacyConfig);
private async migrateLegacyConfig(filePath?: string) {
const myServersCfgFile = await this.readLegacyConfig(filePath);
const legacyConfig = this.parseLegacyConfig(myServersCfgFile);
const newConfig = await this.convertLegacyConfig(legacyConfig);
this.configService.set('connect.config', newConfig);
}

/**
* Parse the legacy config file and return a new config object.
* Transform the legacy config object to the new config format.
* @param filePath - The path to the legacy config file.
* @returns A new config object.
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async parseLegacyConfig(filePath?: string): Promise<MyServersConfig> {
const config = await this.getLegacyConfig(filePath);
public async convertLegacyConfig(config:LegacyConfig): Promise<MyServersConfig> {
return this.validate({
...config.api,
...config.local,
...config.remote,
extraOrigins: csvStringToArray(config.api.extraOrigins),
// Convert string yes/no to boolean
wanaccess: config.remote.wanaccess === 'yes',
upnpEnabled: config.remote.upnpEnabled === 'yes',
// Convert string port to number
wanport: config.remote.wanport ? parseInt(config.remote.wanport, 10) : 0,
});
}

Expand All @@ -166,7 +170,7 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async getLegacyConfig(filePath?: string) {
private async readLegacyConfig(filePath?: string) {
filePath ??= this.configService.get(
'PATHS_MY_SERVERS_CONFIG',
'/boot/config/plugins/dynamix.my.servers/myservers.cfg'
Expand All @@ -177,6 +181,10 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
if (!existsSync(filePath)) {
throw new Error(`Legacy config file does not exist: ${filePath}`);
}
return parseIni(readFileSync(filePath, 'utf8')) as LegacyConfig;
return readFileSync(filePath, 'utf8');
}

public parseLegacyConfig(iniFileContent: string): LegacyConfig {
return parseIni(iniFileContent) as LegacyConfig;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class ConnectConfigService {
*/
resetUser() {
// overwrite identity fields, but retain destructured fields
const { wanaccess, wanport, upnpEnabled, ssoSubIds, ...identity } = emptyMyServersConfig();
const { wanaccess, wanport, upnpEnabled, ...identity } = emptyMyServersConfig();
this.configService.set(this.configKey, {
...this.getConfig(),
...identity,
Expand Down
Loading
Loading