From 2b45413c20691f8b454ca48fdbc6b02573542523 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 21 Feb 2026 16:09:19 -0500 Subject: [PATCH] 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 --- .../src/modules/plugins/plugins.controller.ts | 49 ++++- .../src/modules/plugins/plugins.service.ts | 110 +++++++++- frontend/src/composables/useApi.ts | 37 +++- frontend/src/stores/plugins.ts | 55 ++++- frontend/src/views/admin/PluginsView.vue | 194 ++++++++++++++++-- 5 files changed, 422 insertions(+), 23 deletions(-) diff --git a/backend-nest/src/modules/plugins/plugins.controller.ts b/backend-nest/src/modules/plugins/plugins.controller.ts index df05816..9edc513 100644 --- a/backend-nest/src/modules/plugins/plugins.controller.ts +++ b/backend-nest/src/modules/plugins/plugins.controller.ts @@ -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); + } } diff --git a/backend-nest/src/modules/plugins/plugins.service.ts b/backend-nest/src/modules/plugins/plugins.service.ts index 03308d0..b9fa643 100644 --- a/backend-nest/src/modules/plugins/plugins.service.ts +++ b/backend-nest/src/modules/plugins/plugins.service.ts @@ -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(); + 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 { + 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; + } } diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts index 8310f14..e767623 100644 --- a/frontend/src/composables/useApi.ts +++ b/frontend/src/composables/useApi.ts @@ -107,5 +107,40 @@ export function useApi() { return request(path, { method: 'DELETE' }) } - return { request, get, post, put, del } + /** + * Upload a FormData payload (multipart/form-data). + * Does NOT set Content-Type — browser must set it with the correct boundary. + */ + async function upload(path: string, formData: FormData): Promise { + const headers: Record = {} + + if (auth.accessToken) { + headers['Authorization'] = `Bearer ${auth.accessToken}` + } + + let response = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body: formData, + }) + + if (response.status === 401 && auth.refreshToken) { + await attemptRefresh() + headers['Authorization'] = `Bearer ${auth.accessToken}` + response = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body: formData, + }) + } + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Upload failed' })) + throw new Error(error.message || `HTTP ${response.status}`) + } + + return response.json() + } + + return { request, get, post, put, del, upload } } diff --git a/frontend/src/stores/plugins.ts b/frontend/src/stores/plugins.ts index a8577b6..6fff513 100644 --- a/frontend/src/stores/plugins.ts +++ b/frontend/src/stores/plugins.ts @@ -3,10 +3,39 @@ import { ref } from 'vue' import { useApi } from '@/composables/useApi' import type { PluginEntry } from '@/types' +export interface UmodPlugin { + name: string + title: string + slug: string + author: string + description: string + downloads: number + downloads_shortened: string + download_url: string + latest_release_version: string + latest_release_version_formatted: string + icon_url: string + url: string + tags_all: string + watchers_shortened: string + created_at: string + updated_at: string +} + +export interface UmodBrowseResult { + current_page: number + data: UmodPlugin[] + last_page: number + per_page: number + total: number +} + export const usePluginStore = defineStore('plugins', () => { const plugins = ref([]) const searchResults = ref([]) + const browseResults = ref(null) const isLoading = ref(false) + const isBrowseLoading = ref(false) const api = useApi() async function fetchPlugins() { @@ -18,7 +47,7 @@ export const usePluginStore = defineStore('plugins', () => { } } - async function installPlugin(data: { plugin_name: string; source: string }) { + async function installPlugin(data: { plugin_name: string; umod_slug?: string; source: string }) { await api.post('/plugins/install', data) await fetchPlugins() } @@ -37,6 +66,7 @@ export const usePluginStore = defineStore('plugins', () => { await fetchPlugins() } + // Legacy — kept for backwards compatibility, routes to browse async function searchPlugins(query: string) { isLoading.value = true try { @@ -46,15 +76,38 @@ export const usePluginStore = defineStore('plugins', () => { } } + async function browseUmod(query: string, page = 1, sort = 'downloads') { + isBrowseLoading.value = true + try { + const params = new URLSearchParams({ page: String(page), sort }) + if (query.trim()) params.set('query', query.trim()) + browseResults.value = await api.get(`/plugins/browse?${params.toString()}`) + } finally { + isBrowseLoading.value = false + } + } + + async function uploadPlugin(file: File): Promise { + const formData = new FormData() + formData.append('file', file) + const result = await api.upload('/plugins/upload', formData) + await fetchPlugins() + return result + } + return { plugins, searchResults, + browseResults, isLoading, + isBrowseLoading, fetchPlugins, installPlugin, uninstallPlugin, reloadPlugin, updatePluginConfig, searchPlugins, + browseUmod, + uploadPlugin, } }) diff --git a/frontend/src/views/admin/PluginsView.vue b/frontend/src/views/admin/PluginsView.vue index e0c9458..a966645 100644 --- a/frontend/src/views/admin/PluginsView.vue +++ b/frontend/src/views/admin/PluginsView.vue @@ -1,19 +1,26 @@