mirror of
https://github.com/CommonLoon102/NBloodServerSupervisor.git
synced 2024-12-22 18:52:44 +01:00
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:
parent
3417571b70
commit
3af4c9dfc7
@ -9,6 +9,7 @@ namespace Common
|
||||
public static class CommandLineUtils
|
||||
{
|
||||
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) =>
|
||||
$"nblood -client {host} -port {port} {modCommandLine}";
|
||||
|
@ -7,6 +7,8 @@ namespace Common
|
||||
{
|
||||
public class Constants
|
||||
{
|
||||
public const long FileSizeLimit = 1024 * 1024;
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, Mod> SupportedMods = new Dictionary<string, Mod>()
|
||||
{
|
||||
{ "BLOOD", new Mod("BLOOD", "Blood", "") },
|
||||
|
@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.IO;
|
||||
|
||||
namespace Common
|
||||
{
|
||||
@ -18,7 +19,7 @@ namespace Common
|
||||
|
||||
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();
|
||||
if (serversRunning >= maximumServers)
|
||||
@ -27,7 +28,7 @@ namespace Common
|
||||
Mod mod = GetModByName(modName);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -37,7 +38,7 @@ namespace Common
|
||||
return Constants.SupportedMods["BLOOD"];
|
||||
|
||||
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()];
|
||||
}
|
||||
@ -59,9 +60,11 @@ namespace Common
|
||||
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,
|
||||
WorkingDirectory = CommandLineUtils.BloodDir
|
||||
@ -70,6 +73,18 @@ namespace Common
|
||||
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()
|
||||
{
|
||||
string nbloodServer = nBloodExecutable;
|
||||
|
@ -5,6 +5,8 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Common;
|
||||
|
||||
namespace Supervisor
|
||||
{
|
||||
@ -16,7 +18,8 @@ namespace Supervisor
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromSeconds(2));
|
||||
TempMapFolderCleanup();
|
||||
Thread.Sleep(TimeSpan.FromSeconds(10));
|
||||
KillUnusedServers();
|
||||
}
|
||||
});
|
||||
@ -39,7 +42,30 @@ namespace Supervisor
|
||||
}
|
||||
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...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,8 @@ namespace WebInterface.Controllers
|
||||
[ApiController]
|
||||
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 DateTime _lastRefresh = DateTime.MinValue;
|
||||
private static ListServersResponse _lastServerList = null;
|
||||
@ -71,7 +73,7 @@ namespace WebInterface.Controllers
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex.ToString());
|
||||
return new StartServerResponse("Unhandled exception has been occured. Check the logs for details.");
|
||||
return new StartServerResponse(generalErrorMessage);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -97,7 +99,7 @@ namespace WebInterface.Controllers
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex.ToString());
|
||||
return new ListServersResponse("Unhandled exception has been occured. Check the logs for details.");
|
||||
return new ListServersResponse(generalErrorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WebInterface.Infrastructure;
|
||||
using WebInterface.Services;
|
||||
|
||||
namespace WebInterface.Controllers
|
||||
@ -12,11 +13,18 @@ namespace WebInterface.Controllers
|
||||
{
|
||||
private readonly IPrivateServerService _privateServerService;
|
||||
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;
|
||||
_rateLimiterService = rateLimiterService;
|
||||
_customMapService = customMapService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Route("nblood/private")]
|
||||
@ -41,22 +49,31 @@ namespace WebInterface.Controllers
|
||||
try
|
||||
{
|
||||
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))
|
||||
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,
|
||||
spawnedServer.Port,
|
||||
spawnedServer.Mod.CommandLine);
|
||||
|
||||
viewModel = new StartServerResponse(spawnedServer.Port, commandLine);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (WebInterfaceException ex)
|
||||
{
|
||||
viewModel = new StartServerResponse(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex.ToString());
|
||||
viewModel = new StartServerResponse("Internal server error.");
|
||||
}
|
||||
|
||||
return View("Result", viewModel);
|
||||
}
|
||||
|
19
WebInterface/Infrastructure/WebInterfaceException.cs
Normal file
19
WebInterface/Infrastructure/WebInterfaceException.cs
Normal 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) { }
|
||||
}
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
using Common;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@ -7,6 +9,8 @@ using System.Linq;
|
||||
|
||||
namespace WebInterface
|
||||
{
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = Constants.FileSizeLimit)]
|
||||
[RequestSizeLimit(Constants.FileSizeLimit)]
|
||||
public class PrivateViewModel
|
||||
{
|
||||
[Required]
|
||||
@ -18,5 +22,8 @@ namespace WebInterface
|
||||
public List<SelectListItem> ModNames { get; } = Constants.SupportedMods
|
||||
.Select(m => new SelectListItem(m.Value.FriendlyName, m.Value.Name))
|
||||
.ToList();
|
||||
|
||||
[Display(Name = "Custom map (optional)")]
|
||||
public IFormFile FormFile { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Common;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@ -20,7 +21,8 @@ namespace WebInterface
|
||||
Host.CreateDefaultBuilder(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
webBuilder.UseStartup<Startup>()
|
||||
.ConfigureKestrel((context, options) => options.Limits.MaxRequestBodySize = Constants.FileSizeLimit);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using Common;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using WebInterface.Infrastructure;
|
||||
|
||||
namespace WebInterface.Services
|
||||
{
|
||||
@ -26,7 +29,7 @@ namespace WebInterface.Services
|
||||
"CPBB04.MAP",
|
||||
};
|
||||
|
||||
private List<string> ListableCustomMaps => Directory.GetFiles(Common.CommandLineUtils.BloodDir,
|
||||
private List<string> ListableCustomMaps => Directory.GetFiles(CommandLineUtils.BloodDir,
|
||||
"*.map", SearchOption.TopDirectoryOnly)
|
||||
.Select(m => Path.GetFileName(m))
|
||||
.Where(m => !ContainsString(crypticMaps, m))
|
||||
@ -38,11 +41,11 @@ namespace WebInterface.Services
|
||||
{
|
||||
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
|
||||
{
|
||||
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) =>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@ -9,5 +10,6 @@ namespace WebInterface.Services
|
||||
{
|
||||
IList<string> ListCustomMaps();
|
||||
byte[] GetCustomMapBytes(string map);
|
||||
string StoreTempCustomMap(IFormFile formFile);
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,6 @@ namespace WebInterface.Services
|
||||
{
|
||||
public interface IPrivateServerService
|
||||
{
|
||||
SpawnedServerInfo SpawnNewPrivateServer(int players, string modName);
|
||||
SpawnedServerInfo SpawnNewPrivateServer(int players, string modName, string tempFolderName = "");
|
||||
}
|
||||
}
|
||||
|
@ -15,11 +15,11 @@ namespace WebInterface.Services
|
||||
private static readonly Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
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));
|
||||
|
||||
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");
|
||||
socket.SendTo(payload, webApiListenerEndPoint);
|
||||
|
||||
|
@ -3,24 +3,27 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace WebInterface.Services
|
||||
{
|
||||
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)
|
||||
{
|
||||
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;
|
||||
if (isLimited)
|
||||
return false;
|
||||
}
|
||||
|
||||
requestHistory.AddOrUpdate(remoteIpAddress,
|
||||
requestHistory.AddOrUpdate(hash,
|
||||
(ip) =>
|
||||
{
|
||||
var list = new List<DateTime>();
|
||||
@ -35,5 +38,25 @@ namespace WebInterface.Services
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,8 +3,11 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using Common;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@ -28,7 +31,7 @@ namespace WebInterface
|
||||
supervisor += ".exe";
|
||||
string supervisorPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), supervisor);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -43,6 +46,29 @@ namespace WebInterface
|
||||
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(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.
|
||||
|
@ -6,6 +6,12 @@
|
||||
|
||||
<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>
|
||||
@foreach (var map in Model)
|
||||
{
|
||||
|
@ -6,11 +6,33 @@
|
||||
|
||||
<h1>@ViewData["Title"]</h1>
|
||||
<div class="bordered" style="padding:4px">
|
||||
<form method="post">
|
||||
<label asp-for="ModName"></label>
|
||||
<select asp-for="ModName" asp-items="Model.ModNames"></select> <br />
|
||||
<label asp-for="Players"></label>
|
||||
<input type="number" min="2" max="7" asp-for="Players" /> <br />
|
||||
<form enctype="multipart/form-data" method="post">
|
||||
<dl>
|
||||
<dt>
|
||||
<label asp-for="ModName"></label>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -8,7 +8,8 @@
|
||||
@if (Model.IsSuccess)
|
||||
{
|
||||
<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
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user