feat: Wire uMod browse proxy and custom plugin upload
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:
Vantz Stockwell
2026-02-21 16:09:19 -05:00
parent 38e6d28248
commit 2b45413c20
5 changed files with 422 additions and 23 deletions

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -107,5 +107,40 @@ export function useApi() {
return request<T>(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<T>(path: string, formData: FormData): Promise<T> {
const headers: Record<string, string> = {}
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 }
}

View File

@@ -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<PluginEntry[]>([])
const searchResults = ref<any[]>([])
const browseResults = ref<UmodBrowseResult | null>(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<UmodBrowseResult>(`/plugins/browse?${params.toString()}`)
} finally {
isBrowseLoading.value = false
}
}
async function uploadPlugin(file: File): Promise<PluginEntry> {
const formData = new FormData()
formData.append('file', file)
const result = await api.upload<PluginEntry>('/plugins/upload', formData)
await fetchPlugins()
return result
}
return {
plugins,
searchResults,
browseResults,
isLoading,
isBrowseLoading,
fetchPlugins,
installPlugin,
uninstallPlugin,
reloadPlugin,
updatePluginConfig,
searchPlugins,
browseUmod,
uploadPlugin,
}
})

View File

@@ -1,19 +1,26 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { usePluginStore } from '@/stores/plugins'
import type { UmodPlugin } from '@/stores/plugins'
import { useToastStore } from '@/stores/toast'
import type { PluginEntry } from '@/types'
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2 } from 'lucide-vue-next'
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2, Upload, X } from 'lucide-vue-next'
const pluginStore = usePluginStore()
const toast = useToastStore()
const searchQuery = ref('')
const tab = ref<'installed' | 'browse'>('installed')
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
const browseQuery = ref('')
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
const installing = ref<string | null>(null)
// Upload state
const uploadFile = ref<File | null>(null)
const isDragOver = ref(false)
const isUploading = ref(false)
const uploadInput = ref<HTMLInputElement | null>(null)
const filteredPlugins = computed(() => {
let result = pluginStore.plugins
if (searchQuery.value.trim()) {
@@ -23,6 +30,8 @@ const filteredPlugins = computed(() => {
return result
})
const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
function sourceLabel(source: string): string {
@@ -65,7 +74,7 @@ async function handleUninstall(plugin: PluginEntry) {
async function handleBrowseSearch() {
if (!browseQuery.value.trim()) return
try {
await pluginStore.searchPlugins(browseQuery.value.trim())
await pluginStore.browseUmod(browseQuery.value.trim())
} catch {
toast.error('Failed to search uMod plugins')
}
@@ -76,10 +85,10 @@ function scheduleBrowseSearch() {
browseDebounce.value = setTimeout(handleBrowseSearch, 400)
}
async function installFromBrowse(result: { name: string }) {
async function installFromBrowse(result: UmodPlugin) {
installing.value = result.name
try {
await pluginStore.installPlugin({ plugin_name: result.name, source: 'umod' })
await pluginStore.installPlugin({ plugin_name: result.name, umod_slug: result.slug, source: 'umod' })
toast.success(`${result.name} installed`)
} catch {
toast.error(`Failed to install ${result.name}`)
@@ -88,6 +97,59 @@ async function installFromBrowse(result: { name: string }) {
}
}
// Upload helpers
function isAlreadyInstalled(name: string): boolean {
return pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === name)
}
function validateCsFile(file: File): string | null {
if (!file.name.toLowerCase().endsWith('.cs')) {
return 'Only .cs plugin files are accepted'
}
if (file.size > 5 * 1024 * 1024) {
return 'File must be under 5 MB'
}
return null
}
function handleFilePick(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const err = validateCsFile(file)
if (err) { toast.error(err); return }
uploadFile.value = file
}
function handleDrop(event: DragEvent) {
isDragOver.value = false
const file = event.dataTransfer?.files[0]
if (!file) return
const err = validateCsFile(file)
if (err) { toast.error(err); return }
uploadFile.value = file
}
function clearUpload() {
uploadFile.value = null
if (uploadInput.value) uploadInput.value.value = ''
}
async function handleUpload() {
if (!uploadFile.value) return
isUploading.value = true
try {
await pluginStore.uploadPlugin(uploadFile.value)
toast.success(`${uploadFile.value.name} uploaded successfully`)
clearUpload()
tab.value = 'installed'
} catch (err) {
toast.error((err as Error).message || 'Upload failed')
} finally {
isUploading.value = false
}
}
onMounted(() => {
pluginStore.fetchPlugins()
})
@@ -133,6 +195,13 @@ onMounted(() => {
>
Browse uMod
</button>
<button
@click="tab = 'upload'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'upload' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Upload Custom
</button>
</div>
<div v-if="tab === 'installed'" class="relative flex-1 max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
@@ -230,20 +299,20 @@ onMounted(() => {
<!-- Browse uMod -->
<div v-if="tab === 'browse'">
<!-- Empty state: no search yet -->
<div v-if="!browseQuery.trim() && pluginStore.searchResults.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<div v-if="!browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3>
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p>
</div>
<!-- Loading -->
<div v-else-if="pluginStore.isLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<div v-else-if="pluginStore.isBrowseLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
<p class="text-sm text-neutral-500">Searching uMod...</p>
</div>
<!-- No results -->
<div v-else-if="browseQuery.trim() && pluginStore.searchResults.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<div v-else-if="browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3>
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p>
@@ -251,39 +320,47 @@ onMounted(() => {
<!-- Results -->
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<div v-if="pluginStore.browseResults" class="px-4 py-2 border-b border-neutral-800 flex items-center justify-between">
<p class="text-xs text-neutral-500">
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
&bull; Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
</p>
</div>
<table class="w-full">
<thead>
<tr class="border-b border-neutral-800 text-left">
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Downloads</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr
v-for="result in pluginStore.searchResults"
v-for="result in browsePlugins"
:key="result.name"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-100">{{ result.name }}</p>
<p class="text-sm font-medium text-neutral-100">{{ result.title }}</p>
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p>
</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_version ?? '\u2014' }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_release_version_formatted ?? '\u2014' }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.downloads_shortened ?? '\u2014' }}</td>
<td class="px-4 py-3 text-right">
<button
@click="installFromBrowse(result)"
:disabled="installing === result.name || pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name)"
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ml-auto"
:class="pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name)
:class="isAlreadyInstalled(result.name)
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
: 'bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white'"
>
<Loader2 v-if="installing === result.name" class="w-3.5 h-3.5 animate-spin" />
<Download v-else class="w-3.5 h-3.5" />
{{ pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
{{ isAlreadyInstalled(result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
</button>
</td>
</tr>
@@ -291,5 +368,92 @@ onMounted(() => {
</table>
</div>
</div>
<!-- Upload Custom Plugin -->
<div v-if="tab === 'upload'" class="space-y-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
<h2 class="text-base font-semibold text-neutral-100 mb-1">Upload Custom Plugin</h2>
<p class="text-sm text-neutral-500 mb-6">
Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.
The file will be pushed to your server via the companion agent.
</p>
<!-- Drop zone -->
<div
class="relative border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer"
:class="isDragOver
? 'border-oxide-500 bg-oxide-500/5'
: uploadFile
? 'border-green-600 bg-green-900/10'
: 'border-neutral-700 hover:border-neutral-600'"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="handleDrop"
@click="uploadInput?.click()"
>
<input
ref="uploadInput"
type="file"
accept=".cs"
class="hidden"
@change="handleFilePick"
/>
<!-- No file selected -->
<template v-if="!uploadFile">
<Upload class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<p class="text-sm font-medium text-neutral-300">Drop your .cs file here</p>
<p class="text-xs text-neutral-500 mt-1">or click to browse</p>
</template>
<!-- File selected -->
<template v-else>
<div class="flex items-center justify-center gap-3">
<Puzzle class="w-8 h-8 text-green-400 flex-shrink-0" />
<div class="text-left">
<p class="text-sm font-medium text-neutral-100">{{ uploadFile.name }}</p>
<p class="text-xs text-neutral-500 mt-0.5">{{ (uploadFile.size / 1024).toFixed(1) }} KB</p>
</div>
<button
@click.stop="clearUpload"
class="ml-2 p-1 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Remove"
>
<X class="w-4 h-4" />
</button>
</div>
</template>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 mt-4">
<button
@click="handleUpload"
:disabled="!uploadFile || isUploading"
class="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
<Upload v-else class="w-4 h-4" />
{{ isUploading ? 'Uploading...' : 'Upload Plugin' }}
</button>
<button
v-if="uploadFile"
@click="clearUpload"
class="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
</div>
</div>
<!-- Info card -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<p class="text-xs text-neutral-500 leading-relaxed">
<span class="font-medium text-neutral-400">Note:</span>
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
</p>
</div>
</div>
</div>
</template>