Files
corrosion-admin-panel/backend-nest/src/modules/plugins/plugins.service.ts
Vantz Stockwell 2b45413c20
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
feat: Wire uMod browse proxy and custom plugin upload
Backend:
- GET /plugins/browse proxies uMod search.json filtered to Rust category,
  with 5-minute in-memory Map cache to avoid hammering the upstream API
- POST /plugins/upload accepts .cs files up to 5 MB via multipart, persists
  to plugin_registry, and dispatches plugin_upload action over NATS so the
  companion agent can write the file to the game server
- Legacy GET /plugins/search stub preserved (now directs callers to /browse)
- FileInterceptor + @UploadedFile follow the existing maps upload pattern

Frontend:
- useApi composable gains upload() method for multipart/form-data requests
  (omits Content-Type so the browser sets the correct multipart boundary)
- plugins store adds browseUmod() calling GET /plugins/browse and
  uploadPlugin() calling POST /plugins/upload with FormData;
  UmodPlugin and UmodBrowseResult TypeScript interfaces exported
- PluginsView Browse tab now calls browseUmod() through the backend proxy
  (no cross-origin requests to uMod directly); results show title,
  downloads_shortened, and latest_release_version_formatted from the
  real uMod payload
- New Upload Custom tab: drag-and-drop or click file input for .cs files,
  client-side extension/size validation, spinner during upload, success
  toast + auto-switch to Installed tab on completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:09:19 -05:00

236 lines
7.7 KiB
TypeScript

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<string, UmodCacheEntry>();
private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
constructor(
@InjectRepository(PluginRegistry)
private readonly pluginRegistryRepo: Repository<PluginRegistry>,
private readonly natsService: NatsService,
) {}
async getPlugins(licenseId: string): Promise<PluginRegistry[]> {
return this.pluginRegistryRepo.find({
where: { license_id: licenseId },
order: { installed_at: 'DESC' },
});
}
async installPlugin(licenseId: string, dto: InstallPluginDto): Promise<PluginRegistry> {
// 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<void> {
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<PluginRegistry> {
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<unknown> {
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<PluginRegistry> {
// 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;
}
}