mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
e346f88f42
Implements configurable Audible region selection in setup and admin settings, affecting all Audible API calls and triggering data refresh on change. Adds a user-facing 'Change Password' modal in the header for local users, moving password change from admin-only to all local users via a new /api/auth/change-password endpoint. Updates documentation, API routes, and context to support these features, and removes the old admin-only password change flow.
263 lines
6.6 KiB
TypeScript
263 lines
6.6 KiB
TypeScript
/**
|
|
* Component: Configuration Service
|
|
* Documentation: documentation/backend/services/config.md
|
|
*/
|
|
|
|
import { prisma } from '@/lib/db';
|
|
import { getEncryptionService } from './encryption.service';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
import { AudibleRegion, DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible';
|
|
|
|
const logger = RMABLogger.create('Config');
|
|
|
|
/**
|
|
* Configuration update payload
|
|
*/
|
|
export interface ConfigUpdate {
|
|
key: string;
|
|
value: string;
|
|
encrypted?: boolean;
|
|
category?: string;
|
|
description?: string;
|
|
}
|
|
|
|
/**
|
|
* Plex configuration structure
|
|
*/
|
|
export interface PlexConfig {
|
|
serverUrl: string | null;
|
|
authToken: string | null;
|
|
libraryId: string | null;
|
|
machineIdentifier: string | null;
|
|
}
|
|
|
|
/**
|
|
* Configuration service for reading settings from database
|
|
*/
|
|
export class ConfigurationService {
|
|
private cache: Map<string, string> = new Map();
|
|
private cacheExpiry: Map<string, number> = new Map();
|
|
private readonly CACHE_TTL = 60000; // 1 minute
|
|
|
|
/**
|
|
* Get a configuration value by key (decrypted if encrypted)
|
|
*/
|
|
async get(key: string): Promise<string | null> {
|
|
// Check cache first
|
|
const cached = this.cache.get(key);
|
|
const expiry = this.cacheExpiry.get(key);
|
|
|
|
if (cached && expiry && Date.now() < expiry) {
|
|
return cached;
|
|
}
|
|
|
|
// Fetch from database
|
|
try {
|
|
const config = await prisma.configuration.findUnique({
|
|
where: { key },
|
|
});
|
|
|
|
if (config && config.value) {
|
|
let value = config.value;
|
|
|
|
// Decrypt if encrypted
|
|
if (config.encrypted) {
|
|
const encryptionService = getEncryptionService();
|
|
value = encryptionService.decrypt(config.value);
|
|
}
|
|
|
|
// Cache the decrypted value
|
|
this.cache.set(key, value);
|
|
this.cacheExpiry.set(key, Date.now() + this.CACHE_TTL);
|
|
return value;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.error(`Failed to get config key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get multiple configuration values
|
|
*/
|
|
async getMany(keys: string[]): Promise<Record<string, string | null>> {
|
|
const result: Record<string, string | null> = {};
|
|
|
|
await Promise.all(
|
|
keys.map(async (key) => {
|
|
result[key] = await this.get(key);
|
|
})
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get all configuration items for a specific category
|
|
*/
|
|
async getCategory(category: string): Promise<Record<string, any>> {
|
|
try {
|
|
const configs = await prisma.configuration.findMany({
|
|
where: { category },
|
|
});
|
|
|
|
const result: Record<string, any> = {};
|
|
|
|
for (const config of configs) {
|
|
let value = config.value;
|
|
|
|
// Decrypt if encrypted
|
|
if (config.encrypted && value) {
|
|
const encryptionService = getEncryptionService();
|
|
value = encryptionService.decrypt(value);
|
|
}
|
|
|
|
result[config.key] = {
|
|
value,
|
|
encrypted: config.encrypted,
|
|
description: config.description,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
logger.error(`Failed to get category "${category}"`, { error: error instanceof Error ? error.message : String(error) });
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all configuration items (with masked sensitive values)
|
|
*/
|
|
async getAll(): Promise<Record<string, any>> {
|
|
try {
|
|
const configs = await prisma.configuration.findMany();
|
|
|
|
const result: Record<string, any> = {};
|
|
|
|
for (const config of configs) {
|
|
result[config.key] = {
|
|
value: config.encrypted ? '***ENCRYPTED***' : config.value,
|
|
encrypted: config.encrypted,
|
|
category: config.category,
|
|
description: config.description,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
logger.error('Failed to get all configuration', { error: error instanceof Error ? error.message : String(error) });
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set multiple configuration values (encrypts if needed)
|
|
*/
|
|
async setMany(updates: ConfigUpdate[]): Promise<void> {
|
|
try {
|
|
const encryptionService = getEncryptionService();
|
|
|
|
for (const update of updates) {
|
|
let value = update.value;
|
|
|
|
// Encrypt if needed
|
|
if (update.encrypted) {
|
|
value = encryptionService.encrypt(value);
|
|
}
|
|
|
|
// Upsert configuration
|
|
await prisma.configuration.upsert({
|
|
where: { key: update.key },
|
|
create: {
|
|
key: update.key,
|
|
value,
|
|
encrypted: update.encrypted || false,
|
|
category: update.category,
|
|
description: update.description,
|
|
},
|
|
update: {
|
|
value,
|
|
encrypted: update.encrypted || false,
|
|
category: update.category,
|
|
description: update.description,
|
|
},
|
|
});
|
|
|
|
// Clear cache for this key
|
|
this.clearCache(update.key);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to set configuration', { error: error instanceof Error ? error.message : String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Plex-specific configuration
|
|
*/
|
|
async getPlexConfig(): Promise<PlexConfig> {
|
|
const config = await this.getMany([
|
|
'plex_url',
|
|
'plex_token',
|
|
'plex_audiobook_library_id',
|
|
'plex_machine_identifier',
|
|
]);
|
|
|
|
return {
|
|
serverUrl: config.plex_url,
|
|
authToken: config.plex_token,
|
|
libraryId: config.plex_audiobook_library_id,
|
|
machineIdentifier: config.plex_machine_identifier || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get backend mode (Plex or Audiobookshelf)
|
|
*/
|
|
async getBackendMode(): Promise<'plex' | 'audiobookshelf'> {
|
|
const mode = await this.get('system.backend_mode');
|
|
return (mode as 'plex' | 'audiobookshelf') || 'plex';
|
|
}
|
|
|
|
/**
|
|
* Check if Audiobookshelf mode is enabled
|
|
*/
|
|
async isAudiobookshelfMode(): Promise<boolean> {
|
|
return (await this.getBackendMode()) === 'audiobookshelf';
|
|
}
|
|
|
|
/**
|
|
* Get configured Audible region
|
|
*/
|
|
async getAudibleRegion(): Promise<AudibleRegion> {
|
|
const region = await this.get('audible.region');
|
|
return (region as AudibleRegion) || DEFAULT_AUDIBLE_REGION;
|
|
}
|
|
|
|
/**
|
|
* Clear the cache for a specific key or all keys
|
|
*/
|
|
clearCache(key?: string): void {
|
|
if (key) {
|
|
this.cache.delete(key);
|
|
this.cacheExpiry.delete(key);
|
|
} else {
|
|
this.cache.clear();
|
|
this.cacheExpiry.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let configService: ConfigurationService | null = null;
|
|
|
|
export function getConfigService(): ConfigurationService {
|
|
if (!configService) {
|
|
configService = new ConfigurationService();
|
|
}
|
|
return configService;
|
|
}
|