feat: Wire uMod browse proxy and custom plugin upload
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
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>
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery, ApiConsumes } from '@nestjs/swagger';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { InstallPluginDto } from './dto/install-plugin.dto';
|
||||
import { UpdatePluginConfigDto } from './dto/update-plugin-config.dto';
|
||||
@@ -57,9 +71,38 @@ export class PluginsController {
|
||||
|
||||
@Get('search')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory' })
|
||||
@ApiOperation({ summary: 'Search uMod plugin directory (legacy stub)' })
|
||||
@ApiQuery({ name: 'q', required: true, example: 'kits' })
|
||||
searchUmod(@Query('q') query: string) {
|
||||
return this.pluginsService.searchUmod(query);
|
||||
}
|
||||
|
||||
@Get('browse')
|
||||
@RequirePermission('plugin.view')
|
||||
@ApiOperation({ summary: 'Browse uMod plugin directory (proxied)' })
|
||||
@ApiQuery({ name: 'query', required: false, example: 'vanish' })
|
||||
@ApiQuery({ name: 'page', required: false, example: 1 })
|
||||
@ApiQuery({ name: 'sort', required: false, example: 'downloads' })
|
||||
browseUmod(
|
||||
@Query('query') query: string,
|
||||
@Query('page') page: string,
|
||||
@Query('sort') sort: string,
|
||||
) {
|
||||
return this.pluginsService.browseUmod(query, page ? parseInt(page, 10) : 1, sort);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@RequirePermission('plugin.manage')
|
||||
@ApiOperation({ summary: 'Upload a custom .cs plugin file' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadPlugin(
|
||||
@CurrentTenant() licenseId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.pluginsService.uploadPlugin(licenseId, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common';
|
||||
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';
|
||||
@@ -6,9 +6,16 @@ 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)
|
||||
@@ -122,10 +129,107 @@ export class PluginsService {
|
||||
}
|
||||
|
||||
async searchUmod(query: string): Promise<{ results: any[]; message: string }> {
|
||||
// uMod API integration pending
|
||||
// Legacy stub — use browseUmod via GET /plugins/browse for real results
|
||||
return {
|
||||
results: [],
|
||||
message: 'uMod search integration not yet configured',
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user