diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 70a3b13d..86b0948b 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -24,7 +24,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v2 with: - dotnet-version: '7.0.x' + dotnet-version: '8.0.x' # include-prerelease: true - name: Restore dependencies diff --git a/WowsKarma.Api.Minimap.Client/WowsKarma.Api.Minimap.Client.csproj b/WowsKarma.Api.Minimap.Client/WowsKarma.Api.Minimap.Client.csproj index 6ce8979b..a5956b94 100644 --- a/WowsKarma.Api.Minimap.Client/WowsKarma.Api.Minimap.Client.csproj +++ b/WowsKarma.Api.Minimap.Client/WowsKarma.Api.Minimap.Client.csproj @@ -4,7 +4,7 @@ net6.0 enable enable - 0.1 + 0.1.1 WOWS Karma Minimap API Client Sakura Akeno Isayeki @@ -15,11 +15,11 @@ - - - - - + + + + + diff --git a/WowsKarma.Api/Controllers/Admin/ModActionController.cs b/WowsKarma.Api/Controllers/Admin/ModActionController.cs index f98ad40e..88f4b371 100644 --- a/WowsKarma.Api/Controllers/Admin/ModActionController.cs +++ b/WowsKarma.Api/Controllers/Admin/ModActionController.cs @@ -6,12 +6,11 @@ using WowsKarma.Api.Services.Posts; using WowsKarma.Common; - namespace WowsKarma.Api.Controllers.Admin; [ApiController, Route("api/mod/action"), Authorize(Roles = ApiRoles.CM)] -public class ModActionController : ControllerBase +public sealed class ModActionController : ControllerBase { private readonly ModService _service; @@ -40,22 +39,22 @@ public ModActionController(ModService service) [HttpGet("list"), AllowAnonymous, ProducesResponseType(typeof(IEnumerable), 200), ProducesResponseType(204)] public IActionResult List([FromQuery] Guid postId = default, [FromQuery] uint userId = default) { - IEnumerable modActions; + PostModAction[] modActions = []; if (postId != default) { - modActions = _service.GetPostModActions(postId).ToArray(); + modActions = [.. _service.GetPostModActions(postId)]; } else if (userId is not 0) { - modActions = _service.GetPostModActions(userId).ToArray(); + modActions = [.. _service.GetPostModActions(userId)]; } else { return BadRequest("Please use a search query (Post/User)."); } - return modActions?.Count() is null or 0 + return modActions is [] ? base.StatusCode(204) : base.StatusCode(200, modActions.Adapt>()); } @@ -73,8 +72,8 @@ public IActionResult List([FromQuery] Guid postId = default, [FromQuery] uint us public async Task Submit([FromBody] PostModActionDTO modAction, [FromServices] PostService postService) { - Post post = postService.GetPost(modAction.PostId); - uint modId = uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)); + Post post = postService.GetPost(modAction.PostId) ?? throw new InvalidOperationException("Post ID is invalid."); + uint modId = uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new BadHttpRequestException("Missing NameIdentifier claim.")); if ((post.AuthorId == modId || post.PlayerId == modId) && !User.IsInRole(ApiRoles.Administrator)) { @@ -82,7 +81,7 @@ public async Task Submit([FromBody] PostModActionDTO modAction, } await _service.SubmitPostModActionAsync(modAction with { ModId = modId }); - return StatusCode(202); + return Accepted(); } /// diff --git a/WowsKarma.Api/Controllers/Admin/PlatformBansController.cs b/WowsKarma.Api/Controllers/Admin/PlatformBansController.cs index 7159c7ed..a0721cb2 100644 --- a/WowsKarma.Api/Controllers/Admin/PlatformBansController.cs +++ b/WowsKarma.Api/Controllers/Admin/PlatformBansController.cs @@ -9,7 +9,7 @@ namespace WowsKarma.Api.Controllers.Admin; [ApiController, Route("api/mod/bans"), Authorize(Roles = $"{ApiRoles.CM},{ApiRoles.Administrator}")] -public class PlatformBansController : ControllerBase +public sealed class PlatformBansController : ControllerBase { private readonly ModService _service; @@ -39,23 +39,24 @@ public IActionResult FetchBans(uint userId, bool currentOnly) return Ok(bans.ProjectToType().AsAsyncEnumerable()); } - /// - /// Emits a new Platform Ban. - /// - /// Platform Ban to emit - /// (Helper) Sets a temporary ban, to the number of specified days starting from UTC now. - /// Platform Ban was successfuly submitted. + /// + /// Emits a new Platform Ban. + /// + /// Platform Ban to emit + /// (DI) + /// (Helper) Sets a temporary ban, to the number of specified days starting from UTC now. + /// Platform Ban was successfuly submitted. [HttpPost, ProducesResponseType(202)] public async Task SubmitBan([FromBody] PlatformBanDTO submitted, [FromServices] AuthDbContext authDb, [FromQuery] uint days = 0) { await _service.EmitPlatformBanAsync(submitted with { - ModId = User.ToAccountListing().Id, + ModId = User.ToAccountListing()!.Id, Reverted = false, - BannedUntil = days is 0 ? null : DateTime.UtcNow.AddDays(days) + BannedUntil = days is 0 ? null : DateTimeOffset.UtcNow.AddDays(days) }, authDb); - return StatusCode(202); + return Accepted(); } /// @@ -64,7 +65,7 @@ await _service.EmitPlatformBanAsync(submitted with /// ID of Platform Ban to revert. /// Platform Ban was successfully reverted. [HttpDelete("{id:guid}"), ProducesResponseType(200)] - public async Task RevertBan([FromQuery] Guid id) + public async Task RevertBan(Guid id) { await _service.RevertPlatformBanAsync(id); diff --git a/WowsKarma.Api/Controllers/AuthController.cs b/WowsKarma.Api/Controllers/AuthController.cs index 766a6e92..9bc28a8e 100644 --- a/WowsKarma.Api/Controllers/AuthController.cs +++ b/WowsKarma.Api/Controllers/AuthController.cs @@ -2,35 +2,32 @@ using Microsoft.AspNetCore.Mvc; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; -using Microsoft.AspNetCore.Http; using WowsKarma.Api.Infrastructure.Attributes; using WowsKarma.Api.Services.Authentication; using WowsKarma.Api.Services.Authentication.Jwt; using WowsKarma.Api.Services.Authentication.Wargaming; using WowsKarma.Common; - namespace WowsKarma.Api.Controllers; - /// /// Provides API Authentication endpoints. /// [ApiController, Route("api/[controller]"), ETag(false)] -public class AuthController : ControllerBase +public sealed class AuthController : ControllerBase { - private readonly IConfiguration config; - private readonly UserService userService; - private readonly WargamingAuthService wargamingAuthService; - private readonly JwtService jwtService; + private readonly IConfiguration _config; + private readonly UserService _userService; + private readonly WargamingAuthService _wargamingAuthService; + private readonly JwtService _jwtService; public AuthController(IConfiguration config, UserService userService, WargamingAuthService wargamingAuthService, JwtService jwtService) { - this.config = config; - this.userService = userService; - this.wargamingAuthService = wargamingAuthService; - this.jwtService = jwtService; + _config = config; + _userService = userService; + _wargamingAuthService = wargamingAuthService; + _jwtService = jwtService; } /// @@ -57,33 +54,33 @@ public AuthController(IConfiguration config, UserService userService, WargamingA [HttpGet("wg-callback"), ProducesResponseType(302), ProducesResponseType(200), ProducesResponseType(403)] public async Task WgAuthCallback() { - bool valid = await wargamingAuthService.VerifyIdentity(Request); + bool valid = await _wargamingAuthService.VerifyIdentity(Request); if (!valid) { return StatusCode(403); } - JwtSecurityToken token = await userService.CreateTokenAsync(WargamingIdentity.FromUri(new(Request.Query["openid.identity"].FirstOrDefault() + JwtSecurityToken token = await _userService.CreateTokenAsync(WargamingIdentity.FromUri(new(Request.Query["openid.identity"].FirstOrDefault() ?? throw new BadHttpRequestException("Missing OpenID identity")))); Response.Cookies.Append( - config[$"Api:{Startup.ApiRegion.ToRegionString()}:CookieName"], - jwtService.TokenHandler.WriteToken(token), + _config[$"Api:{Startup.ApiRegion.ToRegionString()}:CookieName"] ?? throw new ApplicationException("Missing Api:{region}:CookieName in configuration."), + _jwtService.TokenHandler.WriteToken(token), new() { - Domain = config[$"Api:{Startup.ApiRegion.ToRegionString()}:CookieDomain"], + Domain = _config[$"Api:{Startup.ApiRegion.ToRegionString()}:CookieDomain"], HttpOnly = false, IsEssential = true, #if RELEASE - Secure = true, + Secure = true, #endif Expires = DateTime.UtcNow.AddDays(7) }); return Request.Query["redirectUri"].FirstOrDefault() is { } redirectUri ? Redirect(redirectUri) - : StatusCode(200); + : Ok(); } /// @@ -94,8 +91,8 @@ public async Task WgAuthCallback() [HttpPost("renew-seed"), Authorize, ProducesResponseType(200), ProducesResponseType(401)] public async Task RenewSeed() { - await userService.RenewSeedTokenAsync(uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier))); - return StatusCode(200); + await _userService.RenewSeedTokenAsync(uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new BadHttpRequestException("Missing NameIdentifier claim."))); + return Ok(); } /// @@ -106,7 +103,7 @@ public async Task RenewSeed() [HttpGet("refresh-token"), Authorize, ProducesResponseType(typeof(string), 200), ProducesResponseType(401)] public async Task RefreshToken() { - JwtSecurityToken token = await userService.CreateTokenAsync(new(User.Claims)); - return StatusCode(200, jwtService.TokenHandler.WriteToken(token)); + JwtSecurityToken token = await _userService.CreateTokenAsync(new(User.Claims)); + return StatusCode(200, _jwtService.TokenHandler.WriteToken(token)); } } diff --git a/WowsKarma.Api/Controllers/ClanController.cs b/WowsKarma.Api/Controllers/ClanController.cs index 442b6a11..9bdbf6c9 100644 --- a/WowsKarma.Api/Controllers/ClanController.cs +++ b/WowsKarma.Api/Controllers/ClanController.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Threading; using Mapster; using Microsoft.AspNetCore.Mvc; using WowsKarma.Api.Services; @@ -8,7 +7,7 @@ namespace WowsKarma.Api.Controllers; [ApiController, Route("api/[controller]")] -public class ClanController : ControllerBase +public sealed class ClanController : ControllerBase { private readonly ClanService _clanService; @@ -34,14 +33,12 @@ public ClanController(ClanService clanService) /// /// Clan Info, with members (if selected) [HttpGet("{clanId}"), ProducesResponseType(typeof(ClanProfileDTO), 200), ProducesResponseType(typeof(ClanProfileFullDTO), 200)] - public async Task GetClan(uint clanId, bool includeMembers = true, CancellationToken ct = default) - { - Clan clan = await _clanService.GetClanAsync(clanId, includeMembers, ct); - - return includeMembers - ? clan.Adapt() - : clan.Adapt(); - } + public async Task GetClan(uint clanId, bool includeMembers = true, CancellationToken ct = default) + => await _clanService.GetClanAsync(clanId, includeMembers, ct) is { } clan + ? includeMembers + ? clan.Adapt() + : clan.Adapt() + : null; /// /// Searches all clans relevant to a given search string. diff --git a/WowsKarma.Api/Controllers/PlayerController.cs b/WowsKarma.Api/Controllers/PlayerController.cs index 328f3dc7..6102bd4f 100644 --- a/WowsKarma.Api/Controllers/PlayerController.cs +++ b/WowsKarma.Api/Controllers/PlayerController.cs @@ -1,17 +1,15 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.ComponentModel.DataAnnotations; -using System.Threading; using Hangfire; using Mapster; using WowsKarma.Api.Services; using WowsKarma.Common; - namespace WowsKarma.Api.Controllers; [ApiController, Route("api/[controller]")] -public class PlayerController : ControllerBase +public sealed class PlayerController : ControllerBase { private readonly PlayerService _playerService; @@ -36,14 +34,10 @@ public PlayerController(PlayerService playerService) /// Account listings for given search query /// No results found for given search query [HttpGet("search/{query}"), ProducesResponseType(typeof(IEnumerable), 200), ProducesResponseType(204)] - public async Task SearchAccount([StringLength(100, MinimumLength = 3), RegularExpression(@"^[a-zA-Z0-9_]*$")] string query) - { - IEnumerable accounts = await _playerService.ListPlayersAsync(query); - - return accounts is null - ? NoContent() - : Ok(accounts); - } + public async Task SearchAccount([StringLength(100, MinimumLength = 3), RegularExpression(@"^[a-zA-Z0-9_]*$")] string query) + => await _playerService.ListPlayersAsync(query) is { Length: not 0 } accounts + ? Ok(accounts) + : NoContent(); /// /// Fetches the player profile for a given Account ID. @@ -63,7 +57,7 @@ public async Task GetAccount(uint id, bool includeClanInfo = true Player playerProfile = await _playerService.GetPlayerAsync(id, false, includeClanInfo); return playerProfile is null - ? NoContent() + ? NotFound() : Ok(playerProfile.Adapt()); } diff --git a/WowsKarma.Api/Controllers/PostController.cs b/WowsKarma.Api/Controllers/PostController.cs index 5126d5ba..a2e601f2 100644 --- a/WowsKarma.Api/Controllers/PostController.cs +++ b/WowsKarma.Api/Controllers/PostController.cs @@ -4,10 +4,7 @@ using Microsoft.AspNetCore.Mvc; using System.Security.Claims; using System.Text.Json; -using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using WowsKarma.Api.Data.Models.Replays; using WowsKarma.Api.Infrastructure.Attributes; using WowsKarma.Api.Infrastructure.Data; using WowsKarma.Api.Services; @@ -22,14 +19,14 @@ namespace WowsKarma.Api.Controllers; [ApiController, Route("api/[controller]")] public sealed class PostController : ControllerBase { - private readonly PlayerService playerService; - private readonly PostService postService; + private readonly PlayerService _playerService; + private readonly PostService _postService; private readonly ILogger _logger; public PostController(PlayerService playerService, PostService postService, ILogger logger) { - this.playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); - this.postService = postService ?? throw new ArgumentNullException(nameof(postService)); + _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); + _postService = postService ?? throw new ArgumentNullException(nameof(postService)); _logger = logger; } @@ -38,7 +35,7 @@ public PostController(PlayerService playerService, PostService postService, ILog /// /// List of post IDs. [HttpGet, ProducesResponseType(StatusCodes.Status200OK)] - public IAsyncEnumerable GetPostIds() => postService.ListPostIdsAsync(); + public IAsyncEnumerable GetPostIds() => _postService.ListPostIdsAsync(); /// /// Fetches player post with given ID @@ -49,8 +46,8 @@ public PostController(PlayerService playerService, PostService postService, ILog /// Post is locked by Community Managers. [HttpGet("{postId:guid}"), ProducesResponseType(typeof(PlayerPostDTO), 200), ProducesResponseType(404), ProducesResponseType(410)] public async Task GetPostAsync(Guid postId) - => await postService.GetPostDTOAsync(postId) is { } post - ? !post.ModLocked || post.Author.Id == User.ToAccountListing().Id || User.IsInRole(ApiRoles.CM) + => await _postService.GetPostDTOAsync(postId) is { } post + ? !post.ModLocked || post.Author.Id == User.ToAccountListing()?.Id || User.IsInRole(ApiRoles.CM) ? Ok(post) : StatusCode(410) : NotFound(); @@ -69,11 +66,11 @@ public IActionResult GetReceivedPosts( [FromQuery] int page = 1, [FromQuery] int pageSize = 10 ) { - IQueryable posts = postService.GetReceivedPosts(userId); + IQueryable posts = _postService.GetReceivedPosts(userId); if (User.ToAccountListing()?.Id != userId || !User.IsInRole(ApiRoles.CM)) { - AccountListingDTO currentUser = User.ToAccountListing(); + AccountListingDTO? currentUser = User.ToAccountListing(); posts = posts.Where(p => !p.ModLocked || (currentUser != null && p.AuthorId == currentUser.Id)); } @@ -110,14 +107,14 @@ public IActionResult GetSentPosts( [FromQuery] int page = 1, [FromQuery] int pageSize = 10 ) { - IQueryable posts = postService.GetSentPosts(userId); + IQueryable posts = _postService.GetSentPosts(userId); if (User.ToAccountListing()?.Id != userId || !User.IsInRole(ApiRoles.CM)) { - posts = posts?.Where(static p => !p.ModLocked); + posts = posts.Where(static p => !p.ModLocked); } - posts?.Include(static p => p.Replay); + posts.Include(static p => p.Replay); // Get the page of results and set headers Page pageResults = posts.Page(pageSize, page); @@ -150,9 +147,9 @@ public IActionResult GetLatestPosts( [FromQuery] bool? hasReplay = null, [FromQuery] bool hideModActions = false ) { - AccountListingDTO currentUser = User.ToAccountListing(); + AccountListingDTO? currentUser = User.ToAccountListing(); - IQueryable posts = postService.GetLatestPosts(); + IQueryable posts = _postService.GetLatestPosts(); if (!User.IsInRole(ApiRoles.CM)) { @@ -191,7 +188,7 @@ public IActionResult GetLatestPosts( /// /// Submits a new post for creation. /// - /// Post object to submit + /// Post object to submit /// Optional replay file to attach to post /// Bypass API Validation for post creation (Admin only) /// Post was successfuly created. @@ -204,26 +201,26 @@ public IActionResult GetLatestPosts( public async Task CreatePost( [FromForm] string postDto, [FromServices] ReplaysIngestService replaysIngestService, - IFormFile replay = null, + IFormFile? replay = null, [FromQuery] bool ignoreChecks = false) { PlayerPostDTO post; try { - post = JsonSerializer.Deserialize(postDto, Common.Utilities.ApiSerializerOptions); + post = JsonSerializer.Deserialize(postDto, Common.Utilities.ApiSerializerOptions) ?? throw new ArgumentNullException(nameof(postDto)); } catch (Exception e) { return BadRequest(e.ToString()); } - if (await playerService.GetPlayerAsync(post.Author.Id) is not { } author) + if (await _playerService.GetPlayerAsync(post.Author.Id) is not { } author) { return StatusCode(404, $"Account {post.Author.Id} not found."); } - if (await playerService.GetPlayerAsync(post.Player.Id) is not { } player) + if (await _playerService.GetPlayerAsync(post.Player.Id) is not { } player) { return StatusCode(404, $"Account {post.Player.Id} not found."); } @@ -237,7 +234,7 @@ public async Task CreatePost( } else { - if (post.Author.Id != uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier))) + if (post.Author.Id != uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new BadHttpRequestException("Missing NameIdentifier claim."))) { return StatusCode(403, "Author is not authorized to post on behalf of other users."); } @@ -255,7 +252,7 @@ public async Task CreatePost( try { - Post created = await postService.CreatePostAsync(post, replay, ignoreChecks); + Post created = await _postService.CreatePostAsync(post, replay, ignoreChecks); return StatusCode(201, created.Id); } catch (ArgumentException) @@ -272,7 +269,7 @@ public async Task CreatePost( { // Log this exception, and store the replay with the RCE the samples. _logger.LogWarning(se, "Replay upload failed for post author {author} due to CVE-2022-31265 exploit detection.", post.Author.Id); - await replaysIngestService.IngestRceFileAsync(replay); + await replaysIngestService.IngestRceFileAsync(replay!); throw se; } @@ -291,7 +288,7 @@ public async Task CreatePost( [ProducesResponseType(200), ProducesResponseType(400), ProducesResponseType(typeof(string), 403), ProducesResponseType(typeof(string), 404)] public async Task EditPost([FromBody] PlayerPostDTO post, [FromQuery] bool ignoreChecks = false) { - if (postService.GetPost(post.Id ?? Guid.Empty) is not { } current) + if (_postService.GetPost(post.Id ?? Guid.Empty) is not { } current) { return StatusCode(404, $"No post with ID {post.Id} found."); } @@ -305,7 +302,7 @@ public async Task EditPost([FromBody] PlayerPostDTO post, [FromQu } else { - if (current.AuthorId != uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier))) + if (current.AuthorId != uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new BadHttpRequestException("Missing NameIdentifier claim."))) { return StatusCode(403, "Author is not authorized to edit posts on behalf of other users."); } @@ -318,8 +315,8 @@ public async Task EditPost([FromBody] PlayerPostDTO post, [FromQu try { - await postService.EditPostAsync(post.Id ?? Guid.Empty, post); - return StatusCode(200); + await _postService.EditPostAsync(post.Id ?? Guid.Empty, post); + return Ok(); } catch (ArgumentException e) { @@ -339,7 +336,7 @@ public async Task EditPost([FromBody] PlayerPostDTO post, [FromQu [ProducesResponseType(205), ProducesResponseType(typeof(string), 403), ProducesResponseType(typeof(string), 404)] public async Task DeletePost(Guid postId, [FromQuery] bool ignoreChecks = false) { - if (postService.GetPost(postId) is not { } post) + if (_postService.GetPost(postId) is not { } post) { return StatusCode(404, $"No post with ID {postId} found."); } @@ -353,7 +350,7 @@ public async Task DeletePost(Guid postId, [FromQuery] bool ignore } else { - if (post.AuthorId != uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier))) + if (post.AuthorId != uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new BadHttpRequestException("Missing NameIdentifier claim."))) { return StatusCode(403, "Author is not authorized to delete posts on behalf of other users."); } @@ -363,7 +360,7 @@ public async Task DeletePost(Guid postId, [FromQuery] bool ignore } } - await postService.DeletePostAsync(postId); + await _postService.DeletePostAsync(postId); return StatusCode(205); } } \ No newline at end of file diff --git a/WowsKarma.Api/Controllers/ProfileController.cs b/WowsKarma.Api/Controllers/ProfileController.cs index 58e54f13..3ce393ab 100644 --- a/WowsKarma.Api/Controllers/ProfileController.cs +++ b/WowsKarma.Api/Controllers/ProfileController.cs @@ -33,7 +33,7 @@ public async Task GetProfileFlagsAsync(uint id) => await _playerS ? Ok(player.Adapt() with { PostsBanned = player.IsBanned(), - ProfileRoles = (await _userService.GetUserAsync(id))?.Roles.Select(r => r.Id) ?? Enumerable.Empty() + ProfileRoles = (await _userService.GetUserAsync(id))?.Roles.Select(r => r.Id) ?? [] }) : NotFound(); @@ -54,13 +54,13 @@ public async Task UpdateProfileFlagsAsync([FromBody] UserProfileF { try { - if (flags.Id != User.ToAccountListing().Id && !User.IsInRole(ApiRoles.Administrator)) + if (flags.Id != User.ToAccountListing()!.Id && !User.IsInRole(ApiRoles.Administrator)) { return StatusCode(403, "User can only update their own profile."); } await _playerService.UpdateProfileFlagsAsync(flags); - return StatusCode(200); + return Ok(); } catch (CooldownException e) { @@ -68,7 +68,7 @@ public async Task UpdateProfileFlagsAsync([FromBody] UserProfileF } catch (ArgumentException) { - return StatusCode(404); + return NotFound(); } } } \ No newline at end of file diff --git a/WowsKarma.Api/Controllers/ReplayController.cs b/WowsKarma.Api/Controllers/ReplayController.cs index 505f8c80..70bebfc9 100644 --- a/WowsKarma.Api/Controllers/ReplayController.cs +++ b/WowsKarma.Api/Controllers/ReplayController.cs @@ -1,11 +1,8 @@ using System.Security; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System.Security.Claims; -using System.Threading; using Hangfire; -using Microsoft.Extensions.Logging; using WowsKarma.Api.Data.Models.Replays; using WowsKarma.Api.Infrastructure.Exceptions; using WowsKarma.Api.Services; @@ -16,7 +13,6 @@ namespace WowsKarma.Api.Controllers; - [ApiController, Route("api/[controller]")] public sealed class ReplayController : ControllerBase { @@ -51,7 +47,7 @@ ILogger logger /// ID of Replay to fetch /// Replay data [HttpGet("{replayId:guid}"), ProducesResponseType(typeof(ReplayDTO), 200)] - public Task GetAsync(Guid replayId) => _ingestService.GetReplayDTOAsync(replayId); + public Task GetAsync(Guid replayId) => _ingestService.GetReplayDTOAsync(replayId); [HttpPost("{postId:guid}"), Authorize, RequestSizeLimit(ReplaysIngestService.MaxReplaySize), ProducesResponseType(201)] public async Task UploadReplayAsync(Guid postId, IFormFile replay, CancellationToken ct, [FromQuery] bool ignoreChecks = false) @@ -70,7 +66,7 @@ public async Task UploadReplayAsync(Guid postId, IFormFile replay } else { - if (current.AuthorId != uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier))) + if (current.AuthorId != uint.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new InvalidOperationException("Missing NameIdentifier claim."))) { return StatusCode(403, "Post Author is not authorized to edit post replays on behalf of other users."); } @@ -80,7 +76,6 @@ public async Task UploadReplayAsync(Guid postId, IFormFile replay } } - try { Replay ingested = await _ingestService.IngestReplayAsync(postId, replay, ct); @@ -106,7 +101,7 @@ public async Task UploadReplayAsync(Guid postId, IFormFile replay /// Start of date/time range /// End of date/time range [HttpPatch("reprocess/replay/all"), Authorize(Roles = ApiRoles.Administrator)] - public async Task ReprocessPostsAsync(DateTime start = default, DateTime end = default, CancellationToken ct = default) + public IActionResult ReprocessPosts(DateTime start = default, DateTime end = default, CancellationToken ct = default) { if (start == default) { @@ -127,7 +122,7 @@ public async Task ReprocessPostsAsync(DateTime start = default, D /// /// [HttpPatch("reprocess/replay/{replayId:guid}"), Authorize(Roles = ApiRoles.Administrator)] - public async Task ReprocessReplayAsync(Guid replayId, CancellationToken ct = default) + public IActionResult ReprocessReplay(Guid replayId, CancellationToken ct = default) { try { @@ -144,7 +139,6 @@ public async Task ReprocessReplayAsync(Guid replayId, Cancellatio /// Triggers minimap rendering on a post's replay (Usable only by Administrators) /// /// The ID of the post to render the replay's minimap for. - /// /// /// Whether to force rendering the minimap, even if it has already been rendered. /// Whether to wait for the job to complete before returning. @@ -185,7 +179,7 @@ public async ValueTask RenderMinimap(Guid postId, /// Cancellation token /// The job was enqueued successfully. [HttpPatch("reprocess/minimap/all"), Authorize(Roles = ApiRoles.Administrator)] - public async Task RenderMinimapsAsync(DateTime start = default, DateTime end = default, bool force = false, CancellationToken ct = default) + public IActionResult RenderMinimaps(DateTime start = default, DateTime end = default, bool force = false, CancellationToken ct = default) { if (start == default) { diff --git a/WowsKarma.Api/Controllers/StatusController.cs b/WowsKarma.Api/Controllers/StatusController.cs index 318b2210..0cb983b7 100644 --- a/WowsKarma.Api/Controllers/StatusController.cs +++ b/WowsKarma.Api/Controllers/StatusController.cs @@ -1,8 +1,5 @@ using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Hosting; using WowsKarma.Api.Infrastructure.Attributes; namespace WowsKarma.Api.Controllers; @@ -11,7 +8,7 @@ namespace WowsKarma.Api.Controllers; /// Provides status endpoints for controlling API lifetime. /// [ApiController, Route("api/[controller]"), ETag(false)] -public class StatusController : Controller +public sealed class StatusController : Controller { /// /// Provides a HTTP ping endpoint. @@ -34,8 +31,6 @@ public IActionResult HandleError() statusCode: StatusCodes.Status500InternalServerError, type: exceptionHandlerFeature.Error.GetType().ToString() ); - - } return Problem(statusCode: StatusCodes.Status500InternalServerError); diff --git a/WowsKarma.Api/Data/ApiDbContext.cs b/WowsKarma.Api/Data/ApiDbContext.cs index f0365864..a1c8f1ee 100644 --- a/WowsKarma.Api/Data/ApiDbContext.cs +++ b/WowsKarma.Api/Data/ApiDbContext.cs @@ -5,47 +5,40 @@ using WowsKarma.Api.Data.Models.Notifications; using WowsKarma.Api.Utilities; - namespace WowsKarma.Api.Data; - -public class ApiDbContext : DbContext +public sealed class ApiDbContext : DbContext { - public DbSet Clans { get; init; } - public DbSet ClanMembers { get; init; } - public DbSet PlatformBans { get; init; } - public DbSet Players { get; init; } - public DbSet Posts { get; init; } - public DbSet PostModActions { get; init; } - public DbSet Replays { get; init; } + public DbSet Clans { get; init; } = null!; + public DbSet ClanMembers { get; init; } = null!; + public DbSet PlatformBans { get; init; } = null!; + public DbSet Players { get; init; } = null!; + public DbSet Posts { get; init; } = null!; + public DbSet PostModActions { get; init; } = null!; + public DbSet Replays { get; init; } = null!; #region Notifications - public DbSet Notifications { get; init; } - - public DbSet PlatformBanNotifications { get; init; } - public DbSet PostAddedNotifications { get; init; } - public DbSet PostEditedNotifications { get; init; } - public DbSet PostDeletedNotifications { get; init; } - public DbSet PostModEditedNotifications { get; init; } - public DbSet PostModDeletedNotifications { get; init; } + public DbSet Notifications { get; init; } = null!; + + public DbSet PlatformBanNotifications { get; init; } = null!; + public DbSet PostAddedNotifications { get; init; } = null!; + public DbSet PostEditedNotifications { get; init; } = null!; + public DbSet PostDeletedNotifications { get; init; } = null!; + public DbSet PostModEditedNotifications { get; init; } = null!; + public DbSet PostModDeletedNotifications { get; init; } = null!; #endregion - - static ApiDbContext() + public ApiDbContext(DbContextOptions options) : base(options) { - NpgsqlConnection.GlobalTypeMapper.MapEnum(); - NpgsqlConnection.GlobalTypeMapper.MapEnum(); - NpgsqlConnection.GlobalTypeMapper.MapEnum(); + } - public ApiDbContext(DbContextOptions options) : base(options) { } - protected override void OnModelCreating(ModelBuilder modelBuilder) { foreach (Type type in modelBuilder.Model.GetEntityTypes().Where(t => t.ClrType.ImplementsInterface(typeof(ITimestamped))).Select(t => t.ClrType)) { modelBuilder.Entity(type) - .Property(nameof(ITimestamped.CreatedAt)) + .Property(nameof(ITimestamped.CreatedAt)) .ValueGeneratedOnAdd() .HasDefaultValueSql("NOW()"); } @@ -132,3 +125,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) #endregion } } + + +public static class ApiDbContextExtensions +{ + public static NpgsqlDataSourceBuilder ConfigureApiDbDataSourceBuilder(this NpgsqlDataSourceBuilder dataSourceBuilder) + { + dataSourceBuilder + .MapEnum() + .MapEnum() + .MapEnum(); + + dataSourceBuilder.EnableDynamicJson(); + + return dataSourceBuilder; + } +} \ No newline at end of file diff --git a/WowsKarma.Api/Data/AuthDbContext.cs b/WowsKarma.Api/Data/AuthDbContext.cs index 83cdfdf2..87d9107b 100644 --- a/WowsKarma.Api/Data/AuthDbContext.cs +++ b/WowsKarma.Api/Data/AuthDbContext.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Npgsql; using WowsKarma.Api.Data.Models.Auth; using WowsKarma.Common; @@ -7,8 +6,8 @@ namespace WowsKarma.Api.Data; public class AuthDbContext : DbContext { - public DbSet Users { get; init; } - public DbSet Roles { get; init; } + public DbSet Users { get; init; } = null!; + public DbSet Roles { get; init; } = null!; public AuthDbContext(DbContextOptions options) : base(options) { } diff --git a/WowsKarma.Api/Data/Models/Auth/Role.cs b/WowsKarma.Api/Data/Models/Auth/Role.cs index 3ccf3b6a..90a63912 100644 --- a/WowsKarma.Api/Data/Models/Auth/Role.cs +++ b/WowsKarma.Api/Data/Models/Auth/Role.cs @@ -3,16 +3,16 @@ namespace WowsKarma.Api.Data.Models.Auth; -public record Role +public sealed record Role { [Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public byte Id { get; init; } [Required] - public string InternalName { get; init; } + public string InternalName { get; init; } = ""; [Required] - public string DisplayName { get; set; } + public string DisplayName { get; set; } = ""; - public IEnumerable Users { get; set; } + public List Users { get; set; } = []; } \ No newline at end of file diff --git a/WowsKarma.Api/Data/Models/Auth/User.cs b/WowsKarma.Api/Data/Models/Auth/User.cs index 4d5f5de5..4a3df1eb 100644 --- a/WowsKarma.Api/Data/Models/Auth/User.cs +++ b/WowsKarma.Api/Data/Models/Auth/User.cs @@ -1,18 +1,17 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; - namespace WowsKarma.Api.Data.Models.Auth; -public record User +public sealed record User { [Required, DatabaseGenerated(DatabaseGeneratedOption.None)] public uint Id { get; init; } - public List Roles { get; set; } + public List Roles { get; set; } = []; [Required] public Guid SeedToken { get; set; } - public DateTime LastTokenRequested { get; set; } + public DateTimeOffset LastTokenRequested { get; set; } } \ No newline at end of file diff --git a/WowsKarma.Api/Data/Models/Clan.cs b/WowsKarma.Api/Data/Models/Clan.cs index 26ff9817..d048ccea 100644 --- a/WowsKarma.Api/Data/Models/Clan.cs +++ b/WowsKarma.Api/Data/Models/Clan.cs @@ -3,23 +3,23 @@ namespace WowsKarma.Api.Data.Models; -public record Clan : ITimestamped +public sealed record Clan : ITimestamped { [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public uint Id { get; init; } - public string Tag { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; + public string Tag { get; set; } = ""; + public string Name { get; set; } = ""; - public string Description { get; set; } = string.Empty; + public string Description { get; set; } = ""; public uint LeagueColor { get; set; } public bool IsDisbanded { get; set; } - public virtual List Members { get; set; } = new(); + public List Members { get; set; } = []; - public DateTime CreatedAt { get; init; } - public DateTime UpdatedAt { get; set; } - public DateTime MembersUpdatedAt { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset MembersUpdatedAt { get; set; } } \ No newline at end of file diff --git a/WowsKarma.Api/Data/Models/ClanMember.cs b/WowsKarma.Api/Data/Models/ClanMember.cs index 6dafb302..0d17c66f 100644 --- a/WowsKarma.Api/Data/Models/ClanMember.cs +++ b/WowsKarma.Api/Data/Models/ClanMember.cs @@ -1,28 +1,26 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using System.Text.Json.Serialization; +using System.ComponentModel.DataAnnotations.Schema; using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows; namespace WowsKarma.Api.Data.Models; -public record ClanMember : IComparable, IComparable +public sealed record ClanMember : IComparable, IComparable { [DatabaseGenerated(DatabaseGeneratedOption.None)] public uint PlayerId { get; init; } - public virtual Player Player { get; init; } + public Player Player { get; init; } = null!; [DatabaseGenerated(DatabaseGeneratedOption.None)] public uint ClanId { get; init; } - public virtual Clan Clan { get; init; } + public Clan Clan { get; init; } = null!; public DateOnly JoinedAt { get; init; } public DateOnly? LeftAt { get; set; } public ClanRole Role { get; set; } - public virtual bool Equals(ClanMember other) => other is not null && other.ClanId == ClanId && other.PlayerId == PlayerId; + public bool Equals(ClanMember? other) => other is not null && other.ClanId == ClanId && other.PlayerId == PlayerId; - public int CompareTo(ClanMember other) => CompareTo(other?.Role ?? ClanRole.Unknown); + public int CompareTo(ClanMember? other) => CompareTo(other?.Role ?? ClanRole.Unknown); // Alex thinks it's terrible. public int CompareTo(ClanRole other) => Role == other diff --git a/WowsKarma.Api/Data/Models/ITimestamped.cs b/WowsKarma.Api/Data/Models/ITimestamped.cs index 9a3ae5ab..65e664af 100644 --- a/WowsKarma.Api/Data/Models/ITimestamped.cs +++ b/WowsKarma.Api/Data/Models/ITimestamped.cs @@ -2,6 +2,6 @@ public interface ITimestamped { - public DateTime CreatedAt { get; init; } - public DateTime UpdatedAt { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } } \ No newline at end of file diff --git a/WowsKarma.Api/Data/Models/Notifications/NotificationBase.cs b/WowsKarma.Api/Data/Models/Notifications/NotificationBase.cs index b8abe734..6052bc49 100644 --- a/WowsKarma.Api/Data/Models/Notifications/NotificationBase.cs +++ b/WowsKarma.Api/Data/Models/Notifications/NotificationBase.cs @@ -14,8 +14,8 @@ public abstract record NotificationBase : INotification public abstract NotificationType Type { get; private protected init; } - public DateTime EmittedAt { get; private protected init; } = DateTime.UtcNow; - public DateTime? AcknowledgedAt { get; set; } + public DateTimeOffset EmittedAt { get; private protected init; } = DateTimeOffset.UtcNow; + public DateTimeOffset? AcknowledgedAt { get; set; } public virtual NotificationBaseDTO ToDTO() => new() { diff --git a/WowsKarma.Api/Data/Models/PlatformBan.cs b/WowsKarma.Api/Data/Models/PlatformBan.cs index 182adc0f..8c042ae9 100644 --- a/WowsKarma.Api/Data/Models/PlatformBan.cs +++ b/WowsKarma.Api/Data/Models/PlatformBan.cs @@ -1,30 +1,28 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; - namespace WowsKarma.Api.Data.Models; - -public record PlatformBan : ITimestamped +public sealed record PlatformBan : ITimestamped { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public Guid Id { get; init; } [Required] public uint UserId { get; init; } - public virtual Player User { get; init; } + public Player User { get; init; } = null!; [Required] public uint ModId { get; init; } - public virtual Player Mod { get; init; } + public Player Mod { get; init; } = null!; [Required] - public string Reason { get; set; } + public string Reason { get; set; } = ""; - public DateTime? BannedUntil { get; set; } + public DateTimeOffset? BannedUntil { get; set; } public bool Reverted { get; set; } - public DateTime CreatedAt { get; init; } - public DateTime UpdatedAt { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } } diff --git a/WowsKarma.Api/Data/Models/Player.cs b/WowsKarma.Api/Data/Models/Player.cs index e8c1fede..1d3d8d53 100644 --- a/WowsKarma.Api/Data/Models/Player.cs +++ b/WowsKarma.Api/Data/Models/Player.cs @@ -1,11 +1,9 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using WowsKarma.Common; - namespace WowsKarma.Api.Data.Models; -public record Player : ITimestamped +public sealed record Player : ITimestamped { internal const int NegativeKarmaAbilityThreshold = -20; @@ -13,12 +11,12 @@ public record Player : ITimestamped [Key, DatabaseGenerated(DatabaseGeneratedOption.None)] public uint Id { get; init; } - public string Username { get; set; } + public string Username { get; set; } = ""; - public DateTime CreatedAt { get; init; } - public DateTime UpdatedAt { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } - public virtual ClanMember ClanMember { get; set; } + public ClanMember? ClanMember { get; set; } public bool WgHidden { get; set; } @@ -29,23 +27,23 @@ public record Player : ITimestamped public int TeamplayRating { get; set; } public int CourtesyRating { get; set; } - public DateTime WgAccountCreatedAt { get; init; } - public DateTime LastBattleTime { get; set; } + public DateTimeOffset WgAccountCreatedAt { get; init; } + public DateTimeOffset LastBattleTime { get; set; } - public virtual List PostsReceived { get; init; } = new(); - public virtual List PostsSent { get; init; } = new(); + public List PostsReceived { get; init; } = []; + public List PostsSent { get; init; } = []; - public virtual List PlatformBans { get; init; } = new(); + public List PlatformBans { get; init; } = []; public bool NegativeKarmaAble => (SiteKarma + GameKarma) > NegativeKarmaAbilityThreshold; public bool PostsBanned { get; set; } public bool OptedOut { get; set; } - public DateTime OptOutChanged { get; set; } + public DateTimeOffset? OptOutChanged { get; set; } public bool IsBanned() => PostsBanned - || PlatformBans?.Any(pb => !pb.Reverted && (pb.BannedUntil is null || pb.BannedUntil > DateTime.UtcNow)) is true; + || PlatformBans?.Any(pb => !pb.Reverted && (pb.BannedUntil is null || pb.BannedUntil > DateTimeOffset.Now)) is true; diff --git a/WowsKarma.Api/Data/Models/Post.cs b/WowsKarma.Api/Data/Models/Post.cs index 92fc95c3..6b309f27 100644 --- a/WowsKarma.Api/Data/Models/Post.cs +++ b/WowsKarma.Api/Data/Models/Post.cs @@ -4,7 +4,7 @@ namespace WowsKarma.Api.Data.Models; -public record Post : ITimestamped +public sealed record Post : ITimestamped { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public Guid Id { get; init; } @@ -12,26 +12,26 @@ public record Post : ITimestamped [Required] public uint PlayerId { get; init; } [Required] - public Player Player { get; init; } + public Player Player { get; init; } = null!; [Required] public uint AuthorId { get; init; } [Required] - public Player Author { get; init; } + public Player Author { get; init; } = null!; public PostFlairs Flairs { get; set; } - public PostFlairsParsed ParsedFlairs => Flairs.ParseFlairsEnum(); + public PostFlairsParsed? ParsedFlairs => Flairs.ParseFlairsEnum(); [Required] - public string Title { get; set; } + public string Title { get; set; } = ""; [Required] - public string Content { get; set; } + public string Content { get; set; } = ""; public Guid? ReplayId { get; set; } - public virtual Replay Replay { get; set; } + public Replay? Replay { get; set; } // Computed by DB Engine (hopefully) - public DateTime CreatedAt { get; init; } - public DateTime UpdatedAt { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } public bool NegativeKarmaAble { get; internal set; } diff --git a/WowsKarma.Api/Data/Models/PostModAction.cs b/WowsKarma.Api/Data/Models/PostModAction.cs index f1cc5887..50a3cbe7 100644 --- a/WowsKarma.Api/Data/Models/PostModAction.cs +++ b/WowsKarma.Api/Data/Models/PostModAction.cs @@ -1,27 +1,23 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; - namespace WowsKarma.Api.Data.Models; /** * Conversion Mapping done in . **/ - -public record PostModAction +public sealed record PostModAction { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public Guid Id { get; init; } - public Post Post { get; init; } + public Post Post { get; init; } = null!; public Guid PostId { get; init; } - - public ModActionType ActionType { get; init; } - public Player Mod { get; init; } + public Player Mod { get; init; } = null!; public uint ModId { get; init; } - public string Reason { get; set; } + public string Reason { get; set; } = ""; } diff --git a/WowsKarma.Api/Data/Models/Replays/Replay.cs b/WowsKarma.Api/Data/Models/Replays/Replay.cs index 7ca7cbde..849f4b5b 100644 --- a/WowsKarma.Api/Data/Models/Replays/Replay.cs +++ b/WowsKarma.Api/Data/Models/Replays/Replay.cs @@ -2,20 +2,19 @@ using System.ComponentModel.DataAnnotations.Schema; using Nodsoft.WowsReplaysUnpack.Core.Models; - namespace WowsKarma.Api.Data.Models.Replays; -public record Replay +public sealed record Replay { [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] public Guid Id { get; init; } [Required] public Guid PostId { get; init; } - public virtual Post Post { get; init; } + public Post Post { get; init; } = null!; - public string BlobName { get; set; } + public string BlobName { get; set; } = ""; public bool MinimapRendered { get; set; } @@ -25,11 +24,11 @@ public record Replay */ [Column(TypeName = "jsonb")] - public virtual ArenaInfo ArenaInfo { get; set; } + public ArenaInfo ArenaInfo { get; set; } = null!; [Column(TypeName = "jsonb")] - public virtual IEnumerable Players { get; set; } + public IEnumerable Players { get; set; } = []; [Column(TypeName = "jsonb")] - public virtual IEnumerable ChatMessages { get; set; } + public IEnumerable ChatMessages { get; set; } = []; } diff --git a/WowsKarma.Api/Data/Models/Replays/ReplayArenaInfo.cs b/WowsKarma.Api/Data/Models/Replays/ReplayArenaInfo.cs index cb28c456..9c83bf42 100644 --- a/WowsKarma.Api/Data/Models/Replays/ReplayArenaInfo.cs +++ b/WowsKarma.Api/Data/Models/Replays/ReplayArenaInfo.cs @@ -7,16 +7,16 @@ namespace WowsKarma.Api.Data.Models.Replays; * https://dev.azure.com/wows-monitor/_git/api?path=/wows-monitor.core/appmodels/arenainfo/Arenainfo.cs */ -public record ReplayArenaInfo +public sealed record ReplayArenaInfo { public short MapId { get; set; } public int PlayerId { get; set; } - public object MatchGroup { get; set; } + public object? MatchGroup { get; set; } - public List Vehicles { get; set; } - public object DateTime { get; set; } - public string Token { get; set; } + public List Vehicles { get; set; } = []; + public object? DateTime { get; set; } + public string? Token { get; set; } public Region Region { get; set; } @@ -37,11 +37,10 @@ public record ReplayArenaInfo //public string Logic { get; set; } //public string PlayerVehicle { get; set; } - [JsonExtensionData] - public Dictionary ExtendedData { get; set; } + [JsonExtensionData] public Dictionary ExtendedData { get; set; } = []; } -public record Ship : IHasRelation +public sealed record Ship : IHasRelation { public int Id { get; set; } @@ -49,6 +48,6 @@ public record Ship : IHasRelation public Relation Relation { get; set; } - public string Name { get; set; } + public string Name { get; set; } = ""; } diff --git a/WowsKarma.Api/Data/Models/Replays/ReplayPlayer.cs b/WowsKarma.Api/Data/Models/Replays/ReplayPlayer.cs index 2dc23f9e..e1f47861 100644 --- a/WowsKarma.Api/Data/Models/Replays/ReplayPlayer.cs +++ b/WowsKarma.Api/Data/Models/Replays/ReplayPlayer.cs @@ -8,8 +8,8 @@ public readonly struct ReplayPlayer public uint AccountId { get; init; } public string Name { get; init; } - public uint ClanId { get; init; } - public string ClanTag { get; init; } + public uint? ClanId { get; init; } + public string? ClanTag { get; init; } public uint TeamId { get; init; } diff --git a/WowsKarma.Api/Hubs/AuthHub.cs b/WowsKarma.Api/Hubs/AuthHub.cs index c1656749..9a46ad0b 100644 --- a/WowsKarma.Api/Hubs/AuthHub.cs +++ b/WowsKarma.Api/Hubs/AuthHub.cs @@ -3,7 +3,7 @@ namespace WowsKarma.Api.Hubs; -public class AuthHub : Hub, IAuthHubInvoke +public sealed class AuthHub : Hub, IAuthHubInvoke { } diff --git a/WowsKarma.Api/Hubs/NotificationsHub.cs b/WowsKarma.Api/Hubs/NotificationsHub.cs index 086ac3df..71b584ed 100644 --- a/WowsKarma.Api/Hubs/NotificationsHub.cs +++ b/WowsKarma.Api/Hubs/NotificationsHub.cs @@ -2,18 +2,14 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using System.Runtime.CompilerServices; -using System.Threading; using WowsKarma.Api.Data.Models.Notifications; using WowsKarma.Api.Services; using WowsKarma.Common.Hubs; - - namespace WowsKarma.Api.Hubs; - [Authorize] -public class NotificationsHub : Hub, INotificationsHubInvoke +public sealed class NotificationsHub : Hub, INotificationsHubInvoke { private readonly NotificationService _service; @@ -24,7 +20,10 @@ public NotificationsHub(NotificationService service) public Task AcknowledgeNotifications(Guid[] notificationIds) { - IQueryable notifications = _service.GetNotifications(notificationIds).Where(n => n.AccountId == uint.Parse(Context.UserIdentifier)); + uint userId = uint.Parse(Context.UserIdentifier ?? throw new InvalidOperationException("No context user identifier. Is the user logged in on the hub?")); + + IQueryable notifications = _service.GetNotifications(notificationIds).Where(n => n.AccountId == userId); + _service.AcknowledgeNotifications(notifications); return Task.CompletedTask; } @@ -43,7 +42,7 @@ public Task AcknowledgeNotifications(Guid[] notificationIds) ct.ThrowIfCancellationRequested(); object notificationDto = item.ToDTO(); - yield return (notificationDto.GetType().FullName, notificationDto); + yield return (notificationDto.GetType().FullName!, notificationDto); } } } diff --git a/WowsKarma.Api/Hubs/PostHub.cs b/WowsKarma.Api/Hubs/PostHub.cs index 4fc3d520..522c53de 100644 --- a/WowsKarma.Api/Hubs/PostHub.cs +++ b/WowsKarma.Api/Hubs/PostHub.cs @@ -3,7 +3,7 @@ namespace WowsKarma.Api.Hubs; -public class PostHub : Hub, IPostHubInvoke +public sealed class PostHub : Hub, IPostHubInvoke { } \ No newline at end of file diff --git a/WowsKarma.Api/Infrastructure/Attributes/ETagAttribute.cs b/WowsKarma.Api/Infrastructure/Attributes/ETagAttribute.cs index f1ff2206..0f67cd71 100644 --- a/WowsKarma.Api/Infrastructure/Attributes/ETagAttribute.cs +++ b/WowsKarma.Api/Infrastructure/Attributes/ETagAttribute.cs @@ -4,7 +4,7 @@ /// Attribute for controlling ETag generation for a given endpoint. /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] -public class ETagAttribute : Attribute +public sealed class ETagAttribute : Attribute { /// /// Initializes a new instance of the class. diff --git a/WowsKarma.Api/Infrastructure/Authorization/HangfireDashboardAuthorizationFilter.cs b/WowsKarma.Api/Infrastructure/Authorization/HangfireDashboardAuthorizationFilter.cs index f5d9a8fe..2816bea8 100644 --- a/WowsKarma.Api/Infrastructure/Authorization/HangfireDashboardAuthorizationFilter.cs +++ b/WowsKarma.Api/Infrastructure/Authorization/HangfireDashboardAuthorizationFilter.cs @@ -1,6 +1,5 @@ using System.Security.Claims; using Hangfire.Dashboard; -using Microsoft.AspNetCore.Http; using WowsKarma.Common; namespace WowsKarma.Api.Infrastructure.Authorization; @@ -9,7 +8,7 @@ namespace WowsKarma.Api.Infrastructure.Authorization; /// Simple RBAC auth filter to check if the user has the Admin role, and grant access to the Hangfire dashboard if so. /// Also grants readonly access to the Hangfire dashboard if the user has the CM role. /// -public class HangfireDashboardAuthorizationFilter : IDashboardAuthorizationFilter +public sealed class HangfireDashboardAuthorizationFilter : IDashboardAuthorizationFilter { public static readonly HangfireDashboardAuthorizationFilter Instance = new(); diff --git a/WowsKarma.Api/Infrastructure/Authorization/PlatformBanAuthorizationHandler.cs b/WowsKarma.Api/Infrastructure/Authorization/PlatformBanAuthorizationHandler.cs index d198d1dd..ba7ec615 100644 --- a/WowsKarma.Api/Infrastructure/Authorization/PlatformBanAuthorizationHandler.cs +++ b/WowsKarma.Api/Infrastructure/Authorization/PlatformBanAuthorizationHandler.cs @@ -1,14 +1,11 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using WowsKarma.Api.Data; namespace WowsKarma.Api.Infrastructure.Authorization; -#nullable enable -public class PlatformBanAuthorizationHandler : AuthorizationHandler +public sealed class PlatformBanAuthorizationHandler : AuthorizationHandler { private readonly ILogger _logger; private readonly ApiDbContext _dbContext; diff --git a/WowsKarma.Api/Infrastructure/Authorization/PlatformBanRequirement.cs b/WowsKarma.Api/Infrastructure/Authorization/PlatformBanRequirement.cs index 4fc36f44..2783e78b 100644 --- a/WowsKarma.Api/Infrastructure/Authorization/PlatformBanRequirement.cs +++ b/WowsKarma.Api/Infrastructure/Authorization/PlatformBanRequirement.cs @@ -5,7 +5,7 @@ namespace WowsKarma.Api.Infrastructure.Authorization; /// /// Provides an authorization requirement to evaluate a user's platform bans. /// -public class PlatformBanRequirement : IAuthorizationRequirement +public sealed class PlatformBanRequirement : IAuthorizationRequirement { /// /// Whether the user should be banned from the current platform. diff --git a/WowsKarma.Api/Infrastructure/Data/Page.cs b/WowsKarma.Api/Infrastructure/Data/Page.cs index a0883e60..7bd99ff0 100644 --- a/WowsKarma.Api/Infrastructure/Data/Page.cs +++ b/WowsKarma.Api/Infrastructure/Data/Page.cs @@ -1,7 +1,5 @@ namespace WowsKarma.Api.Infrastructure.Data; -#nullable enable - /// /// Represents a paged list of items. /// diff --git a/WowsKarma.Api/Infrastructure/Telemetry/HubTelemetryFilter.cs b/WowsKarma.Api/Infrastructure/Telemetry/HubTelemetryFilter.cs index f93755ab..afc06e2b 100644 --- a/WowsKarma.Api/Infrastructure/Telemetry/HubTelemetryFilter.cs +++ b/WowsKarma.Api/Infrastructure/Telemetry/HubTelemetryFilter.cs @@ -4,7 +4,7 @@ namespace WowsKarma.Api.Infrastructure.Telemetry; -public class HubTelemetryFilter : ITelemetryProcessor +public sealed class HubTelemetryFilter : ITelemetryProcessor { private ITelemetryProcessor Next { get; set; } @@ -15,7 +15,7 @@ public HubTelemetryFilter(ITelemetryProcessor next) public void Process(ITelemetry item) { - if (item is RequestTelemetry request and { Name: not null } && request.Name.Contains("hub")) + if (item is RequestTelemetry { Name: not null } request && request.Name.Contains("hub")) { return; } diff --git a/WowsKarma.Api/Infrastructure/Telemetry/TelemetryEnrichment.cs b/WowsKarma.Api/Infrastructure/Telemetry/TelemetryEnrichment.cs index 5843262f..2d40de28 100644 --- a/WowsKarma.Api/Infrastructure/Telemetry/TelemetryEnrichment.cs +++ b/WowsKarma.Api/Infrastructure/Telemetry/TelemetryEnrichment.cs @@ -1,7 +1,6 @@ using Microsoft.ApplicationInsights.AspNetCore.TelemetryInitializers; using Microsoft.ApplicationInsights.Channel; using Microsoft.ApplicationInsights.DataContracts; -using Microsoft.AspNetCore.Http; using WowsKarma.Common; namespace WowsKarma.Api.Infrastructure.Telemetry; @@ -17,14 +16,14 @@ protected override void OnInitializeTelemetry(HttpContext platformContext, Reque telemetry.Context.GlobalProperties["api-region"] = Startup.ApiRegion.ToRegionString(); // User ID - AccountListingDTO userAccount = platformContext.User?.ToAccountListing(); + AccountListingDTO? userAccount = platformContext.User.ToAccountListing(); telemetry.Context.User.AuthenticatedUserId = userAccount?.Id.ToString() ?? string.Empty; // IP Address if (telemetry is ISupportProperties propTelemetry && !propTelemetry.Properties.ContainsKey("client-ip")) { - string clientIPValue = telemetry.Context.Location.Ip; - propTelemetry.Properties.Add("client-ip", clientIPValue); + string clientIpValue = telemetry.Context.Location.Ip; + propTelemetry.Properties.Add("client-ip", clientIpValue); } } } diff --git a/WowsKarma.Api/Middlewares/ETagMiddleware.cs b/WowsKarma.Api/Middlewares/ETagMiddleware.cs index 14d0ca6c..be223d0c 100644 --- a/WowsKarma.Api/Middlewares/ETagMiddleware.cs +++ b/WowsKarma.Api/Middlewares/ETagMiddleware.cs @@ -1,8 +1,5 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Net.Http.Headers; -using System.IO; using System.Security.Cryptography; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; @@ -13,7 +10,7 @@ namespace WowsKarma.Api.Middlewares; /// /// Provides an ETag generation middleware. /// -public class ETagMiddleware +public sealed class ETagMiddleware { private readonly RequestDelegate _next; diff --git a/WowsKarma.Api/Middlewares/RequestLoggingMiddleware.cs b/WowsKarma.Api/Middlewares/RequestLoggingMiddleware.cs index 1a777e44..eefe1ef6 100644 --- a/WowsKarma.Api/Middlewares/RequestLoggingMiddleware.cs +++ b/WowsKarma.Api/Middlewares/RequestLoggingMiddleware.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Features; using Serilog; using Serilog.Events; using System.Diagnostics; @@ -8,18 +7,18 @@ namespace WowsKarma.Api.Middlewares; -public class RequestLoggingMiddleware +public sealed class RequestLoggingMiddleware { private const string MessageTemplate = "{Protocol} {RequestMethod} {RequestPath} by {RemoteUser}, responded {StatusCode} in {Elapsed:0.00} ms"; - private static readonly ILogger logger = Log.ForContext(); - private static readonly HashSet HeaderWhitelist = new() { "Content-Type", "Content-Length", "User-Agent" }; + private static readonly ILogger _logger = Log.ForContext(); + private static readonly HashSet _headerWhitelist = new() { "Content-Type", "Content-Length", "User-Agent" }; - private readonly RequestDelegate next; + private readonly RequestDelegate _next; public RequestLoggingMiddleware(RequestDelegate next) { - this.next = next ?? throw new ArgumentNullException(nameof(next)); + _next = next ?? throw new ArgumentNullException(nameof(next)); } @@ -31,15 +30,15 @@ public async Task Invoke(HttpContext context) try { - await next(context); + await _next(context); double elapsedMs = GetElapsedMilliseconds(start, Stopwatch.GetTimestamp()); - int? statusCode = context.Response?.StatusCode; + int? statusCode = context.Response.StatusCode; LogEventLevel level = statusCode > 499 ? LogEventLevel.Error : LogEventLevel.Information; ILogger log = level is LogEventLevel.Error ? LogForErrorContext(context) - : logger.ForContext("RequestUser", GetRemoteUser(context)); + : _logger.ForContext("RequestUser", GetRemoteUser(context)); log.Write(level, MessageTemplate, context.Request.Protocol, context.Request.Method, GetPath(context), GetRemoteUser(context), statusCode, elapsedMs); } @@ -60,10 +59,10 @@ private static ILogger LogForErrorContext(HttpContext context) HttpRequest request = context.Request; Dictionary loggedHeaders = request.Headers - .Where(h => HeaderWhitelist.Contains(h.Key)) + .Where(h => _headerWhitelist.Contains(h.Key)) .ToDictionary(h => h.Key, h => h.Value.ToString()); - return logger + return _logger .ForContext("RequestHeaders", loggedHeaders, destructureObjects: true) .ForContext("RequestHost", request.Host) .ForContext("RequestProtocol", request.Protocol); @@ -73,5 +72,5 @@ private static ILogger LogForErrorContext(HttpContext context) private static string GetPath(HttpContext context) => context.Features.Get()?.RawTarget ?? context.Request.Path.ToString(); - private static string GetRemoteUser(HttpContext context) => context.User?.FindFirstValue(ClaimTypes.Name) ?? context.Connection.RemoteIpAddress?.ToString() ?? "Unknown"; + private static string GetRemoteUser(HttpContext context) => context.User.FindFirstValue(ClaimTypes.Name) ?? context.Connection.RemoteIpAddress?.ToString() ?? "Unknown"; } \ No newline at end of file diff --git a/WowsKarma.Api/Migrations/ApiDb/20220311183423_AddClans.cs b/WowsKarma.Api/Migrations/ApiDb/20220311183423_AddClans.cs index 7218cc2a..540309dd 100644 --- a/WowsKarma.Api/Migrations/ApiDb/20220311183423_AddClans.cs +++ b/WowsKarma.Api/Migrations/ApiDb/20220311183423_AddClans.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows; #nullable disable diff --git a/WowsKarma.Api/Migrations/ApiDb/20220312144159_ReplaceClanMemberNavigation.cs b/WowsKarma.Api/Migrations/ApiDb/20220312144159_ReplaceClanMemberNavigation.cs index 0ba4c877..9ba99ba4 100644 --- a/WowsKarma.Api/Migrations/ApiDb/20220312144159_ReplaceClanMemberNavigation.cs +++ b/WowsKarma.Api/Migrations/ApiDb/20220312144159_ReplaceClanMemberNavigation.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/WowsKarma.Api/Migrations/ApiDb/20220730123556_AddModEditedNotification.cs b/WowsKarma.Api/Migrations/ApiDb/20220730123556_AddModEditedNotification.cs index 401d153e..c88e5a35 100644 --- a/WowsKarma.Api/Migrations/ApiDb/20220730123556_AddModEditedNotification.cs +++ b/WowsKarma.Api/Migrations/ApiDb/20220730123556_AddModEditedNotification.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/WowsKarma.Api/Migrations/20230721210956_AddReplayMinimapRendered.Designer.cs b/WowsKarma.Api/Migrations/ApiDb/20230721210956_AddReplayMinimapRendered.Designer.cs similarity index 100% rename from WowsKarma.Api/Migrations/20230721210956_AddReplayMinimapRendered.Designer.cs rename to WowsKarma.Api/Migrations/ApiDb/20230721210956_AddReplayMinimapRendered.Designer.cs diff --git a/WowsKarma.Api/Migrations/20230721210956_AddReplayMinimapRendered.cs b/WowsKarma.Api/Migrations/ApiDb/20230721210956_AddReplayMinimapRendered.cs similarity index 100% rename from WowsKarma.Api/Migrations/20230721210956_AddReplayMinimapRendered.cs rename to WowsKarma.Api/Migrations/ApiDb/20230721210956_AddReplayMinimapRendered.cs diff --git a/WowsKarma.Api/Migrations/ApiDb/20240111075051_AddTimestampsOffset.Designer.cs b/WowsKarma.Api/Migrations/ApiDb/20240111075051_AddTimestampsOffset.Designer.cs new file mode 100644 index 00000000..a3848d48 --- /dev/null +++ b/WowsKarma.Api/Migrations/ApiDb/20240111075051_AddTimestampsOffset.Designer.cs @@ -0,0 +1,589 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows; +using Nodsoft.WowsReplaysUnpack.Core.Models; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using WowsKarma.Api.Data; +using WowsKarma.Api.Data.Models.Replays; +using WowsKarma.Common.Models; + +#nullable disable + +namespace WowsKarma.Api.Migrations.ApiDb +{ + [DbContext(typeof(ApiDbContext))] + [Migration("20240111075051_AddTimestampsOffset")] + partial class AddTimestampsOffset + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "clan_role", new[] { "unknown", "commander", "executive_officer", "recruitment_officer", "commissioned_officer", "officer", "private" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_action_type", new[] { "deletion", "update" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "notification_type", new[] { "unknown", "other", "post_added", "post_edited", "post_deleted", "post_mod_edited", "post_mod_deleted", "platform_ban" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Clan", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDisbanded") + .HasColumnType("boolean"); + + b.Property("LeagueColor") + .HasColumnType("bigint"); + + b.Property("MembersUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Clans"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.ClanMember", b => + { + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("ClanId") + .HasColumnType("bigint"); + + b.Property("JoinedAt") + .HasColumnType("date"); + + b.Property("LeftAt") + .HasColumnType("date"); + + b.Property("Role") + .HasColumnType("clan_role"); + + b.HasKey("PlayerId"); + + b.HasIndex("ClanId"); + + b.ToTable("ClanMembers"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.NotificationBase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("bigint"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("notification_type"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Notifications"); + + b.HasDiscriminator("Type").IsComplete(false).HasValue(NotificationType.Unknown); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.PlatformBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BannedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("ModId") + .HasColumnType("bigint"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reverted") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ModId"); + + b.HasIndex("UserId"); + + b.ToTable("PlatformBans"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Player", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CourtesyRating") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("GameKarma") + .HasColumnType("integer"); + + b.Property("LastBattleTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OptOutChanged") + .HasColumnType("timestamp with time zone"); + + b.Property("OptedOut") + .HasColumnType("boolean"); + + b.Property("PerformanceRating") + .HasColumnType("integer"); + + b.Property("PostsBanned") + .HasColumnType("boolean"); + + b.Property("SiteKarma") + .HasColumnType("integer"); + + b.Property("TeamplayRating") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .HasColumnType("text"); + + b.Property("WgAccountCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WgHidden") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Flairs") + .HasColumnType("integer"); + + b.Property("ModLocked") + .HasColumnType("boolean"); + + b.Property("NegativeKarmaAble") + .HasColumnType("boolean"); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ReplayId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ReplayId") + .IsUnique(); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.PostModAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionType") + .HasColumnType("mod_action_type"); + + b.Property("ModId") + .HasColumnType("bigint"); + + b.Property("PostId") + .HasColumnType("uuid"); + + b.Property("Reason") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ModId"); + + b.HasIndex("PostId"); + + b.ToTable("PostModActions"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Replays.Replay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArenaInfo") + .HasColumnType("jsonb"); + + b.Property("BlobName") + .HasColumnType("text"); + + b.Property>("ChatMessages") + .HasColumnType("jsonb"); + + b.Property("MinimapRendered") + .HasColumnType("boolean"); + + b.Property>("Players") + .HasColumnType("jsonb"); + + b.Property("PostId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Replays"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PlatformBanNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("BanId") + .HasColumnType("uuid"); + + b.HasIndex("BanId"); + + b.HasDiscriminator().HasValue(NotificationType.PlatformBan); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostAddedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("PostId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid"); + + b.HasIndex("PostId"); + + b.HasDiscriminator().HasValue(NotificationType.PostAdded); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostDeletedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("PostId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid"); + + b.HasIndex("PostId"); + + b.HasDiscriminator().HasValue(NotificationType.PostDeleted); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostEditedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("PostId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid"); + + b.HasIndex("PostId"); + + b.HasDiscriminator().HasValue(NotificationType.PostEdited); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostModDeletedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("ModActionId") + .HasColumnType("uuid"); + + b.HasIndex("ModActionId"); + + b.HasDiscriminator().HasValue(NotificationType.PostModDeleted); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostModEditedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("ModActionId") + .HasColumnType("uuid"); + + b.HasIndex("ModActionId"); + + b.ToTable("Notifications", t => + { + t.Property("ModActionId") + .HasColumnName("PostModEditedNotification_ModActionId"); + }); + + b.HasDiscriminator().HasValue(NotificationType.PostModEdited); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.ClanMember", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Clan", "Clan") + .WithMany("Members") + .HasForeignKey("ClanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("WowsKarma.Api.Data.Models.Player", "Player") + .WithOne("ClanMember") + .HasForeignKey("WowsKarma.Api.Data.Models.ClanMember", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Clan"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.NotificationBase", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Player", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.PlatformBan", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Player", "Mod") + .WithMany() + .HasForeignKey("ModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("WowsKarma.Api.Data.Models.Player", "User") + .WithMany("PlatformBans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Mod"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Post", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Player", "Author") + .WithMany("PostsSent") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("WowsKarma.Api.Data.Models.Player", "Player") + .WithMany("PostsReceived") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("WowsKarma.Api.Data.Models.Replays.Replay", "Replay") + .WithOne("Post") + .HasForeignKey("WowsKarma.Api.Data.Models.Post", "ReplayId"); + + b.Navigation("Author"); + + b.Navigation("Player"); + + b.Navigation("Replay"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.PostModAction", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Player", "Mod") + .WithMany() + .HasForeignKey("ModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("WowsKarma.Api.Data.Models.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mod"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PlatformBanNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.PlatformBan", "Ban") + .WithMany() + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostAddedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostDeletedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostEditedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostModDeletedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.PostModAction", "ModAction") + .WithMany() + .HasForeignKey("ModActionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ModAction"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostModEditedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.PostModAction", "ModAction") + .WithMany() + .HasForeignKey("ModActionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ModAction"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Clan", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Player", b => + { + b.Navigation("ClanMember"); + + b.Navigation("PlatformBans"); + + b.Navigation("PostsReceived"); + + b.Navigation("PostsSent"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Replays.Replay", b => + { + b.Navigation("Post"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/WowsKarma.Api/Migrations/ApiDb/20240111075051_AddTimestampsOffset.cs b/WowsKarma.Api/Migrations/ApiDb/20240111075051_AddTimestampsOffset.cs new file mode 100644 index 00000000..9f6612f7 --- /dev/null +++ b/WowsKarma.Api/Migrations/ApiDb/20240111075051_AddTimestampsOffset.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WowsKarma.Api.Migrations.ApiDb +{ + /// + public partial class AddTimestampsOffset : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/WowsKarma.Api/Migrations/ApiDb/20240114120317_UpdateNullableContext.Designer.cs b/WowsKarma.Api/Migrations/ApiDb/20240114120317_UpdateNullableContext.Designer.cs new file mode 100644 index 00000000..112cc4e8 --- /dev/null +++ b/WowsKarma.Api/Migrations/ApiDb/20240114120317_UpdateNullableContext.Designer.cs @@ -0,0 +1,599 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows; +using Nodsoft.WowsReplaysUnpack.Core.Models; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using WowsKarma.Api.Data; +using WowsKarma.Api.Data.Models.Replays; +using WowsKarma.Common.Models; + +#nullable disable + +namespace WowsKarma.Api.Migrations.ApiDb +{ + [DbContext(typeof(ApiDbContext))] + [Migration("20240114120317_UpdateNullableContext")] + partial class UpdateNullableContext + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "clan_role", new[] { "unknown", "commander", "executive_officer", "recruitment_officer", "commissioned_officer", "officer", "private" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_action_type", new[] { "deletion", "update" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "notification_type", new[] { "unknown", "other", "post_added", "post_edited", "post_deleted", "post_mod_edited", "post_mod_deleted", "platform_ban" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Clan", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDisbanded") + .HasColumnType("boolean"); + + b.Property("LeagueColor") + .HasColumnType("bigint"); + + b.Property("MembersUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Clans"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.ClanMember", b => + { + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("ClanId") + .HasColumnType("bigint"); + + b.Property("JoinedAt") + .HasColumnType("date"); + + b.Property("LeftAt") + .HasColumnType("date"); + + b.Property("Role") + .HasColumnType("clan_role"); + + b.HasKey("PlayerId"); + + b.HasIndex("ClanId"); + + b.ToTable("ClanMembers"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.NotificationBase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("bigint"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("notification_type"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Notifications"); + + b.HasDiscriminator("Type").IsComplete(false).HasValue(NotificationType.Unknown); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.PlatformBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BannedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("ModId") + .HasColumnType("bigint"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reverted") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ModId"); + + b.HasIndex("UserId"); + + b.ToTable("PlatformBans"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Player", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CourtesyRating") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("GameKarma") + .HasColumnType("integer"); + + b.Property("LastBattleTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OptOutChanged") + .HasColumnType("timestamp with time zone"); + + b.Property("OptedOut") + .HasColumnType("boolean"); + + b.Property("PerformanceRating") + .HasColumnType("integer"); + + b.Property("PostsBanned") + .HasColumnType("boolean"); + + b.Property("SiteKarma") + .HasColumnType("integer"); + + b.Property("TeamplayRating") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.Property("WgAccountCreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WgHidden") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("Flairs") + .HasColumnType("integer"); + + b.Property("ModLocked") + .HasColumnType("boolean"); + + b.Property("NegativeKarmaAble") + .HasColumnType("boolean"); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ReplayId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ReplayId") + .IsUnique(); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.PostModAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionType") + .HasColumnType("mod_action_type"); + + b.Property("ModId") + .HasColumnType("bigint"); + + b.Property("PostId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ModId"); + + b.HasIndex("PostId"); + + b.ToTable("PostModActions"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Replays.Replay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArenaInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("BlobName") + .IsRequired() + .HasColumnType("text"); + + b.Property>("ChatMessages") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MinimapRendered") + .HasColumnType("boolean"); + + b.Property>("Players") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PostId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Replays"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PlatformBanNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("BanId") + .HasColumnType("uuid"); + + b.HasIndex("BanId"); + + b.HasDiscriminator().HasValue(NotificationType.PlatformBan); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostAddedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("PostId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid"); + + b.HasIndex("PostId"); + + b.HasDiscriminator().HasValue(NotificationType.PostAdded); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostDeletedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("PostId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid"); + + b.HasIndex("PostId"); + + b.HasDiscriminator().HasValue(NotificationType.PostDeleted); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostEditedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("PostId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid"); + + b.HasIndex("PostId"); + + b.HasDiscriminator().HasValue(NotificationType.PostEdited); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostModDeletedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("ModActionId") + .HasColumnType("uuid"); + + b.HasIndex("ModActionId"); + + b.HasDiscriminator().HasValue(NotificationType.PostModDeleted); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostModEditedNotification", b => + { + b.HasBaseType("WowsKarma.Api.Data.Models.Notifications.NotificationBase"); + + b.Property("ModActionId") + .HasColumnType("uuid"); + + b.HasIndex("ModActionId"); + + b.ToTable("Notifications", t => + { + t.Property("ModActionId") + .HasColumnName("PostModEditedNotification_ModActionId"); + }); + + b.HasDiscriminator().HasValue(NotificationType.PostModEdited); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.ClanMember", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Clan", "Clan") + .WithMany("Members") + .HasForeignKey("ClanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("WowsKarma.Api.Data.Models.Player", "Player") + .WithOne("ClanMember") + .HasForeignKey("WowsKarma.Api.Data.Models.ClanMember", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Clan"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.NotificationBase", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Player", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.PlatformBan", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Player", "Mod") + .WithMany() + .HasForeignKey("ModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("WowsKarma.Api.Data.Models.Player", "User") + .WithMany("PlatformBans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Mod"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Post", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Player", "Author") + .WithMany("PostsSent") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("WowsKarma.Api.Data.Models.Player", "Player") + .WithMany("PostsReceived") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("WowsKarma.Api.Data.Models.Replays.Replay", "Replay") + .WithOne("Post") + .HasForeignKey("WowsKarma.Api.Data.Models.Post", "ReplayId"); + + b.Navigation("Author"); + + b.Navigation("Player"); + + b.Navigation("Replay"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.PostModAction", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Player", "Mod") + .WithMany() + .HasForeignKey("ModId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("WowsKarma.Api.Data.Models.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mod"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PlatformBanNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.PlatformBan", "Ban") + .WithMany() + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostAddedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostDeletedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostEditedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.Post", "Post") + .WithMany() + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostModDeletedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.PostModAction", "ModAction") + .WithMany() + .HasForeignKey("ModActionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ModAction"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Notifications.PostModEditedNotification", b => + { + b.HasOne("WowsKarma.Api.Data.Models.PostModAction", "ModAction") + .WithMany() + .HasForeignKey("ModActionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ModAction"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Clan", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Player", b => + { + b.Navigation("ClanMember"); + + b.Navigation("PlatformBans"); + + b.Navigation("PostsReceived"); + + b.Navigation("PostsSent"); + }); + + modelBuilder.Entity("WowsKarma.Api.Data.Models.Replays.Replay", b => + { + b.Navigation("Post") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/WowsKarma.Api/Migrations/ApiDb/20240114120317_UpdateNullableContext.cs b/WowsKarma.Api/Migrations/ApiDb/20240114120317_UpdateNullableContext.cs new file mode 100644 index 00000000..abc09e5b --- /dev/null +++ b/WowsKarma.Api/Migrations/ApiDb/20240114120317_UpdateNullableContext.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Nodsoft.WowsReplaysUnpack.Core.Models; +using WowsKarma.Api.Data.Models.Replays; + +#nullable disable + +namespace WowsKarma.Api.Migrations.ApiDb +{ + /// + public partial class UpdateNullableContext : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn>( + name: "Players", + table: "Replays", + type: "jsonb", + nullable: false, + oldClrType: typeof(IEnumerable), + oldType: "jsonb", + oldNullable: true); + + migrationBuilder.AlterColumn>( + name: "ChatMessages", + table: "Replays", + type: "jsonb", + nullable: false, + oldClrType: typeof(IEnumerable), + oldType: "jsonb", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "BlobName", + table: "Replays", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ArenaInfo", + table: "Replays", + type: "jsonb", + nullable: false, + oldClrType: typeof(ArenaInfo), + oldType: "jsonb", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Reason", + table: "PostModActions", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Username", + table: "Players", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "OptOutChanged", + table: "Players", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "Tag", + table: "Clans", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Clans", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Description", + table: "Clans", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn>( + name: "Players", + table: "Replays", + type: "jsonb", + nullable: true, + oldClrType: typeof(IEnumerable), + oldType: "jsonb"); + + migrationBuilder.AlterColumn>( + name: "ChatMessages", + table: "Replays", + type: "jsonb", + nullable: true, + oldClrType: typeof(IEnumerable), + oldType: "jsonb"); + + migrationBuilder.AlterColumn( + name: "BlobName", + table: "Replays", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "ArenaInfo", + table: "Replays", + type: "jsonb", + nullable: true, + oldClrType: typeof(ArenaInfo), + oldType: "jsonb"); + + migrationBuilder.AlterColumn( + name: "Reason", + table: "PostModActions", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Username", + table: "Players", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "OptOutChanged", + table: "Players", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Tag", + table: "Clans", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Clans", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Description", + table: "Clans", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + } + } +} diff --git a/WowsKarma.Api/Migrations/ApiDb/ApiDbContextModelSnapshot.cs b/WowsKarma.Api/Migrations/ApiDb/ApiDbContextModelSnapshot.cs index 9ceaff2b..87968ea9 100644 --- a/WowsKarma.Api/Migrations/ApiDb/ApiDbContextModelSnapshot.cs +++ b/WowsKarma.Api/Migrations/ApiDb/ApiDbContextModelSnapshot.cs @@ -22,7 +22,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.9") + .HasAnnotation("ProductVersion", "8.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "clan_role", new[] { "unknown", "commander", "executive_officer", "recruitment_officer", "commissioned_officer", "officer", "private" }); @@ -35,12 +35,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("bigint"); - b.Property("CreatedAt") + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasDefaultValueSql("NOW()"); b.Property("Description") + .IsRequired() .HasColumnType("text"); b.Property("IsDisbanded") @@ -49,16 +50,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LeagueColor") .HasColumnType("bigint"); - b.Property("MembersUpdatedAt") + b.Property("MembersUpdatedAt") .HasColumnType("timestamp with time zone"); b.Property("Name") + .IsRequired() .HasColumnType("text"); b.Property("Tag") + .IsRequired() .HasColumnType("text"); - b.Property("UpdatedAt") + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); @@ -99,10 +102,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AccountId") .HasColumnType("bigint"); - b.Property("AcknowledgedAt") + b.Property("AcknowledgedAt") .HasColumnType("timestamp with time zone"); - b.Property("EmittedAt") + b.Property("EmittedAt") .HasColumnType("timestamp with time zone"); b.Property("Type") @@ -125,10 +128,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("BannedUntil") + b.Property("BannedUntil") .HasColumnType("timestamp with time zone"); - b.Property("CreatedAt") + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasDefaultValueSql("NOW()"); @@ -143,7 +146,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Reverted") .HasColumnType("boolean"); - b.Property("UpdatedAt") + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); b.Property("UserId") @@ -166,7 +169,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CourtesyRating") .HasColumnType("integer"); - b.Property("CreatedAt") + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasDefaultValueSql("NOW()"); @@ -174,10 +177,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GameKarma") .HasColumnType("integer"); - b.Property("LastBattleTime") + b.Property("LastBattleTime") .HasColumnType("timestamp with time zone"); - b.Property("OptOutChanged") + b.Property("OptOutChanged") .HasColumnType("timestamp with time zone"); b.Property("OptedOut") @@ -195,13 +198,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TeamplayRating") .HasColumnType("integer"); - b.Property("UpdatedAt") + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); b.Property("Username") + .IsRequired() .HasColumnType("text"); - b.Property("WgAccountCreatedAt") + b.Property("WgAccountCreatedAt") .HasColumnType("timestamp with time zone"); b.Property("WgHidden") @@ -225,7 +229,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.Property("CreatedAt") + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasDefaultValueSql("NOW()"); @@ -252,7 +256,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.Property("UpdatedAt") + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); @@ -283,6 +287,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid"); b.Property("Reason") + .IsRequired() .HasColumnType("text"); b.HasKey("Id"); @@ -301,18 +306,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid"); b.Property("ArenaInfo") + .IsRequired() .HasColumnType("jsonb"); b.Property("BlobName") + .IsRequired() .HasColumnType("text"); b.Property>("ChatMessages") + .IsRequired() .HasColumnType("jsonb"); b.Property("MinimapRendered") .HasColumnType("boolean"); b.Property>("Players") + .IsRequired() .HasColumnType("jsonb"); b.Property("PostId") @@ -578,7 +587,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("WowsKarma.Api.Data.Models.Replays.Replay", b => { - b.Navigation("Post"); + b.Navigation("Post") + .IsRequired(); }); #pragma warning restore 612, 618 } diff --git a/WowsKarma.Api/Migrations/AuthDb/20220309215406_UpdateHeuristicsFormat.cs b/WowsKarma.Api/Migrations/AuthDb/20220309215406_UpdateHeuristicsFormat.cs index f42c3dac..0724d2c1 100644 --- a/WowsKarma.Api/Migrations/AuthDb/20220309215406_UpdateHeuristicsFormat.cs +++ b/WowsKarma.Api/Migrations/AuthDb/20220309215406_UpdateHeuristicsFormat.cs @@ -1,6 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using NodaTime; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/WowsKarma.Api/Program.cs b/WowsKarma.Api/Program.cs index 8cc7e212..72089676 100644 --- a/WowsKarma.Api/Program.cs +++ b/WowsKarma.Api/Program.cs @@ -1,10 +1,5 @@ -using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Serilog; -using System.IO; -using Serilog.Events; using WowsKarma.Api.Data; using WowsKarma.Api.Utilities; using WowsKarma.Common; @@ -20,7 +15,7 @@ public static async Task Main(string[] args) using IHost host = CreateHostBuilder(args).Build(); using IServiceScope scope = host.Services.CreateScope(); - Log.Information("Region selected : {Region}", Startup.ApiRegion); + Log.Information("Region selected : {region}", Startup.ApiRegion); await using (ApiDbContext db = scope.ServiceProvider.GetRequiredService()) { await db.Database.MigrateAsync(); diff --git a/WowsKarma.Api/Services/Authentication/Cookie/ForwardCookieAuthenticationHandler.cs b/WowsKarma.Api/Services/Authentication/Cookie/ForwardCookieAuthenticationHandler.cs index 7cc6b553..d660660c 100644 --- a/WowsKarma.Api/Services/Authentication/Cookie/ForwardCookieAuthenticationHandler.cs +++ b/WowsKarma.Api/Services/Authentication/Cookie/ForwardCookieAuthenticationHandler.cs @@ -1,12 +1,7 @@ -using System.Net.Http.Headers; -using System.Text.Encodings.Web; +using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; using WowsKarma.Api.Services.Authentication.Jwt; using WowsKarma.Common; @@ -16,19 +11,20 @@ namespace WowsKarma.Api.Services.Authentication.Cookie; /// Provides a cookie-based authentication implementation, /// forwarding any authentication cookie to the Bearer token system. /// -public class ForwardCookieAuthenticationHandler : JwtAuthenticationHandler +public sealed class ForwardCookieAuthenticationHandler : JwtAuthenticationHandler { private readonly string _cookieName; public ForwardCookieAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, UserService userService, IConfiguration configuration) : base(options, logger, encoder, clock, userService) { - _cookieName = configuration[$"API:{Startup.ApiRegion.ToRegionString()}:CookieName"]; + _cookieName = configuration[$"API:{Startup.ApiRegion.ToRegionString()}:CookieName"] + ?? throw new($"Missing configuration value for API:{Startup.ApiRegion.ToRegionString()}:CookieName"); } protected override Task HandleAuthenticateAsync() { - if (Request.Cookies.TryGetValue(_cookieName, out string cookie)) + if (Request.Cookies.TryGetValue(_cookieName, out string? cookie)) { Request.Headers.Authorization = new($"Bearer {cookie}"); } diff --git a/WowsKarma.Api/Services/Authentication/Jwt/ApiRole.cs b/WowsKarma.Api/Services/Authentication/Jwt/ApiRole.cs index 777698f1..514c8b69 100644 --- a/WowsKarma.Api/Services/Authentication/Jwt/ApiRole.cs +++ b/WowsKarma.Api/Services/Authentication/Jwt/ApiRole.cs @@ -1,8 +1,10 @@ -using Microsoft.AspNetCore.Identity; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Identity; namespace WowsKarma.Api.Services.Authentication.Jwt; -public class ApiRole : IdentityRole +[UsedImplicitly] +public sealed class ApiRole : IdentityRole { public ApiRole() : base() { } public ApiRole(string name) : base(name) diff --git a/WowsKarma.Api/Services/Authentication/Jwt/ApplicationUser.cs b/WowsKarma.Api/Services/Authentication/Jwt/ApplicationUser.cs index ae3147f5..56510e32 100644 --- a/WowsKarma.Api/Services/Authentication/Jwt/ApplicationUser.cs +++ b/WowsKarma.Api/Services/Authentication/Jwt/ApplicationUser.cs @@ -1,7 +1,9 @@ -using Microsoft.AspNetCore.Identity; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Identity; namespace WowsKarma.Api.Services.Authentication.Jwt; +[UsedImplicitly] public class ApplicationUser : IdentityUser { public ApplicationUser() : base() { } diff --git a/WowsKarma.Api/Services/Authentication/Jwt/JwtAuthenticationHandler.cs b/WowsKarma.Api/Services/Authentication/Jwt/JwtAuthenticationHandler.cs index c994c7da..51fc1a12 100644 --- a/WowsKarma.Api/Services/Authentication/Jwt/JwtAuthenticationHandler.cs +++ b/WowsKarma.Api/Services/Authentication/Jwt/JwtAuthenticationHandler.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Security.Claims; using System.Text.Encodings.Web; @@ -31,8 +30,9 @@ protected override async Task HandleAuthenticateAsync() try { - if (new Guid(baseResult.Principal.FindFirstValue("seed")) is var seed - && await userService.ValidateUserSeedTokenAsync(uint.Parse(baseResult.Principal.FindFirstValue(ClaimTypes.NameIdentifier)), seed)) + if (baseResult.Principal.FindFirstValue("seed") is { } seed + && uint.TryParse(baseResult.Principal.FindFirstValue(ClaimTypes.NameIdentifier), out uint id) + && await userService.ValidateUserSeedTokenAsync(id, new(seed))) { isValid = true; } diff --git a/WowsKarma.Api/Services/Authentication/Jwt/JwtService.cs b/WowsKarma.Api/Services/Authentication/Jwt/JwtService.cs index 51b3c7a5..8fc454e0 100644 --- a/WowsKarma.Api/Services/Authentication/Jwt/JwtService.cs +++ b/WowsKarma.Api/Services/Authentication/Jwt/JwtService.cs @@ -3,39 +3,37 @@ using System.Security.Claims; using System.Text; - - namespace WowsKarma.Api.Services.Authentication.Jwt; -public class JwtService +public sealed class JwtService { internal JwtSecurityTokenHandler TokenHandler { get; private init; } - private static IConfiguration configuration; - private static SymmetricSecurityKey authSigningKey; + private static IConfiguration _configuration = null!; + private static SymmetricSecurityKey _authSigningKey = null!; public JwtService(IConfiguration configuration, JwtSecurityTokenHandler handler) { - JwtService.configuration ??= configuration; + _configuration = configuration; TokenHandler = handler; - authSigningKey = new(Encoding.UTF8.GetBytes(configuration["JWT:Secret"])); + _authSigningKey = new(Encoding.UTF8.GetBytes(configuration["JWT:Secret"] ?? throw new("Missing JWT:Secret configuration value"))); } public static JwtSecurityToken GenerateToken(IEnumerable authClaims) => new( - issuer: configuration["JWT:ValidIssuer"], - audience: configuration["JWT:ValidAudience"], + issuer: _configuration["JWT:ValidIssuer"], + audience: _configuration["JWT:ValidAudience"], expires: DateTime.UtcNow.AddDays(8), claims: authClaims, - signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)); + signingCredentials: new(_authSigningKey, SecurityAlgorithms.HmacSha256)); - public ClaimsPrincipal ValidateToken(string token) + public ClaimsPrincipal? ValidateToken(string token) { TokenValidationParameters validationParameters = new() { - IssuerSigningKey = authSigningKey, - ValidAudience = configuration["JWT:ValidAudience"], - ValidIssuer = configuration["JWT:ValidIssuer"], + IssuerSigningKey = _authSigningKey, + ValidAudience = _configuration["JWT:ValidAudience"], + ValidIssuer = _configuration["JWT:ValidIssuer"], ValidateLifetime = true, ValidateAudience = true, ValidateIssuer = true, diff --git a/WowsKarma.Api/Services/Authentication/UserService.cs b/WowsKarma.Api/Services/Authentication/UserService.cs index 7338b391..1a3e0233 100644 --- a/WowsKarma.Api/Services/Authentication/UserService.cs +++ b/WowsKarma.Api/Services/Authentication/UserService.cs @@ -7,32 +7,44 @@ using WowsKarma.Api.Hubs; using WowsKarma.Api.Services.Authentication.Jwt; using WowsKarma.Api.Services.Authentication.Wargaming; -using WowsKarma.Common; using WowsKarma.Common.Hubs; namespace WowsKarma.Api.Services.Authentication; -public class UserService +/// +/// Provides a service to fetch and update API users. +/// +public sealed class UserService { private const string SeedTokenClaimType = "seed"; - private readonly AuthDbContext context; + private readonly AuthDbContext _context; private readonly IHubContext _hubContext; public UserService(AuthDbContext context, IHubContext hubContext) { - this.context = context; + _context = context; _hubContext = hubContext; } - public Task GetUserAsync(uint id) => context.Users.Include(u => u.Roles).FirstOrDefaultAsync(u => u.Id == id); - - public async Task> GetUserClaimsAsync(uint id) => await GetUserAsync(id) is { } user - ? from role in user.Roles select new Claim(ClaimTypes.Role, role.InternalName) - : Enumerable.Empty(); + /// + /// Gets a user by their ID. + /// + /// The user's ID. + /// The user, or if not found. + public async Task GetUserAsync(uint id) => await _context.Users.Include(u => u.Roles).FirstOrDefaultAsync(u => u.Id == id); + + /// + /// Gets a user's claims + /// + /// + /// + public async Task> GetUserClaimsAsync(uint id) => await GetUserAsync(id) is { Roles: [..] roles } + ? from role in roles select new Claim(ClaimTypes.Role, role.InternalName) + : []; public async Task GetUserSeedTokenAsync(uint id) { - if (await context.Users.FindAsync(id) is not { } user) + if (await _context.Users.FindAsync(id) is not { } user) { user = new() { @@ -40,22 +52,22 @@ public async Task GetUserSeedTokenAsync(uint id) SeedToken = Guid.NewGuid() }; - await context.Users.AddAsync(user); + await _context.Users.AddAsync(user); } - user.LastTokenRequested = DateTime.UtcNow; - await context.SaveChangesAsync(); + user.LastTokenRequested = DateTimeOffset.UtcNow; + await _context.SaveChangesAsync(); return user.SeedToken; } - public async Task ValidateUserSeedTokenAsync(uint id, Guid seedToken) => await context.Users.FindAsync(id) is { } user && user.SeedToken == seedToken; + public async Task ValidateUserSeedTokenAsync(uint id, Guid seedToken) => await _context.Users.FindAsync(id) is { SeedToken: var st } && st == seedToken; public async Task RenewSeedTokenAsync(uint id) { if (await GetUserAsync(id) is { } user) { user.SeedToken = Guid.NewGuid(); - await context.SaveChangesAsync(); + await _context.SaveChangesAsync(); await _hubContext.Clients.All.SeedTokenInvalidated(user.Id); } @@ -65,7 +77,7 @@ public async Task CreateTokenAsync(WargamingIdentity identity) { (uint id, _) = identity.GetAccountListing(); - if (identity.Claims.Where(c => c.Type is ClaimTypes.Role).ToArray() is { Length: > 0 } claims) + if (identity.Claims.Where(c => c.Type is ClaimTypes.Role).ToArray() is { Length: not 0 } claims) { foreach (Claim c in claims) { diff --git a/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthClientFactory.cs b/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthClientFactory.cs index 0b1d6300..307a3233 100644 --- a/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthClientFactory.cs +++ b/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthClientFactory.cs @@ -1,17 +1,16 @@ -using System.Net.Http; -using Nodsoft.Wargaming.Api.Common; +using Nodsoft.Wargaming.Api.Common; using WowsKarma.Common; namespace WowsKarma.Api.Services.Authentication.Wargaming; -public class WargamingAuthClientFactory +public sealed class WargamingAuthClientFactory { - private readonly IHttpClientFactory httpClientFactory; + private readonly IHttpClientFactory _httpClientFactory; public WargamingAuthClientFactory(IHttpClientFactory httpClientFactory) { - this.httpClientFactory = httpClientFactory; + _httpClientFactory = httpClientFactory; } - public HttpClient GetClient(Region region) => httpClientFactory.CreateClient("wargaming-auth-" + region.ToRegionString()); + public HttpClient GetClient(Region region) => _httpClientFactory.CreateClient($"wargaming-auth-{region.ToRegionString()}"); } \ No newline at end of file diff --git a/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthExtensions.cs b/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthExtensions.cs index e67eb821..bd741866 100644 --- a/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthExtensions.cs +++ b/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthExtensions.cs @@ -1,10 +1,8 @@ -using System.Net.Http; -using WowsKarma.Api.Services.Authentication.Wargaming; +using WowsKarma.Api.Services.Authentication.Wargaming; using WowsKarma.Common; using WowsKarma.Api; - - +// ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; public static class WargamingAuthExtensions @@ -16,9 +14,10 @@ public static IServiceCollection AddWargamingAuth(this IServiceCollection servic services.AddHttpClient($"wargaming-auth-{Startup.ApiRegion.ToRegionString()}", c => { - c.BaseAddress = new Uri($"https://{Startup.ApiRegion.ToWargamingSubdomain()}.wargaming.net"); + c.BaseAddress = new($"https://{Startup.ApiRegion.ToWargamingSubdomain()}.wargaming.net"); c.DefaultRequestHeaders.Add("Accept", "application/json"); - }).ConfigureHttpMessageHandlerBuilder(c => c.PrimaryHandler = new HttpClientHandler() { MaxConnectionsPerServer = 200, UseProxy = false }); + }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { MaxConnectionsPerServer = 200, UseProxy = false }); return services; } diff --git a/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthService.cs b/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthService.cs index b40a714d..89cef0b3 100644 --- a/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthService.cs +++ b/WowsKarma.Api/Services/Authentication/Wargaming/WargamingAuthService.cs @@ -1,50 +1,41 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System.Net.Http; +using Microsoft.AspNetCore.Mvc; using WowsKarma.Common; using static WowsKarma.Common.Utilities; - - namespace WowsKarma.Api.Services.Authentication.Wargaming; -internal class WargamingAuthConfig -{ - public string VerifyIdentityUri { get; set; } -} - -public class WargamingAuthService +public sealed class WargamingAuthService { - public static Uri OpenIdDomain { get; } = new($"https://{Startup.ApiRegion.ToWargamingSubdomain()}.wargaming.net/id/openid"); + private static readonly Uri _openIdDomain = new($"https://{Startup.ApiRegion.ToWargamingSubdomain()}.wargaming.net/id/openid"); - private static string callbackUrl; + private static string? _callbackUrl; - private readonly WargamingAuthClientFactory authClientFactory; + private readonly WargamingAuthClientFactory _authClientFactory; - public WargamingAuthService(IConfiguration configuration, ILogger logger, WargamingAuthClientFactory authClientFactory) + public WargamingAuthService(IConfiguration configuration, WargamingAuthClientFactory authClientFactory) { - callbackUrl ??= configuration[$"Api:{Startup.ApiRegion.ToRegionString()}:WgAuthCallback"]; - this.authClientFactory = authClientFactory; + _callbackUrl ??= configuration[$"Api:{Startup.ApiRegion.ToRegionString()}:WgAuthCallback"]; + _authClientFactory = authClientFactory; } - public static IActionResult RedirectToLogin(IDictionary extraRedirectParams = null) => new RedirectResult(GetAuthUri(extraRedirectParams).ToString()); + public static IActionResult RedirectToLogin(IReadOnlyDictionary? extraRedirectParams = null) => new RedirectResult(GetAuthUri(extraRedirectParams).ToString()); - public static Uri GetAuthUri(IDictionary extraRedirectParams = null) + public static Uri GetAuthUri(IReadOnlyDictionary? extraRedirectParams = null) { - string verifyIdentityUri = callbackUrl; + string verifyIdentityUri = _callbackUrl!; - if (extraRedirectParams?.Any() is true) + if (extraRedirectParams is { Count: not 0 }) { - string queryString = string.Join('&', extraRedirectParams - .Where(e => !string.IsNullOrEmpty(e.Value)) - .Select(param => $"{param.Key}={param.Value}")); + string queryString = string.Join('&', + from e in extraRedirectParams + where e is { Value: not (null or "") } + select $"{e.Key}={e.Value}"); verifyIdentityUri += $"?{queryString}"; } - UriBuilder builder = new(OpenIdDomain) + UriBuilder builder = new(_openIdDomain) { Query = BuildQuery( ("openid.ns", "http://specs.openid.net/auth/2.0"), @@ -63,19 +54,19 @@ public async Task VerifyIdentity(HttpRequest context) { // https://eu.wargaming.net/id/503276471-cpt_stewie/ - Dictionary paramDict = context.Query.ToDictionary(kv => kv.Key, kv => kv.Value.FirstOrDefault()); + Dictionary paramDict = context.Query.ToDictionary(kv => kv.Key, kv => kv.Value.FirstOrDefault()); bool isValid = await IsValid(paramDict); return isValid; } - private async Task IsValid(IDictionary paramDict) + private async Task IsValid(IDictionary paramDict) { paramDict["openid.mode"] = "check_authentication"; - using HttpClient httpClient = authClientFactory.GetClient(Startup.ApiRegion); - using HttpResponseMessage response = await httpClient.PostAsync("id/openid" + paramDict.BuildQuery(), null); + using HttpClient httpClient = _authClientFactory.GetClient(Startup.ApiRegion); + using HttpResponseMessage response = await httpClient.PostAsync($"id/openid{paramDict.BuildQuery()}", null); string stringResponse = await response.Content.ReadAsStringAsync(); return stringResponse.Contains("is_valid:true"); } diff --git a/WowsKarma.Api/Services/Authentication/Wargaming/WargamingIdentity.cs b/WowsKarma.Api/Services/Authentication/Wargaming/WargamingIdentity.cs index 29a55654..f8ced58e 100644 --- a/WowsKarma.Api/Services/Authentication/Wargaming/WargamingIdentity.cs +++ b/WowsKarma.Api/Services/Authentication/Wargaming/WargamingIdentity.cs @@ -4,7 +4,7 @@ namespace WowsKarma.Api.Services.Authentication.Wargaming; -public class WargamingIdentity : ClaimsIdentity +public sealed class WargamingIdentity : ClaimsIdentity { public new const string AuthenticationType = "Wargaming"; @@ -18,22 +18,23 @@ public static WargamingIdentity FromUri(Uri identityUri) string accountId = segment[..index]; string nickname = segment[(index + 1)..^1].Replace("/", string.Empty); - List claims = new() - { + List claims = + [ new(ClaimTypes.NameIdentifier, accountId), new(ClaimTypes.Name, nickname), new(WargamingClaimTypes.Region, ((int)region).ToString()), new(WargamingClaimTypes.RegionName, region.ToString()) - }; + ]; return new(claims); } - public AccountListingDTO GetAccountListing() + public AccountListingDTO? GetAccountListing() { - if (uint.TryParse(Claims.FirstOrDefault(c => c.Type is ClaimTypes.NameIdentifier)?.Value, out uint id)) + if (uint.TryParse(Claims.FirstOrDefault(c => c.Type is ClaimTypes.NameIdentifier)?.Value, out uint id) + && Claims.FirstOrDefault(c => c.Type is ClaimTypes.Name) is { Value: { Length: not 0 } name }) { - return new(id, Claims.FirstOrDefault(c => c.Type is ClaimTypes.Name)?.Value); + return new(id, name); } return null; diff --git a/WowsKarma.Api/Services/ClanService.cs b/WowsKarma.Api/Services/ClanService.cs index 29605e30..eda2b7ce 100644 --- a/WowsKarma.Api/Services/ClanService.cs +++ b/WowsKarma.Api/Services/ClanService.cs @@ -1,19 +1,17 @@ using System.ComponentModel.DataAnnotations; using System.Drawing; -using System.Threading; using Mapster; using Microsoft.EntityFrameworkCore; using Nodsoft.Wargaming.Api.Client.Clients.Wows; using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows.Clans; using WowsKarma.Api.Data; using WowsKarma.Api.Utilities; -using WowsKarma.Common; using ApiClanMember = Nodsoft.Wargaming.Api.Common.Data.Responses.Wows.Clans.ClanMember; using ClanMember = WowsKarma.Api.Data.Models.ClanMember; namespace WowsKarma.Api.Services; -public class ClanService +public sealed class ClanService { public static TimeSpan ClanInfoUpdateSpan { get; } = TimeSpan.FromHours(4); public static TimeSpan ClanMemberUpdateSpan { get; } = TimeSpan.FromHours(4); @@ -51,11 +49,11 @@ public async Task> SearchClansAsync([MinLength(2)] s Name = x.Name, Tag = x.Tag, LeagueColor = (uint)ColorTranslator.FromHtml(x.HexColor).ToArgb() - }); + }) ?? []; - public async Task GetClanAsync(uint clanId, bool includeMembers = false, CancellationToken ct = default) + public async Task GetClanAsync(uint clanId, bool includeMembers = false, CancellationToken ct = default) { - Clan clan = await GetDbClans(includeMembers).AsNoTracking().FirstOrDefaultAsync(c => c.Id == clanId, ct); + Clan? clan = await GetDbClans(includeMembers).AsNoTracking().FirstOrDefaultAsync(c => c.Id == clanId, ct); bool updateInfo = clan is null || ClanInfoUpdateNeeded(clan); bool updateMembers = includeMembers && (clan is null || ClanMembersUpdateNeeded(clan)); @@ -64,7 +62,7 @@ public async Task GetClanAsync(uint clanId, bool includeMembers = false, C clan = await UpdateClanInfoAsync(_context, clanId, clan, ct); } - if (updateMembers) + if (updateMembers && clan is not null) { clan = await UpdateClanMembersAsync(_context, clan, ct); } @@ -72,21 +70,30 @@ public async Task GetClanAsync(uint clanId, bool includeMembers = false, C return clan; } - internal async Task UpdateClanInfoAsync(ApiDbContext context, uint clanId, Clan clan, CancellationToken ct) + internal async Task UpdateClanInfoAsync(ApiDbContext context, uint clanId, Clan? clan, CancellationToken ct) { - ClanInfo apiClan = (await _clansApi.FetchClanViewAsync(clanId, ct))?.Clan; - clan = clan is null - ? apiClan?.Adapt() - : clan with + ClanInfo? apiClan = (await _clansApi.FetchClanViewAsync(clanId, ct))?.Clan; + + if (clan is null) + { + clan = apiClan?.Adapt(); + } + else if (apiClan is not null) + { + clan = clan with { Tag = apiClan.Tag, Name = apiClan.Name, Description = apiClan.Description, LeagueColor = (uint)ColorTranslator.FromHtml(apiClan.Color).ToArgb() }; + } - clan!.UpdatedAt = DateTime.UtcNow; - await context.Clans.Upsert(clan).On(c => c.Id).RunAsync(ct); + if (clan is not null) + { + clan.UpdatedAt = DateTimeOffset.UtcNow; + await context.Clans.Upsert(clan).On(c => c.Id).RunAsync(ct); + } return clan; } @@ -104,33 +111,33 @@ internal async Task UpdateClanMembersAsync(ApiDbContext context, Clan clan foreach (uint id in missing) { - Player dbPlayer = (await _vortex.FetchAccountAsync(id, ct)).ToDbModel(); + Player dbPlayer = (await _vortex.FetchAccountAsync(id, ct) ?? throw new InvalidOperationException($"Player {id} not found.")).ToDbModel(); players.Add(id, dbPlayer); context.Players.Add(dbPlayer); } foreach (uint id in outdated) { - Player dbPlayer = Player.MapFromApi(players[id], await context.Players.FindAsync(new object[] { id }, ct)); - dbPlayer.UpdatedAt = DateTime.UtcNow; + Player dbPlayer = Player.MapFromApi(players[id], await context.Players.FindAsync([id], ct)); + dbPlayer.UpdatedAt = DateTimeOffset.UtcNow; players[id] = dbPlayer; context.Update(players[id]); } - clan.Members = new(members.Values.Select(x => new ClanMember + clan.Members = [..members.Values.Select(x => new ClanMember { PlayerId = x.Id, Player = players[x.Id], ClanId = clan.Id, JoinedAt = DateOnly.FromDateTime(DateTime.UtcNow - TimeSpan.FromDays(x.DaysInClan)), Role = x.Role.Name - })); + })]; context.RemoveRange(clan.Members.Where(x => x.ClanId == clan.Id && !members.ContainsKey(x.PlayerId))); await context.SaveChangesAsync(ct); - clan.MembersUpdatedAt = DateTime.UtcNow; + clan.MembersUpdatedAt = DateTimeOffset.UtcNow; // await context.Clans.Upsert(clan).RunAsync(ct); await context.ClanMembers.UpsertRange(clan.Members).On(c => c.PlayerId).RunAsync(ct); @@ -138,6 +145,6 @@ internal async Task UpdateClanMembersAsync(ApiDbContext context, Clan clan return clan; } - public static bool ClanInfoUpdateNeeded(Clan clan) => clan.UpdatedAt + ClanInfoUpdateSpan < DateTime.UtcNow; - public static bool ClanMembersUpdateNeeded(Clan clan) => clan.MembersUpdatedAt + ClanMemberUpdateSpan < DateTime.UtcNow; + public static bool ClanInfoUpdateNeeded(Clan clan) => clan.UpdatedAt + ClanInfoUpdateSpan < DateTimeOffset.UtcNow; + public static bool ClanMembersUpdateNeeded(Clan clan) => clan.MembersUpdatedAt + ClanMemberUpdateSpan < DateTimeOffset.UtcNow; } \ No newline at end of file diff --git a/WowsKarma.Api/Services/Discord/ModActionWebhookService.cs b/WowsKarma.Api/Services/Discord/ModActionWebhookService.cs index 4774204f..c4aec209 100644 --- a/WowsKarma.Api/Services/Discord/ModActionWebhookService.cs +++ b/WowsKarma.Api/Services/Discord/ModActionWebhookService.cs @@ -4,11 +4,11 @@ namespace WowsKarma.Api.Services.Discord; -public class ModActionWebhookService : WebhookService +public sealed class ModActionWebhookService : WebhookService { public ModActionWebhookService(IConfiguration configuration) : base(configuration) { - foreach (string webhookLink in configuration.GetSection($"Discord:Webhooks:{Startup.ApiRegion.ToRegionString()}:ModActions").Get()) + foreach (string webhookLink in configuration.GetSection($"Discord:Webhooks:{Startup.ApiRegion.ToRegionString()}:ModActions").Get() ?? []) { Client.AddWebhookAsync(new(webhookLink)).GetAwaiter().GetResult(); } @@ -68,12 +68,12 @@ public async Task SendPlatformBanWebhookAsync(PlatformBan ban) Color = DiscordColor.Red }; - embed.AddField("Banned by", $"[{ban.Mod?.Username ?? "Unknown"}]({ban.Mod.GetPlayerProfileLink()})", true); - embed.AddField("Reason", ban.Reason, false); + embed.AddField("Banned by", $"[{ban.Mod?.Username ?? "Unknown"}]({ban.Mod?.GetPlayerProfileLink()})", true); + embed.AddField("Reason", ban.Reason); if (ban.BannedUntil is not null) { - embed.AddField("Until", $""); + embed.AddField("Until", $""); } await Client.BroadcastMessageAsync(GetCurrentRegionWebhookBuilder() @@ -83,9 +83,9 @@ await Client.BroadcastMessageAsync(GetCurrentRegionWebhookBuilder() private static DiscordEmbedBuilder AddModActionContent(DiscordEmbedBuilder embed, PostModAction modAction) { - embed.AddField("Moderated by", $"[{modAction.Mod?.Username ?? "Unknown"}]({modAction.Mod.GetPlayerProfileLink()})", true); + embed.AddField("Moderated by", $"[{modAction.Mod?.Username ?? "Unknown"}]({modAction.Mod?.GetPlayerProfileLink()})", true); embed.AddField("Post Author", $"[{modAction.Post.Author?.Username ?? "Unknown"}]({modAction.Post.Author?.GetPlayerProfileLink()})", true); - embed.AddField("Reason", modAction.Reason, false); + embed.AddField("Reason", modAction.Reason); return embed; } diff --git a/WowsKarma.Api/Services/Discord/PostWebhookService.cs b/WowsKarma.Api/Services/Discord/PostWebhookService.cs index b50d8d4e..2c2388d5 100644 --- a/WowsKarma.Api/Services/Discord/PostWebhookService.cs +++ b/WowsKarma.Api/Services/Discord/PostWebhookService.cs @@ -3,14 +3,13 @@ using WowsKarma.Common; using WowsKarma.Common.Models.DTOs.Replays; - namespace WowsKarma.Api.Services.Discord; -public class PostWebhookService : WebhookService +public sealed class PostWebhookService : WebhookService { public PostWebhookService(IConfiguration configuration) : base(configuration) { - foreach (string webhookLink in configuration.GetSection($"Discord:Webhooks:{Startup.ApiRegion.ToRegionString()}:Posts").Get()) + foreach (string webhookLink in configuration.GetSection($"Discord:Webhooks:{Startup.ApiRegion.ToRegionString()}:Posts").Get() ?? []) { Client.AddWebhookAsync(new(webhookLink)).GetAwaiter().GetResult(); } @@ -73,7 +72,7 @@ public async Task SendDeletedPostWebhookAsync(PlayerPostDTO post) null or _ => "Neutral" }; - private static DiscordEmbedBuilder AddReplayStatus(DiscordEmbedBuilder embed, ReplayDTO replay) => embed.AddField("Replay", replay is null + private static DiscordEmbedBuilder AddReplayStatus(DiscordEmbedBuilder embed, ReplayDTO? replay) => embed.AddField("Replay", replay is null ? "*No replay provided*" : $"[{replay.Id}]({replay.DownloadUri})" ); @@ -81,7 +80,7 @@ private static DiscordEmbedBuilder AddReplayStatus(DiscordEmbedBuilder embed, Re private static DiscordEmbedBuilder AddPostContent(DiscordEmbedBuilder embed, PlayerPostDTO post) { embed.Description = post.Content; - PostFlairsParsed parsedFlairs = post.Flairs.ParseFlairsEnum(); + PostFlairsParsed? parsedFlairs = post.Flairs.ParseFlairsEnum(); embed.AddField("Performance", GetFlairValueString(parsedFlairs?.Performance), true); embed.AddField("Teamplay", GetFlairValueString(parsedFlairs?.Teamplay), true); diff --git a/WowsKarma.Api/Services/Discord/WebhookService.cs b/WowsKarma.Api/Services/Discord/WebhookService.cs index 412a7f55..49e9398f 100644 --- a/WowsKarma.Api/Services/Discord/WebhookService.cs +++ b/WowsKarma.Api/Services/Discord/WebhookService.cs @@ -8,26 +8,26 @@ public abstract class WebhookService { protected DiscordWebhookClient Client { get; private init; } - private readonly Uri webhookUserAvatarLink; - private readonly string apiRegionString; + private readonly Uri _webhookUserAvatarLink; + private readonly string _apiRegionString; - public WebhookService(IConfiguration configuration) + protected WebhookService(IConfiguration configuration) { Client = new(); - apiRegionString = Startup.ApiRegion.ToRegionString(); - webhookUserAvatarLink = new(configuration["Discord:WebhookAvatarPath"]); + _apiRegionString = Startup.ApiRegion.ToRegionString(); + _webhookUserAvatarLink = new(configuration["Discord:WebhookAvatarPath"] ?? throw new InvalidOperationException("Missing Discord:WebhookAvatarPath in configuration.")); } protected DiscordWebhookBuilder GetCurrentRegionWebhookBuilder() => new() { - AvatarUrl = webhookUserAvatarLink.AbsoluteUri, - Username = $"WOWS Karma ({apiRegionString})" + AvatarUrl = _webhookUserAvatarLink.AbsoluteUri, + Username = $"WOWS Karma ({_apiRegionString})" }; protected DiscordEmbedBuilder.EmbedFooter GetDefaultFooter() => new() { - IconUrl = webhookUserAvatarLink.AbsoluteUri, - Text = $"WOWS Karma ({apiRegionString}) v{Startup.DisplayVersion} - Powered by Nodsoft Systems" + IconUrl = _webhookUserAvatarLink.AbsoluteUri, + Text = $"WOWS Karma ({_apiRegionString}) v{Startup.DisplayVersion} - Powered by Nodsoft Systems" }; } \ No newline at end of file diff --git a/WowsKarma.Api/Services/KarmaService.cs b/WowsKarma.Api/Services/KarmaService.cs index 38f564aa..879aea22 100644 --- a/WowsKarma.Api/Services/KarmaService.cs +++ b/WowsKarma.Api/Services/KarmaService.cs @@ -4,7 +4,7 @@ public class KarmaService { public KarmaService() { } - public static void UpdatePlayerKarma(Player player, PostFlairsParsed newFlairs, PostFlairsParsed oldFlairs, bool allowNegative) + public static void UpdatePlayerKarma(Player player, PostFlairsParsed? newFlairs, PostFlairsParsed? oldFlairs, bool allowNegative) { sbyte? newKarmaBalance = newFlairs is null ? null : PostFlairsUtils.CountBalance(newFlairs); sbyte? oldKarmaBalance = oldFlairs is null ? null : PostFlairsUtils.CountBalance(oldFlairs); @@ -36,7 +36,7 @@ public static void UpdatePlayerKarma(Player player, PostFlairsParsed newFlairs, } } - public static void UpdatePlayerRatings(Player player, PostFlairsParsed postFlairs, PostFlairsParsed oldFlairs) + public static void UpdatePlayerRatings(Player player, PostFlairsParsed? postFlairs, PostFlairsParsed? oldFlairs) { player.PerformanceRating = UpdateRating(player.PerformanceRating, postFlairs?.Performance, oldFlairs?.Performance); player.TeamplayRating = UpdateRating(player.TeamplayRating, postFlairs?.Teamplay, oldFlairs?.Teamplay); diff --git a/WowsKarma.Api/Services/MinimapRenderingService.cs b/WowsKarma.Api/Services/MinimapRenderingService.cs index b864a7c3..8b375fa6 100644 --- a/WowsKarma.Api/Services/MinimapRenderingService.cs +++ b/WowsKarma.Api/Services/MinimapRenderingService.cs @@ -1,11 +1,8 @@ -using System.IO; -using System.Threading; -using Azure.Storage.Blobs; +using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Hangfire; using Hangfire.Tags.Attributes; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; using WowsKarma.Api.Data; using WowsKarma.Api.Data.Models.Replays; using WowsKarma.Api.Minimap.Client; @@ -39,7 +36,8 @@ IConfiguration configuration _context = context; _logger = logger; - string connectionString = configuration[$"API:{Startup.ApiRegion.ToRegionString()}:Azure:Storage:ConnectionString"]; + string connectionString = configuration[$"API:{Startup.ApiRegion.ToRegionString()}:Azure:Storage:ConnectionString"] + ?? throw new ArgumentException("Missing API:{region}:Azure:Storage:ConnectionString configuration value."); BlobServiceClient serviceClient = new(connectionString); _containerClient = serviceClient.GetBlobContainerClient(MinimapBlobContainer); diff --git a/WowsKarma.Api/Services/ModService.cs b/WowsKarma.Api/Services/ModService.cs index 1066dcf6..7161caf6 100644 --- a/WowsKarma.Api/Services/ModService.cs +++ b/WowsKarma.Api/Services/ModService.cs @@ -1,17 +1,16 @@ -using Mapster; +using System.Diagnostics; +using Mapster; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.Extensions.Logging; using WowsKarma.Api.Data; using WowsKarma.Api.Data.Models.Notifications; using WowsKarma.Api.Services.Discord; using WowsKarma.Api.Services.Posts; - namespace WowsKarma.Api.Services; -public class ModService +public sealed class ModService { private readonly ILogger _logger; private readonly ModActionWebhookService _webhookService; @@ -28,7 +27,7 @@ public ModService(ILogger logger, ApiDbContext context, ModActionWeb _notifications = notifications; } - public Task GetModActionAsync(Guid id) => _context.PostModActions.AsNoTracking().FirstOrDefaultAsync(ma => ma.Id == id); + public Task GetModActionAsync(Guid id) => _context.PostModActions.AsNoTracking().FirstOrDefaultAsync(ma => ma.Id == id); public IQueryable GetPostModActions(Guid postId) => _context.PostModActions.AsNoTracking() .Include(ma => ma.Post) @@ -40,7 +39,7 @@ public IQueryable GetPostModActions(uint playerId) => _context.Po .Include(ma => ma.Mod) .Where(ma => ma.Post.AuthorId == playerId); - public Task GetPlatformBan(Guid id) => _context.PlatformBans.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id); + public Task GetPlatformBan(Guid id) => _context.PlatformBans.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id); public IQueryable GetPlatformBans(uint userId) => _context.PlatformBans.AsNoTracking().Where(b => b.UserId == userId); @@ -52,11 +51,14 @@ public async Task SubmitPostModActionAsync(PostModActionDTO modAction) switch (modAction.ActionType) { case ModActionType.Deletion: + { await _postService.DeletePostAsync(modAction.PostId, true); await _notifications.SendNewNotification(PostModDeletedNotification.FromModAction(entityEntry.Entity)); break; + } case ModActionType.Update: + { PlayerPostDTO current = _postService.GetPost(modAction.PostId).Adapt(); await _postService.EditPostAsync(modAction.PostId, current with @@ -67,6 +69,10 @@ await _postService.EditPostAsync(modAction.PostId, current with }, true); break; + } + + default: + throw new UnreachableException(); } await entityEntry.Reference(pma => pma.Mod).LoadAsync(); @@ -81,9 +87,8 @@ public async Task RevertModActionAsync(Guid modActionId) _context.PostModActions.Remove(modAction); await _postService.RevertPostModLockAsync(modAction.PostId); - await _context.SaveChangesAsync(); - + await _webhookService.SendModActionRevertedWebhookAsync(modAction); } @@ -126,9 +131,9 @@ await _notifications.SendNewNotification(new PlatformBanNotification public async Task RevertPlatformBanAsync(Guid id) { - PlatformBan ban = await _context.PlatformBans.FindAsync(id); + PlatformBan ban = await _context.PlatformBans.FindAsync(id) ?? throw new ArgumentException($"Platform ban with id {id} not found"); ban.Reverted = true; await _context.SaveChangesAsync(); - _logger.LogInformation("Reverted Ban {BanId}.", ban.Id); + _logger.LogInformation("Reverted Ban {banId}.", ban.Id); } } diff --git a/WowsKarma.Api/Services/NotificationService.cs b/WowsKarma.Api/Services/NotificationService.cs index 397d1c01..dc874cc1 100644 --- a/WowsKarma.Api/Services/NotificationService.cs +++ b/WowsKarma.Api/Services/NotificationService.cs @@ -3,8 +3,6 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Query; -using Microsoft.Extensions.Logging; using WowsKarma.Api.Data; using WowsKarma.Api.Data.Models.Notifications; using WowsKarma.Api.Hubs; @@ -13,7 +11,6 @@ namespace WowsKarma.Api.Services; - public class NotificationService { private readonly ILogger _logger; @@ -41,7 +38,7 @@ from n in _context.Set().IncludeAllNotificationsChildNavs() public IQueryable GetNotifications(Guid[] ids) => _context.Set().Where(n => ids.Contains(n.Id)); - public TNotification GetNotification(Guid id) where TNotification : NotificationBase + public TNotification? GetNotification(Guid id) where TNotification : NotificationBase => _context.Set().FirstOrDefault(n => n.Id == id); public async Task SendNewNotification(TNotification notification) where TNotification : NotificationBase @@ -55,7 +52,7 @@ public async Task SendNewNotification(TNotification notification) NotificationBaseDTO dto = entry.Entity.ToDTO(); await _hub.Clients.User(notification.AccountId.ToString()).NewNotification(typeof(TNotification).FullName!, dto); - _logger.LogInformation("Sent notification {NotificationId} to user {UserId}.", notification.Id, notification.AccountId); + _logger.LogInformation("Sent notification {notificationId} to user {userId}.", notification.Id, notification.AccountId); } public async Task AcknowledgeNotifications(Guid[] ids) @@ -72,22 +69,25 @@ public void AcknowledgeNotifications(IEnumerable notifications { if (notifications.Any()) { + List ids = []; + foreach (NotificationBase notification in notifications) { notification.AcknowledgedAt = DateTime.UtcNow; + ids.Add(notification.Id); } _context.SaveChanges(); - _logger.LogInformation("Acknowledged Notifications {NotificationId}.", string.Join(", ", notifications.Select(n => n.Id))); + _logger.LogInformation("Acknowledged Notifications {notificationId}.", string.Join(", ", ids)); } } - public async Task DeleteNotification(Guid id) + public async Task DeleteNotificationAsync(Guid id) { NotificationBase notification = await _context.Set().FindAsync(id) ?? throw new ArgumentException("No notification found for given ID.", nameof(id)); _context.Remove(notification); await _context.SaveChangesAsync(); - _logger.LogInformation("Removed Notification {Id}.", id); + _logger.LogInformation("Removed Notification {id}.", id); await _hub.Clients.All.DeletedNotification(id); } } @@ -97,7 +97,7 @@ public static class NotificationServiceExtensions public static IQueryable IncludeAllNotificationsChildNavs(this IQueryable query) { //PlatformBanNotification - query = query.Include(static n => (n as PlatformBanNotification).Ban); + query = query.Include(static n => (n as PlatformBanNotification)!.Ban); // PostAddedNotification @@ -107,10 +107,10 @@ public static IQueryable IncludeAllNotificationsChildNavs(this query = query.IncludeAllPostNotificationsChildNavs(); // PostModEditedNotification - query = query.Include(static n => (n as PostModEditedNotification).ModAction); + query = query.Include(static n => (n as PostModEditedNotification)!.ModAction); // PostModDeletedNotification - query = query.Include(static n => (n as PostModDeletedNotification).ModAction); + query = query.Include(static n => (n as PostModDeletedNotification)!.ModAction); return query; } @@ -123,10 +123,10 @@ public static IQueryable IncludeAllNotificationsChildNavs(this public static IQueryable IncludeAllPostNotificationsChildNavs(this IQueryable query) where TNotification : PostNotificationBase { - query = query.Include(static n => (n as TNotification).Post) + query = query.Include(static n => (n as TNotification)!.Post) .ThenInclude(static p => p.Author); - query = query.Include(static n => (n as TNotification).Post) + query = query.Include(static n => (n as TNotification)!.Post) .ThenInclude(static p => p.Player); return query; diff --git a/WowsKarma.Api/Services/PlayerService.cs b/WowsKarma.Api/Services/PlayerService.cs index 1949b86c..3e8dc0a8 100644 --- a/WowsKarma.Api/Services/PlayerService.cs +++ b/WowsKarma.Api/Services/PlayerService.cs @@ -1,9 +1,7 @@ using Microsoft.EntityFrameworkCore; -using System.Threading; using Hangfire; using Hangfire.Tags.Attributes; using Nodsoft.Wargaming.Api.Client.Clients.Wows; -using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows.Public; using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows.Vortex; using WowsKarma.Api.Data; using WowsKarma.Api.Infrastructure.Exceptions; @@ -42,7 +40,7 @@ public PlayerService(ApiDbContext context, WowsPublicApiClient wgApi, WowsVortex [Tag("player", "update", "batch"), JobDisplayName("Perform API fetch on player batch")] public async Task> GetPlayersAsync(IEnumerable ids, bool includeRelated = false, bool includeClanInfo = false, CancellationToken ct = default) { - List players = new(); + List players = []; foreach (uint id in ids.AsParallel().WithCancellation(ct)) { @@ -139,14 +137,10 @@ public IEnumerable GetPlayersFullKarma(IEnumerable ac .Where(p => accountIds.Contains(p.Id)) .Select(p => new AccountKarmaDTO(p.Id, p.SiteKarma))); - public async Task> ListPlayersAsync(string search) - { - AccountListing[] result = (await _wgApi.ListPlayersAsync(search))?.Data?.ToArray(); - - return result is { Length: > 0 } - ? result.Select(listing => listing.ToDTO()) - : null; - } + public async Task ListPlayersAsync(string search) + => (await _wgApi.ListPlayersAsync(search))?.Data?.ToArray() is { Length: not 0 } result + ? result.Select(Conversions.ToDto).ToArray() + : []; internal async Task UpdatePlayerClanStatusAsync(Player player, CancellationToken ct = default) { @@ -267,7 +261,7 @@ public async Task RecalculatePlayerMetrics(uint playerId, CancellationToken ct) } internal static bool UpdateNeeded(Player player) => player.UpdatedAt + DataUpdateSpan < DateTime.UtcNow; - internal static bool IsOptOutOnCooldown(DateTime lastChange) => lastChange + OptOutCooldownSpan > DateTime.UtcNow; + internal static bool IsOptOutOnCooldown(DateTimeOffset? lastChange) => lastChange is not null && lastChange + OptOutCooldownSpan > DateTimeOffset.UtcNow; private static void SetPlayerMetrics(Player player, int site, int performance, int teamplay, int courtesy) { diff --git a/WowsKarma.Api/Services/Posts/PostService.cs b/WowsKarma.Api/Services/Posts/PostService.cs index d4948b83..25615e3a 100644 --- a/WowsKarma.Api/Services/Posts/PostService.cs +++ b/WowsKarma.Api/Services/Posts/PostService.cs @@ -1,17 +1,14 @@ -using System.Threading; using Mapster; -using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using WowsKarma.Api.Data; -using WowsKarma.Api.Data.Models.Notifications; using WowsKarma.Api.Data.Models.Replays; using WowsKarma.Api.Infrastructure.Exceptions; using WowsKarma.Api.Services.Replays; namespace WowsKarma.Api.Services.Posts; -public class PostService +public sealed class PostService { public const ushort PostTitleMaxSize = 60; public const ushort PostContentMaxSize = 2000; @@ -38,10 +35,12 @@ public PostService(ApiDbContext context, PlayerService playerService, ReplaysIng ); /// - /// Gets a post by id. + /// Gets a post by its ID. /// - public Post GetPost(Guid id) => GetPost(_context, id); - internal static Post GetPost(ApiDbContext context, Guid id) => context.Posts + /// The post's ID. + /// The post, or if not found. + public Post? GetPost(Guid id) => GetPost(_context, id); + internal static Post? GetPost(ApiDbContext context, Guid id) => context.Posts .Include(p => p.Author) .ThenInclude(p => p.ClanMember) .ThenInclude(p => p.Clan) @@ -53,17 +52,23 @@ internal static Post GetPost(ApiDbContext context, Guid id) => context.Posts .Include(p => p.Replay) .FirstOrDefault(p => p.Id == id); - public async Task GetPostDTOAsync(Guid id) + /// + /// Gets a post's DTO by its ID. + /// + /// The post's ID. + /// The post, or if not found. + public async Task GetPostDTOAsync(Guid id) { - Post post = GetPost(id); - PlayerPostDTO postDTO = post?.Adapt(); + if (GetPost(id) is not { } post) + { + return null; + } + + PlayerPostDTO postDto = post.Adapt(); - return post?.ReplayId is null - ? postDTO - : postDTO with - { - Replay = await _replayService.GetReplayDTOAsync(post.ReplayId.Value) - }; + return post.ReplayId is null + ? postDto + : postDto with { Replay = await _replayService.GetReplayDTOAsync(post.ReplayId.Value) }; } public IQueryable GetReceivedPosts(uint playerId) => _context.Posts.AsNoTracking() @@ -87,7 +92,7 @@ public IQueryable GetSentPosts(uint authorId) => _context.Posts.AsNoTracki .ThenInclude(p => p.ClanMember) .ThenInclude(p => p.Clan) - .Where(p => p.AuthorId == authorId)? + .Where(p => p.AuthorId == authorId) .OrderByDescending(p => p.CreatedAt); public IQueryable GetLatestPosts() => _context.Posts.AsNoTracking() @@ -101,11 +106,11 @@ public IQueryable GetLatestPosts() => _context.Posts.AsNoTracking() .OrderByDescending(p => p.CreatedAt); - public async Task CreatePostAsync(PlayerPostDTO postDto, IFormFile replayFile, bool bypassChecks) + public async Task CreatePostAsync(PlayerPostDTO postDto, IFormFile? replayFile, bool bypassChecks) { bool hasReplay = replayFile is not null; - Task replayIngestTask = hasReplay ? _replayService.IngestReplayAsync(replayFile, CancellationToken.None) : null; + Task? replayIngestTask = hasReplay ? _replayService.IngestReplayAsync(replayFile, CancellationToken.None) : null; try { @@ -141,7 +146,7 @@ public async Task CreatePostAsync(PlayerPostDTO postDto, IFormFile replayF if (hasReplay) { - Replay replay = await replayIngestTask; + Replay replay = await replayIngestTask!; entry.Entity.ReplayId = replay.Id; entry.Entity.Replay = replay; @@ -160,13 +165,13 @@ public async Task EditPostAsync(Guid id, PlayerPostDTO edited, bool modEditLock ValidatePostContents(edited); Post current = await _context.Posts.FindAsync(id) ?? throw new ArgumentException($"Post {id} not found", nameof(id)); - PostFlairsParsed previousFlairs = current.ParsedFlairs; + PostFlairsParsed? previousFlairs = current.ParsedFlairs; Player player = await _context.Players.FindAsync(current.PlayerId) ?? throw new ArgumentException($"Player Account {edited.Player.Id} not found", nameof(edited)); current.Title = edited.Title; current.Content = edited.Content; current.Flairs = edited.Flairs; - current.UpdatedAt = DateTime.UtcNow; // Forcing UpdatedAt refresh + current.UpdatedAt = DateTimeOffset.UtcNow; // Forcing UpdatedAt refresh current.ReadOnly = current.ReadOnly || modEditLock; KarmaService.UpdatePlayerKarma(player, current.ParsedFlairs, previousFlairs, current.NegativeKarmaAble); @@ -181,7 +186,7 @@ public async Task EditPostAsync(Guid id, PlayerPostDTO edited, bool modEditLock public async Task DeletePostAsync(Guid id, bool modLock = false) { Post post = await _context.Posts.FindAsync(id) ?? throw new ArgumentException($"Post {id} not found", nameof(id)); - Player player = await _context.Players.FindAsync(post.PlayerId)!; + Player player = await _context.Players.FindAsync(post.PlayerId) ?? throw new ArgumentException($"Player Account {post.PlayerId} not found", nameof(id)); if (modLock) { @@ -235,16 +240,16 @@ from p in _context.Posts if (filteredPosts.Any()) { - PlayerPostDTO lastAuthoredPost = filteredPosts.OrderBy(p => p.CreatedAt).LastOrDefault()?.Adapt(); + PlayerPostDTO? lastAuthoredPost = filteredPosts.OrderBy(p => p.CreatedAt).LastOrDefault()?.Adapt(); if (lastAuthoredPost is { CreatedAt: not null }) { - DateTime endsAt = lastAuthoredPost.CreatedAt.Value.Add(CooldownPeriod); - return endsAt > DateTime.UtcNow; + DateTimeOffset endsAt = lastAuthoredPost.CreatedAt.Value.Add(CooldownPeriod); + return endsAt > DateTimeOffset.UtcNow; } } - + return false; } } \ No newline at end of file diff --git a/WowsKarma.Api/Services/Posts/PostUpdatesBroadcastService.cs b/WowsKarma.Api/Services/Posts/PostUpdatesBroadcastService.cs index 22611efc..7f064547 100644 --- a/WowsKarma.Api/Services/Posts/PostUpdatesBroadcastService.cs +++ b/WowsKarma.Api/Services/Posts/PostUpdatesBroadcastService.cs @@ -1,5 +1,4 @@ -using System.Threading; -using Hangfire; +using Hangfire; using Hangfire.Tags.Attributes; using Mapster; using Microsoft.AspNetCore.SignalR; @@ -10,7 +9,6 @@ using WowsKarma.Common.Hubs; -#nullable enable namespace WowsKarma.Api.Services.Posts; // ReSharper disable MemberCanBePrivate.Global @@ -82,7 +80,7 @@ public static void OnPostDeletionAsync(PlayerPostDTO post, bool modlock) public async Task LogPostCreationAsync(Guid postId) { // Get the post from the database, and adapt to DTO. - Post post = PostService.GetPost(_dbContext, postId); + Post post = PostService.GetPost(_dbContext, postId) ?? throw new InvalidOperationException($"Post {postId} not found."); PlayerPostDTO postDto = post.Adapt(); // Send the webhook. @@ -93,7 +91,7 @@ public async Task LogPostCreationAsync(Guid postId) public async Task BroadcastPostCreationAsync(Guid postId) { // Get the post from the database, and adapt to DTO. - Post post = PostService.GetPost(_dbContext, postId); + Post post = PostService.GetPost(_dbContext, postId) ?? throw new InvalidOperationException($"Post {postId} not found."); PlayerPostDTO postDto = post.Adapt(); // Send the update to the clients. @@ -104,7 +102,7 @@ public async Task BroadcastPostCreationAsync(Guid postId) public async Task NotifyPostCreationAsync(Guid postId) { // Get the post from the database, and adapt to DTO. - Post post = PostService.GetPost(_dbContext, postId); + Post post = PostService.GetPost(_dbContext, postId) ?? throw new InvalidOperationException($"Post {postId} not found."); // Send the notification. await _notificationService.SendNewNotification(new PostAddedNotification @@ -122,7 +120,7 @@ await _notificationService.SendNewNotification(new PostAddedNotification public async Task LogPostEditionAsync(Guid postId) { // Get the post from the database, and adapt to DTO. - Post post = PostService.GetPost(_dbContext, postId); + Post post = PostService.GetPost(_dbContext, postId) ?? throw new InvalidOperationException($"Post {postId} not found."); PlayerPostDTO postDto = post.Adapt(); // Send the webhook. @@ -133,7 +131,7 @@ public async Task LogPostEditionAsync(Guid postId) public async Task BroadcastPostEditionAsync(Guid postId) { // Get the post from the database, and adapt to DTO. - Post post = PostService.GetPost(_dbContext, postId); + Post post = PostService.GetPost(_dbContext, postId) ?? throw new InvalidOperationException($"Post {postId} not found."); PlayerPostDTO postDto = post.Adapt(); // Send the update to the clients. @@ -144,7 +142,7 @@ public async Task BroadcastPostEditionAsync(Guid postId) public async Task NotifyPostEditionAsync(Guid postId) { // Get the post from the database, and adapt to DTO. - Post post = PostService.GetPost(_dbContext, postId); + Post post = PostService.GetPost(_dbContext, postId) ?? throw new InvalidOperationException($"Post {postId} not found."); // Send the notification. await _notificationService.SendNewNotification(new PostEditedNotification diff --git a/WowsKarma.Api/Services/Replays/ReplaysIngestService.cs b/WowsKarma.Api/Services/Replays/ReplaysIngestService.cs index 88c49d23..6900cb99 100644 --- a/WowsKarma.Api/Services/Replays/ReplaysIngestService.cs +++ b/WowsKarma.Api/Services/Replays/ReplaysIngestService.cs @@ -1,12 +1,8 @@ using Azure.Storage.Blobs; using Mapster; -using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.Extensions.Logging; -using System.IO; using System.Security; -using System.Threading; using Hangfire; using Hangfire.Tags.Attributes; using WowsKarma.Api.Data; @@ -17,7 +13,7 @@ namespace WowsKarma.Api.Services.Replays; -public class ReplaysIngestService +public sealed class ReplaysIngestService { public const string ReplayBlobContainer = "replays"; public const string SecurityBlobContainer = "rce-replays"; @@ -32,7 +28,9 @@ public class ReplaysIngestService public ReplaysIngestService(ILogger logger, IConfiguration configuration, ApiDbContext context, ReplaysProcessService processService) { - string connectionString = configuration[$"API:{Startup.ApiRegion.ToRegionString()}:Azure:Storage:ConnectionString"]; + string connectionString = configuration[$"API:{Startup.ApiRegion.ToRegionString()}:Azure:Storage:ConnectionString"] + ?? throw new InvalidOperationException("Missing API:Azure:Storage:ConnectionString in configuration."); + _serviceClient = new(connectionString); _containerClient = _serviceClient.GetBlobContainerClient(ReplayBlobContainer); _securityContainerClient = _serviceClient.GetBlobContainerClient(SecurityBlobContainer); @@ -49,18 +47,26 @@ public ReplaysIngestService(ILogger logger, IConfiguration private static readonly Func> _listReplaysAsync = EF.CompileAsyncQuery( (ApiDbContext context) => context.Replays.Select(r => r.Id)); - public Replay GetReplay(Guid id) => _context.Replays.Find(id); + /// + /// Gets a replay by its ID. + /// + /// The replay's ID. + /// The replay, or if not found. + public Replay? GetReplay(Guid id) => _context.Replays.Find(id); - public async Task GetReplayDTOAsync(Guid id) + public async Task GetReplayDTOAsync(Guid id) { - Replay replay = await _context.Replays.FindAsync(id); + if (await _context.Replays.FindAsync(id) is not { } replay) + { + return null; + } return new() { Id = replay.Id, PostId = replay.PostId, - ChatMessages = replay.ChatMessages.Adapt>() - .Select(m => m with { Username = replay.Players.FirstOrDefault(p => p.AccountId == m.PlayerId).Name }), + ChatMessages = replay.ChatMessages?.Adapt>() + .Select(m => m with { Username = replay.Players.FirstOrDefault(p => p.AccountId == m.PlayerId).Name }) ?? [], Players = replay.Players.Adapt>(), DownloadUri = $"{_containerClient.Uri}/{ReplayBlobContainer}/{replay.BlobName}", MinimapUri = replay.MinimapRendered ? $"{_serviceClient.Uri}{MinimapRenderingService.MinimapBlobContainer}/{replay.Id}.mp4" : null @@ -75,7 +81,8 @@ public async Task IngestReplayAsync(Guid postId, IFormFile replayFile, C throw new ArgumentOutOfRangeException(nameof(replayFile)); } - Post post = await _context.Posts.FindAsync(new object[] { postId }, cancellationToken: ct); + Post post = await _context.Posts.FindAsync([postId], cancellationToken: ct) + ?? throw new ArgumentException("No post was found for specified GUID.", nameof(postId)); Replay replay = await _processService.ProcessReplayAsync(new Replay(), replayFile.OpenReadStream(), ct); @@ -83,7 +90,7 @@ public async Task IngestReplayAsync(Guid postId, IFormFile replayFile, C if (post.ReplayId is { } existingReplayId) { - await RemoveReplayAsync(GetReplay(existingReplayId)); + await RemoveReplayAsync(GetReplay(existingReplayId) ?? throw new InvalidOperationException("Post has a replay ID, but no replay was found.")); } EntityEntry entityEntry = _context.Replays.Add(replay with { PostId = postId }); @@ -124,7 +131,7 @@ internal async Task IngestRceFileAsync(IFormFile replayFile) { string blobName = $"{Guid.NewGuid():N}-{replayFile.FileName}"; await _securityContainerClient.UploadBlobAsync(blobName, replayFile.OpenReadStream()); - _logger.LogInformation("Ingested RCE file {BlobName}. Link: {Uri}", blobName, _securityContainerClient.GetBlobClient(blobName).Uri); + _logger.LogInformation("Ingested RCE file {blobName}. Link: {uri}", blobName, _securityContainerClient.GetBlobClient(blobName).Uri); } public async Task FetchReplayFileAsync(Guid replayId, CancellationToken ct) @@ -179,11 +186,11 @@ public async Task RemoveReplayAsync(Replay replay) // Catch any CVE-2022-31265 related exceptions and log them. catch (InvalidReplayException e) when (e.InnerException is SecurityException se && se.Data["exploit"] is "CVE-2022-31265") { - _logger.LogWarning("CVE-2022-31265 exploit detected in replay {ReplayId}. Please delete both post and replay from the platform at once.", replay.Id); + _logger.LogWarning("CVE-2022-31265 exploit detected in replay {replayId}. Please delete both post and replay from the platform at once.", replay.Id); } catch (Exception e) { - _logger.LogWarning(e, "Failed to reprocess replay {ReplayId}.", replay.Id); + _logger.LogWarning(e, "Failed to reprocess replay {replayId}.", replay.Id); } return null; @@ -217,7 +224,7 @@ public async Task ReprocessReplayAsync(Guid replayId, CancellationToken ct) [JobDisplayName("Reprocess all replays within date range"), Tag("replay", "recalculation", "batch")] public async Task ReprocessAllReplaysAsync(DateTime? start, DateTime? end, CancellationToken ct) { - _logger.LogWarning("Started reprocessing all replays between {Start:g} and {End:g}", start, end); + _logger.LogWarning("Started reprocessing all replays between {start:g} and {end:g}", start, end); var replayStubs = await _context.Posts.Include(static p => p.Replay) .Where(r => r.Replay != null && r.CreatedAt >= start && r.CreatedAt <= end) @@ -229,9 +236,9 @@ public async Task ReprocessAllReplaysAsync(DateTime? start, DateTime? end, Cance }) .ToArrayAsync(ct); - _logger.LogWarning("Database readout complete. {Count} replays will be reprocessed.", replayStubs.Length); + _logger.LogWarning("Database readout complete. {count} replays will be reprocessed.", replayStubs.Length); - List replays = new(); + List replays = []; foreach (Replay replay in replayStubs) { @@ -241,11 +248,11 @@ public async Task ReprocessAllReplaysAsync(DateTime? start, DateTime? end, Cance } } - _logger.LogWarning("Finished file reprocessing of {Count} replays. Saving to database...", replayStubs.Length); + _logger.LogWarning("Finished file reprocessing of {count} replays. Saving to database...", replayStubs.Length); _context.UpdateRange(replays); await _context.SaveChangesAsync(ct); - _logger.LogWarning("Replay Files reprocessing complete! Reprocessed {Count} replays total.", replayStubs.Length); + _logger.LogWarning("Replay Files reprocessing complete! Reprocessed {count} replays total.", replayStubs.Length); } } diff --git a/WowsKarma.Api/Services/Replays/ReplaysProcessService.cs b/WowsKarma.Api/Services/Replays/ReplaysProcessService.cs index f86a1db7..7a1c44a5 100644 --- a/WowsKarma.Api/Services/Replays/ReplaysProcessService.cs +++ b/WowsKarma.Api/Services/Replays/ReplaysProcessService.cs @@ -1,8 +1,6 @@ using Mapster; -using System.IO; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading; using Nodsoft.WowsReplaysUnpack.ExtendedData; using Nodsoft.WowsReplaysUnpack.ExtendedData.Models; using Nodsoft.WowsReplaysUnpack.Services; @@ -13,10 +11,9 @@ using ReplayPlayerRaw = Nodsoft.WowsReplaysUnpack.ExtendedData.Models.ReplayPlayer; - namespace WowsKarma.Api.Services.Replays; -public class ReplaysProcessService +public sealed class ReplaysProcessService { public static JsonSerializerOptions SerializerOptions { get; } = new() { @@ -37,7 +34,7 @@ public ReplaysProcessService(ReplayUnpackerFactory replayUnpacker, ApiDbContext public async Task ProcessReplayAsync(Guid replayId, Stream replayStream, CancellationToken ct) { - Replay replay = await _context.Replays.FindAsync(new object[] { replayId }, cancellationToken: ct) + Replay replay = await _context.Replays.FindAsync([replayId], cancellationToken: ct) ?? throw new ArgumentException("No replay was found for specified GUID.", nameof(replayId)); await ProcessReplayAsync(replay, replayStream, ct); diff --git a/WowsKarma.Api/Startup.cs b/WowsKarma.Api/Startup.cs index 6ced6c85..8a6dcd78 100644 --- a/WowsKarma.Api/Startup.cs +++ b/WowsKarma.Api/Startup.cs @@ -1,17 +1,13 @@ using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Nodsoft.WowsReplaysUnpack; using System.IdentityModel.Tokens.Jwt; -using System.IO; using System.Net; using System.Reflection; using System.Text; @@ -25,6 +21,7 @@ using Nodsoft.Wargaming.Api.Client.Clients.Wows; using Nodsoft.Wargaming.Api.Common; using Nodsoft.WowsReplaysUnpack.ExtendedData; +using Npgsql; using WowsKarma.Api.Data; using WowsKarma.Api.Hubs; using WowsKarma.Api.Infrastructure.Authorization; @@ -46,7 +43,7 @@ namespace WowsKarma.Api; public sealed class Startup { public static Region ApiRegion { get; private set; } - public static string DisplayVersion { get; private set; } + public static string DisplayVersion { get; private set; } = "0.0.0"; public IConfiguration Configuration { get; } @@ -54,7 +51,7 @@ public Startup(IConfiguration configuration) { Configuration = configuration; ApiRegion = Common.Utilities.GetRegionConfigString(Configuration["Api:CurrentRegion"] ?? "EU"); - DisplayVersion = typeof(Startup).Assembly.GetCustomAttribute()?.InformationalVersion; + DisplayVersion = typeof(Startup).Assembly.GetCustomAttribute()!.InformationalVersion; } @@ -186,8 +183,12 @@ public void ConfigureServices(IServiceCollection services) string dbConnectionString = $"ApiDbConnectionString:{ApiRegion.ToRegionString()}"; int dbPoolSize = Configuration.GetValue("Database:PoolSize"); + NpgsqlDataSource apiDbDataSourceBuilder = new NpgsqlDataSourceBuilder(Configuration.GetConnectionString(dbConnectionString)) + .ConfigureApiDbDataSourceBuilder() + .Build(); + services.AddDbContextPool( - o => o.UseNpgsql(Configuration.GetConnectionString(dbConnectionString), + o => o.UseNpgsql(apiDbDataSourceBuilder, p => { p.EnableRetryOnFailure(); @@ -231,8 +232,10 @@ public void ConfigureServices(IServiceCollection services) { options.TypeNameHandling = TypeNameHandling.Auto; }); - - config.UsePostgreSqlStorage(Configuration.GetConnectionString(dbConnectionString), new() { SchemaName = "hangfire", PrepareSchemaIfNecessary = true }); + + config.UsePostgreSqlStorage(options => options.UseNpgsqlConnection(Configuration.GetConnectionString(dbConnectionString))); + //config.UsePostgreSqlStorage(Configuration.GetConnectionString(dbConnectionString), new() { SchemaName = "hangfire", PrepareSchemaIfNecessary = true }); + config.UseSerilogLogProvider(); config.UseTagsWithPostgreSql(); }); @@ -299,7 +302,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) builder.AllowCredentials(); }); - IPAddress[] allowedProxies = Configuration.GetSection("AllowedProxies").Get()?.Select(IPAddress.Parse).ToArray(); + IPAddress[] allowedProxies = Configuration.GetSection("AllowedProxies").Get()?.Select(IPAddress.Parse).ToArray() ?? []; // Nginx configuration step ForwardedHeadersOptions forwardedHeadersOptions = new() @@ -331,7 +334,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapHangfireDashboard("/hangfire", new() { AppPath = ApiRegion.GetRegionWebDomain(), - Authorization = new[] { HangfireDashboardAuthorizationFilter.Instance }, + Authorization = [ HangfireDashboardAuthorizationFilter.Instance ], IsReadOnlyFunc = HangfireDashboardAuthorizationFilter.IsAccessReadOnly, DashboardTitle = $"WOWS Karma API ({ApiRegion.ToRegionString()})" }); diff --git a/WowsKarma.Api/Utilities/Conversions.cs b/WowsKarma.Api/Utilities/Conversions.cs index 87c2ea79..edf1928e 100644 --- a/WowsKarma.Api/Utilities/Conversions.cs +++ b/WowsKarma.Api/Utilities/Conversions.cs @@ -1,4 +1,6 @@ using System.Drawing; +using System.Linq.Expressions; +using Hangfire.Annotations; using Mapster; using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows.Public; using Nodsoft.Wargaming.Api.Common.Data.Responses.Wows.Vortex; @@ -9,8 +11,11 @@ namespace WowsKarma.Api.Utilities; public static class Conversions { + [UsedImplicitly] public static void ConfigureMapping() { + TypeAdapterConfig.GlobalSettings.Compiler = exp => exp.CompileWithDebugInfo(); + TypeAdapterConfig .NewConfig() .IgnoreNullValues(true) @@ -30,7 +35,7 @@ public static void ConfigureMapping() ) .Map(dest => dest.Author.Clan, src => src.Author.ClanMember.Clan) .Map(dest => dest.Player.Clan, src => src.Player.ClanMember.Clan); - + TypeAdapterConfig .NewConfig() .Ignore(dest => dest.Post) @@ -40,7 +45,7 @@ public static void ConfigureMapping() .NewConfig() .IgnoreNullValues(true) .Map(dest => dest.LeagueColor, src => (uint)ColorTranslator.FromHtml(src.Color).ToArgb()) - .Map(dest => dest.CreatedAt, src => src.CreatedAt.UtcDateTime) + .Map(dest => dest.CreatedAt, src => src.CreatedAt.ToUniversalTime()) .Ignore(dest => dest.UpdatedAt); TypeAdapterConfig @@ -70,7 +75,6 @@ public static void ConfigureMapping() .NewConfig() .IgnoreNullValues(true) .Map(dest => dest.Members, src => src.Members) - .Fork(fork => fork.ForType() .Ignore(dest => dest.ClanInfo)); @@ -81,9 +85,13 @@ public static void ConfigureMapping() TypeAdapterConfig.NewConfig().MapWith(x => DateOnly.FromDateTime(x)); TypeAdapterConfig.NewConfig().MapWith(x => x == null ? DateTime.UnixEpoch : x.Value.ToDateTime(TimeOnly.MinValue)); + + TypeAdapterConfig.NewConfig().MapWith(x => DateOnly.FromDateTime(x.UtcDateTime)); + TypeAdapterConfig.NewConfig().MapWith(x => x == null ? DateTimeOffset.UnixEpoch : new(x.Value.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero)); } - public static AccountListingDTO ToDTO(this AccountListing accountListing) => new(accountListing.AccountId, accountListing.Nickname); + [Pure] + public static AccountListingDTO ToDto(this AccountListing accountListing) => new(accountListing.AccountId, accountListing.Nickname); public static Player ToDbModel(this VortexAccountInfo accountInfo) { @@ -103,8 +111,4 @@ public static Player ToDbModel(this VortexAccountInfo accountInfo) LastBattleTime = DateTime.UnixEpoch.AddSeconds(accountInfo.Statistics.Basic!.LastBattleTime) }; } - - public static Player[] ToDbModel(this VortexAccountInfo[] accountInfos) => Array.ConvertAll(accountInfos, ToDbModel); - - public static int ToInt(this PostFlairs input) => (int)input; } \ No newline at end of file diff --git a/WowsKarma.Api/Utilities/HttpExtensions.cs b/WowsKarma.Api/Utilities/HttpExtensions.cs index 35c2d456..e983cfe5 100644 --- a/WowsKarma.Api/Utilities/HttpExtensions.cs +++ b/WowsKarma.Api/Utilities/HttpExtensions.cs @@ -1,13 +1,8 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; -using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Cors.Infrastructure; using WowsKarma.Api.Infrastructure.Data; namespace WowsKarma.Api.Utilities; -#nullable enable - /// /// Provides HTTP extensions for request/response interaction. /// @@ -18,20 +13,18 @@ public static class HttpExtensions /// /// The response to add the headers to. /// The page metadata. - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void AddPaginationHeaders(this HttpResponse response, PageMeta pageMeta) { - response.Headers.Add("Content-Page-Current", pageMeta.CurrentPage.ToString()); - response.Headers.Add("Content-Page-Size", pageMeta.PageSize.ToString()); - response.Headers.Add("Content-Page-Total", pageMeta.TotalPages.ToString()); - response.Headers.Add("Content-Items-Total", pageMeta.ItemsCount.ToString()); + response.Headers.Append("Content-Page-Current", pageMeta.CurrentPage.ToString()); + response.Headers.Append("Content-Page-Size", pageMeta.PageSize.ToString()); + response.Headers.Append("Content-Page-Total", pageMeta.TotalPages.ToString()); + response.Headers.Append("Content-Items-Total", pageMeta.ItemsCount.ToString()); } /// /// Sets up CORS configuration for pagination headers. /// /// The builder to add the configuration to. - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void WithExposedPaginationHeaders(this CorsPolicyBuilder builder) { builder.WithExposedHeaders("Content-Page-Current", "Content-Page-Size", "Content-Page-Total", "Content-Items-Total"); diff --git a/WowsKarma.Api/Utilities/Links.cs b/WowsKarma.Api/Utilities/Links.cs index 647beb1f..fd06f5d6 100644 --- a/WowsKarma.Api/Utilities/Links.cs +++ b/WowsKarma.Api/Utilities/Links.cs @@ -1,9 +1,8 @@ -using WowsKarma.Common; +using JetBrains.Annotations; +using WowsKarma.Common; -#nullable enable namespace WowsKarma.Api.Utilities; - /// /// Provides web link utilities. /// @@ -14,21 +13,26 @@ public static class Links /// /// The player. /// The player's profile link + [Pure] public static string GetPlayerProfileLink(this Player player) => $"{Startup.ApiRegion.GetRegionWebDomain()}player/{player.Id},{player.Username}"; - + /// + [Pure] public static string GetPlayerProfileLink(this AccountListingDTO player) => $"{Startup.ApiRegion.GetRegionWebDomain()}player/{player.Id},{player.Username}"; - + /// + [Pure] public static string GetPlayerProfileLink(this PlayerProfileDTO player) => $"{Startup.ApiRegion.GetRegionWebDomain()}player/{player.Id},{player.Username}"; - + /// /// Gets a player post's link on the web app. /// /// The post. /// The post's link + [Pure] public static string GetPostLink(this Post post) => $"{Startup.ApiRegion.GetRegionWebDomain()}posts/view/{post.Id}"; - + /// + [Pure] public static string GetPostLink(this PlayerPostDTO post) => $"{Startup.ApiRegion.GetRegionWebDomain()}posts/view/{post.Id}"; } \ No newline at end of file diff --git a/WowsKarma.Api/Utilities/LinqExtensions.cs b/WowsKarma.Api/Utilities/LinqExtensions.cs index 96dc8911..aebe1cb0 100644 --- a/WowsKarma.Api/Utilities/LinqExtensions.cs +++ b/WowsKarma.Api/Utilities/LinqExtensions.cs @@ -1,5 +1,4 @@ -using System.Runtime.CompilerServices; -using WowsKarma.Api.Infrastructure.Data; +using WowsKarma.Api.Infrastructure.Data; namespace WowsKarma.Api.Utilities; diff --git a/WowsKarma.Api/Utilities/Reflection.cs b/WowsKarma.Api/Utilities/Reflection.cs index 76c16d81..7d87e915 100644 --- a/WowsKarma.Api/Utilities/Reflection.cs +++ b/WowsKarma.Api/Utilities/Reflection.cs @@ -1,6 +1,9 @@ -namespace WowsKarma.Api.Utilities; +using JetBrains.Annotations; + +namespace WowsKarma.Api.Utilities; public static class Reflection { + [Pure] public static bool ImplementsInterface(this Type type, Type interfaceType) => type.GetInterfaces().Any(t => t == interfaceType); } diff --git a/WowsKarma.Api/WowsKarma.Api.csproj b/WowsKarma.Api/WowsKarma.Api.csproj index 240f3f5d..ee964521 100644 --- a/WowsKarma.Api/WowsKarma.Api.csproj +++ b/WowsKarma.Api/WowsKarma.Api.csproj @@ -1,10 +1,13 @@ - net7.0 + net8.0 preview - 0.17.1 - 0.17.1 + enable + enable + + 0.17.2 + 0.17.2 Sakura Akeno Isayeki Nodsoft Systems WOWS Karma (API) @@ -23,38 +26,37 @@ - - - - - + + + + + + - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - - - + + + + - diff --git a/WowsKarma.Common/Models/DTOs/Clans/ClanProfileDTO.cs b/WowsKarma.Common/Models/DTOs/Clans/ClanProfileDTO.cs index 3848d407..527620db 100644 --- a/WowsKarma.Common/Models/DTOs/Clans/ClanProfileDTO.cs +++ b/WowsKarma.Common/Models/DTOs/Clans/ClanProfileDTO.cs @@ -6,13 +6,13 @@ public record ClanProfileDTO : ClanListingDTO public bool IsDisbanded { get; init; } - public DateTime CreatedAt { get; init; } - public DateTime UpdatedAt { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; init; } } public record ClanProfileFullDTO : ClanProfileDTO { public virtual List Members { get; init; } = new(); - public DateTime MembersUpdatedAt { get; init; } + public DateTimeOffset MembersUpdatedAt { get; init; } } \ No newline at end of file diff --git a/WowsKarma.Common/Models/DTOs/Notifications/NotificationBaseDTO.cs b/WowsKarma.Common/Models/DTOs/Notifications/NotificationBaseDTO.cs index 39c938cf..ce07afdf 100644 --- a/WowsKarma.Common/Models/DTOs/Notifications/NotificationBaseDTO.cs +++ b/WowsKarma.Common/Models/DTOs/Notifications/NotificationBaseDTO.cs @@ -14,10 +14,8 @@ public record NotificationBaseDTO : INotification public NotificationType Type { get; init; } - public DateTime EmittedAt { get; init; } - - public DateTime? AcknowledgedAt { get; init; } - + public DateTimeOffset EmittedAt { get; init; } + public DateTimeOffset? AcknowledgedAt { get; init; } } } diff --git a/WowsKarma.Common/Models/DTOs/Notifications/PlatformBanNotificationDTO.cs b/WowsKarma.Common/Models/DTOs/Notifications/PlatformBanNotificationDTO.cs index 83dc5d89..6948da69 100644 --- a/WowsKarma.Common/Models/DTOs/Notifications/PlatformBanNotificationDTO.cs +++ b/WowsKarma.Common/Models/DTOs/Notifications/PlatformBanNotificationDTO.cs @@ -3,5 +3,5 @@ public record PlatformBanNotificationDTO : NotificationBaseDTO { public string Reason { get; set; } = string.Empty; - public DateTime? Until { get; set; } + public DateTimeOffset? Until { get; set; } } diff --git a/WowsKarma.Common/Models/DTOs/PlatformBanDTO.cs b/WowsKarma.Common/Models/DTOs/PlatformBanDTO.cs index 4a745ea1..a25ed3a5 100644 --- a/WowsKarma.Common/Models/DTOs/PlatformBanDTO.cs +++ b/WowsKarma.Common/Models/DTOs/PlatformBanDTO.cs @@ -24,10 +24,10 @@ public record PlatformBanDTO [Required] public string Reason { get; set; } = string.Empty; - public DateTime? BannedUntil { get; set; } + public DateTimeOffset? BannedUntil { get; set; } public bool Reverted { get; set; } - public DateTime CreatedAt { get; init; } - public DateTime UpdatedAt { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } } diff --git a/WowsKarma.Common/Models/DTOs/PlayerClanProfileDTO.cs b/WowsKarma.Common/Models/DTOs/PlayerClanProfileDTO.cs index f915a0ca..9f441a29 100644 --- a/WowsKarma.Common/Models/DTOs/PlayerClanProfileDTO.cs +++ b/WowsKarma.Common/Models/DTOs/PlayerClanProfileDTO.cs @@ -10,5 +10,5 @@ public record PlayerClanProfileDTO // HACK: DateOnly / TimeOnly serialization is not supported by STJ as of now. // See: https://github.com/dotnet/runtime/issues/53539 - public DateTime JoinedClanAt { get; init; } + public DateTimeOffset JoinedClanAt { get; init; } } \ No newline at end of file diff --git a/WowsKarma.Common/Models/DTOs/PlayerPostDTO.cs b/WowsKarma.Common/Models/DTOs/PlayerPostDTO.cs index 107d08c9..87693cdf 100644 --- a/WowsKarma.Common/Models/DTOs/PlayerPostDTO.cs +++ b/WowsKarma.Common/Models/DTOs/PlayerPostDTO.cs @@ -24,6 +24,6 @@ public record PlayerPostDTO public ReplayState ReplayState { get; init; } // Computed by DB Engine (hopefully) - public DateTime? CreatedAt { get; init; } - public DateTime? UpdatedAt { get; init; } + public DateTimeOffset? CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; init; } } diff --git a/WowsKarma.Common/Models/DTOs/PlayerProfileDTO.cs b/WowsKarma.Common/Models/DTOs/PlayerProfileDTO.cs index 49b9fe9b..de68d91c 100644 --- a/WowsKarma.Common/Models/DTOs/PlayerProfileDTO.cs +++ b/WowsKarma.Common/Models/DTOs/PlayerProfileDTO.cs @@ -18,9 +18,9 @@ public record PlayerProfileDTO public int RatingTeamplay { get; init; } public int RatingCourtesy { get; init; } - public DateTime WgAccountCreatedAt { get; init; } - public DateTime LastBattleTime { get; init; } - public DateTime OptOutChanged { get; init; } + public DateTimeOffset WgAccountCreatedAt { get; init; } + public DateTimeOffset LastBattleTime { get; init; } + public DateTimeOffset? OptOutChanged { get; init; } public bool NegativeKarmaAble { get; init; } public bool PostsBanned { get; init; } diff --git a/WowsKarma.Common/Models/DTOs/Replays/ReplayChatMessageDTO.cs b/WowsKarma.Common/Models/DTOs/Replays/ReplayChatMessageDTO.cs index 7829592d..c8c46987 100644 --- a/WowsKarma.Common/Models/DTOs/Replays/ReplayChatMessageDTO.cs +++ b/WowsKarma.Common/Models/DTOs/Replays/ReplayChatMessageDTO.cs @@ -1,11 +1,14 @@ namespace WowsKarma.Common.Models.DTOs.Replays; +/// +/// HACK: Fields are set as nullable to disable API validation. +/// public struct ReplayChatMessageDTO { - public uint PlayerId { get; init; } - public string Username { get; init; } + public uint? PlayerId { get; init; } + public string? Username { get; init; } - public string MessageGroup { get; init; } + public string? MessageGroup { get; init; } - public string MessageContent { get; init; } + public string? MessageContent { get; init; } } diff --git a/WowsKarma.Common/Models/DTOs/Replays/ReplayDTO.cs b/WowsKarma.Common/Models/DTOs/Replays/ReplayDTO.cs index 2e2a19f0..3f368ed8 100644 --- a/WowsKarma.Common/Models/DTOs/Replays/ReplayDTO.cs +++ b/WowsKarma.Common/Models/DTOs/Replays/ReplayDTO.cs @@ -1,16 +1,16 @@ namespace WowsKarma.Common.Models.DTOs.Replays; -public record ReplayDTO +public sealed record ReplayDTO { public Guid Id { get; init; } public Guid PostId { get; init; } - public string DownloadUri { get; init; } + public string DownloadUri { get; init; } = ""; public string? MinimapUri { get; init; } - public IEnumerable Players { get; set; } + public IEnumerable Players { get; set; } = []; - public IEnumerable ChatMessages { get; set; } + public IEnumerable ChatMessages { get; set; } = []; } diff --git a/WowsKarma.Common/Models/DTOs/UserProfileFlagsDTO.cs b/WowsKarma.Common/Models/DTOs/UserProfileFlagsDTO.cs index 1253ef29..e6db851c 100644 --- a/WowsKarma.Common/Models/DTOs/UserProfileFlagsDTO.cs +++ b/WowsKarma.Common/Models/DTOs/UserProfileFlagsDTO.cs @@ -7,7 +7,7 @@ public sealed record UserProfileFlagsDTO public bool PostsBanned { get; init; } public bool OptedOut { get; init; } - public DateTime OptOutChanged { get; init; } - - public IEnumerable ProfileRoles { get; init; } + public DateTimeOffset OptOutChanged { get; init; } + + public IEnumerable ProfileRoles { get; init; } = []; } diff --git a/WowsKarma.Common/Models/INotification.cs b/WowsKarma.Common/Models/INotification.cs index b892a940..7223b76d 100644 --- a/WowsKarma.Common/Models/INotification.cs +++ b/WowsKarma.Common/Models/INotification.cs @@ -8,8 +8,8 @@ public interface INotification public NotificationType Type { get; } - public DateTime EmittedAt { get; } + public DateTimeOffset EmittedAt { get; } - public DateTime? AcknowledgedAt { get; } + public DateTimeOffset? AcknowledgedAt { get; } } } diff --git a/WowsKarma.Common/Models/PostFlairs.cs b/WowsKarma.Common/Models/PostFlairs.cs index 3a1787e6..9690755b 100644 --- a/WowsKarma.Common/Models/PostFlairs.cs +++ b/WowsKarma.Common/Models/PostFlairs.cs @@ -44,10 +44,15 @@ public static PostFlairs SanitizeFlairs(this PostFlairs flairs) Courtesy = ParseBalancedFlags(flairs, PostFlairs.CourtesyGood, PostFlairs.CourtesyBad) }; - public static PostFlairs ToEnum(this PostFlairsParsed flairsParsed) + public static PostFlairs ToEnum(this PostFlairsParsed? flairsParsed) { int flairCount = 0x00; + if (flairsParsed is null) + { + return PostFlairs.Neutral; + } + flairCount += flairsParsed.Performance is null ? 0x00 : flairsParsed.Performance.Value ? 0x01 : 0x02; flairCount += flairsParsed.Teamplay is null ? 0x00 : flairsParsed.Teamplay.Value ? 0x04 : 0x08; flairCount += flairsParsed.Courtesy is null ? 0x00 : flairsParsed.Courtesy.Value ? 0x10 : 0x20; diff --git a/WowsKarma.Common/Utilities.cs b/WowsKarma.Common/Utilities.cs index 9fb2c85e..81a4c459 100644 --- a/WowsKarma.Common/Utilities.cs +++ b/WowsKarma.Common/Utilities.cs @@ -3,11 +3,11 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using JetBrains.Annotations; using Nodsoft.Wargaming.Api.Common; using WowsKarma.Common.Models; using WowsKarma.Common.Models.DTOs; -#nullable enable namespace WowsKarma.Common; @@ -23,7 +23,8 @@ public static class Utilities { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - + + [Pure] public static Region GetRegionConfigString(string configString) => configString switch { "EU" => Region.EU, @@ -33,6 +34,7 @@ public static class Utilities _ => throw new ArgumentOutOfRangeException(nameof(configString)) }; + [Pure] public static string ToRegionString(this Region region) => region switch { Region.EU => "EU", @@ -42,6 +44,7 @@ public static class Utilities _ => throw new ArgumentOutOfRangeException(nameof(region)) }; + [Pure] public static string ToWargamingSubdomain(this Region region) => region switch { Region.EU => "eu", @@ -51,6 +54,7 @@ public static class Utilities _ => throw new ArgumentOutOfRangeException(nameof(region)) }; + [Pure] public static Region FromWargamingSubdomain(this string? subdomain) => subdomain switch { "eu" => Region.EU, @@ -60,6 +64,7 @@ public static class Utilities _ => throw new ArgumentOutOfRangeException(nameof(subdomain)) }; + [Pure] public static string GetRegionWebDomain(this Region region) => region switch { Region.EU => "https://wows-karma.com/", @@ -69,6 +74,7 @@ public static class Utilities _ => throw new ArgumentOutOfRangeException(nameof(region)) }; + [Pure] public static string GetRegionApiDomain(this Region region) => region switch { Region.EU => "https://api.wows-karma.com/", @@ -78,6 +84,7 @@ public static class Utilities _ => throw new ArgumentOutOfRangeException(nameof(region)) }; + [Pure] public static string BuildQuery(params (string parameter, string value)[] arguments) { StringBuilder path = new(); @@ -90,27 +97,31 @@ public static string BuildQuery(params (string parameter, string value)[] argume return path.ToString(); } - public static string BuildQuery(this IDictionary arguments) + [Pure] + public static string BuildQuery(this IDictionary arguments) { - using IEnumerator> enumerator = arguments.GetEnumerator(); + using IEnumerator> enumerator = arguments.GetEnumerator(); StringBuilder path = new(); for (int i = 0; i < arguments.Count; i++) { enumerator.MoveNext(); - KeyValuePair current = enumerator.Current; - path.Append($"{(i is 0 ? '?' : '&')}{current.Key}={Uri.EscapeDataString(current.Value)}"); + if (enumerator.Current is (var key, { } value)) + { + path.Append($"{(i is 0 ? '?' : '&')}{key}={Uri.EscapeDataString(value)}"); + } } return path.ToString(); } - + public static AccountListingDTO? ToAccountListing(this ClaimsPrincipal? claimsPrincipal) => uint.TryParse(claimsPrincipal?.FindFirst(ClaimTypes.NameIdentifier)?.Value, out uint accountId) ? new AccountListingDTO(accountId, claimsPrincipal.FindFirst(ClaimTypes.Name)!.Value) : null; + [Pure] public static Type? GetType(string typeName) { if (Type.GetType(typeName) is { } type) @@ -129,6 +140,7 @@ public static string BuildQuery(this IDictionary arguments) return null; } + [Pure] public static ReplayChatMessageChannel GetMessageChannelType(string messageGroup) => messageGroup switch { "battle_common" => ReplayChatMessageChannel.All, @@ -137,6 +149,7 @@ public static string BuildQuery(this IDictionary arguments) _ => ReplayChatMessageChannel.Unknown }; + [Pure] public static string GetDisplayString(this ReplayChatMessageChannel channel) => channel switch { ReplayChatMessageChannel.All => "All", diff --git a/WowsKarma.Common/WowsKarma.Common.csproj b/WowsKarma.Common/WowsKarma.Common.csproj index ed4d7163..c527277c 100644 --- a/WowsKarma.Common/WowsKarma.Common.csproj +++ b/WowsKarma.Common/WowsKarma.Common.csproj @@ -1,9 +1,10 @@  - net7.0 + net8.0 preview enable + enable diff --git a/wowskarma.app/angular.json b/wowskarma.app/angular.json index 8cf0080c..2acbe42d 100644 --- a/wowskarma.app/angular.json +++ b/wowskarma.app/angular.json @@ -44,8 +44,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kb", - "maximumError": "1mb" + "maximumWarning": "1mb", + "maximumError": "4mb" }, { "type": "anyComponentStyle", diff --git a/wowskarma.app/src/app/app.module.ts b/wowskarma.app/src/app/app.module.ts index f1219e47..361d7e6b 100644 --- a/wowskarma.app/src/app/app.module.ts +++ b/wowskarma.app/src/app/app.module.ts @@ -3,7 +3,7 @@ import { NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BrowserModule } from "@angular/platform-browser"; import { ServiceWorkerModule } from "@angular/service-worker"; -import { NgbCollapseModule, NgbPaginationModule, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { NgbCollapseModule, NgbPaginationModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ModActionTypeDisplayPipe } from 'src/app/services/pipes/mod-action-type-display.pipe'; import { AppRoutingModule } from "./app-routing.module"; import { AppWrapperComponent } from "./app-wrapper.component"; @@ -135,7 +135,7 @@ import { UserRolesComponent } from './shared/components/icons/user-roles/user-ro }), NgbCollapseModule, NgbPaginationModule, - NgbTooltip, + NgbTooltipModule ], providers: [ AuthService, diff --git a/wowskarma.app/src/app/pages/player/profile/profile.component.html b/wowskarma.app/src/app/pages/player/profile/profile.component.html index 37eeea03..93387c52 100644 --- a/wowskarma.app/src/app/pages/player/profile/profile.component.html +++ b/wowskarma.app/src/app/pages/player/profile/profile.component.html @@ -1,9 +1,9 @@
-

+

-
+
@@ -13,11 +13,11 @@

>[{{clan.tag}}] - {{profile.username}} + {{profile.username}} - +
{{profileTotalKarma}} - +