All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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>
236 lines
7.7 KiB
TypeScript
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;
|
|
}
|
|
}
|