mirror of
https://github.com/RaidMax/IW4M-Admin.git
synced 2025-06-10 15:20:48 -05:00
update schema to support unique guid + game combinations
This commit is contained in:
@ -4,7 +4,6 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Data.Context;
|
||||
using Data.Models;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
@ -25,26 +24,32 @@ namespace SharedLibraryCore
|
||||
/// <summary>
|
||||
/// life span in months
|
||||
/// </summary>
|
||||
private const int COOKIE_LIFESPAN = 3;
|
||||
private const int CookieLifespan = 3;
|
||||
|
||||
private static readonly byte[] LocalHost = { 127, 0, 0, 1 };
|
||||
private static string SocialLink;
|
||||
private static string SocialTitle;
|
||||
protected readonly DatabaseContext Context;
|
||||
private static string _socialLink;
|
||||
private static string _socialTitle;
|
||||
|
||||
protected List<Page> Pages;
|
||||
protected List<string> PermissionsSet;
|
||||
protected bool Authorized { get; set; }
|
||||
protected TranslationLookup Localization { get; }
|
||||
protected EFClient Client { get; }
|
||||
protected ApplicationConfiguration AppConfig { get; }
|
||||
|
||||
public IManager Manager { get; }
|
||||
|
||||
public BaseController(IManager manager)
|
||||
{
|
||||
AlertManager = manager.AlertManager;
|
||||
Manager = manager;
|
||||
Localization ??= Utilities.CurrentLocalization.LocalizationIndex;
|
||||
Localization = Utilities.CurrentLocalization.LocalizationIndex;
|
||||
AppConfig = Manager.GetApplicationSettings().Configuration();
|
||||
|
||||
if (AppConfig.EnableSocialLink && SocialLink == null)
|
||||
if (AppConfig.EnableSocialLink && _socialLink == null)
|
||||
{
|
||||
SocialLink = AppConfig.SocialLinkAddress;
|
||||
SocialTitle = AppConfig.SocialLinkTitle;
|
||||
_socialLink = AppConfig.SocialLinkAddress;
|
||||
_socialTitle = AppConfig.SocialLinkTitle;
|
||||
}
|
||||
|
||||
Pages = Manager.GetPageList().Pages
|
||||
@ -59,7 +64,7 @@ namespace SharedLibraryCore
|
||||
ViewBag.EnableColorCodes = AppConfig.EnableColorCodes;
|
||||
ViewBag.Language = Utilities.CurrentLocalization.Culture.TwoLetterISOLanguageName;
|
||||
|
||||
Client ??= new EFClient
|
||||
Client = new EFClient
|
||||
{
|
||||
ClientId = -1,
|
||||
Level = Data.Models.Client.EFClient.Permission.Banned,
|
||||
@ -67,11 +72,7 @@ namespace SharedLibraryCore
|
||||
};
|
||||
}
|
||||
|
||||
public IManager Manager { get; }
|
||||
protected bool Authorized { get; set; }
|
||||
protected TranslationLookup Localization { get; }
|
||||
protected EFClient Client { get; }
|
||||
protected ApplicationConfiguration AppConfig { get; }
|
||||
|
||||
|
||||
protected async Task SignInAsync(ClaimsPrincipal claimsPrinciple)
|
||||
{
|
||||
@ -79,7 +80,7 @@ namespace SharedLibraryCore
|
||||
new AuthenticationProperties
|
||||
{
|
||||
AllowRefresh = true,
|
||||
ExpiresUtc = DateTime.UtcNow.AddMonths(COOKIE_LIFESPAN),
|
||||
ExpiresUtc = DateTime.UtcNow.AddMonths(CookieLifespan),
|
||||
IsPersistent = true,
|
||||
IssuedUtc = DateTime.UtcNow
|
||||
});
|
||||
@ -99,7 +100,7 @@ namespace SharedLibraryCore
|
||||
Client.ClientId = clientId;
|
||||
Client.NetworkId = clientId == 1
|
||||
? 0
|
||||
: User.Claims.First(_claim => _claim.Type == ClaimTypes.PrimarySid).Value
|
||||
: User.Claims.First(claim => claim.Type == ClaimTypes.PrimarySid).Value
|
||||
.ConvertGuidToLong(NumberStyles.HexNumber);
|
||||
Client.Level = (Data.Models.Client.EFClient.Permission)Enum.Parse(
|
||||
typeof(Data.Models.Client.EFClient.Permission),
|
||||
@ -107,6 +108,9 @@ namespace SharedLibraryCore
|
||||
Client.CurrentAlias = new EFAlias
|
||||
{ Name = User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value };
|
||||
Authorized = Client.ClientId >= 0;
|
||||
Client.GameName =
|
||||
Enum.Parse<Reference.Game>(User.Claims
|
||||
.First(claim => claim.Type == ClaimTypes.PrimaryGroupSid).Value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,6 +138,7 @@ namespace SharedLibraryCore
|
||||
new Claim(ClaimTypes.Role, Client.Level.ToString()),
|
||||
new Claim(ClaimTypes.Sid, Client.ClientId.ToString()),
|
||||
new Claim(ClaimTypes.PrimarySid, Client.NetworkId.ToString("X")),
|
||||
new Claim(ClaimTypes.PrimaryGroupSid, Client.GameName.ToString())
|
||||
};
|
||||
var claimsIdentity = new ClaimsIdentity(claims, "login");
|
||||
SignInAsync(new ClaimsPrincipal(claimsIdentity)).Wait();
|
||||
@ -153,8 +158,8 @@ namespace SharedLibraryCore
|
||||
ViewBag.Url = AppConfig.WebfrontUrl;
|
||||
ViewBag.User = Client;
|
||||
ViewBag.Version = Manager.Version;
|
||||
ViewBag.SocialLink = SocialLink ?? "";
|
||||
ViewBag.SocialTitle = SocialTitle;
|
||||
ViewBag.SocialLink = _socialLink ?? "";
|
||||
ViewBag.SocialTitle = _socialTitle;
|
||||
ViewBag.Pages = Pages;
|
||||
ViewBag.Localization = Utilities.CurrentLocalization.LocalizationIndex;
|
||||
ViewBag.CustomBranding = shouldUseCommunityName
|
||||
|
@ -381,7 +381,7 @@ namespace SharedLibraryCore.Commands
|
||||
{
|
||||
// todo: don't do the lookup here
|
||||
var penalties = await gameEvent.Owner.Manager.GetPenaltyService().GetActivePenaltiesAsync(gameEvent.Target.AliasLinkId,
|
||||
gameEvent.Target.CurrentAliasId, gameEvent.Target.NetworkId, gameEvent.Target.CurrentAlias.IPAddress);
|
||||
gameEvent.Target.CurrentAliasId, gameEvent.Target.NetworkId, gameEvent.Target.GameName, gameEvent.Target.CurrentAlias.IPAddress);
|
||||
|
||||
if (penalties
|
||||
.FirstOrDefault(p =>
|
||||
@ -897,7 +897,7 @@ namespace SharedLibraryCore.Commands
|
||||
public override async Task ExecuteAsync(GameEvent E)
|
||||
{
|
||||
var existingPenalties = await E.Owner.Manager.GetPenaltyService()
|
||||
.GetActivePenaltiesAsync(E.Target.AliasLinkId, E.Target.CurrentAliasId, E.Target.NetworkId, E.Target.IPAddress);
|
||||
.GetActivePenaltiesAsync(E.Target.AliasLinkId, E.Target.CurrentAliasId, E.Target.NetworkId, E.Target.GameName, E.Target.IPAddress);
|
||||
var penalty = existingPenalties.FirstOrDefault(b => b.Type > EFPenalty.PenaltyType.Kick);
|
||||
|
||||
if (penalty == null)
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using Data.Models.Client;
|
||||
using SharedLibraryCore.Configuration;
|
||||
using SharedLibraryCore.Helpers;
|
||||
using SharedLibraryCore.Interfaces;
|
||||
|
||||
namespace SharedLibraryCore.Commands
|
||||
@ -19,13 +20,17 @@ namespace SharedLibraryCore.Commands
|
||||
RequiresTarget = false;
|
||||
}
|
||||
|
||||
public override Task ExecuteAsync(GameEvent E)
|
||||
public override Task ExecuteAsync(GameEvent gameEvent)
|
||||
{
|
||||
var state = E.Owner.Manager.TokenAuthenticator.GenerateNextToken(E.Origin.NetworkId);
|
||||
E.Origin.Tell(string.Format(_translationLookup["COMMANDS_GENERATETOKEN_SUCCESS"], state.Token,
|
||||
$"{state.RemainingTime} {_translationLookup["GLOBAL_MINUTES"]}", E.Origin.ClientId));
|
||||
var state = gameEvent.Owner.Manager.TokenAuthenticator.GenerateNextToken(new TokenIdentifier
|
||||
{
|
||||
Game = gameEvent.Origin.GameName,
|
||||
NetworkId = gameEvent.Origin.NetworkId
|
||||
});
|
||||
gameEvent.Origin.Tell(string.Format(_translationLookup["COMMANDS_GENERATETOKEN_SUCCESS"], state.Token,
|
||||
$"{state.RemainingTime} {_translationLookup["GLOBAL_MINUTES"]}", gameEvent.Origin.ClientId));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
11
SharedLibraryCore/Helpers/TokenIdentifier.cs
Normal file
11
SharedLibraryCore/Helpers/TokenIdentifier.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using Data.Models;
|
||||
using SharedLibraryCore.Interfaces;
|
||||
|
||||
namespace SharedLibraryCore.Helpers;
|
||||
|
||||
public class TokenIdentifier : ITokenIdentifier
|
||||
{
|
||||
public long NetworkId { get; set; }
|
||||
public Reference.Game Game { get; set; }
|
||||
public string Token { get; set; }
|
||||
}
|
@ -4,7 +4,6 @@ namespace SharedLibraryCore.Helpers
|
||||
{
|
||||
public sealed class TokenState
|
||||
{
|
||||
public long NetworkId { get; set; }
|
||||
public DateTime RequestTime { get; set; } = DateTime.Now;
|
||||
public TimeSpan TokenDuration { get; set; }
|
||||
public string Token { get; set; }
|
||||
@ -12,4 +11,4 @@ namespace SharedLibraryCore.Helpers
|
||||
public string RemainingTime => Math.Round(-(DateTime.Now - RequestTime).Subtract(TokenDuration).TotalMinutes, 1)
|
||||
.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ namespace SharedLibraryCore.Interfaces
|
||||
Task<T> Delete(T entity);
|
||||
Task<T> Update(T entity);
|
||||
Task<T> Get(int entityID);
|
||||
Task<T> GetUnique(long entityProperty);
|
||||
Task<T> GetUnique(long entityProperty, object altKey);
|
||||
Task<IList<T>> Find(Func<T, bool> expression);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,16 +7,15 @@ namespace SharedLibraryCore.Interfaces
|
||||
/// <summary>
|
||||
/// generates and returns a token for the given network id
|
||||
/// </summary>
|
||||
/// <param name="networkId">network id of the players to generate the token for</param>
|
||||
/// <param name="authInfo">auth information for next token generation</param>
|
||||
/// <returns>4 character string token</returns>
|
||||
TokenState GenerateNextToken(long networkId);
|
||||
TokenState GenerateNextToken(ITokenIdentifier authInfo);
|
||||
|
||||
/// <summary>
|
||||
/// authorizes given token
|
||||
/// </summary>
|
||||
/// <param name="networkId">network id of the client to authorize</param>
|
||||
/// <param name="token">token to authorize</param>
|
||||
/// <param name="authInfo">auth information</param>
|
||||
/// <returns>true if token authorized successfully, false otherwise</returns>
|
||||
bool AuthorizeToken(long networkId, string token);
|
||||
bool AuthorizeToken(ITokenIdentifier authInfo);
|
||||
}
|
||||
}
|
||||
|
11
SharedLibraryCore/Interfaces/ITokenIdentifier.cs
Normal file
11
SharedLibraryCore/Interfaces/ITokenIdentifier.cs
Normal file
@ -0,0 +1,11 @@
|
||||
|
||||
using Data.Models;
|
||||
|
||||
namespace SharedLibraryCore.Interfaces;
|
||||
|
||||
public interface ITokenIdentifier
|
||||
{
|
||||
long NetworkId { get; }
|
||||
Reference.Game Game { get; set; }
|
||||
string Token { get; set; }
|
||||
}
|
@ -682,7 +682,7 @@ namespace SharedLibraryCore.Database.Models
|
||||
|
||||
// we want to get any penalties that are tied to their IP or AliasLink (but not necessarily their GUID)
|
||||
var activePenalties = await CurrentServer.Manager.GetPenaltyService()
|
||||
.GetActivePenaltiesAsync(AliasLinkId, CurrentAliasId, NetworkId, ipAddress);
|
||||
.GetActivePenaltiesAsync(AliasLinkId, CurrentAliasId, NetworkId, GameName, ipAddress);
|
||||
var banPenalty = activePenalties.FirstOrDefault(_penalty => _penalty.Type == EFPenalty.PenaltyType.Ban);
|
||||
var tempbanPenalty =
|
||||
activePenalties.FirstOrDefault(_penalty => _penalty.Type == EFPenalty.PenaltyType.TempBan);
|
||||
|
@ -23,25 +23,25 @@ namespace SharedLibraryCore.Services
|
||||
{
|
||||
public class ClientService : IEntityService<EFClient>, IResourceQueryHelper<FindClientRequest, FindClientResult>
|
||||
{
|
||||
private static readonly Func<DatabaseContext, long, Task<EFClient>> _getUniqueQuery =
|
||||
EF.CompileAsyncQuery((DatabaseContext context, long networkId) =>
|
||||
private static readonly Func<DatabaseContext, long, Reference.Game, Task<EFClient>> GetUniqueQuery =
|
||||
EF.CompileAsyncQuery((DatabaseContext context, long networkId, Reference.Game game) =>
|
||||
context.Clients
|
||||
.Select(_client => new EFClient
|
||||
.Select(client => new EFClient
|
||||
{
|
||||
ClientId = _client.ClientId,
|
||||
AliasLinkId = _client.AliasLinkId,
|
||||
Level = _client.Level,
|
||||
Connections = _client.Connections,
|
||||
FirstConnection = _client.FirstConnection,
|
||||
LastConnection = _client.LastConnection,
|
||||
Masked = _client.Masked,
|
||||
NetworkId = _client.NetworkId,
|
||||
TotalConnectionTime = _client.TotalConnectionTime,
|
||||
AliasLink = _client.AliasLink,
|
||||
Password = _client.Password,
|
||||
PasswordSalt = _client.PasswordSalt
|
||||
ClientId = client.ClientId,
|
||||
AliasLinkId = client.AliasLinkId,
|
||||
Level = client.Level,
|
||||
Connections = client.Connections,
|
||||
FirstConnection = client.FirstConnection,
|
||||
LastConnection = client.LastConnection,
|
||||
Masked = client.Masked,
|
||||
NetworkId = client.NetworkId,
|
||||
TotalConnectionTime = client.TotalConnectionTime,
|
||||
AliasLink = client.AliasLink,
|
||||
Password = client.Password,
|
||||
PasswordSalt = client.PasswordSalt
|
||||
})
|
||||
.FirstOrDefault(c => c.NetworkId == networkId)
|
||||
.FirstOrDefault(client => client.NetworkId == networkId && client.GameName == game)
|
||||
);
|
||||
|
||||
private readonly ApplicationConfiguration _appConfig;
|
||||
@ -235,10 +235,14 @@ namespace SharedLibraryCore.Services
|
||||
return foundClient.Client;
|
||||
}
|
||||
|
||||
public virtual async Task<EFClient> GetUnique(long entityAttribute)
|
||||
public virtual async Task<EFClient> GetUnique(long entityAttribute, object altKey = null)
|
||||
{
|
||||
if (altKey is not Reference.Game game)
|
||||
{
|
||||
throw new ArgumentException($"Alternate key must be of type {nameof(Reference.Game)}");
|
||||
}
|
||||
await using var context = _contextFactory.CreateContext(false);
|
||||
return await _getUniqueQuery(context, entityAttribute);
|
||||
return await GetUniqueQuery(context, entityAttribute, game);
|
||||
}
|
||||
|
||||
public async Task<EFClient> Update(EFClient temporalClient)
|
||||
@ -285,7 +289,7 @@ namespace SharedLibraryCore.Services
|
||||
entity.PasswordSalt = temporalClient.PasswordSalt;
|
||||
}
|
||||
|
||||
entity.GameName ??= temporalClient.GameName;
|
||||
entity.GameName = temporalClient.GameName;
|
||||
|
||||
// update in database
|
||||
await context.SaveChangesAsync();
|
||||
@ -758,19 +762,20 @@ namespace SharedLibraryCore.Services
|
||||
{
|
||||
await using var context = _contextFactory.CreateContext(false);
|
||||
return await context.Clients
|
||||
.Select(_client => new EFClient
|
||||
.Select(client => new EFClient
|
||||
{
|
||||
NetworkId = _client.NetworkId,
|
||||
ClientId = _client.ClientId,
|
||||
NetworkId = client.NetworkId,
|
||||
ClientId = client.ClientId,
|
||||
CurrentAlias = new EFAlias
|
||||
{
|
||||
Name = _client.CurrentAlias.Name
|
||||
Name = client.CurrentAlias.Name
|
||||
},
|
||||
Password = _client.Password,
|
||||
PasswordSalt = _client.PasswordSalt,
|
||||
Level = _client.Level
|
||||
Password = client.Password,
|
||||
PasswordSalt = client.PasswordSalt,
|
||||
GameName = client.GameName,
|
||||
Level = client.Level
|
||||
})
|
||||
.FirstAsync(_client => _client.ClientId == clientId);
|
||||
.FirstAsync(client => client.ClientId == clientId);
|
||||
}
|
||||
|
||||
public async Task<List<EFClient>> GetPrivilegedClients(bool includeName = true)
|
||||
@ -860,15 +865,16 @@ namespace SharedLibraryCore.Services
|
||||
|
||||
// we want to project our results
|
||||
var iqClientProjection = iqClients.OrderByDescending(_client => _client.LastConnection)
|
||||
.Select(_client => new PlayerInfo
|
||||
.Select(client => new PlayerInfo
|
||||
{
|
||||
Name = _client.CurrentAlias.Name,
|
||||
LevelInt = (int)_client.Level,
|
||||
LastConnection = _client.LastConnection,
|
||||
ClientId = _client.ClientId,
|
||||
IPAddress = _client.CurrentAlias.IPAddress.HasValue
|
||||
? _client.CurrentAlias.SearchableIPAddress
|
||||
: ""
|
||||
Name = client.CurrentAlias.Name,
|
||||
LevelInt = (int)client.Level,
|
||||
LastConnection = client.LastConnection,
|
||||
ClientId = client.ClientId,
|
||||
IPAddress = client.CurrentAlias.IPAddress.HasValue
|
||||
? client.CurrentAlias.SearchableIPAddress
|
||||
: "",
|
||||
Game = client.GameName
|
||||
});
|
||||
|
||||
var clients = await iqClientProjection.ToListAsync();
|
||||
|
@ -88,7 +88,7 @@ namespace SharedLibraryCore.Services
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<EFPenalty> GetUnique(long entityProperty)
|
||||
public Task<EFPenalty> GetUnique(long entityProperty, object altKey)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
@ -139,10 +139,10 @@ namespace SharedLibraryCore.Services
|
||||
LinkedPenalties.Contains(pi.Penalty.Type) && pi.Penalty.Active &&
|
||||
(pi.Penalty.Expires == null || pi.Penalty.Expires > DateTime.UtcNow);
|
||||
|
||||
public async Task<List<EFPenalty>> GetActivePenaltiesAsync(int linkId, int currentAliasId, long networkId,
|
||||
public async Task<List<EFPenalty>> GetActivePenaltiesAsync(int linkId, int currentAliasId, long networkId, Reference.Game game,
|
||||
int? ip = null)
|
||||
{
|
||||
var penaltiesByIdentifier = await GetActivePenaltiesByIdentifier(ip, networkId);
|
||||
var penaltiesByIdentifier = await GetActivePenaltiesByIdentifier(ip, networkId, game);
|
||||
|
||||
if (penaltiesByIdentifier.Any())
|
||||
{
|
||||
@ -183,12 +183,12 @@ namespace SharedLibraryCore.Services
|
||||
return activePenalties.OrderByDescending(p => p.When).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<EFPenalty>> GetActivePenaltiesByIdentifier(int? ip, long networkId)
|
||||
public async Task<List<EFPenalty>> GetActivePenaltiesByIdentifier(int? ip, long networkId, Reference.Game game)
|
||||
{
|
||||
await using var context = _contextFactory.CreateContext(false);
|
||||
|
||||
var activePenaltiesIds = context.PenaltyIdentifiers.Where(identifier =>
|
||||
identifier.IPv4Address != null && identifier.IPv4Address == ip || identifier.NetworkId == networkId)
|
||||
identifier.IPv4Address != null && identifier.IPv4Address == ip || identifier.NetworkId == networkId && identifier.Penalty.Offender.GameName == game)
|
||||
.Where(FilterById);
|
||||
return await activePenaltiesIds.Select(ids => ids.Penalty).ToListAsync();
|
||||
}
|
||||
@ -214,12 +214,12 @@ namespace SharedLibraryCore.Services
|
||||
return await activePenaltiesIds.Select(ids => ids.Penalty).ToListAsync();
|
||||
}
|
||||
|
||||
public virtual async Task RemoveActivePenalties(int aliasLinkId, long networkId, int? ipAddress = null)
|
||||
public virtual async Task RemoveActivePenalties(int aliasLinkId, long networkId, Reference.Game game, int? ipAddress = null)
|
||||
{
|
||||
await using var context = _contextFactory.CreateContext();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var activePenalties = await GetActivePenaltiesByIdentifier(ipAddress, networkId);
|
||||
var activePenalties = await GetActivePenaltiesByIdentifier(ipAddress, networkId, game);
|
||||
|
||||
if (activePenalties.Any())
|
||||
{
|
||||
|
@ -4,7 +4,7 @@
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<PackageId>RaidMax.IW4MAdmin.SharedLibraryCore</PackageId>
|
||||
<Version>2022.6.9.1</Version>
|
||||
<Version>2022.6.15.1</Version>
|
||||
<Authors>RaidMax</Authors>
|
||||
<Company>Forever None</Company>
|
||||
<Configurations>Debug;Release;Prerelease</Configurations>
|
||||
@ -19,7 +19,7 @@
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<Description>Shared Library for IW4MAdmin</Description>
|
||||
<PackageVersion>2022.6.9.1</PackageVersion>
|
||||
<PackageVersion>2022.6.15.1</PackageVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
@ -1181,7 +1181,8 @@ namespace SharedLibraryCore
|
||||
Meta = client.Meta,
|
||||
ReceivedPenalties = client.ReceivedPenalties,
|
||||
AdministeredPenalties = client.AdministeredPenalties,
|
||||
Active = client.Active
|
||||
Active = client.Active,
|
||||
GameName = client.GameName
|
||||
};
|
||||
}
|
||||
|
||||
@ -1264,5 +1265,8 @@ namespace SharedLibraryCore
|
||||
|
||||
return allRules[index];
|
||||
}
|
||||
|
||||
public static string MakeAbbreviation(string gameName) => string.Join("",
|
||||
gameName.Split(' ').Select(word => char.ToUpper(word.First())).ToArray());
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user