started update for readme

start update for version changes
hopefully fixed pesky stat bug
move vpn detection into script plugin
This commit is contained in:
RaidMax 2018-08-26 19:20:47 -05:00
parent 1343d4959e
commit 0538d9f479
22 changed files with 388 additions and 192 deletions

View File

@ -415,6 +415,34 @@ namespace IW4MAdmin.Application
try try
{ {
await newEvent.Owner.ExecuteEvent(newEvent); await newEvent.Owner.ExecuteEvent(newEvent);
// todo: this is a hacky mess
if (newEvent.Origin?.DelayedEvents?.Count > 0 &&
newEvent.Origin?.State == Player.ClientState.Connected)
{
var events = newEvent.Origin.DelayedEvents;
// add the delayed event to the queue
while (events?.Count > 0)
{
var e = events.Dequeue();
e.Origin = newEvent.Origin;
// check if the target was assigned
if (e.Target != null)
{
// update the target incase they left or have newer info
e.Target = newEvent.Owner.GetPlayersAsList()
.FirstOrDefault(p => p.NetworkId == e.Target.NetworkId);
// we have to throw out the event because they left
if (e.Target == null)
{
Logger.WriteWarning($"Delayed event for {e.Origin} was removed because the target has left");
continue;
}
}
this.GetEventHandler().AddEvent(e);
}
}
#if DEBUG #if DEBUG
Logger.WriteDebug("Processed Event"); Logger.WriteDebug("Processed Event");
#endif #endif

View File

@ -1,42 +0,0 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace IW4MAdmin.Application.Misc
{
public class VPNCheck
{
public static async Task<bool> UsingVPN(string ip, string apiKey)
{
#if DEBUG
return await Task.FromResult(false);
#else
try
{
using (var RequestClient = new System.Net.Http.HttpClient())
{
RequestClient.DefaultRequestHeaders.Add("X-Key", apiKey);
string response = await RequestClient.GetStringAsync($"http://v2.api.iphub.info/ip/{ip}");
var responseJson = JsonConvert.DeserializeObject<JObject>(response);
int blockType = Convert.ToInt32(responseJson["block"]);
/*if (responseJson.ContainsKey("isp"))
{
if (responseJson["isp"].ToString() == "TSF-IP-CORE")
return true;
}*/
return blockType == 1;
}
}
catch (Exception)
{
return false;
}
#endif
}
}
}

View File

@ -16,7 +16,6 @@ using SharedLibraryCore.Configuration;
using SharedLibraryCore.Exceptions; using SharedLibraryCore.Exceptions;
using SharedLibraryCore.Localization; using SharedLibraryCore.Localization;
using IW4MAdmin.Application.Misc;
using IW4MAdmin.Application.RconParsers; using IW4MAdmin.Application.RconParsers;
using IW4MAdmin.Application.EventParsers; using IW4MAdmin.Application.EventParsers;
using IW4MAdmin.Application.IO; using IW4MAdmin.Application.IO;
@ -96,9 +95,6 @@ namespace IW4MAdmin
{ {
return true; return true;
} }
// if they're authenticated but haven't been added yet
// we want to set their delayed events
var delayedEventQueue = Players[polledPlayer.ClientNumber].DelayedEvents;
#if !DEBUG #if !DEBUG
if (polledPlayer.Name.Length < 3) if (polledPlayer.Name.Length < 3)
@ -253,14 +249,6 @@ namespace IW4MAdmin
return true; return true;
} }
if (!Manager.GetApplicationSettings().Configuration().EnableClientVPNs &&
Manager.GetApplicationSettings().Configuration().VpnExceptionIds?.FirstOrDefault(i => i == player.ClientId) != null &&
await VPNCheck.UsingVPN(player.IPAddressString, Manager.GetApplicationSettings().Configuration().IPHubAPIKey))
{
await player.Kick(Utilities.CurrentLocalization.LocalizationIndex["SERVER_KICK_VPNS_NOTALLOWED"], new Player() { ClientId = 1 });
return true;
}
var e = new GameEvent() var e = new GameEvent()
{ {
Type = GameEvent.EventType.Connect, Type = GameEvent.EventType.Connect,
@ -271,28 +259,6 @@ namespace IW4MAdmin
Manager.GetEventHandler().AddEvent(e); Manager.GetEventHandler().AddEvent(e);
player.State = Player.ClientState.Connected; player.State = Player.ClientState.Connected;
// add the delayed event to the queue
while (delayedEventQueue?.Count > 0)
{
e = delayedEventQueue.Dequeue();
e.Origin = player;
// check if the target was assigned
if (e.Target != null)
{
// update the target incase they left or have newer info
e.Target = GetPlayersAsList()
.FirstOrDefault(p => p.NetworkId == e.Target.NetworkId);
// we have to throw out the event because they left
if (e.Target == null)
{
Logger.WriteWarning($"Delayed event for {e.Origin} was removed because the target has left");
continue;
}
}
Manager.GetEventHandler().AddEvent(e);
}
return true; return true;
} }
@ -353,24 +319,54 @@ namespace IW4MAdmin
} }
} }
// this allows us to catch exceptions but still run it parallel //// this allows us to catch exceptions but still run it parallel
async Task pluginHandlingAsync(Task onEvent, string pluginName) //async Task pluginHandlingAsync(Task onEvent, string pluginName)
//{
// try
// {
// await onEvent;
// }
// // this happens if a plugin (login) wants to stop commands from executing
// catch (AuthorizationException e)
// {
// await E.Origin.Tell($"{loc["COMMAND_NOTAUTHORIZED"]} - {e.Message}");
// canExecuteCommand = false;
// }
// catch (Exception Except)
// {
// Logger.WriteError($"{loc["SERVER_PLUGIN_ERROR"]} [{pluginName}]");
// Logger.WriteDebug(String.Format("Error Message: {0}", Except.Message));
// Logger.WriteDebug(String.Format("Error Trace: {0}", Except.StackTrace));
// while (Except.InnerException != null)
// {
// Except = Except.InnerException;
// Logger.WriteDebug($"Inner exception: {Except.Message}");
// }
// }
//}
//var pluginTasks = SharedLibraryCore.Plugins.PluginImporter.ActivePlugins.
// Select(p => pluginHandlingAsync(p.OnEventAsync(E, this), p.Name));
//// execute all the plugin updates simultaneously
//await Task.WhenAll(pluginTasks);
foreach (var plugin in SharedLibraryCore.Plugins.PluginImporter.ActivePlugins)
{ {
try try
{ {
await onEvent; await plugin.OnEventAsync(E, this);
} }
// this happens if a plugin (login) wants to stop commands from executing
catch (AuthorizationException e) catch (AuthorizationException e)
{ {
await E.Origin.Tell($"{loc["COMMAND_NOTAUTHORIZED"]} - {e.Message}"); await E.Origin.Tell($"{loc["COMMAND_NOTAUTHORIZED"]} - {e.Message}");
canExecuteCommand = false; canExecuteCommand = false;
} }
catch (Exception Except) catch (Exception Except)
{ {
Logger.WriteError($"{loc["SERVER_PLUGIN_ERROR"]} [{pluginName}]"); Logger.WriteError($"{loc["SERVER_PLUGIN_ERROR"]} [{plugin.Name}]");
Logger.WriteDebug(String.Format("Error Message: {0}", Except.Message)); Logger.WriteDebug(String.Format("Error Message: {0}", Except.Message));
Logger.WriteDebug(String.Format("Error Trace: {0}", Except.StackTrace)); Logger.WriteDebug(String.Format("Error Trace: {0}", Except.StackTrace));
while (Except.InnerException != null) while (Except.InnerException != null)
@ -381,12 +377,6 @@ namespace IW4MAdmin
} }
} }
var pluginTasks = SharedLibraryCore.Plugins.PluginImporter.ActivePlugins.
Select(p => pluginHandlingAsync(p.OnEventAsync(E, this), p.Name));
// execute all the plugin updates simultaneously
await Task.WhenAll(pluginTasks);
// hack: this prevents commands from getting executing that 'shouldn't' be // hack: this prevents commands from getting executing that 'shouldn't' be
if (E.Type == GameEvent.EventType.Command && if (E.Type == GameEvent.EventType.Command &&
E.Extra != null && E.Extra != null &&
@ -406,19 +396,15 @@ namespace IW4MAdmin
{ {
if (E.Type == GameEvent.EventType.Connect) if (E.Type == GameEvent.EventType.Connect)
{ {
// this may be a fix for a hard to reproduce null exception error ChatHistory.Add(new ChatInfo()
lock (ChatHistory)
{ {
ChatHistory.Add(new ChatInfo() Name = E.Origin?.Name ?? "ERROR!",
{ Message = "CONNECTED",
Name = E.Origin?.Name ?? "ERROR!", Time = DateTime.UtcNow
Message = "CONNECTED", });
Time = DateTime.UtcNow
});
}
if (E.Origin.Level > Player.Permission.Moderator) if (E.Origin.Level > Player.Permission.Moderator)
await E.Origin.Tell(string.Format(loc["SERVER_REPORT_COUNT"], E.Owner.Reports.Count)); await E.Origin.Tell(string.Format(loc["SERVER_REPORT_COUNT"], E.Owner.Reports.Count));
} }
else if (E.Type == GameEvent.EventType.Join) else if (E.Type == GameEvent.EventType.Join)
@ -592,6 +578,8 @@ namespace IW4MAdmin
Owner = this Owner = this
}; };
client.State = Player.ClientState.Disconnecting;
Manager.GetEventHandler().AddEvent(e); Manager.GetEventHandler().AddEvent(e);
// todo: needed? // todo: needed?
// wait until the disconnect event is complete // wait until the disconnect event is complete
@ -601,11 +589,10 @@ namespace IW4MAdmin
AuthQueue.AuthenticateClients(CurrentPlayers); AuthQueue.AuthenticateClients(CurrentPlayers);
// all polled players should be authenticated foreach (var c in AuthQueue.GetAuthenticatedClients())
var addPlayerTasks = AuthQueue.GetAuthenticatedClients() {
.Select(client => AddPlayer(client)); await AddPlayer(c);
}
await Task.WhenAll(addPlayerTasks);
return CurrentPlayers.Count; return CurrentPlayers.Count;
} }

View File

@ -1,7 +1,7 @@
certifi==2018.4.16 certifi>=2018.4.16
chardet==3.0.4 chardet>=3.0.4
idna==2.7 idna>=2.7
pip==18.0 pip>=18.0
requests==2.19.1 requests>=2.19.1
setuptools==39.0.1 setuptools>=39.0.1
urllib3==1.23 urllib3>=1.23

View File

@ -35,6 +35,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IW4ScriptCommands", "Plugin
EndProject EndProject
Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "DiscordWebhook", "DiscordWebhook\DiscordWebhook.pyproj", "{15A81D6E-7502-46CE-8530-0647A380B5F4}" Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "DiscordWebhook", "DiscordWebhook\DiscordWebhook.pyproj", "{15A81D6E-7502-46CE-8530-0647A380B5F4}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ScriptPlugins", "ScriptPlugins", "{3F9ACC27-26DB-49FA-BCD2-50C54A49C9FA}"
ProjectSection(SolutionItems) = preProject
Plugins\ScriptPlugins\VPNDetection.js = Plugins\ScriptPlugins\VPNDetection.js
EndProjectSection
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -312,6 +317,7 @@ Global
{D9F2ED28-6FA5-40CA-9912-E7A849147AB1} = {26E8B310-269E-46D4-A612-24601F16065F} {D9F2ED28-6FA5-40CA-9912-E7A849147AB1} = {26E8B310-269E-46D4-A612-24601F16065F}
{B72DEBFB-9D48-4076-8FF5-1FD72A830845} = {26E8B310-269E-46D4-A612-24601F16065F} {B72DEBFB-9D48-4076-8FF5-1FD72A830845} = {26E8B310-269E-46D4-A612-24601F16065F}
{6C706CE5-A206-4E46-8712-F8C48D526091} = {26E8B310-269E-46D4-A612-24601F16065F} {6C706CE5-A206-4E46-8712-F8C48D526091} = {26E8B310-269E-46D4-A612-24601F16065F}
{3F9ACC27-26DB-49FA-BCD2-50C54A49C9FA} = {26E8B310-269E-46D4-A612-24601F16065F}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {84F8F8E0-1F73-41E0-BD8D-BB6676E2EE87} SolutionGuid = {84F8F8E0-1F73-41E0-BD8D-BB6676E2EE87}

View File

@ -108,7 +108,6 @@
<Folder Include="master\templates\" /> <Folder Include="master\templates\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="FolderProfile.pubxml" />
<Content Include="master\config\master.json" /> <Content Include="master\config\master.json" />
<Content Include="requirements.txt" /> <Content Include="requirements.txt" />
<Content Include="master\templates\index.html" /> <Content Include="master\templates\index.html" />
@ -125,7 +124,7 @@
<Architecture>X64</Architecture> <Architecture>X64</Architecture>
</Interpreter> </Interpreter>
</ItemGroup> </ItemGroup>
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.Web.targets" /> <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.Web.targets" />
<!-- Specify pre- and post-build commands in the BeforeBuild and <!-- Specify pre- and post-build commands in the BeforeBuild and
AfterBuild targets below. --> AfterBuild targets below. -->
<Target Name="BeforeBuild"> <Target Name="BeforeBuild">

View File

@ -15,9 +15,9 @@ class Base():
self.scheduler.start() self.scheduler.start()
self.scheduler.add_job( self.scheduler.add_job(
func=self._remove_staleinstances, func=self._remove_staleinstances,
trigger=IntervalTrigger(seconds=120), trigger=IntervalTrigger(seconds=60),
id='stale_instance_remover', id='stale_instance_remover',
name='Remove stale instances if no heartbeat in 120 seconds', name='Remove stale instances if no heartbeat in 60 seconds',
replace_existing=True replace_existing=True
) )
self.scheduler.add_job( self.scheduler.add_job(
@ -41,7 +41,7 @@ class Base():
def _remove_staleinstances(self): def _remove_staleinstances(self):
for key, value in list(self.instance_list.items()): for key, value in list(self.instance_list.items()):
if int(time.time()) - value.last_heartbeat > 120: if int(time.time()) - value.last_heartbeat > 60:
print('[_remove_staleinstances] removing stale instance {id}'.format(id=key)) print('[_remove_staleinstances] removing stale instance {id}'.format(id=key))
del self.instance_list[key] del self.instance_list[key]
del self.token_list[key] del self.token_list[key]

View File

@ -11,7 +11,7 @@ class Authenticate(Resource):
if ctx.get_token(instance_id) is not False: if ctx.get_token(instance_id) is not False:
return { 'message' : 'that id already has a token'}, 401 return { 'message' : 'that id already has a token'}, 401
else: else:
expires = datetime.timedelta(days=1) expires = datetime.timedelta(days=30)
token = create_access_token(instance_id, expires_delta=expires) token = create_access_token(instance_id, expires_delta=expires)
ctx.add_token(instance_id, token) ctx.add_token(instance_id, token)
return { 'access_token' : token }, 200 return { 'access_token' : token }, 200

View File

@ -0,0 +1,63 @@
const plugin = {
author: 'RaidMax',
version: 1.0,
name: 'VPN Kick Plugin',
manager: null,
logger: null,
vpnExceptionIds: [],
checkForVpn(origin) {
let exempt = false;
// prevent players that are exempt from being kicked
this.vpnExceptionIds.forEach(function(id) {
if (id === origin.ClientId) {
exempt = true;
return false;
}
});
if (exempt) {
return;
}
let usingVPN = false;
try {
let httpRequest = System.Net.WebRequest.Create('https://api.xdefcon.com/proxy/check/?ip=' + origin.IPAddressString);
let response = httpRequest.GetResponse();
let data = response.GetResponseStream();
let streamReader = new System.IO.StreamReader(data);
let jsonResponse = streamReader.ReadToEnd();
streamReader.Dispose();
response.Close();
let parsedJSON = JSON.parse(jsonResponse);
usingVPN = parsedJSON['success'] && parsedJSON['proxy'];
} catch (e) {
this.logger.WriteError(e.message);
}
if (usingVPN) {
let library = importNamespace('SharedLibraryCore');
let kickOrigin = new library.Objects.Player();
kickOrigin.ClientId = 1;
origin.Kick(_localization.LocalizationIndex["SERVER_KICK_VPNS_NOTALLOWED"], kickOrigin);
}
},
onEventAsync(gameEvent, server) {
// connect event
if (gameEvent.Type === 3) {
this.checkForVpn(gameEvent.Origin)
}
},
onLoadAsync(manager) {
this.manager = manager;
this.logger = manager.GetLogger();
},
onUnloadAsync() {},
onTickAsync(server) {}
}

View File

@ -435,6 +435,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
case Penalty.PenaltyType.Ban: case Penalty.PenaltyType.Ban:
if (attacker.Level == Player.Permission.Banned) if (attacker.Level == Player.Permission.Banned)
break; break;
await saveLog();
await attacker.Ban(Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_CHEAT_DETECTED"], new Player() await attacker.Ban(Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_CHEAT_DETECTED"], new Player()
{ {
ClientId = 1, ClientId = 1,
@ -448,7 +449,6 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
} }
} }
}); });
await saveLog();
break; break;
case Penalty.PenaltyType.Flag: case Penalty.PenaltyType.Flag:
if (attacker.Level != Player.Permission.User) if (attacker.Level != Player.Permission.User)
@ -937,7 +937,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
int serverId = sv.GetHashCode(); int serverId = sv.GetHashCode();
var statsSvc = ContextThreads[serverId]; var statsSvc = ContextThreads[serverId];
// Log.WriteDebug("Syncing stats contexts"); // Log.WriteDebug("Syncing stats contexts");
await statsSvc.ServerStatsSvc.SaveChangesAsync(); await statsSvc.ServerStatsSvc.SaveChangesAsync();
//await statsSvc.ClientStatSvc.SaveChangesAsync(); //await statsSvc.ClientStatSvc.SaveChangesAsync();
await statsSvc.KillStatsSvc.SaveChangesAsync(); await statsSvc.KillStatsSvc.SaveChangesAsync();

View File

@ -66,7 +66,7 @@ namespace IW4MAdmin.Plugins.Stats.Web.Controllers
.Include(s => s.HitDestination) .Include(s => s.HitDestination)
.Include(s => s.CurrentViewAngle) .Include(s => s.CurrentViewAngle)
.Include(s => s.PredictedViewAngles) .Include(s => s.PredictedViewAngles)
.OrderBy(s => s.When) .OrderBy(s => new { s.When, s.Hits })
.ToListAsync(); .ToListAsync();
if (penaltyInfo != null) if (penaltyInfo != null)

194
README.md
View File

@ -1,19 +1,16 @@
# IW4MAdmin # IW4MAdmin
### Quick Start Guide ### Quick Start Guide
### Version 2.1 ### Version 2.2
_______ _______
### About ### About
**IW4MAdmin** is an administration tool for [IW4x](https://iw4xcachep26muba.onion.link/), [Pluto T6](https://forum.plutonium.pw/category/33/plutonium-t6), [Pluto IW5](https://forum.plutonium.pw/category/5/plutonium-iw5), and most Call of Duty® dedicated servers. It allows complete control of your server; from changing maps, to banning players, **IW4MAdmin** monitors and records activity on your server(s). With plugin support, extending its functionality is a breeze. **IW4MAdmin** is an administration tool for [IW4x](https://iw4xcachep26muba.onion.link/), [Pluto T6](https://forum.plutonium.pw/category/33/plutonium-t6), [Pluto IW5](https://forum.plutonium.pw/category/5/plutonium-iw5), and most Call of Duty® dedicated servers. It allows complete control of your server; from changing maps, to banning players, **IW4MAdmin** monitors and records activity on your server(s). With plugin support, extending its functionality is a breeze.
### Download
Latest binary builds are always available at https://raidmax.org/IW4MAdmin
### Setup ### Setup
**IW4MAdmin** requires minimal configuration to run. There is only one prerequisite. **IW4MAdmin** requires minimal configuration to run. There is only one prerequisite.
* [.NET Core 2.0.7 Runtime](https://www.microsoft.com/net/download/dotnet-core/runtime-2.0.7) *or newer* * [.NET Core 2.1 Runtime](https://www.microsoft.com/net/download) *or newer*
1. Extract `IW4MAdmin-<version>.zip` 1. Extract `IW4MAdmin-<version>.zip`
2. Open command prompt or terminal in the extracted folder 2. Run `StartIW4MAdmin.cmd`
3. Run `dotnet IW4MAdmin.dll`
___ ___
### Configuration ### Configuration
@ -37,14 +34,33 @@ When **IW4MAdmin** is launched for the _first time_, you will be prompted to set
* Allow clients to use a [VPN](https://en.wikipedia.org/wiki/Virtual_private_network) * Allow clients to use a [VPN](https://en.wikipedia.org/wiki/Virtual_private_network)
* _This feature requires an active api key on [iphub.info](https://iphub.info/)_ * _This feature requires an active api key on [iphub.info](https://iphub.info/)_
`Enable discord link` `Enable social link`
* Shows a link to your server's discord on the webfront * Shows a link to your community's social media/website on the webfront
* _This feature requires an invite link to your discord server_
`Use Custom Encoding Parser` `Use Custom Encoding Parser`
* Allows alternative encodings to be used for parsing game information and events * Allows alternative encodings to be used for parsing game information and events
* **Russian users should use this and then specify** `windows-1251` **as the encoding string** * **Russian users should use this and then specify** `windows-1251` **as the encoding string**
#### Server Configuration
After initial configuration is finished, you will be prompted to configure your servers for **IW4MAdmin**.
`Enter server IP Address`
* For almost all scenarios `127.0.0.1` is sufficient
`Enter server port`
* The port that your server is listening on (can be obtained via `net_port`)
`Enter server RCon password`
* The *\(R\)emote (Con)sole* password set in your server configuration (can be obtained via `rcon_password`)
`Use Pluto T6 parser`
* Used if setting up a server for Plutonium T6 (BO2)
`Use Pluto IW5 parser`
* Used if setting a server for Plutonium IW5 (MW3)
`Enter number of reserved slots`
* The number of client slots reserver for privileged players (unavailable for regular users to occupy)
#### Advanced Configuration #### Advanced Configuration
If you wish to further customize your experience of **IW4MAdmin**, the following configuration file(s) will allow you to changes core options using any text-editor. If you wish to further customize your experience of **IW4MAdmin**, the following configuration file(s) will allow you to changes core options using any text-editor.
@ -54,6 +70,20 @@ If you wish to further customize your experience of **IW4MAdmin**, the following
`WebfrontBindUrl` `WebfrontBindUrl`
* Specifies the address and port the webfront will listen on. * Specifies the address and port the webfront will listen on.
* The value can be an [IP Address](https://en.wikipedia.org/wiki/IP_address):port or [Domain Name](https://en.wikipedia.org/wiki/Domain_name):port * The value can be an [IP Address](https://en.wikipedia.org/wiki/IP_address):port or [Domain Name](https://en.wikipedia.org/wiki/Domain_name):port
* Example http://gameserver.com:8080
`CustomLocale`
* Specifies a [locale name](https://msdn.microsoft.com/en-us/library/39cwe7zf.aspx) to use instead of system default
* Locale must be from the `Equivalent Locale Name` column
`ConnectionString`
* Specifies the [connection string](https://www.connectionstrings.com/mysql/) to a MySQL server that is used instead of SQLite
`RConPollRate`
* Specifies (in milliseconds) how often to poll each server for updates
`VpnExceptionIds`
* Specifies the list of `Client IDs` exempt from the VPN check (if enabled)
`Servers` `Servers`
* Specifies the list of servers **IW4MAdmin** will monitor * Specifies the list of servers **IW4MAdmin** will monitor
@ -67,12 +97,22 @@ If you wish to further customize your experience of **IW4MAdmin**, the following
* Specifies the list of messages that are broadcasted to the particular server * Specifies the list of messages that are broadcasted to the particular server
* `Rules` * `Rules`
* Specifies the list of rules that apply to the particular server * Specifies the list of rules that apply to the particular server
* `ReservedSlotNumber`
* Specifies the number of client slots to reserve for privileged users
`AutoMessagePeriod` `AutoMessagePeriod`
* Specifies (in seconds) how often messages should be broadcasted to the server(s) * Specifies (in seconds) how often messages should be broadcasted to each server
`AutoMessages` `AutoMessages`
* Specifies the list of messages that are broadcasted to **all** servers * Specifies the list of messages that are broadcasted to **all** servers
* Specially formatted **tokens** can be used to broadcast dynamic information
* `{{TOTALPLAYERS}}` &mdash; displays how many players have connected
* `{{TOPSTATS}}` &mdash; displays the top 5 players on the server based on performance
* `{{MOSTPLAYED}}` &mdash; displays the top 5 players based on number of kills
* `{{TOTALPLAYTIME}}` &mdash; displays the cumulative play time (in man-hours) on all monitored servers
* `{{VERSION}}` &mdash; displays the version of **IW4MAdmin**
* `{{ADMINS}}` &mdash; displays the currently connected and *unmasked* privileged users online
* `{{NEXTMAP}} &dmash; displays the next map in rotation
`GlobalRules` `GlobalRules`
* Specifies the list of rules that apply to **all** servers` * Specifies the list of rules that apply to **all** servers`
@ -88,44 +128,48 @@ ___
### Commands ### Commands
|Name |Alias|Description |Requires Target|Syntax |Required Level| |Name |Alias|Description |Requires Target|Syntax |Required Level|
|--------------| -----| --------------------------------------------------------| -----------------| -------------| ----------------| |--------------| -----| --------------------------------------------------------| -----------------| -------------| ----------------|
|prune|pa|demote any admins that have not connected recently (defaults to 30 days)|False|!pa \<optional inactive days\>|Owner| |prune|pa|demote any privileged clients that have not connected recently (defaults to 30 days)|False|!pa \<optional inactive days\>|Owner|
|quit|q|quit IW4MAdmin|False|!q |Owner| |quit|q|quit IW4MAdmin|False|!q |Owner|
|rcon|rcon|send rcon command to server|False|!rcon \<command\>|Owner| |rcon|rcon|send rcon command to server|False|!rcon \<commands\>|Owner|
|ban|b|permanently ban a player from the server|True|!b \<player\> \<reason\>|SeniorAdmin| |ban|b|permanently ban a client from the server|True|!b \<player\> \<reason\>|SeniorAdmin|
|unban|ub|unban player by database id|True|!ub \<databaseID\> \<reason\>|SeniorAdmin| |unban|ub|unban client by client id|True|!ub \<client id\> \<reason\>|SeniorAdmin|
|find|f|find player in database|False|!f \<player\>|Administrator| |find|f|find client in database|False|!f \<player\>|Administrator|
|killserver|kill|kill the game server|False|!kill |Administrator| |killserver|kill|kill the game server|False|!kill |Administrator|
|map|m|change to specified map|False|!m \<map\>|Administrator| |map|m|change to specified map|False|!m \<map\>|Administrator|
|maprotate|mr|cycle to the next map in rotation|False|!mr |Administrator| |maprotate|mr|cycle to the next map in rotation|False|!mr |Administrator|
|plugins|p|view all loaded plugins|False|!p |Administrator| |plugins|p|view all loaded plugins|False|!p |Administrator|
|alias|known|get past aliases and ips of a player|True|!known \<player\>|Moderator| |tempban|tb|temporarily ban a client for specified time (defaults to 1 hour)|True|!tb \<player\> \<duration (m\|h\|d\|w\|y)\> \<reason\>|Administrator|
|baninfo|bi|get information about a ban for a player|True|!bi \<player\>|Moderator| |alias|known|get past aliases and ips of a client|True|!known \<player\>|Moderator|
|baninfo|bi|get information about a ban for a client|True|!bi \<player\>|Moderator|
|fastrestart|fr|fast restart current map|False|!fr |Moderator| |fastrestart|fr|fast restart current map|False|!fr |Moderator|
|flag|fp|flag a suspicious player and announce to admins on join|True|!fp \<player\> \<reason\>|Moderator| |flag|fp|flag a suspicious client and announce to admins on join|True|!fp \<player\> \<reason\>|Moderator|
|kick|k|kick a client by name|True|!k \<player\> \<reason\>|Moderator|
|list|l|list active clients|False|!l |Moderator| |list|l|list active clients|False|!l |Moderator|
|mask|hide|hide your presence as an administrator|False|!hide |Moderator| |mask|hide|hide your presence as a privileged client|False|!hide |Moderator|
|reports|reps|get or clear recent reports|False|!reps \<optional clear\>|Moderator| |reports|reps|get or clear recent reports|False|!reps \<optional clear\>|Moderator|
|say|s|broadcast message to all players|False|!s \<message\>|Moderator| |say|s|broadcast message to all clients|False|!s \<message\>|Moderator|
|setlevel|sl|set player to specified administration level|True|!sl \<player\> \<level\>|Moderator| |setlevel|sl|set client to specified privilege level|True|!sl \<player\> \<level\>|Moderator|
|setpassword|sp|set your authentication password|False|!sp \<password\>|Moderator| |setpassword|sp|set your authentication password|False|!sp \<password\>|Moderator|
|tempban|tb|temporarily ban a player for specified time (defaults to 1 hour)|True|!tb \<player\> \<duration (m\|h\|d\|w\|y)\> \<reason\>|Moderator| |unflag|uf|Remove flag for client|True|!uf \<player\>|Moderator|
|uptime|up|get current application running time|False|!up |Moderator| |uptime|up|get current application running time|False|!up |Moderator|
|usage|us|get current application memory usage|False|!us |Moderator| |usage|us|get application memory usage|False|!us |Moderator|
|kick|k|kick a player by name|True|!k \<player\> \<reason\>|Trusted| |balance|bal|balance teams|False|!bal |Trusted|
|login|l|login using password|False|!l \<password\>|Trusted| |login|li|login using password|False|!li \<password\>|Trusted|
|warn|w|warn player for infringing rules|True|!w \<player\> \<reason\>|Trusted| |warn|w|warn client for infringing rules|True|!w \<player\> \<reason\>|Trusted|
|warnclear|wc|remove all warning for a player|True|!wc \<player\>|Trusted| |warnclear|wc|remove all warnings for a client|True|!wc \<player\>|Trusted|
|admins|a|list currently connected admins|False|!a |User| |admins|a|list currently connected privileged clients|False|!a |User|
|getexternalip|ip|view your external IP address|False|!ip |User| |getexternalip|ip|view your external IP address|False|!ip |User|
|help|h|list all available commands|False|!h \<optional command\>|User| |help|h|list all available commands|False|!h \<optional commands\>|User|
|ping|pi|get client's ping|False|!pi \<optional client\>|User| |mostplayed|mp|view the top 5 dedicated players on the server|False|!mp |User|
|privatemessage|pm|send message to other player|True|!pm \<player\> \<message\>|User| |owner|iamgod|claim ownership of the server|False|!iamgod |User|
|report|rep|report a player for suspicious behavior|True|!rep \<player\> \<reason\>|User| |ping|pi|get client's latency|False|!pi \<optional player\>|User|
|privatemessage|pm|send message to other client|True|!pm \<player\> \<message\>|User|
|report|rep|report a client for suspicious behavior|True|!rep \<player\> \<reason\>|User|
|resetstats|rs|reset your stats to factory-new|False|!rs |User| |resetstats|rs|reset your stats to factory-new|False|!rs |User|
|rules|r|list server rules|False|!r |User| |rules|r|list server rules|False|!r |User|
|stats|xlrstats|view your stats|False|!xlrstats \<optional player\>|User| |stats|xlrstats|view your stats|False|!xlrstats \<optional player\>|User|
|topstats|ts|view the top 5 players on this server|False|!ts |User| |topstats|ts|view the top 5 players in this server|False|!ts |User|
|whoami|who|give information about yourself.|False|!who |User| |whoami|who|give information about yourself|False|!who |User|
_These commands include all shipped plugin commands._ _These commands include all shipped plugin commands._
@ -192,6 +236,7 @@ ___
|resetstats|rs|reset your stats to factory-new|False|!rs |User| |resetstats|rs|reset your stats to factory-new|False|!rs |User|
|stats|xlrstats|view your stats|False|!xlrstats \<optional player\>|User| |stats|xlrstats|view your stats|False|!xlrstats \<optional player\>|User|
|topstats|ts|view the top 5 players on this server|False|!ts |User| |topstats|ts|view the top 5 players on this server|False|!ts |User|
|mostplayed|mp|view the top 5 dedicated players on the server|False|!mp |User|
- To qualify for top stats, a client must have played for at least `1 hour` and connected within the past `30 days`. - To qualify for top stats, a client must have played for at least `1 hour` and connected within the past `30 days`.
@ -208,6 +253,7 @@ ___
#### Profanity Determent #### Profanity Determent
- This plugin warns and kicks players for using profanity - This plugin warns and kicks players for using profanity
- Profane words and warning message can be specified in `ProfanityDetermentSettings.json` - Profane words and warning message can be specified in `ProfanityDetermentSettings.json`
- If a client's name contains a word listed in the settings, they will immediately be kicked
___ ___
### Webfront ### Webfront
`Home` `Home`
@ -221,6 +267,7 @@ ___
`Login` `Login`
* Allows privileged users to login using their `Client ID` and password set via `setpassword` * Allows privileged users to login using their `Client ID` and password set via `setpassword`
* `ClientID` is a number that can be found by using `!find <client name>` or find the client on the webfront and copy the ID following `ProfileAsync/`
`Profile` `Profile`
* Shows a client's information and history * Shows a client's information and history
@ -229,7 +276,78 @@ ___
* Allows logged in privileged users to execute commands as if they are in-game * Allows logged in privileged users to execute commands as if they are in-game
--- ---
### Extending Plugins
#### Code
IW4Madmin functionality can be extended by writing additional plugins in C#.
#### JavaScript
IW4MAdmin functionality can be extended using JavaScript.
The JavaScript parser supports [some](https://github.com/sebastienros/jint/issues/343) of ECMAScript 6's new features.
#### Plugin Object Template
In order to be properly parsed by the JavaScript engine, every plugin must conform to the following template.
```js
const plugin = {
author: 'YourHandle',
version: 1.0,
name: 'Sample JavaScript Plugin',
### Misc onEventAsync(gameEvent, server) {
},
onLoadAsync(manager) {
},
onUnloadAsync() {
},
onTickAsync(server) {
}
}
```
#### Required Properties
- `author` &mdash; [string] Author of the plugin (usually your name or online name/alias)
- `version` &mdash; [float] Version number of your plugin (useful if you release several different versions)
- `name` &mdash; [string] Name of your plugin (be descriptive!)
- `onEventAsync` &mdash; [function] Handler executed when an event occurs
- `gameEvent` &mdash; [parameter object] Object containing event type, origin, target, and other info (see the GameEvent class declaration)
- `server` &mdash; [parameter object] Object containing information and methods about the server the event occured on (see the Server class declaration)
- `onLoadAsync` &mdash; [function] Handler executed when the plugin is loaded by code
- `manager` &mdash; [parameter object] Object reference to the application manager (see the IManager interface definition)
- `onUnloadAsync` &mdash; [function] Handler executed when the plugin is unloaded by code (see live reloading)
- `onTickAsync` &mdash; [function] Handler executed approximately once per second by code *(unimplemented as of version 2.1)*
- `server` &mdash; [parameter object] Object containing information and methods about the server the event occured on (see the Server class declaration)
### Live Reloading
Thanks to JavaScript's flexibility and parsability, the plugin importer scans the plugins folder and reloads the JavaScript plugins on demand as they're modified. This allows faster development/testing/debugging.
---
### Discord Webhook
If you'd like to receive notifications on your Discord guild, configure and start `DiscordWebhook.py`
#### Requirements
- [Python 3.6](https://www.python.org/downloads/) or newer
- The following [PIP](https://pypi.org/project/pip/) packages (provided in `requirements.txt`)
```certifi>=2018.4.16
chardet>=3.0.4
idna>=2.7
pip>=18.0
requests>=2.19.1
setuptools>=39.0.1
urllib3>=1.23
```
#### Configuration Options
- `IW4MAdminUrl` &mdash; Base url corresponding to your IW4MAdmin `WebfrontBindUrl`.
Example http://127.0.0.1
- `DiscordWebhookNotificationUrl` &mdash; [required] Discord generated URL to send notifications/alerts to; this includes **Reports** and **Bans**
Example https://discordapp.com/api/webhooks/id/token
- `DiscordWebhookInformationUrl` &mdash; [optional] Discord generated URL to send information to; this includes information such as player messages
- `NotifyRoleIds` &mdash; [optional] List of [discord role ids](https://discordhelp.net/role-id) to mention when notification hook is sent
#### Launching
With Python installed, open a terminal/command prompt window open in the `Webhook` folder and execute `python DiscordWebhook.py`
---
## Misc
#### Anti-cheat
This is an [IW4x](https://iw4xcachep26muba.onion.link/) only feature (wider game support planned), that uses analytics to detect aimbots and aim-assist tools.
To utilize anti-cheat, enable it during setup **and** copy `_customcallbacks.gsc` from `userraw` into your `IW4x Server\userraw\scripts` folder.
The anti-cheat feature is a work in progress and as such will be constantly tweaked and may not be 100% accurate, however the goal is to deter as many cheaters as possible from IW4x.
#### Database Storage #### Database Storage
All **IW4MAdmin** information is stored in `Database.db`. Should you need to reset your database, this file can simply be deleted. Additionally, this file should be preserved during updates to retain client information. By default, all **IW4MAdmin** information is stored in `Database.db`. Should you need to reset your database, this file can simply be deleted. Additionally, this file should be preserved during updates to retain client information.
Setting the `ConnectionString` property in `IW4MAdminSettings.json` will cause **IW4MAdmin** to attempt to use a MySQL connection for database storage.

View File

@ -22,13 +22,13 @@ namespace SharedLibraryCore.Configuration
public string CustomLocale { get; set; } public string CustomLocale { get; set; }
public string ConnectionString { get; set; } public string ConnectionString { get; set; }
public int RConPollRate { get; set; } = 5000; public int RConPollRate { get; set; } = 5000;
public List<int> VpnExceptionIds { get; set; }
public string Id { get; set; } public string Id { get; set; }
public List<ServerConfiguration> Servers { get; set; } public List<ServerConfiguration> Servers { get; set; }
public int AutoMessagePeriod { get; set; } public int AutoMessagePeriod { get; set; }
public List<string> AutoMessages { get; set; } public List<string> AutoMessages { get; set; }
public List<string> GlobalRules { get; set; } public List<string> GlobalRules { get; set; }
public List<MapConfiguration> Maps { get; set; } public List<MapConfiguration> Maps { get; set; }
public List<int> VpnExceptionIds { get; set; }
public IBaseConfiguration Generate() public IBaseConfiguration Generate()
{ {

View File

@ -9,8 +9,8 @@ namespace SharedLibraryCore.Configuration
public string IPAddress { get; set; } public string IPAddress { get; set; }
public ushort Port { get; set; } public ushort Port { get; set; }
public string Password { get; set; } public string Password { get; set; }
public List<string> Rules { get; set; } public IList<string> Rules { get; set; }
public List<string> AutoMessages { get; set; } public IList<string> AutoMessages { get; set; }
public bool UseT6MParser { get; set; } public bool UseT6MParser { get; set; }
public bool UseIW5MParser { get; set; } public bool UseIW5MParser { get; set; }
public string ManualLogPath { get; set; } public string ManualLogPath { get; set; }

View File

@ -87,6 +87,7 @@ namespace SharedLibraryCore
{ {
return queuedEvent.Origin != null && return queuedEvent.Origin != null &&
!queuedEvent.Origin.IsAuthenticated && !queuedEvent.Origin.IsAuthenticated &&
queuedEvent.Origin.State != Player.ClientState.Connected &&
// we want to allow join and quit events // we want to allow join and quit events
queuedEvent.Type != EventType.Join && queuedEvent.Type != EventType.Join &&
queuedEvent.Type != EventType.Quit && queuedEvent.Type != EventType.Quit &&
@ -104,6 +105,7 @@ namespace SharedLibraryCore
{ {
return queuedEvent.Target != null && return queuedEvent.Target != null &&
!queuedEvent.Target.IsAuthenticated && !queuedEvent.Target.IsAuthenticated &&
queuedEvent.Target.State != Player.ClientState.Connected &&
queuedEvent.Target.NetworkId != 0; queuedEvent.Target.NetworkId != 0;
} }
} }

View File

@ -13,37 +13,33 @@ namespace SharedLibraryCore.Plugins
public static List<IPlugin> ActivePlugins = new List<IPlugin>(); public static List<IPlugin> ActivePlugins = new List<IPlugin>();
public static List<Assembly> PluginAssemblies = new List<Assembly>(); public static List<Assembly> PluginAssemblies = new List<Assembly>();
private static void LoadScriptPlugins(IManager mgr)
{
string[] scriptFileNames = Directory.GetFiles($"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}", "*.js");
foreach(string fileName in scriptFileNames)
{
var plugin = new ScriptPlugin(fileName);
plugin.Initialize(mgr).Wait();
ActivePlugins.Add(plugin);
}
}
public static bool Load(IManager Manager) public static bool Load(IManager Manager)
{ {
string[] dllFileNames = Directory.GetFiles($"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}", "*.dll"); string[] dllFileNames = Directory.GetFiles($"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}", "*.dll");
string[] scriptFileNames = Directory.GetFiles($"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}", "*.js");
if (dllFileNames.Length == 0) if (dllFileNames.Length == 0 &&
scriptFileNames.Length == 0)
{ {
Manager.GetLogger().WriteDebug(Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_IMPORTER_NOTFOUND"]); Manager.GetLogger().WriteDebug(Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_IMPORTER_NOTFOUND"]);
return true; return true;
} }
// load up the script plugins
foreach (string fileName in scriptFileNames)
{
var plugin = new ScriptPlugin(fileName);
plugin.Initialize(Manager).Wait();
Manager.GetLogger().WriteDebug($"Loaded script plugin \"{ plugin.Name }\" [{plugin.Version}]");
ActivePlugins.Add(plugin);
}
ICollection<Assembly> assemblies = new List<Assembly>(dllFileNames.Length); ICollection<Assembly> assemblies = new List<Assembly>(dllFileNames.Length);
foreach (string dllFile in dllFileNames) foreach (string dllFile in dllFileNames)
{ {
// byte[] rawDLL = File.ReadAllBytes(dllFile);
//Assembly assembly = Assembly.Load(rawDLL);
assemblies.Add(Assembly.LoadFrom(dllFile)); assemblies.Add(Assembly.LoadFrom(dllFile));
} }
int LoadedPlugins = 0;
int LoadedCommands = 0; int LoadedCommands = 0;
foreach (Assembly Plugin in assemblies) foreach (Assembly Plugin in assemblies)
{ {
@ -74,19 +70,17 @@ namespace SharedLibraryCore.Plugins
ActivePlugins.Add(newNotify); ActivePlugins.Add(newNotify);
PluginAssemblies.Add(Plugin); PluginAssemblies.Add(Plugin);
Manager.GetLogger().WriteDebug($"Loaded plugin \"{ newNotify.Name }\" [{newNotify.Version}]"); Manager.GetLogger().WriteDebug($"Loaded plugin \"{ newNotify.Name }\" [{newNotify.Version}]");
LoadedPlugins++;
} }
} }
catch (Exception E) catch (Exception E)
{ {
Manager.GetLogger().WriteWarning($"Could not load plugin {Plugin.Location} - {E.Message}"); Manager.GetLogger().WriteWarning($"{Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_IMPORTER_ERROR"]} {Plugin.Location} - {E.Message}");
} }
} }
} }
} }
LoadScriptPlugins(Manager); Manager.GetLogger().WriteInfo($"Loaded {ActivePlugins.Count} plugins and registered {LoadedCommands} commands.");
Manager.GetLogger().WriteInfo($"Loaded {LoadedPlugins} plugins and registered {LoadedCommands} commands.");
return true; return true;
} }
} }

View File

@ -14,7 +14,7 @@ namespace SharedLibraryCore
public float Version { get; set; } public float Version { get; set; }
public string Author {get;set;} public string Author { get; set; }
private Jint.Engine ScriptEngine; private Jint.Engine ScriptEngine;
private readonly string FileName; private readonly string FileName;
@ -49,15 +49,22 @@ namespace SharedLibraryCore
public async Task Initialize(IManager mgr) public async Task Initialize(IManager mgr)
{ {
bool firstRun = ScriptEngine == null;
// it's been loaded before so we need to call the unload event // it's been loaded before so we need to call the unload event
if (ScriptEngine != null) if (!firstRun)
{ {
await OnUnloadAsync(); await OnUnloadAsync();
} }
Manager = mgr; Manager = mgr;
string script = File.ReadAllText(FileName); string script = File.ReadAllText(FileName);
ScriptEngine = new Jint.Engine(); ScriptEngine = new Jint.Engine(cfg =>
cfg.AllowClr(new[]
{
typeof(System.Net.WebRequest).Assembly,
typeof(Objects.Player).Assembly,
})
.CatchClrExceptions());
ScriptEngine.Execute(script); ScriptEngine.Execute(script);
ScriptEngine.SetValue("_localization", Utilities.CurrentLocalization); ScriptEngine.SetValue("_localization", Utilities.CurrentLocalization);
@ -67,7 +74,7 @@ namespace SharedLibraryCore
this.Name = pluginObject.name; this.Name = pluginObject.name;
this.Version = (float)pluginObject.version; this.Version = (float)pluginObject.version;
if (ScriptEngine != null) if (!firstRun)
{ {
await OnLoadAsync(mgr); await OnLoadAsync(mgr);
} }

View File

@ -20,6 +20,16 @@ namespace SharedLibraryCore
public static Encoding EncodingType; public static Encoding EncodingType;
public static Localization.Layout CurrentLocalization; public static Localization.Layout CurrentLocalization;
public static string HttpRequest(string location, string header, string headerValue)
{
using (var RequestClient = new System.Net.Http.HttpClient())
{
RequestClient.DefaultRequestHeaders.Add(header, headerValue);
string response = RequestClient.GetStringAsync(location).Result;
return response;
}
}
//Get string with specified number of spaces -- really only for visual output //Get string with specified number of spaces -- really only for visual output
public static String GetSpaces(int Num) public static String GetSpaces(int Num)
{ {

View File

@ -7,7 +7,7 @@
} }
<div id="profile_wrapper" class="row d-flex d-sm-inline-flex justify-content-center justify-content-left pb-3"> <div id="profile_wrapper" class="row d-flex d-sm-inline-flex justify-content-center justify-content-left pb-3">
<div class="mr-auto ml-auto ml-sm-0 mr-sm-0"> <div class="mr-auto ml-auto ml-sm-0 mr-sm-0">
<div id="profile_avatar" class="mb-4 mb-md-0 text-center level-bgcolor-@Model.LevelInt" style="background-image:url('@string.Format("https://gravatar.com/avatar/{0}?size=168&default=blank&rating=pg", gravatarUrl)"> <div id="profile_avatar" class="mb-4 mb-md-0 text-center level-bgcolor-@Model.LevelInt" style="background-image:url('@string.Format("https://gravatar.com/avatar/{0}?size=168&default=blank&rating=pg", gravatarUrl)')">
@if (string.IsNullOrEmpty(gravatarUrl)) @if (string.IsNullOrEmpty(gravatarUrl))
{ {
<span class="profile-shortcode">@shortCode</span> <span class="profile-shortcode">@shortCode</span>

View File

@ -29,7 +29,7 @@
<tr class="d-table-row d-md-none bg-dark"> <tr class="d-table-row d-md-none bg-dark">
<th scope="row" class="bg-primary">@loc["WEBFRONT_PENALTY_TEMPLATE_ADMIN"]</th> <th scope="row" class="bg-primary">@loc["WEBFRONT_PENALTY_TEMPLATE_ADMIN"]</th>
<td> <td>
@Html.ActionLink(Model.PunisherName, "ProfileAsync", "Client", new { id = Model.PunisherId }, new { @class = "level-color-" + Model.PunisherLevelId }) }) @Html.ActionLink(Model.PunisherName, "ProfileAsync", "Client", new { id = Model.PunisherId }, new { @class = "level-color-" + Model.PunisherLevelId })
</td> </td>
</tr> </tr>
@ -43,7 +43,7 @@
} }
else else
{ {
<span> @Model.TimeRemaining @loc["WEBFRONT_PENALTY_TEMPLATE_REMAINING"]</span> <span> @Model.TimeRemaining</span>
} }
} }
</td> </td>
@ -60,7 +60,7 @@
@Model.Offense @Model.Offense
</td> </td>
<td> <td>
@Html.ActionLink(Model.PunisherName, "ProfileAsync", "Client", new { id = Model.PunisherId }, new { @class = "level-color-" + Model.PunisherLevelId }) }) @Html.ActionLink(Model.PunisherName, "ProfileAsync", "Client", new { id = Model.PunisherId }, new { @class = "level-color-" + Model.PunisherLevelId })
</td> </td>
<td class="text-right text-light"> <td class="text-right text-light">
@{ @{
@ -70,7 +70,7 @@
} }
else else
{ {
<span> @Model.TimeRemaining <!-- @loc["WEBFRONT_PENALTY_TEMPLATE_REMAINING"] --></span> <span> @Model.TimeRemaining </span>
} }
} }
</td> </td>

View File

@ -47,7 +47,7 @@
color: rgba(235, 211, 101, 0.75); color: rgba(235, 211, 101, 0.75);
} }
.level-bgcolor-moderator, .level-bgcolor-3 { .level-bgcolor-moderator, .level-bgcolor-3 {
background-color: #f0de8b; background-color: #f0de8b;
background-color: rgba(235, 211, 101, 0.75); background-color: rgba(235, 211, 101, 0.75);
} }
@ -57,7 +57,7 @@
color: rgba(236, 130, 222, 0.69); color: rgba(236, 130, 222, 0.69);
} }
.level-bgcolor-administrator, .level.bgcolor-4 { .level-bgcolor-administrator, .level-bgcolor-4 {
background-color: #f1a8e8; background-color: #f1a8e8;
background-color: rgba(236, 130, 222, 0.69); background-color: rgba(236, 130, 222, 0.69);
} }
@ -80,6 +80,14 @@
background-color: rgb(0, 122, 204); background-color: rgb(0, 122, 204);
} }
.level-color-8 {
color: #de4423;
}
.level-bgcolor-8 {
background-color: #de4423;
}
.profile-meta-title { .profile-meta-title {
color: white; color: white;
} }

View File

@ -1,5 +1,21 @@
Version 2.2: Version 2.2:
-upgraded projects to .NET 2.1
-added top player stats page -added top player stats page
-added JavaScript plugin support
-added webhook script to send notifications to discord
-added abillity to exempt specific clients from VPN check
-added reserved slots for privileged users
-added support for localized permission levels
-added linux support!
-added {{NEXTMAP}}, {{ADMINS}}, and {{MOSTPLAYED}} automessage tokens
-updated event api
-updated webfront tweak
-update client search by IP
-updated event management and client authentication
-fixed some namespace discrepancies
-fixed parsing of certain chat messages
-fixed various bugs
-introduced new bugs to fix in the next version
Version 2.1: Version 2.1:
CHANGELOG: CHANGELOG: