add option to play custom maps on private servers (#9)

* add option to play custom maps on private servers

* don't show every exception to the end-user

* hash the IPs

* add description about the purpose of the public custom map list

* add punctuation to error messages
This commit is contained in:
CommonLoon102 2020-01-29 15:32:23 +00:00 committed by GitHub
parent 3417571b70
commit 3af4c9dfc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 240 additions and 34 deletions

View File

@ -9,6 +9,7 @@ namespace Common
public static class CommandLineUtils public static class CommandLineUtils
{ {
public static string BloodDir => Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "blood"); public static string BloodDir => Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "blood");
public static string TempMapDir => Path.Combine(BloodDir, "tempmaps");
public static string GetClientLaunchCommand(string host, int port, string modCommandLine) => public static string GetClientLaunchCommand(string host, int port, string modCommandLine) =>
$"nblood -client {host} -port {port} {modCommandLine}"; $"nblood -client {host} -port {port} {modCommandLine}";

View File

@ -7,6 +7,8 @@ namespace Common
{ {
public class Constants public class Constants
{ {
public const long FileSizeLimit = 1024 * 1024;
public static readonly IReadOnlyDictionary<string, Mod> SupportedMods = new Dictionary<string, Mod>() public static readonly IReadOnlyDictionary<string, Mod> SupportedMods = new Dictionary<string, Mod>()
{ {
{ "BLOOD", new Mod("BLOOD", "Blood", "") }, { "BLOOD", new Mod("BLOOD", "Blood", "") },

View File

@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Linq; using System.Linq;
using System.Net.NetworkInformation; using System.Net.NetworkInformation;
using System.IO;
namespace Common namespace Common
{ {
@ -18,7 +19,7 @@ namespace Common
private static readonly Random rnd = new Random(); private static readonly Random rnd = new Random();
public static SpawnedServerInfo SpawnServer(int players, string modName) public static SpawnedServerInfo SpawnServer(int players, string modName, string tempFolderName = "")
{ {
int serversRunning = Process.GetProcessesByName(nBloodExecutable).Count(); int serversRunning = Process.GetProcessesByName(nBloodExecutable).Count();
if (serversRunning >= maximumServers) if (serversRunning >= maximumServers)
@ -27,7 +28,7 @@ namespace Common
Mod mod = GetModByName(modName); Mod mod = GetModByName(modName);
int port = GetPort(); int port = GetPort();
var process = Process.Start(GetProcessStartInfo(players, port, mod)); var process = Process.Start(GetProcessStartInfo(players, port, mod, tempFolderName));
return new SpawnedServerInfo(process, port, mod); return new SpawnedServerInfo(process, port, mod);
} }
@ -37,7 +38,7 @@ namespace Common
return Constants.SupportedMods["BLOOD"]; return Constants.SupportedMods["BLOOD"];
if (!Constants.SupportedMods.ContainsKey(modName.ToUpper())) if (!Constants.SupportedMods.ContainsKey(modName.ToUpper()))
throw new Exception("This mod is not supported: " + modName); throw new Exception($"This mod is not supported: {modName}.");
return Constants.SupportedMods[modName.ToUpper()]; return Constants.SupportedMods[modName.ToUpper()];
} }
@ -59,9 +60,11 @@ namespace Common
return port; return port;
} }
private static ProcessStartInfo GetProcessStartInfo(int maxPlayers, int port, Mod mod) private static ProcessStartInfo GetProcessStartInfo(int maxPlayers, int port, Mod mod, string tempFolderName = "")
{ {
var psi = new ProcessStartInfo(GetExecutableName(), $"-server {maxPlayers} -port {port} -pname Server {mod.CommandLine}") string cmd = GetCommand(maxPlayers, port, mod, tempFolderName);
var psi = new ProcessStartInfo(GetExecutableName(), cmd)
{ {
UseShellExecute = true, UseShellExecute = true,
WorkingDirectory = CommandLineUtils.BloodDir WorkingDirectory = CommandLineUtils.BloodDir
@ -70,6 +73,18 @@ namespace Common
return psi; return psi;
} }
private static string GetCommand(int maxPlayers, int port, Mod mod, string tempFolderName)
{
string cmd = $"-server {maxPlayers} -port {port} -pname Server {mod.CommandLine}";
if (!string.IsNullOrWhiteSpace(tempFolderName))
{
string tempFolderPath = Path.Combine(CommandLineUtils.TempMapDir, tempFolderName);
cmd += $" -j={tempFolderPath}";
}
return cmd;
}
private static string GetExecutableName() private static string GetExecutableName()
{ {
string nbloodServer = nBloodExecutable; string nbloodServer = nBloodExecutable;

View File

@ -5,6 +5,8 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using Common;
namespace Supervisor namespace Supervisor
{ {
@ -16,7 +18,8 @@ namespace Supervisor
{ {
while (true) while (true)
{ {
Thread.Sleep(TimeSpan.FromSeconds(2)); TempMapFolderCleanup();
Thread.Sleep(TimeSpan.FromSeconds(10));
KillUnusedServers(); KillUnusedServers();
} }
}); });
@ -39,7 +42,30 @@ namespace Supervisor
} }
catch catch
{ {
//Log... // Log...
}
}
private static void TempMapFolderCleanup()
{
try
{
if (!Directory.Exists(CommandLineUtils.TempMapDir))
{
Directory.CreateDirectory(CommandLineUtils.TempMapDir);
}
foreach (var dir in Directory.GetDirectories(CommandLineUtils.TempMapDir))
{
if (DateTime.UtcNow - File.GetCreationTimeUtc(dir) > TimeSpan.FromDays(1))
{
Directory.Delete(dir, recursive: true);
}
}
}
catch
{
// Log...
} }
} }
} }

View File

@ -18,6 +18,8 @@ namespace WebInterface.Controllers
[ApiController] [ApiController]
public class NBloodController : ControllerBase public class NBloodController : ControllerBase
{ {
private const string generalErrorMessage = "Unhandled exception has been occured. Check the server logs for details.";
private static bool _isBusy = false; private static bool _isBusy = false;
private static DateTime _lastRefresh = DateTime.MinValue; private static DateTime _lastRefresh = DateTime.MinValue;
private static ListServersResponse _lastServerList = null; private static ListServersResponse _lastServerList = null;
@ -71,7 +73,7 @@ namespace WebInterface.Controllers
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex.ToString()); _logger.LogError(ex.ToString());
return new StartServerResponse("Unhandled exception has been occured. Check the logs for details."); return new StartServerResponse(generalErrorMessage);
} }
finally finally
{ {
@ -97,7 +99,7 @@ namespace WebInterface.Controllers
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex.ToString()); _logger.LogError(ex.ToString());
return new ListServersResponse("Unhandled exception has been occured. Check the logs for details."); return new ListServersResponse(generalErrorMessage);
} }
} }
} }

View File

@ -1,9 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Common; using Common;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using WebInterface.Infrastructure;
using WebInterface.Services; using WebInterface.Services;
namespace WebInterface.Controllers namespace WebInterface.Controllers
@ -12,11 +13,18 @@ namespace WebInterface.Controllers
{ {
private readonly IPrivateServerService _privateServerService; private readonly IPrivateServerService _privateServerService;
private readonly IRateLimiterService _rateLimiterService; private readonly IRateLimiterService _rateLimiterService;
private readonly ICustomMapService _customMapService;
private readonly ILogger<PrivateController> _logger;
public PrivateController(IPrivateServerService privateServerService, IRateLimiterService rateLimiterService) public PrivateController(IPrivateServerService privateServerService,
IRateLimiterService rateLimiterService,
ICustomMapService customMapService,
ILogger<PrivateController> logger)
{ {
_privateServerService = privateServerService; _privateServerService = privateServerService;
_rateLimiterService = rateLimiterService; _rateLimiterService = rateLimiterService;
_customMapService = customMapService;
_logger = logger;
} }
[Route("nblood/private")] [Route("nblood/private")]
@ -41,22 +49,31 @@ namespace WebInterface.Controllers
try try
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
throw new Exception("Something went off the rails."); throw new WebInterfaceException("Something went off the rails.");
if (!_rateLimiterService.IsRequestAllowed(HttpContext.Connection.RemoteIpAddress)) if (!_rateLimiterService.IsRequestAllowed(HttpContext.Connection.RemoteIpAddress))
throw new Exception("Sorry, you have requested too many servers recently, you need to wait some time."); throw new WebInterfaceException("Sorry, you have requested too many servers recently, you need to wait some time.");
var spawnedServer = _privateServerService.SpawnNewPrivateServer(request.Players + 1, request.ModName); string tempFolderName = "";
if ((request.FormFile?.Length ?? 0) > 0)
tempFolderName = _customMapService.StoreTempCustomMap(request.FormFile);
var spawnedServer = _privateServerService.SpawnNewPrivateServer(request.Players + 1, request.ModName, tempFolderName ?? "");
string commandLine = CommandLineUtils.GetClientLaunchCommand(HttpContext.Request.Host.Host, string commandLine = CommandLineUtils.GetClientLaunchCommand(HttpContext.Request.Host.Host,
spawnedServer.Port, spawnedServer.Port,
spawnedServer.Mod.CommandLine); spawnedServer.Mod.CommandLine);
viewModel = new StartServerResponse(spawnedServer.Port, commandLine); viewModel = new StartServerResponse(spawnedServer.Port, commandLine);
} }
catch (Exception ex) catch (WebInterfaceException ex)
{ {
viewModel = new StartServerResponse(ex.Message); viewModel = new StartServerResponse(ex.Message);
} }
catch (Exception ex)
{
_logger.LogError(ex.ToString());
viewModel = new StartServerResponse("Internal server error.");
}
return View("Result", viewModel); return View("Result", viewModel);
} }

View File

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace WebInterface.Infrastructure
{
[Serializable]
public class WebInterfaceException : Exception
{
public WebInterfaceException() { }
public WebInterfaceException(string message) : base(message) { }
public WebInterfaceException(string message, Exception inner) : base(message, inner) { }
protected WebInterfaceException(
System.Runtime.Serialization.SerializationInfo info,
System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
}
}

View File

@ -1,4 +1,6 @@
using Common; using Common;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -7,6 +9,8 @@ using System.Linq;
namespace WebInterface namespace WebInterface
{ {
[RequestFormLimits(MultipartBodyLengthLimit = Constants.FileSizeLimit)]
[RequestSizeLimit(Constants.FileSizeLimit)]
public class PrivateViewModel public class PrivateViewModel
{ {
[Required] [Required]
@ -18,5 +22,8 @@ namespace WebInterface
public List<SelectListItem> ModNames { get; } = Constants.SupportedMods public List<SelectListItem> ModNames { get; } = Constants.SupportedMods
.Select(m => new SelectListItem(m.Value.FriendlyName, m.Value.Name)) .Select(m => new SelectListItem(m.Value.FriendlyName, m.Value.Name))
.ToList(); .ToList();
[Display(Name = "Custom map (optional)")]
public IFormFile FormFile { get; set; }
} }
} }

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Common;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@ -20,7 +21,8 @@ namespace WebInterface
Host.CreateDefaultBuilder(args) Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => .ConfigureWebHostDefaults(webBuilder =>
{ {
webBuilder.UseStartup<Startup>(); webBuilder.UseStartup<Startup>()
.ConfigureKestrel((context, options) => options.Limits.MaxRequestBodySize = Constants.FileSizeLimit);
}); });
} }
} }

View File

@ -1,8 +1,11 @@
using System; using Common;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using WebInterface.Infrastructure;
namespace WebInterface.Services namespace WebInterface.Services
{ {
@ -26,7 +29,7 @@ namespace WebInterface.Services
"CPBB04.MAP", "CPBB04.MAP",
}; };
private List<string> ListableCustomMaps => Directory.GetFiles(Common.CommandLineUtils.BloodDir, private List<string> ListableCustomMaps => Directory.GetFiles(CommandLineUtils.BloodDir,
"*.map", SearchOption.TopDirectoryOnly) "*.map", SearchOption.TopDirectoryOnly)
.Select(m => Path.GetFileName(m)) .Select(m => Path.GetFileName(m))
.Where(m => !ContainsString(crypticMaps, m)) .Where(m => !ContainsString(crypticMaps, m))
@ -38,11 +41,11 @@ namespace WebInterface.Services
{ {
if (ListableCustomMaps.Any(m => StringsAreSame(m, map))) if (ListableCustomMaps.Any(m => StringsAreSame(m, map)))
{ {
return File.ReadAllBytes(Path.Combine(Common.CommandLineUtils.BloodDir, map)); return File.ReadAllBytes(Path.Combine(CommandLineUtils.BloodDir, map));
} }
else else
{ {
throw new Exception($"Cannot download this map: {map}"); throw new WebInterfaceException($"Cannot download this map: {map}.");
} }
} }
@ -51,5 +54,37 @@ namespace WebInterface.Services
private bool StringsAreSame(string left, string right) => private bool StringsAreSame(string left, string right) =>
string.Compare(left, right, StringComparison.OrdinalIgnoreCase) == 0; string.Compare(left, right, StringComparison.OrdinalIgnoreCase) == 0;
public string StoreTempCustomMap(IFormFile formFile)
{
string filename = Path.GetFileNameWithoutExtension(formFile.FileName);
ValidateFilename(filename);
string tempFolderName = Path.GetRandomFileName();
string mapPath = Path.Combine(CommandLineUtils.TempMapDir, tempFolderName);
if (!Directory.Exists(mapPath))
Directory.CreateDirectory(mapPath);
mapPath = Path.Combine(mapPath, filename + ".map");
FileStream fs = new FileStream(mapPath, FileMode.CreateNew);
formFile.CopyTo(fs);
return tempFolderName;
}
private void ValidateFilename(string filename)
{
if (string.IsNullOrWhiteSpace(filename))
throw new WebInterfaceException("Invalid filename.");
if (ContainsString(crypticMaps, filename + ".map"))
throw new WebInterfaceException($"You cannot play this map ({filename}) as a custom map.");
foreach (var chr in Path.GetInvalidFileNameChars())
{
if (filename.Contains(chr))
throw new WebInterfaceException("Invalid characters in the file name of the custom map.");
}
}
} }
} }

View File

@ -1,4 +1,5 @@
using System; using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -9,5 +10,6 @@ namespace WebInterface.Services
{ {
IList<string> ListCustomMaps(); IList<string> ListCustomMaps();
byte[] GetCustomMapBytes(string map); byte[] GetCustomMapBytes(string map);
string StoreTempCustomMap(IFormFile formFile);
} }
} }

View File

@ -8,6 +8,6 @@ namespace WebInterface.Services
{ {
public interface IPrivateServerService public interface IPrivateServerService
{ {
SpawnedServerInfo SpawnNewPrivateServer(int players, string modName); SpawnedServerInfo SpawnNewPrivateServer(int players, string modName, string tempFolderName = "");
} }
} }

View File

@ -15,11 +15,11 @@ namespace WebInterface.Services
private static readonly Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); private static readonly Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
private static readonly IPEndPoint webApiListenerEndPoint = new IPEndPoint(IPAddress.Loopback, 11028); private static readonly IPEndPoint webApiListenerEndPoint = new IPEndPoint(IPAddress.Loopback, 11028);
public SpawnedServerInfo SpawnNewPrivateServer(int players, string modName) public SpawnedServerInfo SpawnNewPrivateServer(int players, string modName, string tempFolderName = "")
{ {
players = Math.Min(8, Math.Max(3, players)); players = Math.Min(8, Math.Max(3, players));
SpawnedServerInfo serverProcess = ProcessSpawner.SpawnServer(players, modName); SpawnedServerInfo serverProcess = ProcessSpawner.SpawnServer(players, modName, tempFolderName);
byte[] payload = Encoding.ASCII.GetBytes($"B{serverProcess.Port}\t{serverProcess.Process.Id}\0"); byte[] payload = Encoding.ASCII.GetBytes($"B{serverProcess.Port}\t{serverProcess.Process.Id}\0");
socket.SendTo(payload, webApiListenerEndPoint); socket.SendTo(payload, webApiListenerEndPoint);

View File

@ -3,24 +3,27 @@ using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace WebInterface.Services namespace WebInterface.Services
{ {
public class RateLimiterService : IRateLimiterService public class RateLimiterService : IRateLimiterService
{ {
private static readonly ConcurrentDictionary<IPAddress, List<DateTime>> requestHistory = new ConcurrentDictionary<IPAddress, List<DateTime>>(); private static readonly ConcurrentDictionary<string, List<DateTime>> requestHistory = new ConcurrentDictionary<string, List<DateTime>>();
public bool IsRequestAllowed(IPAddress remoteIpAddress) public bool IsRequestAllowed(IPAddress remoteIpAddress)
{ {
if (requestHistory.TryGetValue(remoteIpAddress, out var list)) string hash = GetHash(remoteIpAddress);
if (requestHistory.TryGetValue(hash, out var list))
{ {
bool isLimited = list.Count(e => DateTime.UtcNow - e < TimeSpan.FromHours(1)) >= 5; bool isLimited = list.Count(e => DateTime.UtcNow - e < TimeSpan.FromHours(1)) >= 5;
if (isLimited) if (isLimited)
return false; return false;
} }
requestHistory.AddOrUpdate(remoteIpAddress, requestHistory.AddOrUpdate(hash,
(ip) => (ip) =>
{ {
var list = new List<DateTime>(); var list = new List<DateTime>();
@ -35,5 +38,25 @@ namespace WebInterface.Services
return true; return true;
} }
private string GetHash(IPAddress remoteIpAddress)
{
const string pepper = "Somewhere, over the rainbow, way up this pepper.";
var data = Encoding.ASCII.GetBytes(remoteIpAddress.ToString() + pepper);
string hash;
using (SHA512 shaM = new SHA512Managed())
{
byte[] bytes = shaM.ComputeHash(data);
for (int i = 0; i < 1000; i++)
{
bytes = shaM.ComputeHash(bytes);
}
bytes = bytes.Take(bytes.Length / 4).ToArray();
hash = Encoding.ASCII.GetString(bytes);
}
return hash;
}
} }
} }

View File

@ -3,8 +3,11 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Common;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@ -28,7 +31,7 @@ namespace WebInterface
supervisor += ".exe"; supervisor += ".exe";
string supervisorPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), supervisor); string supervisorPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), supervisor);
if (!File.Exists(supervisorPath)) if (!File.Exists(supervisorPath))
throw new Exception($"Couldn't find {supervisor} at {supervisorPath}"); throw new Exception($"Couldn't find {supervisor} at {supervisorPath}.");
Process.Start(supervisorPath); Process.Start(supervisorPath);
} }
@ -43,6 +46,29 @@ namespace WebInterface
services.Add(new ServiceDescriptor(typeof(IPrivateServerService), typeof(PrivateServerService), ServiceLifetime.Transient)); services.Add(new ServiceDescriptor(typeof(IPrivateServerService), typeof(PrivateServerService), ServiceLifetime.Transient));
services.Add(new ServiceDescriptor(typeof(IRateLimiterService), typeof(RateLimiterService), ServiceLifetime.Singleton)); services.Add(new ServiceDescriptor(typeof(IRateLimiterService), typeof(RateLimiterService), ServiceLifetime.Singleton));
services.Add(new ServiceDescriptor(typeof(ICustomMapService), typeof(CustomMapService), ServiceLifetime.Transient)); services.Add(new ServiceDescriptor(typeof(ICustomMapService), typeof(CustomMapService), ServiceLifetime.Transient));
services.Configure<FormOptions>(options =>
{
// Set the limit to 1 MB
options.MultipartBodyLengthLimit = Constants.FileSizeLimit;
});
services.AddRazorPages()
.AddRazorPagesOptions(options =>
{
options.Conventions
.AddPageApplicationModelConvention("/nblood/private", model =>
{
model.Filters.Add(
new RequestFormLimitsAttribute()
{
// Set the limit to 1 MB
MultipartBodyLengthLimit = Constants.FileSizeLimit
});
model.Filters.Add(
new RequestSizeLimitAttribute(Constants.FileSizeLimit));
});
});
} }
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@ -6,6 +6,12 @@
<h1>List of available custom maps</h1> <h1>List of available custom maps</h1>
<p>You can play these custom maps even on the public servers.
If you want to play on your own map which is not listed here,
you can request a new private server and upload your map which will be
only available for that match, and the map won't appear in the below list.
</p>
<ul> <ul>
@foreach (var map in Model) @foreach (var map in Model)
{ {

View File

@ -6,11 +6,33 @@
<h1>@ViewData["Title"]</h1> <h1>@ViewData["Title"]</h1>
<div class="bordered" style="padding:4px"> <div class="bordered" style="padding:4px">
<form method="post"> <form enctype="multipart/form-data" method="post">
<label asp-for="ModName"></label> <dl>
<select asp-for="ModName" asp-items="Model.ModNames"></select> <br /> <dt>
<label asp-for="Players"></label> <label asp-for="ModName"></label>
<input type="number" min="2" max="7" asp-for="Players" /> <br /> </dt>
<dd>
<select asp-for="ModName" asp-items="Model.ModNames"></select>
</dd>
</dl>
<dl>
<dt>
<label asp-for="Players"></label>
</dt>
<dd>
<input type="number" min="2" max="7" asp-for="Players" />
</dd>
</dl>
<dl>
<dt>
<label asp-for="FormFile"></label>
</dt>
<dd>
<input asp-for="FormFile" type="file" accept=".map">
</dd>
</dl>
<button asp-route="RequestPrivateServer">Request</button> <button asp-route="RequestPrivateServer">Request</button>
</form> </form>
</div> </div>

View File

@ -8,7 +8,8 @@
@if (Model.IsSuccess) @if (Model.IsSuccess)
{ {
<p>Command: <span class="code">@Model.CommandLine</span></p> <p>Command: <span class="code">@Model.CommandLine</span></p>
<p>You have 10 minutes to join.</p> <p>At least 1 person must join in 10 minutes, otherwise the server will be killed.</p>
<p>If you have provided a custom map, you have to type its name in the user map section when starting a new game.</p>
} }
else else
{ {