import { Injectable, NotFoundException, ConflictException, BadRequestException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PluginRegistry } from '../../entities/plugin-registry.entity'; import { InstallPluginDto } from './dto/install-plugin.dto'; import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto'; import { NatsService } from '../../services/nats.service'; interface UmodCacheEntry { data: unknown; timestamp: number; } @Injectable() export class PluginsService { private readonly logger = new Logger(PluginsService.name); private readonly umodCache = new Map(); private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes constructor( @InjectRepository(PluginRegistry) private readonly pluginRegistryRepo: Repository, private readonly natsService: NatsService, ) {} async getPlugins(licenseId: string): Promise { return this.pluginRegistryRepo.find({ where: { license_id: licenseId }, order: { installed_at: 'DESC' }, }); } async installPlugin(licenseId: string, dto: InstallPluginDto): Promise { // Check if plugin already exists const existing = await this.pluginRegistryRepo.findOne({ where: { license_id: licenseId, plugin_name: dto.plugin_name, }, }); if (existing) { throw new ConflictException(`Plugin ${dto.plugin_name} is already installed`); } const plugin = this.pluginRegistryRepo.create({ license_id: licenseId, plugin_name: dto.plugin_name, umod_slug: dto.umod_slug, source: dto.source || 'manual', is_installed: true, is_loaded: false, }); const saved = await this.pluginRegistryRepo.save(plugin); try { await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, { action: 'plugin_install', plugin_name: dto.plugin_name, umod_slug: dto.umod_slug, timestamp: new Date().toISOString(), }); this.logger.log(`Plugin install dispatched for ${dto.plugin_name} on license ${licenseId}`); } catch (err) { this.logger.error(`Failed to dispatch plugin install for ${dto.plugin_name} on license ${licenseId}: ${(err as Error).message}`); } return saved; } async uninstallPlugin(licenseId: string, pluginId: string): Promise { const plugin = await this.pluginRegistryRepo.findOne({ where: { id: pluginId, license_id: licenseId }, }); if (!plugin) { throw new NotFoundException(`Plugin ${pluginId} not found`); } await this.pluginRegistryRepo.delete({ id: pluginId, license_id: licenseId }); await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, { action: 'unload', plugin_name: plugin.plugin_name, timestamp: new Date().toISOString(), }); this.logger.log(`Plugin uninstall dispatched for ${plugin.plugin_name} on license ${licenseId}`); } async reloadPlugin( licenseId: string, pluginId: string, ): Promise<{ reloaded: boolean; plugin_name: string }> { const plugin = await this.pluginRegistryRepo.findOne({ where: { id: pluginId, license_id: licenseId }, }); if (!plugin) { throw new NotFoundException(`Plugin ${pluginId} not found`); } await this.natsService.publish(`corrosion.${licenseId}.cmd.plugin`, { action: 'reload', plugin_name: plugin.plugin_name, timestamp: new Date().toISOString(), }); this.logger.log(`Plugin reload dispatched for ${plugin.plugin_name} on license ${licenseId}`); return { reloaded: true, plugin_name: plugin.plugin_name }; } async updateConfig( licenseId: string, pluginId: string, dto: UpdatePluginConfigDto, ): Promise { const plugin = await this.pluginRegistryRepo.findOne({ where: { id: pluginId, license_id: licenseId }, }); if (!plugin) { throw new NotFoundException(`Plugin ${pluginId} not found`); } Object.assign(plugin, dto); plugin.updated_at = new Date(); return this.pluginRegistryRepo.save(plugin); } async searchUmod(query: string): Promise<{ results: any[]; message: string }> { // Legacy stub — use browseUmod via GET /plugins/browse for real results return { results: [], message: 'Use GET /plugins/browse for uMod search', }; } async browseUmod(query: string, page = 1, sort = 'downloads'): Promise { const cacheKey = `${query ?? ''}:${page}:${sort}`; const cached = this.umodCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) { this.logger.debug(`uMod cache hit for key "${cacheKey}"`); return cached.data; } const params = new URLSearchParams({ page: String(page), sort, sortdir: 'desc', 'categories[]': 'rust', }); if (query?.trim()) { params.set('query', query.trim()); } const url = `https://umod.org/plugins/search.json?${params.toString()}`; try { const response = await fetch(url, { headers: { 'User-Agent': 'CorrosionPanel/1.0' }, signal: AbortSignal.timeout(8000), }); if (!response.ok) { this.logger.warn(`uMod API returned ${response.status} for query "${query}"`); return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 }; } const data = await response.json(); this.umodCache.set(cacheKey, { data, timestamp: Date.now() }); this.logger.log(`uMod browse: query="${query}" page=${page} sort=${sort} → ${(data as any)?.total ?? '?'} results`); return data; } catch (err) { this.logger.error(`uMod browse failed for query "${query}": ${(err as Error).message}`); return { current_page: 1, data: [], last_page: 1, per_page: 20, total: 0 }; } } async uploadPlugin(licenseId: string, file: Express.Multer.File): Promise { // Validate extension const originalName = file.originalname ?? ''; if (!originalName.toLowerCase().endsWith('.cs')) { throw new BadRequestException('Only .cs plugin files are accepted'); } // Validate size (5 MB) const MAX_BYTES = 5 * 1024 * 1024; if (file.size > MAX_BYTES) { throw new BadRequestException('Plugin file exceeds the 5 MB limit'); } // Derive plugin name from filename (strip .cs) const pluginName = originalName.replace(/\.cs$/i, ''); // Check for duplicate const existing = await this.pluginRegistryRepo.findOne({ where: { license_id: licenseId, plugin_name: pluginName }, }); if (existing) { throw new ConflictException(`Plugin "${pluginName}" is already installed`); } // Persist record const plugin = this.pluginRegistryRepo.create({ license_id: licenseId, plugin_name: pluginName, source: 'manual', is_installed: true, is_loaded: false, }); const saved = await this.pluginRegistryRepo.save(plugin); // Dispatch to companion agent via NATS try { const content = file.buffer.toString('base64'); await this.natsService.publish(`corrosion.${licenseId}.cmd.server`, { action: 'plugin_upload', filename: originalName, content, timestamp: new Date().toISOString(), }); this.logger.log(`Plugin upload dispatched: "${originalName}" (${file.size} bytes) for license ${licenseId}`); } catch (err) { this.logger.error(`NATS publish failed for plugin upload "${originalName}" on license ${licenseId}: ${(err as Error).message}`); // Don't fail the request — plugin record is saved, NATS delivery is best-effort } return saved; } }