From 473c247a2cc498a9d0f86256de6a133e9061b6f2 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 16 Oct 2026 18:26:53 +0200 Subject: [PATCH] feat: propagate CancellationToken throughout the backend Thread cancellation tokens from controllers, SignalR hubs, auth handlers, the LiveControlGateway receive loop and cron jobs down into every service and EF Core/HttpClient/HybridCache call that accepts one. - Services (device, OTA, configuration, control sender, webhook, device update, account, API token, session create/logout) take a token. - Hubs use Context.ConnectionAborted; auth handlers use RequestAborted. - Cron jobs use the host's ApplicationStopping token; Execute() keeps no parameters so Hangfire job signatures are unchanged. - Email outbox delivery stops on shutdown and always records a send's outcome once the email has gone out. - Add .editorconfig disabling the primary-constructor suggestion. --- .editorconfig | 7 + .../Tests/AutomationTokenTests.cs | 10 +- .../Account/Authenticated/ChangeEmail.cs | 5 +- .../Account/Authenticated/ChangePassword.cs | 5 +- .../Account/Authenticated/ChangeUsername.cs | 6 +- .../Account/Authenticated/Deactivate.cs | 4 +- API/Controller/Account/LoginV2.cs | 2 +- API/Controller/Account/Logout.cs | 4 +- .../Account/PasswordResetComplete.cs | 6 +- .../Account/PasswordResetInitiateV2.cs | 2 +- API/Controller/Admin/AutomationTokenList.cs | 5 +- API/Controller/Admin/Configuration.cs | 8 +- API/Controller/Admin/DeactivateUser.cs | 5 +- API/Controller/Admin/DeleteUser.cs | 5 +- API/Controller/Admin/EmailOutboxList.cs | 7 +- API/Controller/Admin/EmailOutboxStats.cs | 4 +- .../Admin/EmailProviderBlacklistList.cs | 5 +- API/Controller/Admin/GetOnlineDevices.cs | 4 +- API/Controller/Admin/GetUsers.cs | 7 +- API/Controller/Admin/ReactivateUser.cs | 5 +- API/Controller/Admin/SetUserPassword.cs | 2 +- API/Controller/Admin/UserNameBlacklistAdd.cs | 4 +- API/Controller/Admin/UserNameBlacklistList.cs | 5 +- API/Controller/Admin/WebhookAdd.cs | 4 +- API/Controller/Admin/WebhookList.cs | 4 +- API/Controller/Admin/WebhookRemove.cs | 4 +- API/Controller/Device/GetSelf.cs | 4 +- API/Controller/Device/Pair.cs | 15 +- API/Controller/Devices/DeviceOtaController.cs | 7 +- API/Controller/Devices/DevicesController.cs | 59 +++---- API/Controller/Devices/GetShockers.cs | 5 +- API/Controller/OAuth/HandOff.cs | 4 +- API/Controller/OAuth/SignupFinalize.cs | 5 +- .../Public/PublicShareController.cs | 5 +- API/Controller/Shares/DeleteShareCode.cs | 5 +- API/Controller/Shares/LinkShareCode.cs | 12 +- API/Controller/Shares/Links/AddShocker.cs | 11 +- .../Shares/Links/CreatePublicShare.cs | 4 +- .../Shares/Links/DeletePublicShare.cs | 5 +- API/Controller/Shares/Links/EditShocker.cs | 9 +- API/Controller/Shares/Links/PauseShocker.cs | 9 +- API/Controller/Shares/Links/RemoveShocker.cs | 7 +- .../Shares/UserShares/CreateShareInvite.cs | 12 +- API/Controller/Shares/UserShares/Invites.cs | 19 +-- .../Shares/UserShares/PauseShockerShares.cs | 7 +- .../Shares/UserShares/RemoveShockerShares.cs | 6 +- .../Shares/UserShares/UpdateShockerShares.cs | 8 +- API/Controller/Shockers/EditShocker.cs | 14 +- API/Controller/Shockers/GetShockerById.cs | 5 +- API/Controller/Shockers/GetShockerLogs.cs | 6 +- API/Controller/Shockers/ListSharedShockers.cs | 4 +- API/Controller/Shockers/PauseShocker.cs | 10 +- API/Controller/Shockers/RegisterShocker.cs | 12 +- API/Controller/Shockers/RemoveShocker.cs | 10 +- API/Controller/Shockers/SendControl.cs | 10 +- API/Controller/Shockers/ShockerShares.cs | 50 +++--- API/Controller/Tokens/ReportTokens.cs | 3 +- API/Controller/Tokens/Tokens.cs | 45 +++--- API/Services/Account/AccountService.cs | 144 +++++++++--------- API/Services/Account/IAccountService.cs | 21 ++- .../DeviceUpdate/DeviceUpdateService.cs | 8 +- .../DeviceUpdate/IDeviceUpdateService.cs | 9 +- API/Services/Token/ApiTokenService.cs | 50 +++--- API/Services/Token/IApiTokenService.cs | 18 +-- .../ApiTokenAuthentication.cs | 2 +- .../HubAuthentication.cs | 2 +- .../UserSessionAuthentication.cs | 2 +- Common/Hubs/PublicShareHub.cs | 8 +- Common/Hubs/UserHub.cs | 14 +- Common/OpenShockControllerBase.cs | 4 +- .../Configuration/ConfigurationService.cs | 79 +++++----- .../Configuration/IConfigurationService.cs | 26 ++-- Common/Services/ControlSender.cs | 16 +- Common/Services/Device/DeviceService.cs | 4 +- Common/Services/Device/IDeviceService.cs | 3 +- Common/Services/IControlSender.cs | 4 +- Common/Services/Ota/IOtaService.cs | 18 ++- Common/Services/Ota/OtaService.cs | 30 ++-- Common/Services/Session/ISessionService.cs | 4 +- Common/Services/Session/SessionService.cs | 10 +- Common/Services/Webhook/IWebhookService.cs | 8 +- Common/Services/Webhook/WebhookService.cs | 19 +-- Cron/DashboardAdminAuth.cs | 6 +- Cron/Jobs/ClearOldAuditLogsJob.cs | 7 +- Cron/Jobs/ClearOldEmailChangesJob.cs | 7 +- Cron/Jobs/ClearOldPasswordResetsJob.cs | 8 +- Cron/Jobs/ClearOldShockerControlLogs.cs | 8 +- Cron/Jobs/DeleteDeactivatedAccounts.cs | 8 +- .../Jobs/DeleteExpiredAutomatedAccountsJob.cs | 7 +- Cron/Jobs/EmailOutboxDeliveryJob.cs | 38 +++-- Cron/Jobs/OtaTimeoutJob.cs | 8 +- .../Controllers/HubV1Controller.cs | 21 ++- .../Controllers/HubV2Controller.cs | 21 ++- .../FlatbuffersWebsocketBaseController.cs | 5 +- 94 files changed, 659 insertions(+), 504 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..2657c3ea --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.cs] +# Do not suggest converting constructors into primary constructors +csharp_style_prefer_primary_constructors = false +dotnet_diagnostic.IDE0290.severity = none +resharper_convert_to_primary_constructor_highlighting = none diff --git a/API.IntegrationTests/Tests/AutomationTokenTests.cs b/API.IntegrationTests/Tests/AutomationTokenTests.cs index ca3a653b..4c858099 100644 --- a/API.IntegrationTests/Tests/AutomationTokenTests.cs +++ b/API.IntegrationTests/Tests/AutomationTokenTests.cs @@ -235,7 +235,7 @@ public async Task PasswordReset_ForNormalAccount_StartsResetAndAudits() // --- Admin endpoints --- [Test] - public async Task Create_ReturnsWorkingSecret() + public async Task Create_ReturnsWorkingSecret(CancellationToken cancellationToken) { using var client = await CreateAdminClient("autocreate"); @@ -252,14 +252,14 @@ public async Task Create_ReturnsWorkingSecret() await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); var service = scope.ServiceProvider.GetRequiredService(); - var resolved = await service.ResolveAsync(secret, CancellationToken.None); + var resolved = await service.ResolveAsync(secret, cancellationToken); await Assert.That(resolved).IsNotNull(); await Assert.That(resolved!.Id).IsEqualTo(id); await Assert.That(resolved.Types).Contains(AutomationTokenType.Turnstile); } [Test] - public async Task Rotate_OldSecretStopsResolving() + public async Task Rotate_OldSecretStopsResolving(CancellationToken cancellationToken) { var (tokenId, oldSecret) = await CreateAutomationTokenInDb(); using var client = await CreateAdminClient("autorotate"); @@ -271,8 +271,8 @@ public async Task Rotate_OldSecretStopsResolving() await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); var service = scope.ServiceProvider.GetRequiredService(); - await Assert.That(await service.ResolveAsync(oldSecret, CancellationToken.None)).IsNull(); - await Assert.That((await service.ResolveAsync(newSecret, CancellationToken.None))?.Id).IsEqualTo(tokenId); + await Assert.That(await service.ResolveAsync(oldSecret, cancellationToken)).IsNull(); + await Assert.That((await service.ResolveAsync(newSecret, cancellationToken))?.Id).IsEqualTo(tokenId); } [Test] diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index cbffd17b..100df693 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -21,6 +21,7 @@ public sealed partial class AuthenticatedAccountController /// address; the change is not applied until that link is opened. /// /// + /// [HttpPost("email-change")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] @@ -30,7 +31,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse [ProducesResponseType(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. - public async Task ChangeEmail([FromBody] ChangeEmailRequest body) + public async Task ChangeEmail([FromBody] ChangeEmailRequest body, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(CurrentUser.PasswordHash)) { @@ -42,7 +43,7 @@ public async Task ChangeEmail([FromBody] ChangeEmailRequest body) return Problem(AccountError.PasswordChangeInvalidPassword); } - var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id); + var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id, cancellationToken: cancellationToken); return result switch { diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index ea414d1e..80f9ff3b 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -20,6 +20,7 @@ public sealed partial class AuthenticatedAccountController /// Change the password of the current user /// /// + /// /// /// [HttpPost("password")] @@ -28,7 +29,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // PasswordNotSet // notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller. - public async Task ChangePassword([FromBody] ChangePasswordRequest body) + public async Task ChangePassword([FromBody] ChangePasswordRequest body, CancellationToken cancellationToken) { // OAuth-only accounts that have never set a password must go through the email-confirmed // /password/set flow rather than silently setting one through this endpoint. @@ -42,7 +43,7 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest return Problem(AccountError.PasswordChangeInvalidPassword); } - var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id); + var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id, cancellationToken: cancellationToken); return result switch { diff --git a/API/Controller/Account/Authenticated/ChangeUsername.cs b/API/Controller/Account/Authenticated/ChangeUsername.cs index 6bc21e58..9538ce9b 100644 --- a/API/Controller/Account/Authenticated/ChangeUsername.cs +++ b/API/Controller/Account/Authenticated/ChangeUsername.cs @@ -19,6 +19,7 @@ public sealed partial class AuthenticatedAccountController /// Change the username of the current user /// /// + /// /// /// [HttpPost("username")] @@ -27,10 +28,11 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // UsernameTaken [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // UsernameInvalid [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged - public async Task ChangeUsername([FromBody] ChangeUsernameRequest body) + public async Task ChangeUsername([FromBody] ChangeUsernameRequest body, CancellationToken cancellationToken) { var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username, actorId: CurrentUser.Id, - ignoreLimit: CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System)); + ignoreLimit: CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System), + cancellationToken: cancellationToken); return result switch { diff --git a/API/Controller/Account/Authenticated/Deactivate.cs b/API/Controller/Account/Authenticated/Deactivate.cs index ef0d66c6..a6ee2357 100644 --- a/API/Controller/Account/Authenticated/Deactivate.cs +++ b/API/Controller/Account/Authenticated/Deactivate.cs @@ -20,9 +20,9 @@ public sealed partial class AuthenticatedAccountController [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // CannotDeactivatePrivledgedAccount - public async Task Deactivate() + public async Task Deactivate(CancellationToken cancellationToken) { - var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true); + var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true, cancellationToken: cancellationToken); return deactivationResult switch { Results.Success => NoContent(), diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index bbf67df7..4fbe8bc8 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -65,7 +65,7 @@ public async Task LoginV2( if (!await automationTokens.TryRecordUseAsync(account.Id, AutomationTokenFlow.Login, cancellationToken)) return Problem(AutomationTokenError.NotAllowedForAccount); - await CreateSession(account.Id, cookieDomain); + await CreateSession(account.Id, cookieDomain, cancellationToken); return Ok(LoginV2OkResponse.FromUser(account)); } diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs index 7a530793..cc54aa1c 100644 --- a/API/Controller/Account/Logout.cs +++ b/API/Controller/Account/Logout.cs @@ -10,7 +10,7 @@ public sealed partial class AccountController [HttpPost("logout")] [ProducesResponseType(StatusCodes.Status200OK)] [MapToApiVersion("1")] - public async Task Logout([FromServices] ISessionService sessionService) + public async Task Logout([FromServices] ISessionService sessionService, CancellationToken cancellationToken) { // Remove session if valid if (HttpContext.TryGetUserSessionToken(out var sessionToken)) @@ -18,7 +18,7 @@ public async Task Logout([FromServices] ISessionService sessionSe var session = await sessionService.GetSessionByTokenAsync(sessionToken); if (session is not null) { - await sessionService.LogoutSessionAsync(session); + await sessionService.LogoutSessionAsync(session, cancellationToken); } } diff --git a/API/Controller/Account/PasswordResetComplete.cs b/API/Controller/Account/PasswordResetComplete.cs index b524b1e2..6a984690 100644 --- a/API/Controller/Account/PasswordResetComplete.cs +++ b/API/Controller/Account/PasswordResetComplete.cs @@ -34,6 +34,7 @@ public IActionResult PasswordResetCompleteLegacy([FromRoute] Guid passwordResetI /// The id of the password reset /// The secret of the password reset /// + /// /// Password successfully changed /// Password reset process not found [HttpPost("password-reset/{passwordResetId}/{secret}/complete")] @@ -43,9 +44,10 @@ public IActionResult PasswordResetCompleteLegacy([FromRoute] Guid passwordResetI [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound [MapToApiVersion("1")] public async Task PasswordResetComplete([FromRoute] Guid passwordResetId, - [FromRoute] string secret, [FromBody] PasswordResetProcessData body) + [FromRoute] string secret, [FromBody] PasswordResetProcessData body, + CancellationToken cancellationToken) { - var passwordResetComplete = await _accountService.CompletePasswordResetFlowAsync(passwordResetId, secret, body.Password); + var passwordResetComplete = await _accountService.CompletePasswordResetFlowAsync(passwordResetId, secret, body.Password, cancellationToken); return passwordResetComplete switch { diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 7e40bf5d..70d8987a 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -48,7 +48,7 @@ public async Task PasswordResetInitiateV2([FromBody] PasswordRese return Ok(); } - await _accountService.CreatePasswordResetFlowAsync(body.Email); + await _accountService.CreatePasswordResetFlowAsync(body.Email, cancellationToken); return Ok(); } diff --git a/API/Controller/Admin/AutomationTokenList.cs b/API/Controller/Admin/AutomationTokenList.cs index 57672668..9bcf4f8f 100644 --- a/API/Controller/Admin/AutomationTokenList.cs +++ b/API/Controller/Admin/AutomationTokenList.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OpenShock.API.Controller.Admin.DTOs; @@ -10,11 +11,11 @@ public sealed partial class AdminController /// Lists all automation tokens /// [HttpGet("automationTokens")] - public async IAsyncEnumerable ListAutomationTokens() + public async IAsyncEnumerable ListAutomationTokens([EnumeratorCancellation] CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); - await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable()) + await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable().WithCancellation(cancellationToken)) { yield return AutomationTokenDto.FromEntity(token); } diff --git a/API/Controller/Admin/Configuration.cs b/API/Controller/Admin/Configuration.cs index 45e8e639..bda22f6e 100644 --- a/API/Controller/Admin/Configuration.cs +++ b/API/Controller/Admin/Configuration.cs @@ -58,7 +58,8 @@ public async Task ConfigurationAdd([FromBody] ConfigurationAddIte body.Name, body.Description, body.Type, - body.Value + body.Value, + cancellationToken ); return result switch @@ -90,7 +91,8 @@ public async Task ConfigurationUpdate([FromBody] ConfigurationUpd var result = await configurationService.TryUpdateItemAsync( body.Name, body.Description, - body.Value + body.Value, + cancellationToken ); return result switch @@ -117,7 +119,7 @@ public async Task ConfigurationDelete([FromRoute] string name, [F { PedanticallyEnsureAdmin(); - var result = await configurationService.TryDeleteItemAsync(name); + var result = await configurationService.TryDeleteItemAsync(name, cancellationToken); return result switch { diff --git a/API/Controller/Admin/DeactivateUser.cs b/API/Controller/Admin/DeactivateUser.cs index 4eecea7d..9dad8e19 100644 --- a/API/Controller/Admin/DeactivateUser.cs +++ b/API/Controller/Admin/DeactivateUser.cs @@ -22,11 +22,12 @@ public async Task DeactivateUser( [FromRoute] Guid userId, [FromQuery(Name = "deleteLater")] bool deleteLater, [FromQuery][MaxLength(ApiHardLimits.AuditReasonMaxLength)] string? reason, - [FromServices] IAccountService accountService) + [FromServices] IAccountService accountService, + CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); - var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater, reason); + var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater, reason, cancellationToken); return deactivationResult switch { Results.Success => Ok("Account deactivated"), diff --git a/API/Controller/Admin/DeleteUser.cs b/API/Controller/Admin/DeleteUser.cs index 5197d411..045157de 100644 --- a/API/Controller/Admin/DeleteUser.cs +++ b/API/Controller/Admin/DeleteUser.cs @@ -21,11 +21,12 @@ public sealed partial class AdminController public async Task DeleteUser( [FromRoute] Guid userId, [FromQuery][MaxLength(ApiHardLimits.AuditReasonMaxLength)] string? reason, - [FromServices] IAccountService accountService) + [FromServices] IAccountService accountService, + CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); - var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId, reason); + var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId, reason, cancellationToken); return result switch { Results.Success => Ok("Account deleted"), diff --git a/API/Controller/Admin/EmailOutboxList.cs b/API/Controller/Admin/EmailOutboxList.cs index de9501f5..71739035 100644 --- a/API/Controller/Admin/EmailOutboxList.cs +++ b/API/Controller/Admin/EmailOutboxList.cs @@ -27,7 +27,8 @@ public async Task GetEmailOutbox( [FromQuery(Name = "$filter")] string filterQuery = "", [FromQuery(Name = "$orderby")] string orderbyQuery = "", [FromQuery(Name = "$offset")][Range(0, int.MaxValue)] int offset = 0, - [FromQuery(Name = "$limit")][Range(1, 1000)] int limit = 100 + [FromQuery(Name = "$limit")][Range(1, 1000)] int limit = 100, + CancellationToken cancellationToken = default ) { PedanticallyEnsureAdmin(); @@ -67,14 +68,14 @@ public async Task GetEmailOutbox( // its custom data reader can't apply Npgsql's jsonb -> Dictionary conversion for // the Payload column and blindly casts the raw jsonb string to the dictionary, throwing // InvalidCastException. Plain async queries let Npgsql materialize Payload correctly. - var total = await query.LongCountAsync(); + var total = await query.LongCountAsync(cancellationToken); if (offset != 0) { query = query.Skip(offset); } - var messages = await query.Take(limit).ToArrayAsync(); + var messages = await query.Take(limit).ToArrayAsync(cancellationToken); return Ok(new Paginated { diff --git a/API/Controller/Admin/EmailOutboxStats.cs b/API/Controller/Admin/EmailOutboxStats.cs index 18b2918b..0b91ca9c 100644 --- a/API/Controller/Admin/EmailOutboxStats.cs +++ b/API/Controller/Admin/EmailOutboxStats.cs @@ -15,7 +15,7 @@ public sealed partial class AdminController /// Unauthorized [HttpGet("email/outbox/stats")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - public async Task GetEmailOutboxStats() + public async Task GetEmailOutboxStats(CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); @@ -23,7 +23,7 @@ public async Task GetEmailOutboxStats() .AsNoTracking() .GroupBy(m => m.Status) .Select(g => new { Status = g.Key, Count = g.LongCount() }) - .ToDictionaryAsync(x => x.Status, x => x.Count); + .ToDictionaryAsync(x => x.Status, x => x.Count, cancellationToken); long Count(EmailStatus status) => counts.GetValueOrDefault(status); diff --git a/API/Controller/Admin/EmailProviderBlacklistList.cs b/API/Controller/Admin/EmailProviderBlacklistList.cs index fa9355bc..340cb8a0 100644 --- a/API/Controller/Admin/EmailProviderBlacklistList.cs +++ b/API/Controller/Admin/EmailProviderBlacklistList.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Net.Mail; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -8,7 +9,7 @@ namespace OpenShock.API.Controller.Admin; public sealed partial class AdminController { [HttpGet("blacklist/emailProviders")] - public async IAsyncEnumerable ListEmailProviderBlacklist([FromQuery(Name="match")] string? match) + public async IAsyncEnumerable ListEmailProviderBlacklist([FromQuery(Name="match")] string? match, [EnumeratorCancellation] CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); @@ -23,7 +24,7 @@ public async IAsyncEnumerable ListEmailProviderBlackl match = parsed.Host.ToLowerInvariant(); } - await foreach (var item in _db.EmailProviderBlacklists.AsNoTracking().AsAsyncEnumerable()) + await foreach (var item in _db.EmailProviderBlacklists.AsNoTracking().AsAsyncEnumerable().WithCancellation(cancellationToken)) { if (match is not null && !match.EndsWith(item.Domain)) continue; diff --git a/API/Controller/Admin/GetOnlineDevices.cs b/API/Controller/Admin/GetOnlineDevices.cs index 42817119..0900e41c 100644 --- a/API/Controller/Admin/GetOnlineDevices.cs +++ b/API/Controller/Admin/GetOnlineDevices.cs @@ -20,7 +20,7 @@ public sealed partial class AdminController /// Unauthorized [HttpGet("monitoring/onlineDevices")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - public async Task GetOnlineDevices() + public async Task GetOnlineDevices(CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); @@ -40,7 +40,7 @@ public async Task GetOnlineDevices() Name = x.Owner.Name } }) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); return LegacyDataOk( allOnlineDevices diff --git a/API/Controller/Admin/GetUsers.cs b/API/Controller/Admin/GetUsers.cs index 7611aacd..900a8614 100644 --- a/API/Controller/Admin/GetUsers.cs +++ b/API/Controller/Admin/GetUsers.cs @@ -26,7 +26,8 @@ public async Task GetUsers( [FromQuery(Name = "$filter")] string filterQuery = "", [FromQuery(Name = "$orderby")] string orderbyQuery = "", [FromQuery(Name = "$offset")][Range(0, int.MaxValue)] int offset = 0, - [FromQuery(Name = "$limit")][Range(1, 1000)] int limit = 100 + [FromQuery(Name = "$limit")][Range(1, 1000)] int limit = 100, + CancellationToken cancellationToken = default ) { PedanticallyEnsureAdmin(); @@ -73,10 +74,10 @@ public async Task GetUsers( return Ok(new Paginated { - Data = await deferredUsers.ToArrayAsync(), + Data = await deferredUsers.ToArrayAsync(cancellationToken), Offset = offset, Limit = limit, - Total = await deferredCount.ValueAsync(), + Total = await deferredCount.ValueAsync(cancellationToken), }); } } \ No newline at end of file diff --git a/API/Controller/Admin/ReactivateUser.cs b/API/Controller/Admin/ReactivateUser.cs index 89f6ed40..50565836 100644 --- a/API/Controller/Admin/ReactivateUser.cs +++ b/API/Controller/Admin/ReactivateUser.cs @@ -21,11 +21,12 @@ public sealed partial class AdminController public async Task ReactivateUser( [FromRoute] Guid userId, [FromQuery][MaxLength(ApiHardLimits.AuditReasonMaxLength)] string? reason, - [FromServices] IAccountService accountService) + [FromServices] IAccountService accountService, + CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); - var reactivationResult = await accountService.ReactivateAccountAsync(CurrentUser.Id, userId, reason); + var reactivationResult = await accountService.ReactivateAccountAsync(CurrentUser.Id, userId, reason, cancellationToken); return reactivationResult switch { Results.Success => Ok("Account reactivated"), diff --git a/API/Controller/Admin/SetUserPassword.cs b/API/Controller/Admin/SetUserPassword.cs index aee21267..310f6d88 100644 --- a/API/Controller/Admin/SetUserPassword.cs +++ b/API/Controller/Admin/SetUserPassword.cs @@ -34,7 +34,7 @@ public async Task SetUserPassword([FromRoute(Name = "userId")] Gu { PedanticallyEnsureAdmin(); - var result = await accountService.ChangePasswordAsync(userId, body.Password, actorId: CurrentUser.Id); + var result = await accountService.ChangePasswordAsync(userId, body.Password, actorId: CurrentUser.Id, cancellationToken: cancellationToken); return result switch { diff --git a/API/Controller/Admin/UserNameBlacklistAdd.cs b/API/Controller/Admin/UserNameBlacklistAdd.cs index 4f6d477b..6c4bbe37 100644 --- a/API/Controller/Admin/UserNameBlacklistAdd.cs +++ b/API/Controller/Admin/UserNameBlacklistAdd.cs @@ -10,7 +10,7 @@ public sealed partial class AdminController [HttpPost("blacklist/usernames")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task AddUsernameBlacklist([FromBody] AddUsernameBlacklistDto body) + public async Task AddUsernameBlacklist([FromBody] AddUsernameBlacklistDto body, CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); @@ -21,7 +21,7 @@ public async Task AddUsernameBlacklist([FromBody] AddUsernameBlac MatchType = body.MatchType }; _db.UserNameBlacklists.Add(entry); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return Ok(); } } diff --git a/API/Controller/Admin/UserNameBlacklistList.cs b/API/Controller/Admin/UserNameBlacklistList.cs index 66e4bca3..55981054 100644 --- a/API/Controller/Admin/UserNameBlacklistList.cs +++ b/API/Controller/Admin/UserNameBlacklistList.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OpenShock.API.Controller.Admin.DTOs; @@ -7,13 +8,13 @@ namespace OpenShock.API.Controller.Admin; public sealed partial class AdminController { [HttpGet("blacklist/usernames")] - public async IAsyncEnumerable ListUsernameBlacklist([FromQuery(Name="match")] string? match) + public async IAsyncEnumerable ListUsernameBlacklist([FromQuery(Name="match")] string? match, [EnumeratorCancellation] CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); match = string.IsNullOrWhiteSpace(match) ? null : match.Trim().ToLowerInvariant(); - await foreach (var item in _db.UserNameBlacklists.AsNoTracking().AsAsyncEnumerable()) + await foreach (var item in _db.UserNameBlacklists.AsNoTracking().AsAsyncEnumerable().WithCancellation(cancellationToken)) { if (match is not null && !item.IsMatch(match)) continue; diff --git a/API/Controller/Admin/WebhookAdd.cs b/API/Controller/Admin/WebhookAdd.cs index e8bee8bc..dd3f0119 100644 --- a/API/Controller/Admin/WebhookAdd.cs +++ b/API/Controller/Admin/WebhookAdd.cs @@ -19,11 +19,11 @@ public sealed partial class AdminController [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task AddWebhook([FromBody] AddWebhookDto body, [FromServices] IWebhookService webhookService) + public async Task AddWebhook([FromBody] AddWebhookDto body, [FromServices] IWebhookService webhookService, CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); - var result = await webhookService.AddWebhookAsync(body.Name, body.Url); + var result = await webhookService.AddWebhookAsync(body.Name, body.Url, cancellationToken); return result switch { Success success => Ok(success.Value), diff --git a/API/Controller/Admin/WebhookList.cs b/API/Controller/Admin/WebhookList.cs index abbf8961..ae85150f 100644 --- a/API/Controller/Admin/WebhookList.cs +++ b/API/Controller/Admin/WebhookList.cs @@ -12,10 +12,10 @@ public sealed partial class AdminController /// OK /// Unauthorized [HttpGet("webhooks")] - public async Task ListWebhooks([FromServices] IWebhookService webhookService) + public async Task ListWebhooks([FromServices] IWebhookService webhookService, CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); - return await webhookService.GetWebhooksAsync(); + return await webhookService.GetWebhooksAsync(cancellationToken); } } \ No newline at end of file diff --git a/API/Controller/Admin/WebhookRemove.cs b/API/Controller/Admin/WebhookRemove.cs index 71b73f05..f745d8bf 100644 --- a/API/Controller/Admin/WebhookRemove.cs +++ b/API/Controller/Admin/WebhookRemove.cs @@ -14,11 +14,11 @@ public sealed partial class AdminController [HttpDelete("webhooks/{id}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task RemoveWebhook([FromRoute] Guid id, [FromServices] IWebhookService webhookService) + public async Task RemoveWebhook([FromRoute] Guid id, [FromServices] IWebhookService webhookService, CancellationToken cancellationToken) { PedanticallyEnsureAdmin(); - bool removed = await webhookService.RemoveWebhookAsync(id); + bool removed = await webhookService.RemoveWebhookAsync(id, cancellationToken); return removed ? Ok() : Problem(AdminError.WebhookNotFound); } } \ No newline at end of file diff --git a/API/Controller/Device/GetSelf.cs b/API/Controller/Device/GetSelf.cs index 8ddaf3ed..151a390c 100644 --- a/API/Controller/Device/GetSelf.cs +++ b/API/Controller/Device/GetSelf.cs @@ -18,7 +18,7 @@ public sealed partial class DeviceController [HttpGet("self")] [MapToApiVersion("1")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - public async Task GetSelf() + public async Task GetSelf(CancellationToken cancellationToken) { var identity = User.GetOpenShockHubIdentity(); var currentHubId = identity.GetClaimValueAsGuid(OpenShockAuthClaims.HubId); @@ -29,7 +29,7 @@ public async Task GetSelf() Id = x.Id, RfId = x.RfId, Model = x.Model - }).ToArrayAsync(); + }).ToArrayAsync(cancellationToken); return LegacyDataOk(new DeviceSelfResponse { diff --git a/API/Controller/Device/Pair.cs b/API/Controller/Device/Pair.cs index 061f9ebe..f9427173 100644 --- a/API/Controller/Device/Pair.cs +++ b/API/Controller/Device/Pair.cs @@ -19,6 +19,7 @@ public sealed partial class DeviceController /// Pair a device with a pair code. /// /// The pair code to pair with. + /// /// Successfully assigned LCG node /// No such pair code exists [AllowAnonymous] @@ -28,9 +29,9 @@ public sealed partial class DeviceController [EnableRateLimiting("auth")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PairCodeNotFound - public async Task Pair([FromRoute] string pairCode) + public async Task Pair([FromRoute] string pairCode, CancellationToken cancellationToken) { - return await PairInternal(pairCode); + return await PairInternal(pairCode, cancellationToken); } /// @@ -41,12 +42,12 @@ public async Task Pair([FromRoute] string pairCode) [HttpGet("~/{version:apiVersion}/pair/{pairCode}")] [ApiExplorerSettings(IgnoreApi = true)] [EnableRateLimiting("auth")] - public async Task PairDeprecated([FromRoute] string pairCode) + public async Task PairDeprecated([FromRoute] string pairCode, CancellationToken cancellationToken) { - return await PairInternal(pairCode); + return await PairInternal(pairCode, cancellationToken); } - private async Task PairInternal(string pairCode) + private async Task PairInternal(string pairCode, CancellationToken cancellationToken) { var devicePairs = _redis.RedisCollection(); @@ -54,12 +55,12 @@ private async Task PairInternal(string pairCode) if (pair is null) return Problem(PairError.PairCodeNotFound); await devicePairs.DeleteAsync(pair); - var device = await _db.Devices.Where(x => x.Id == pair.Id).Select(x => new { x.Token, x.OwnerId }).FirstOrDefaultAsync(); + var device = await _db.Devices.Where(x => x.Id == pair.Id).Select(x => new { x.Token, x.OwnerId }).FirstOrDefaultAsync(cancellationToken); if (device is null) throw new Exception("Device not found for pair code"); try { - await _deviceUpdateService.UpdateDevice(device.OwnerId, pair.Id, DeviceUpdateType.Paired); + await _deviceUpdateService.UpdateDevice(device.OwnerId, pair.Id, DeviceUpdateType.Paired, cancellationToken); } catch (Exception ex) { diff --git a/API/Controller/Devices/DeviceOtaController.cs b/API/Controller/Devices/DeviceOtaController.cs index 221fda68..39db5a33 100644 --- a/API/Controller/Devices/DeviceOtaController.cs +++ b/API/Controller/Devices/DeviceOtaController.cs @@ -37,20 +37,21 @@ public DevicesOtaController(OpenShockContext db, ILogger logg /// /// Id of the device /// + /// /// OK /// Could not find device or you do not have access to it [HttpGet("{deviceId}/ota")] [MapToApiVersion("1")] [ProducesResponseType>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound - public async Task GetOtaUpdateHistory([FromRoute] Guid deviceId, [FromServices] IOtaService otaService) + public async Task GetOtaUpdateHistory([FromRoute] Guid deviceId, [FromServices] IOtaService otaService, CancellationToken cancellationToken) { // Check if user owns device or has a share var deviceExistsAndYouHaveAccess = await _db.Devices.AnyAsync(x => - x.Id == deviceId && x.OwnerId == CurrentUser.Id); + x.Id == deviceId && x.OwnerId == CurrentUser.Id, cancellationToken); if (!deviceExistsAndYouHaveAccess) return Problem(HubError.HubNotFound); - return LegacyDataOk(await otaService.GetUpdates(deviceId)); + return LegacyDataOk(await otaService.GetUpdates(deviceId, cancellationToken)); } } \ No newline at end of file diff --git a/API/Controller/Devices/DevicesController.cs b/API/Controller/Devices/DevicesController.cs index 3a634ae3..04551bac 100644 --- a/API/Controller/Devices/DevicesController.cs +++ b/API/Controller/Devices/DevicesController.cs @@ -54,12 +54,13 @@ public IActionResult ListDevices() /// Get a device by its id /// /// + /// /// The device [HttpGet("{deviceId}")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound [MapToApiVersion("1")] - public async Task GetDeviceById([FromRoute] Guid deviceId) + public async Task GetDeviceById([FromRoute] Guid deviceId, CancellationToken cancellationToken) { var hasAuthPerms = IsAllowed(PermissionType.Devices_Auth); @@ -71,7 +72,7 @@ public async Task GetDeviceById([FromRoute] Guid deviceId) Name = x.Name, CreatedOn = x.CreatedAt, Token = hasAuthPerms ? x.Token : null - }).FirstOrDefaultAsync(); + }).FirstOrDefaultAsync(cancellationToken); if (device is null) return Problem(HubError.HubNotFound); return LegacyDataOk(device); @@ -83,6 +84,7 @@ public async Task GetDeviceById([FromRoute] Guid deviceId) /// /// /// + /// /// Successfully updated device /// Device does not exist [HttpPatch("{deviceId}")] @@ -91,15 +93,15 @@ public async Task GetDeviceById([FromRoute] Guid deviceId) [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound [MapToApiVersion("1")] - public async Task EditDevice([FromRoute] Guid deviceId, [FromBody] HubEditRequest body, [FromServices] IDeviceUpdateService updateService) + public async Task EditDevice([FromRoute] Guid deviceId, [FromBody] HubEditRequest body, [FromServices] IDeviceUpdateService updateService, CancellationToken cancellationToken) { - var device = await _db.Devices.FirstOrDefaultAsync(x => x.OwnerId == CurrentUser.Id && x.Id == deviceId); + var device = await _db.Devices.FirstOrDefaultAsync(x => x.OwnerId == CurrentUser.Id && x.Id == deviceId, cancellationToken); if (device is null) return Problem(HubError.HubNotFound); device.Name = body.Name; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await updateService.UpdateDeviceForAllShared(CurrentUser.Id, device.Id, DeviceUpdateType.Updated); + await updateService.UpdateDeviceForAllShared(CurrentUser.Id, device.Id, DeviceUpdateType.Updated, cancellationToken); return Ok(); } @@ -108,6 +110,7 @@ public async Task EditDevice([FromRoute] Guid deviceId, [FromBody /// Regenerate a device token /// /// The id of the device to regenerate the token for + /// /// Successfully regenerated device token /// Device does not exist /// Failed to save regenerated token @@ -116,14 +119,14 @@ public async Task EditDevice([FromRoute] Guid deviceId, [FromBody [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Text.Plain)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound [MapToApiVersion("1")] - public async Task RegenerateDeviceToken([FromRoute] Guid deviceId) + public async Task RegenerateDeviceToken([FromRoute] Guid deviceId, CancellationToken cancellationToken) { - var device = await _db.Devices.FirstOrDefaultAsync(x => x.OwnerId == CurrentUser.Id && x.Id == deviceId); + var device = await _db.Devices.FirstOrDefaultAsync(x => x.OwnerId == CurrentUser.Id && x.Id == deviceId, cancellationToken); if (device is null) return Problem(HubError.HubNotFound); device.Token = CryptoUtils.RandomString(256); - var affected = await _db.SaveChangesAsync(); + var affected = await _db.SaveChangesAsync(cancellationToken); if (affected <= 0) throw new Exception("Failed to save regenerated token"); return Ok(device.Token); @@ -134,6 +137,7 @@ public async Task RegenerateDeviceToken([FromRoute] Guid deviceId /// /// The id of the device to delete /// + /// /// Successfully deleted device /// Device does not exist [HttpDelete("{deviceId}")] @@ -141,12 +145,12 @@ public async Task RegenerateDeviceToken([FromRoute] Guid deviceId [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound [MapToApiVersion("1")] - public async Task RemoveDevice([FromRoute] Guid deviceId, [FromServices] IDeviceUpdateService updateService) + public async Task RemoveDevice([FromRoute] Guid deviceId, [FromServices] IDeviceUpdateService updateService, CancellationToken cancellationToken) { - var affected = await _db.Devices.Where(x => x.Id == deviceId).WhereIsUserOrPrivileged(x => x.Owner, CurrentUser).ExecuteDeleteAsync(); + var affected = await _db.Devices.Where(x => x.Id == deviceId).WhereIsUserOrPrivileged(x => x.Owner, CurrentUser).ExecuteDeleteAsync(cancellationToken); if (affected <= 0) return Problem(HubError.HubNotFound); - await updateService.UpdateDeviceForAllShared(CurrentUser.Id, deviceId, DeviceUpdateType.Deleted); + await updateService.UpdateDeviceForAllShared(CurrentUser.Id, deviceId, DeviceUpdateType.Deleted, cancellationToken); return Ok(); } @@ -159,11 +163,11 @@ public async Task RemoveDevice([FromRoute] Guid deviceId, [FromSe [TokenPermission(PermissionType.Devices_Edit)] [ProducesResponseType(StatusCodes.Status201Created, MediaTypeNames.Text.Plain)] [MapToApiVersion("1")] - public Task CreateDevice([FromServices] IDeviceUpdateService updateService) + public Task CreateDevice([FromServices] IDeviceUpdateService updateService, CancellationToken cancellationToken) => CreateDeviceV2(new HubCreateRequest { Name = $"New Hub {DateTimeOffset.UtcNow:d}" - }, updateService); + }, updateService, cancellationToken); /// @@ -175,9 +179,9 @@ public Task CreateDevice([FromServices] IDeviceUpdateService upda [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status201Created, MediaTypeNames.Text.Plain)] [MapToApiVersion("2")] - public async Task CreateDeviceV2([FromBody] HubCreateRequest body, [FromServices] IDeviceUpdateService updateService) + public async Task CreateDeviceV2([FromBody] HubCreateRequest body, [FromServices] IDeviceUpdateService updateService, CancellationToken cancellationToken) { - int nDevices = await _db.Devices.CountAsync(d => d.OwnerId == CurrentUser.Id); + int nDevices = await _db.Devices.CountAsync(d => d.OwnerId == CurrentUser.Id, cancellationToken); if (nDevices >= HardLimits.MaxHubsPerUser) { return Problem(HubError.TooManyHubs); @@ -191,9 +195,9 @@ public async Task CreateDeviceV2([FromBody] HubCreateRequest body Token = CryptoUtils.RandomString(256) }; _db.Devices.Add(device); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await updateService.UpdateDevice(CurrentUser.Id, device.Id, DeviceUpdateType.Created); + await updateService.UpdateDevice(CurrentUser.Id, device.Id, DeviceUpdateType.Created, cancellationToken); return Created($"/1/devices/{device.Id}", device.Id); } @@ -202,6 +206,7 @@ public async Task CreateDeviceV2([FromBody] HubCreateRequest body /// Get a pair code for a device /// /// + /// /// The pair code /// Device does not exist or does not belong to you [HttpGet("{deviceId}/pair")] @@ -209,11 +214,11 @@ public async Task CreateDeviceV2([FromBody] HubCreateRequest body [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound [MapToApiVersion("1")] - public async Task GetPairCode([FromRoute] Guid deviceId) + public async Task GetPairCode([FromRoute] Guid deviceId, CancellationToken cancellationToken) { var devicePairs = _redis.RedisCollection(); - var deviceExists = await _db.Devices.AnyAsync(x => x.Id == deviceId && x.OwnerId == CurrentUser.Id); + var deviceExists = await _db.Devices.AnyAsync(x => x.Id == deviceId && x.OwnerId == CurrentUser.Id, cancellationToken); if (!deviceExists) return Problem(HubError.HubNotFound); await _redis.Connection.UnlinkAsync($"{typeof(DevicePair).FullName}:{deviceId}"); @@ -233,6 +238,7 @@ public async Task GetPairCode([FromRoute] Guid deviceId) /// Get LCG info for a device if it is online and connected to a LCG node /// /// + /// /// LCG node was found and device is online /// Device does not exist or does not belong to you /// Device is not online @@ -240,9 +246,9 @@ public async Task GetPairCode([FromRoute] Guid deviceId) [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound, DeviceIsNotOnline [MapToApiVersion("1")] - public async Task GetLiveControlGatewayInfo([FromRoute] Guid deviceId) + public async Task GetLiveControlGatewayInfo([FromRoute] Guid deviceId, CancellationToken cancellationToken) { - var result = await ResolveDeviceGatewayAsync(deviceId); + var result = await ResolveDeviceGatewayAsync(deviceId, cancellationToken); if (result is not LcgNode gateway) return Problem((OpenShockProblem)result.Value!); return LegacyDataOk(new LcgResponse @@ -262,9 +268,9 @@ public async Task GetLiveControlGatewayInfo([FromRoute] Guid devi [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound, DeviceIsNotOnline [MapToApiVersion("2")] - public async Task GetLiveControlGatewayInfoV2([FromRoute] Guid deviceId) + public async Task GetLiveControlGatewayInfoV2([FromRoute] Guid deviceId, CancellationToken cancellationToken) { - var result = await ResolveDeviceGatewayAsync(deviceId); + var result = await ResolveDeviceGatewayAsync(deviceId, cancellationToken); if (result is not LcgNode gateway) return Problem((OpenShockProblem)result.Value!); return Ok(new LcgResponseV2 @@ -281,12 +287,13 @@ public async Task GetLiveControlGatewayInfoV2([FromRoute] Guid de /// when the caller lacks access, the hub is offline, or it has /// no gateway. /// - private async Task> ResolveDeviceGatewayAsync(Guid deviceId) + private async Task> ResolveDeviceGatewayAsync(Guid deviceId, CancellationToken cancellationToken) { // Check if user owns device or has a share var deviceExistsAndYouHaveAccess = await _db.Devices.AnyAsync(x => x.Id == deviceId && (x.OwnerId == CurrentUser.Id || x.Shockers.Any(y => y.UserShares.Any( - z => z.SharedWithUserId == CurrentUser.Id)))); + z => z.SharedWithUserId == CurrentUser.Id))), + cancellationToken); if (!deviceExistsAndYouHaveAccess) return HubError.HubNotFound; // Check if device is online diff --git a/API/Controller/Devices/GetShockers.cs b/API/Controller/Devices/GetShockers.cs index 54fbf8e2..26e6e214 100644 --- a/API/Controller/Devices/GetShockers.cs +++ b/API/Controller/Devices/GetShockers.cs @@ -17,15 +17,16 @@ public sealed partial class DevicesController /// Get all shockers for a device /// /// The device id + /// /// All shockers for the device /// Device does not exists or you do not have access to it. [HttpGet("{deviceId}/shockers")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // DeviceNotFound [MapToApiVersion("1")] - public async Task GetShockers([FromRoute] Guid deviceId) + public async Task GetShockers([FromRoute] Guid deviceId, CancellationToken cancellationToken) { - var deviceExists = await _db.Devices.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == deviceId); + var deviceExists = await _db.Devices.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == deviceId, cancellationToken); if (!deviceExists) return Problem(HubError.HubNotFound); var shockers = _db.Shockers diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index ccff2fc3..93e38917 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -84,7 +84,7 @@ await _accountService.IsEmailRegisteredAsync(externalEmail, cancellationToken)) return RedirectFrontendError("internalError"); } - await CreateSession(connection.UserId, domain); + await CreateSession(connection.UserId, domain, cancellationToken); // Flow cookie no longer needed await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); @@ -124,7 +124,7 @@ await _accountService.IsEmailRegisteredAsync(externalEmail, cancellationToken)) return RedirectFrontendConnections("internalError"); } - await CreateSession(userId, domain); + await CreateSession(userId, domain, cancellationToken); // Flow cookie no longer needed await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index 3acee31c..659b0cf5 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -99,7 +99,8 @@ public async Task OAuthSignupFinalize( provider, auth.ExternalAccountId, auth.ExternalAccountDisplayName ?? auth.ExternalAccountName, - isEmailTrusted + isEmailTrusted, + cancellationToken ); if (created is not User newUser) @@ -112,7 +113,7 @@ public async Task OAuthSignupFinalize( // Authenticate the client if its activated (create session and set session cookie) if (newUser.ActivatedAt is not null) { - await CreateSession(newUser.Id, domain); + await CreateSession(newUser.Id, domain, cancellationToken); } // Clear the temporary OAuth flow cookie. diff --git a/API/Controller/Public/PublicShareController.cs b/API/Controller/Public/PublicShareController.cs index cfb7b43b..e670392e 100644 --- a/API/Controller/Public/PublicShareController.cs +++ b/API/Controller/Public/PublicShareController.cs @@ -18,13 +18,14 @@ public sealed partial class PublicController /// Gets information about a public share. /// /// + /// /// The public share information was successfully retrieved. /// The public share does not exist. [HttpGet("shares/links/{publicShareId}")] [Tags("Public Shocker Shares")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PublicShareNotFound - public async Task GetPublicShare([FromRoute] Guid publicShareId) + public async Task GetPublicShare([FromRoute] Guid publicShareId, CancellationToken cancellationToken) { var publicShare = await _db.PublicShares .Where(x => x.Id == publicShareId && x.Owner.UserDeactivation == null) @@ -63,7 +64,7 @@ public async Task GetPublicShare([FromRoute] Guid publicShareId) Paused = PublicShareUtils.GetPausedReason(y.IsPaused, y.Shocker.IsPaused), } }) - }).FirstOrDefaultAsync(); + }).FirstOrDefaultAsync(cancellationToken); if (publicShare is null) return Problem(PublicShareError.PublicShareNotFound); diff --git a/API/Controller/Shares/DeleteShareCode.cs b/API/Controller/Shares/DeleteShareCode.cs index af903286..07f8d05c 100644 --- a/API/Controller/Shares/DeleteShareCode.cs +++ b/API/Controller/Shares/DeleteShareCode.cs @@ -17,18 +17,19 @@ public sealed partial class SharesController /// Delete a share code /// /// + /// /// Deleted share code /// Share code not found or does not belong to you [HttpDelete("code/{shareCodeId}")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShareCodeNotFound [MapToApiVersion("1")] - public async Task DeleteShareCode([FromRoute] Guid shareCodeId) + public async Task DeleteShareCode([FromRoute] Guid shareCodeId, CancellationToken cancellationToken) { var affected = await _db.ShockerShareCodes .Where(x => x.Id == shareCodeId) .WhereIsUserOrPrivileged(x => x.Shocker.Device.Owner, CurrentUser) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); if (affected <= 0) { return Problem(ShareCodeError.ShareCodeNotFound); diff --git a/API/Controller/Shares/LinkShareCode.cs b/API/Controller/Shares/LinkShareCode.cs index 035881d8..2b1852c2 100644 --- a/API/Controller/Shares/LinkShareCode.cs +++ b/API/Controller/Shares/LinkShareCode.cs @@ -19,6 +19,7 @@ public sealed partial class SharesController /// /// /// + /// /// Linked share code /// Share code not found or does not belong to you /// You cannot link your own shocker code / You already have this shocker linked to your account @@ -30,16 +31,17 @@ public sealed partial class SharesController [MapToApiVersion("1")] public async Task LinkShareCode( [FromRoute] Guid shareCodeId, - [FromServices] IDeviceUpdateService deviceUpdateService + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken ) { var shareCode = await _db.ShockerShareCodes.Where(x => x.Id == shareCodeId && x.Shocker.Device.Owner.UserDeactivation == null).Select(x => new { Share = x, x.Shocker.Device.OwnerId, x.Shocker.DeviceId - }).FirstOrDefaultAsync(); + }).FirstOrDefaultAsync(cancellationToken); if (shareCode is null) return Problem(ShareCodeError.ShareCodeNotFound); if (shareCode.OwnerId == CurrentUser.Id) return Problem(ShareCodeError.CantLinkOwnShareCode); - if (await _db.UserShares.AnyAsync(x => x.ShockerId == shareCode.Share.ShockerId && x.SharedWithUserId == CurrentUser.Id)) + if (await _db.UserShares.AnyAsync(x => x.ShockerId == shareCode.Share.ShockerId && x.SharedWithUserId == CurrentUser.Id, cancellationToken)) return Problem(ShareCodeError.ShockerAlreadyLinked); _db.UserShares.Add(new UserShare @@ -56,9 +58,9 @@ [FromServices] IDeviceUpdateService deviceUpdateService }); _db.ShockerShareCodes.Remove(shareCode.Share); - if (await _db.SaveChangesAsync() <= 1) throw new Exception("Error while linking share code to your account"); + if (await _db.SaveChangesAsync(cancellationToken) <= 1) throw new Exception("Error while linking share code to your account"); - await deviceUpdateService.UpdateDevice(shareCode.OwnerId, shareCode.DeviceId, DeviceUpdateType.ShockerUpdated, CurrentUser.Id); + await deviceUpdateService.UpdateDevice(shareCode.OwnerId, shareCode.DeviceId, DeviceUpdateType.ShockerUpdated, CurrentUser.Id, cancellationToken); return LegacyEmptyOk("Successfully linked share code"); } diff --git a/API/Controller/Shares/Links/AddShocker.cs b/API/Controller/Shares/Links/AddShocker.cs index 039b0b20..5ad5ce61 100644 --- a/API/Controller/Shares/Links/AddShocker.cs +++ b/API/Controller/Shares/Links/AddShocker.cs @@ -18,6 +18,7 @@ public sealed partial class ShareLinksController /// /// /// + /// /// Successfully added shocker /// Public share or shocker does not exist /// Shocker already exists in public share @@ -26,16 +27,16 @@ public sealed partial class ShareLinksController [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PublicShareNotFound, ShockerNotFound [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // ShockerAlreadyInPublicShare - public async Task AddShocker([FromRoute] Guid publicShareId, [FromRoute] Guid shockerId) + public async Task AddShocker([FromRoute] Guid publicShareId, [FromRoute] Guid shockerId, CancellationToken cancellationToken) { - var exists = await _db.PublicShares.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == publicShareId && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow)); + var exists = await _db.PublicShares.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == publicShareId && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow), cancellationToken); if (!exists) return Problem(PublicShareError.PublicShareNotFound); var ownShocker = - await _db.Shockers.AnyAsync(x => x.Id == shockerId && x.Device.OwnerId == CurrentUser.Id); + await _db.Shockers.AnyAsync(x => x.Id == shockerId && x.Device.OwnerId == CurrentUser.Id, cancellationToken); if (!ownShocker) return Problem(ShockerError.ShockerNotFound); - if (await _db.PublicShareShockerMappings.AnyAsync(x => x.PublicShareId == publicShareId && x.ShockerId == shockerId)) + if (await _db.PublicShareShockerMappings.AnyAsync(x => x.PublicShareId == publicShareId && x.ShockerId == shockerId, cancellationToken)) return Problem(PublicShareError.ShockerAlreadyInPublicShare); _db.PublicShareShockerMappings.Add(new PublicShareShocker @@ -49,7 +50,7 @@ public async Task AddShocker([FromRoute] Guid publicShareId, [Fro IsPaused = false }); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return LegacyEmptyOk("Successfully added shocker"); } diff --git a/API/Controller/Shares/Links/CreatePublicShare.cs b/API/Controller/Shares/Links/CreatePublicShare.cs index 2c02b4e8..21572e28 100644 --- a/API/Controller/Shares/Links/CreatePublicShare.cs +++ b/API/Controller/Shares/Links/CreatePublicShare.cs @@ -18,7 +18,7 @@ public sealed partial class ShareLinksController [TokenPermission(PermissionType.Publicshares_Edit)] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - public async Task CreatePublicShare([FromBody] PublicShareCreate body) + public async Task CreatePublicShare([FromBody] PublicShareCreate body, CancellationToken cancellationToken) { // Expiry date in the past is not allowed, but null (no expiry) is allowed if (body.ExpiresOn <= DateTime.UtcNow) @@ -34,7 +34,7 @@ public async Task CreatePublicShare([FromBody] PublicShareCreate ExpiresAt = body.ExpiresOn is null ? null : DateTime.SpecifyKind(body.ExpiresOn.Value, DateTimeKind.Utc) }; _db.PublicShares.Add(entity); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return LegacyDataOk(entity.Id); } diff --git a/API/Controller/Shares/Links/DeletePublicShare.cs b/API/Controller/Shares/Links/DeletePublicShare.cs index eec3efcd..2ef354d2 100644 --- a/API/Controller/Shares/Links/DeletePublicShare.cs +++ b/API/Controller/Shares/Links/DeletePublicShare.cs @@ -18,18 +18,19 @@ public sealed partial class ShareLinksController /// Deletes a public share /// /// + /// /// Deleted public share /// Public share not found or does not belong to you [HttpDelete("{publicShareId}")] [TokenPermission(PermissionType.Publicshares_Edit)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PublicShareNotFound - public async Task DeletePublicShare([FromRoute] Guid publicShareId) + public async Task DeletePublicShare([FromRoute] Guid publicShareId, CancellationToken cancellationToken) { var result = await _db.PublicShares .Where(x => x.Id == publicShareId && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow)) .WhereIsUserOrPrivileged(x => x.Owner, CurrentUser) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); return result > 0 ? LegacyEmptyOk("Deleted public share") diff --git a/API/Controller/Shares/Links/EditShocker.cs b/API/Controller/Shares/Links/EditShocker.cs index b30adc8b..4c954afc 100644 --- a/API/Controller/Shares/Links/EditShocker.cs +++ b/API/Controller/Shares/Links/EditShocker.cs @@ -19,6 +19,7 @@ public sealed partial class ShareLinksController /// /// /// + /// /// Successfully updated shocker /// Public share or shocker does not exist /// Shocker does not exist in public share @@ -27,14 +28,14 @@ public sealed partial class ShareLinksController [TokenPermission(PermissionType.Publicshares_Edit)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Text.Plain)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PublicShareNotFound, ShockerNotInPublicShare - public async Task EditShocker([FromRoute] Guid publicShareId, [FromRoute] Guid shockerId, [FromBody] PublicShareEditShocker body) + public async Task EditShocker([FromRoute] Guid publicShareId, [FromRoute] Guid shockerId, [FromBody] PublicShareEditShocker body, CancellationToken cancellationToken) { - var exists = await _db.PublicShares.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == publicShareId && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow)); + var exists = await _db.PublicShares.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == publicShareId && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow), cancellationToken); if (!exists) return Problem(PublicShareError.PublicShareNotFound); var shocker = await _db.PublicShareShockerMappings.FirstOrDefaultAsync(x => - x.PublicShareId == publicShareId && x.ShockerId == shockerId); + x.PublicShareId == publicShareId && x.ShockerId == shockerId, cancellationToken); if (shocker is null) return Problem(PublicShareError.ShockerNotInPublicShare); shocker.AllowShock = body.Permissions.Shock; @@ -45,7 +46,7 @@ await _db.PublicShareShockerMappings.FirstOrDefaultAsync(x => shocker.MaxDuration = body.Limits.Duration; shocker.Cooldown = body.Cooldown; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return LegacyEmptyOk("Successfully updated shocker"); } } \ No newline at end of file diff --git a/API/Controller/Shares/Links/PauseShocker.cs b/API/Controller/Shares/Links/PauseShocker.cs index a350cd3d..f438c18f 100644 --- a/API/Controller/Shares/Links/PauseShocker.cs +++ b/API/Controller/Shares/Links/PauseShocker.cs @@ -21,6 +21,7 @@ public sealed partial class ShareLinksController /// /// /// + /// /// Successfully updated paused state shocker /// Public share or shocker does not exist /// Shocker does not exist in public share @@ -29,18 +30,18 @@ public sealed partial class ShareLinksController [TokenPermission(PermissionType.Publicshares_Pause)] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PublicShareNotFound, ShockerNotInPublicShare - public async Task PauseShocker([FromRoute] Guid publicShareId, [FromRoute] Guid shockerId, [FromBody] PauseRequest body) + public async Task PauseShocker([FromRoute] Guid publicShareId, [FromRoute] Guid shockerId, [FromBody] PauseRequest body, CancellationToken cancellationToken) { - var exists = await _db.PublicShares.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == publicShareId && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow)); + var exists = await _db.PublicShares.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == publicShareId && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow), cancellationToken); if (!exists) return Problem(PublicShareError.PublicShareNotFound); var shocker = await _db.PublicShareShockerMappings.Where(x => - x.PublicShareId == publicShareId && x.ShockerId == shockerId).Include(x => x.Shocker).FirstOrDefaultAsync(); + x.PublicShareId == publicShareId && x.ShockerId == shockerId).Include(x => x.Shocker).FirstOrDefaultAsync(cancellationToken); if (shocker is null) return Problem(PublicShareError.ShockerNotInPublicShare); shocker.IsPaused = body.Pause; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return LegacyDataOk(PublicShareUtils.GetPausedReason(shocker.IsPaused, shocker.Shocker.IsPaused)); } diff --git a/API/Controller/Shares/Links/RemoveShocker.cs b/API/Controller/Shares/Links/RemoveShocker.cs index c452d5d5..54d0454d 100644 --- a/API/Controller/Shares/Links/RemoveShocker.cs +++ b/API/Controller/Shares/Links/RemoveShocker.cs @@ -19,6 +19,7 @@ public sealed partial class ShareLinksController /// /// /// + /// /// Successfully removed shocker /// Public share or shocker does not exist /// Shocker does not exist in public share @@ -26,12 +27,12 @@ public sealed partial class ShareLinksController [TokenPermission(PermissionType.Publicshares_Edit)] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PublicShareNotFound, ShockerNotInPublicShare - public async Task RemoveShocker([FromRoute] Guid publicShareId, [FromRoute] Guid shockerId) + public async Task RemoveShocker([FromRoute] Guid publicShareId, [FromRoute] Guid shockerId, CancellationToken cancellationToken) { var exists = await _db.PublicShares .Where(x => x.Id == publicShareId && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow)) .WhereIsUserOrPrivileged(x => x.Owner, CurrentUser) - .AnyAsync(); + .AnyAsync(cancellationToken); if (!exists) { return Problem(PublicShareError.PublicShareNotFound); @@ -39,7 +40,7 @@ public async Task RemoveShocker([FromRoute] Guid publicShareId, [ var affected = await _db.PublicShareShockerMappings .Where(x => x.PublicShareId == publicShareId && x.ShockerId == shockerId) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); if (affected <= 0) { return Problem(PublicShareError.ShockerNotInPublicShare); diff --git a/API/Controller/Shares/UserShares/CreateShareInvite.cs b/API/Controller/Shares/UserShares/CreateShareInvite.cs index 44fb896d..09e65e2e 100644 --- a/API/Controller/Shares/UserShares/CreateShareInvite.cs +++ b/API/Controller/Shares/UserShares/CreateShareInvite.cs @@ -22,7 +22,7 @@ public sealed partial class UserSharesController [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // UserNotFound, ShareCreateShockerNotFound [ProducesResponseType(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // ShareCreateCannotShareWithSelf [MapToApiVersion("2")] - public async Task CreateShareInvite([FromBody] CreateShareRequest body) + public async Task CreateShareInvite([FromBody] CreateShareRequest body, CancellationToken cancellationToken) { if (body.User == CurrentUser.Id) { @@ -38,10 +38,10 @@ public async Task CreateShareInvite([FromBody] CreateShareRequest var existsFuture = _db.Users.AsNoTracking().DeferredAny(x => x.Id == body.User).FutureValue(); // We can already resolve the futures here since this is the last future query - if (!await existsFuture.ValueAsync()) return Problem(UserError.UserNotFound); + if (!await existsFuture.ValueAsync(cancellationToken)) return Problem(UserError.UserNotFound); } - var belongsToUs = await belongsToUsFuture.ToArrayAsync(); + var belongsToUs = await belongsToUsFuture.ToArrayAsync(cancellationToken); var missingShockers = providedShockerIds.Except(belongsToUs).ToArray(); @@ -50,7 +50,7 @@ public async Task CreateShareInvite([FromBody] CreateShareRequest return Problem(ShareError.ShareCreateShockerNotFound(missingShockers)); } - await using var transaction = await _db.Database.BeginTransactionAsync(); + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); var shareInvite = new UserShareInvite { @@ -76,9 +76,9 @@ public async Task CreateShareInvite([FromBody] CreateShareRequest }); } - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(); + await transaction.CommitAsync(cancellationToken); return Ok(shareInvite.Id); } diff --git a/API/Controller/Shares/UserShares/Invites.cs b/API/Controller/Shares/UserShares/Invites.cs index dc6c8851..da5b505e 100644 --- a/API/Controller/Shares/UserShares/Invites.cs +++ b/API/Controller/Shares/UserShares/Invites.cs @@ -87,10 +87,10 @@ public IAsyncEnumerable GetIncomingInvitesList() [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShareRequestNotFound [TokenPermission(PermissionType.Usershares_Edit)] [MapToApiVersion("2")] - public async Task DeleteOutgoingInvite([FromRoute] Guid inviteId) + public async Task DeleteOutgoingInvite([FromRoute] Guid inviteId, CancellationToken cancellationToken) { var deletedShareRequest = await _db.UserShareInvites - .Where(x => x.Id == inviteId && x.OwnerId == CurrentUser.Id).ExecuteDeleteAsync(); + .Where(x => x.Id == inviteId && x.OwnerId == CurrentUser.Id).ExecuteDeleteAsync(cancellationToken); if (deletedShareRequest <= 0) return Problem(ShareError.ShareRequestNotFound); @@ -102,10 +102,10 @@ public async Task DeleteOutgoingInvite([FromRoute] Guid inviteId) [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShareRequestNotFound [TokenPermission(PermissionType.Usershares_Edit)] [MapToApiVersion("2")] - public async Task DenyIncomingInvite([FromRoute] Guid inviteId) + public async Task DenyIncomingInvite([FromRoute] Guid inviteId, CancellationToken cancellationToken) { var deletedShareRequest = await _db.UserShareInvites - .Where(x => x.Id == inviteId && x.RecipientUserId == CurrentUser.Id).ExecuteDeleteAsync(); + .Where(x => x.Id == inviteId && x.RecipientUserId == CurrentUser.Id).ExecuteDeleteAsync(cancellationToken); if (deletedShareRequest <= 0) return Problem(ShareError.ShareRequestNotFound); @@ -117,17 +117,18 @@ public async Task DenyIncomingInvite([FromRoute] Guid inviteId) /// /// /// + /// /// [HttpPost("invites/incoming/{inviteId}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShareRequestNotFound [TokenPermission(PermissionType.Usershares_Edit)] [MapToApiVersion("2")] - public async Task RedeemInvite([FromRoute] Guid inviteId, [FromServices] IDeviceUpdateService deviceUpdateService) + public async Task RedeemInvite([FromRoute] Guid inviteId, [FromServices] IDeviceUpdateService deviceUpdateService, CancellationToken cancellationToken) { var shareRequest = await _db.UserShareInvites .Include(x => x.ShockerMappings).ThenInclude(x => x.Shocker).Include(x => x.Owner) - .FirstOrDefaultAsync(x => x.Id == inviteId && (x.RecipientUserId == null || x.RecipientUserId == CurrentUser.Id)); + .FirstOrDefaultAsync(x => x.Id == inviteId && (x.RecipientUserId == null || x.RecipientUserId == CurrentUser.Id), cancellationToken); if (shareRequest is null) return Problem(ShareError.ShareRequestNotFound); @@ -137,7 +138,7 @@ public async Task RedeemInvite([FromRoute] Guid inviteId, [FromSe return Problem(ShareError.ShareRequestCreateCannotShareWithSelf); } - var alreadySharedShockers = await _db.UserShares.Where(x => x.Shocker.Device.Owner.Id == shareRequest.OwnerId && x.SharedWithUserId == CurrentUser.Id).ToListAsync(); + var alreadySharedShockers = await _db.UserShares.Where(x => x.Shocker.Device.Owner.Id == shareRequest.OwnerId && x.SharedWithUserId == CurrentUser.Id).ToListAsync(cancellationToken); foreach (var shareInvitationShocker in shareRequest.ShockerMappings) { @@ -175,12 +176,12 @@ public async Task RedeemInvite([FromRoute] Guid inviteId, [FromSe _db.UserShareInvites.Remove(shareRequest); - if (await _db.SaveChangesAsync() < 1) throw new Exception("Error while linking share code to your account"); + if (await _db.SaveChangesAsync(cancellationToken) < 1) throw new Exception("Error while linking share code to your account"); var affectedHubs = shareRequest.ShockerMappings.Select(x => x.ShockerId).Distinct(); foreach (var affectedHub in affectedHubs) { - await deviceUpdateService.UpdateDevice(shareRequest.OwnerId, affectedHub, DeviceUpdateType.ShockerUpdated, CurrentUser.Id); + await deviceUpdateService.UpdateDevice(shareRequest.OwnerId, affectedHub, DeviceUpdateType.ShockerUpdated, CurrentUser.Id, cancellationToken); } var returnObject = new V2UserSharesListItemDto() diff --git a/API/Controller/Shares/UserShares/PauseShockerShares.cs b/API/Controller/Shares/UserShares/PauseShockerShares.cs index 6cc3f960..a6b69a1c 100644 --- a/API/Controller/Shares/UserShares/PauseShockerShares.cs +++ b/API/Controller/Shares/UserShares/PauseShockerShares.cs @@ -20,13 +20,14 @@ public sealed partial class UserSharesController public async Task BulkPauseUserShareShockers( [FromRoute] Guid userId, [FromBody] PauseUserShareShockersRequest pauseRequest, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { var affected = await _db.UserShares .Include(x => x.Shocker) .Where(x => pauseRequest.Shockers.Contains(x.ShockerId) && x.SharedWithUserId == userId && x.Shocker.Device.OwnerId == CurrentUser.Id) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); // Check if we have all shockers to delete var missingShockers = pauseRequest.Shockers.Except(affected.Select(x => x.ShockerId)).ToArray(); @@ -41,7 +42,7 @@ public async Task BulkPauseUserShareShockers( _db.UserShares.Update(userShare); } - var deletedRecords = await _db.SaveChangesAsync(); + var deletedRecords = await _db.SaveChangesAsync(cancellationToken); var uniqueHubIds = affected.Select(x => x.Shocker.DeviceId).Distinct(); var updateTasks = uniqueHubIds.Select(device => diff --git a/API/Controller/Shares/UserShares/RemoveShockerShares.cs b/API/Controller/Shares/UserShares/RemoveShockerShares.cs index 01061943..4aa549a0 100644 --- a/API/Controller/Shares/UserShares/RemoveShockerShares.cs +++ b/API/Controller/Shares/UserShares/RemoveShockerShares.cs @@ -16,13 +16,13 @@ public sealed partial class UserSharesController [MapToApiVersion("2")] [TokenPermission(PermissionType.Shockers_Edit)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task BulkRemoveUserShareShockers([FromRoute] Guid userId , [FromBody] [MinLength(1)] Guid[] shockerIds, [FromServices] IDeviceUpdateService deviceUpdateService) + public async Task BulkRemoveUserShareShockers([FromRoute] Guid userId , [FromBody] [MinLength(1)] Guid[] shockerIds, [FromServices] IDeviceUpdateService deviceUpdateService, CancellationToken cancellationToken) { var affected = await _db.UserShares .Where(x => shockerIds.Contains(x.ShockerId) && x.SharedWithUserId == userId && (x.Shocker.Device.OwnerId == CurrentUser.Id || x.SharedWithUserId == CurrentUser.Id)) .Select(x => new { DeviceId = x.Shocker.Device.Id, OwnerId = x.Shocker.Device.OwnerId, UserShare = x }) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); // Check if we have all shockers to delete var missingShockers = shockerIds.Except(affected.Select(x => x.UserShare.ShockerId)).ToArray(); @@ -33,7 +33,7 @@ public async Task BulkRemoveUserShareShockers([FromRoute] Guid us _db.UserShares.RemoveRange(affected.Select(x => x.UserShare)); - var deletedRecords = await _db.SaveChangesAsync(); + var deletedRecords = await _db.SaveChangesAsync(cancellationToken); var updateTasks = affected.DistinctBy(x => x.DeviceId).Select(x => deviceUpdateService.UpdateDevice(x.OwnerId, x.DeviceId, DeviceUpdateType.ShockerUpdated, userId)); diff --git a/API/Controller/Shares/UserShares/UpdateShockerShares.cs b/API/Controller/Shares/UserShares/UpdateShockerShares.cs index d47e7503..1b44edc2 100644 --- a/API/Controller/Shares/UserShares/UpdateShockerShares.cs +++ b/API/Controller/Shares/UserShares/UpdateShockerShares.cs @@ -22,6 +22,7 @@ public sealed partial class UserSharesController /// /// /// + /// /// Successfully updated share code /// The share code does not exist or you do not have access to it. [HttpPatch("{userId:guid}/shockers")] @@ -34,13 +35,14 @@ public sealed partial class UserSharesController public async Task BulkUserShareShockersUpdate( [FromRoute] Guid userId, [FromBody] BulkUserShareShockersUpdateRequest body, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { var affected = await _db.UserShares.Where(x => body.Shockers.Contains(x.ShockerId) && x.SharedWithUserId == userId && x.Shocker.Device.OwnerId == CurrentUser.Id).Select(x => new { Share = x, x.Shocker.DeviceId, Owner = x.Shocker.Device.OwnerId }) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); var missingShockers = body.Shockers.Except(affected.Select(x => x.Share.ShockerId)).ToArray(); if (missingShockers.Length > 0) @@ -60,7 +62,7 @@ public async Task BulkUserShareShockersUpdate( _db.UserShares.Update(share.Share); } - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); var uniqueHubIds = affected.Select(x => x.Share.Shocker.DeviceId).Distinct(); var updateTasks = uniqueHubIds.Select(device => diff --git a/API/Controller/Shockers/EditShocker.cs b/API/Controller/Shockers/EditShocker.cs index 67622006..16d26fb3 100644 --- a/API/Controller/Shockers/EditShocker.cs +++ b/API/Controller/Shockers/EditShocker.cs @@ -22,6 +22,7 @@ public sealed partial class ShockerController /// /// /// + /// /// Successfully updated shocker /// Shocker does not exist [HttpPatch("{shockerId}")] @@ -33,12 +34,13 @@ public sealed partial class ShockerController public async Task EditShocker( [FromRoute] Guid shockerId, [FromBody] NewShocker body, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { - var device = await _db.Devices.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == body.Device); + var device = await _db.Devices.AnyAsync(x => x.OwnerId == CurrentUser.Id && x.Id == body.Device, cancellationToken); if (!device) return Problem(HubError.HubNotFound); - var shocker = await _db.Shockers.FirstOrDefaultAsync(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId); + var shocker = await _db.Shockers.FirstOrDefaultAsync(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId, cancellationToken); if (shocker is null) return Problem(ShockerError.ShockerNotFound); var oldDevice = shocker.DeviceId; @@ -47,12 +49,12 @@ public async Task EditShocker( shocker.RfId = body.RfId; shocker.Model = body.Model; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); if (oldDevice != body.Device) - await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, oldDevice, DeviceUpdateType.ShockerUpdated); + await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, oldDevice, DeviceUpdateType.ShockerUpdated, cancellationToken); - await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, body.Device, DeviceUpdateType.ShockerUpdated); + await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, body.Device, DeviceUpdateType.ShockerUpdated, cancellationToken); return LegacyEmptyOk("Shocker updated successfully"); } diff --git a/API/Controller/Shockers/GetShockerById.cs b/API/Controller/Shockers/GetShockerById.cs index 0148cd93..a59b19b7 100644 --- a/API/Controller/Shockers/GetShockerById.cs +++ b/API/Controller/Shockers/GetShockerById.cs @@ -17,13 +17,14 @@ public sealed partial class ShockerController /// Get information about a shocker. /// /// + /// /// The shocker information was successfully retrieved. /// The shocker does not exist or you do not have access to it. [HttpGet("{shockerId}")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShockerNotFound [MapToApiVersion("1")] - public async Task GetShockerById([FromRoute] Guid shockerId) + public async Task GetShockerById([FromRoute] Guid shockerId, CancellationToken cancellationToken) { var shocker = await _db.Shockers.Where(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId).Select(x => new ShockerWithDevice { @@ -34,7 +35,7 @@ public async Task GetShockerById([FromRoute] Guid shockerId) Device = x.DeviceId, Model = x.Model, IsPaused = x.IsPaused - }).FirstOrDefaultAsync(); + }).FirstOrDefaultAsync(cancellationToken); if (shocker is null) return Problem(ShockerError.ShockerNotFound); return LegacyDataOk(shocker); diff --git a/API/Controller/Shockers/GetShockerLogs.cs b/API/Controller/Shockers/GetShockerLogs.cs index a42a9140..59123b89 100644 --- a/API/Controller/Shockers/GetShockerLogs.cs +++ b/API/Controller/Shockers/GetShockerLogs.cs @@ -38,6 +38,7 @@ public sealed partial class ShockerController /// /// /// + /// /// The logs /// Shocker does not exist [HttpGet("{shockerId}/logs")] @@ -46,9 +47,10 @@ public sealed partial class ShockerController [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShockerNotFound [MapToApiVersion("1")] public async Task GetShockerLogs([FromRoute] Guid shockerId, [FromQuery(Name = "offset")] uint offset = 0, - [FromQuery, Range(1, 500)] uint limit = 100) + [FromQuery, Range(1, 500)] uint limit = 100, + CancellationToken cancellationToken = default) { - var exists = await _db.Shockers.AnyAsync(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId); + var exists = await _db.Shockers.AnyAsync(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId, cancellationToken); if (!exists) return Problem(ShockerError.ShockerNotFound); var logs = _db.ShockerControlLogs diff --git a/API/Controller/Shockers/ListSharedShockers.cs b/API/Controller/Shockers/ListSharedShockers.cs index ae66098c..10b94b46 100644 --- a/API/Controller/Shockers/ListSharedShockers.cs +++ b/API/Controller/Shockers/ListSharedShockers.cs @@ -27,7 +27,7 @@ public sealed partial class ShockerController [HttpGet("shared")] [MapToApiVersion("1")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - public async Task ListSharedShockers() + public async Task ListSharedShockers(CancellationToken cancellationToken) { var sharedShockersData = await _db.UserShares .AsNoTracking() @@ -58,7 +58,7 @@ public async Task ListSharedShockers() } } }) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); var sharesResponse = sharedShockersData .GroupBy(x => new OwnerGroupKey(x.OwnerId, x.OwnerName, x.OwnerEmail)) diff --git a/API/Controller/Shockers/PauseShocker.cs b/API/Controller/Shockers/PauseShocker.cs index 321b5978..27a8e3f3 100644 --- a/API/Controller/Shockers/PauseShocker.cs +++ b/API/Controller/Shockers/PauseShocker.cs @@ -22,6 +22,7 @@ public sealed partial class ShockerController /// /// /// + /// /// Successfully set pause state /// Shocker not found or does not belong to you [HttpPost("{shockerId}/pause")] @@ -31,14 +32,15 @@ public sealed partial class ShockerController [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShockerNotFound [MapToApiVersion("1")] public async Task PauseShocker([FromRoute] Guid shockerId, [FromBody] PauseRequest body, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { - var shocker = await _db.Shockers.FirstOrDefaultAsync(x => x.Id == shockerId && x.Device.OwnerId == CurrentUser.Id); + var shocker = await _db.Shockers.FirstOrDefaultAsync(x => x.Id == shockerId && x.Device.OwnerId == CurrentUser.Id, cancellationToken); if (shocker is null) return Problem(ShockerError.ShockerNotFound); shocker.IsPaused = body.Pause; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, shocker.DeviceId, DeviceUpdateType.ShockerUpdated); + await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, shocker.DeviceId, DeviceUpdateType.ShockerUpdated, cancellationToken); return LegacyDataOk(body.Pause); } diff --git a/API/Controller/Shockers/RegisterShocker.cs b/API/Controller/Shockers/RegisterShocker.cs index 000f8359..91458699 100644 --- a/API/Controller/Shockers/RegisterShocker.cs +++ b/API/Controller/Shockers/RegisterShocker.cs @@ -34,12 +34,13 @@ public sealed partial class ShockerController [MapToApiVersion("1")] public async Task RegisterShocker( [FromBody] NewShocker body, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { var device = await _db.Devices.Where(x => x.OwnerId == CurrentUser.Id && x.Id == body.Device) - .Select(x => x.Id).FirstOrDefaultAsync(); + .Select(x => x.Id).FirstOrDefaultAsync(cancellationToken); if (device == Guid.Empty) return Problem(HubError.HubNotFound); - var shockerCount = await _db.Shockers.CountAsync(x => x.DeviceId == body.Device); + var shockerCount = await _db.Shockers.CountAsync(x => x.DeviceId == body.Device, cancellationToken); if (shockerCount >= HardLimits.MaxShockersPerHub) return Problem(HubError.TooManyShockers); @@ -52,10 +53,11 @@ public async Task RegisterShocker( DeviceId = body.Device }; _db.Shockers.Add(shocker); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, device, - DeviceUpdateType.ShockerUpdated); + DeviceUpdateType.ShockerUpdated, + cancellationToken); return LegacyDataCreated($"/1/shockers/{shocker.Id}", shocker.Id); } diff --git a/API/Controller/Shockers/RemoveShocker.cs b/API/Controller/Shockers/RemoveShocker.cs index 94c2b311..1fb0c64b 100644 --- a/API/Controller/Shockers/RemoveShocker.cs +++ b/API/Controller/Shockers/RemoveShocker.cs @@ -21,6 +21,7 @@ public sealed partial class ShockerController /// /// /// + /// /// Successfully deleted shocker /// Shocker does not exist [HttpDelete("{shockerId}")] @@ -30,12 +31,13 @@ public sealed partial class ShockerController [MapToApiVersion("1")] public async Task RemoveShocker( [FromRoute] Guid shockerId, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { var affected = await _db.Shockers .Where(x => x.Id == shockerId) .WhereIsUserOrPrivileged(x => x.Device.Owner, CurrentUser) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); if (affected is null) { @@ -43,9 +45,9 @@ public async Task RemoveShocker( } _db.Shockers.Remove(affected); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, affected.DeviceId, DeviceUpdateType.ShockerUpdated); + await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, affected.DeviceId, DeviceUpdateType.ShockerUpdated, cancellationToken); return LegacyEmptyOk("Shocker removed successfully"); } diff --git a/API/Controller/Shockers/SendControl.cs b/API/Controller/Shockers/SendControl.cs index f920149c..55b46aa8 100644 --- a/API/Controller/Shockers/SendControl.cs +++ b/API/Controller/Shockers/SendControl.cs @@ -34,7 +34,8 @@ public sealed partial class ShockerController public async Task SendControl( [FromBody] ControlRequest body, [FromServices] IHubContext userHub, - [FromServices] IControlSender controlSender) + [FromServices] IControlSender controlSender, + CancellationToken cancellationToken) { var sender = new ControlLogSender { @@ -55,7 +56,7 @@ public async Task SendControl( tokenLimits = ApiTokenControlLimits.FromToken(apiToken); } - var controlAction = await controlSender.ControlByUser(body.Shocks, sender, userHub.Clients, tokenLimits); + var controlAction = await controlSender.ControlByUser(body.Shocks, sender, userHub.Clients, tokenLimits, cancellationToken); return controlAction switch { Success => LegacyEmptyOk("Successfully sent control messages"), @@ -81,12 +82,13 @@ public async Task SendControl( public Task SendControl_DEPRECATED( [FromBody] IReadOnlyList body, [FromServices] IHubContext userHub, - [FromServices] IControlSender controlSender) + [FromServices] IControlSender controlSender, + CancellationToken cancellationToken) { return SendControl(new ControlRequest { Shocks = body, CustomName = null - }, userHub, controlSender); + }, userHub, controlSender, cancellationToken); } } \ No newline at end of file diff --git a/API/Controller/Shockers/ShockerShares.cs b/API/Controller/Shockers/ShockerShares.cs index abb806f7..ca6408e8 100644 --- a/API/Controller/Shockers/ShockerShares.cs +++ b/API/Controller/Shockers/ShockerShares.cs @@ -22,15 +22,16 @@ public sealed partial class ShockerController /// Get all user shares for a shocker /// /// Id of the shocker + /// /// OK /// The shocker does not exist or you do not have access to it. [HttpGet("{shockerId}/shares")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShockerNotFound [MapToApiVersion("1")] - public async Task GetUserShares([FromRoute] Guid shockerId) + public async Task GetUserShares([FromRoute] Guid shockerId, CancellationToken cancellationToken) { - var owns = await _db.Shockers.AnyAsync(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId); + var owns = await _db.Shockers.AnyAsync(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId, cancellationToken); if (!owns) return Problem(ShockerError.ShockerNotFound); var shares = _db.UserShares @@ -69,14 +70,15 @@ public async Task GetUserShares([FromRoute] Guid shockerId) /// List all share codes for a shocker /// /// + /// /// OK [HttpGet("{shockerId}/shareCodes")] [ProducesResponseType>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ShockerNotFound [MapToApiVersion("1")] - public async Task ShockerShareCodeList([FromRoute] Guid shockerId) + public async Task ShockerShareCodeList([FromRoute] Guid shockerId, CancellationToken cancellationToken) { - var owns = await _db.Shockers.AnyAsync(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId); + var owns = await _db.Shockers.AnyAsync(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId, cancellationToken); if (!owns) return Problem(ShockerError.ShockerNotFound); var shares = _db.ShockerShareCodes @@ -103,6 +105,7 @@ public sealed class ShareCodeInfo /// /// /// + /// /// The share code was successfully created. /// Shocker does not exists or you do not have access to it. /// @@ -115,11 +118,12 @@ public sealed class ShareCodeInfo public async Task ShockerShareCodeCreate( [FromRoute] Guid shockerId, [FromBody] ShockerPermLimitPair body, - [FromServices] IDeviceUpdateService deviceUpdateService + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken ) { var device = await _db.Shockers.Where(x => x.Device.OwnerId == CurrentUser.Id && x.Id == shockerId) - .Select(x => x.DeviceId).FirstOrDefaultAsync(); + .Select(x => x.DeviceId).FirstOrDefaultAsync(cancellationToken); if (device == Guid.Empty) return Problem(ShockerError.ShockerNotFound); var newCode = new ShockerShareCode @@ -135,9 +139,9 @@ [FromServices] IDeviceUpdateService deviceUpdateService IsPaused = false }; _db.ShockerShareCodes.Add(newCode); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, device, DeviceUpdateType.ShockerUpdated); + await deviceUpdateService.UpdateDeviceForAllShared(CurrentUser.Id, device, DeviceUpdateType.ShockerUpdated, cancellationToken); return LegacyDataOk(newCode.Id); } @@ -148,6 +152,7 @@ [FromServices] IDeviceUpdateService deviceUpdateService /// /// /// + /// /// Successfully removed share code /// Share does not exists or device/shocker does not belong to you nor is shared with you [HttpDelete("{shockerId}/shares/{sharedWithUserId}")] @@ -158,18 +163,19 @@ [FromServices] IDeviceUpdateService deviceUpdateService public async Task ShockerShareRemove( [FromRoute] Guid shockerId, [FromRoute] Guid sharedWithUserId, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { var affected = await _db.UserShares.Where(x => x.ShockerId == shockerId && x.SharedWithUserId == sharedWithUserId && (x.Shocker.Device.OwnerId == CurrentUser.Id || x.SharedWithUserId == CurrentUser.Id)) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); if (affected <= 0) return Problem(ShockerError.ShockerNotFound); var device = await _db.Shockers.Where(x => x.Id == shockerId) - .Select(x => new { x.DeviceId, x.Device.OwnerId }).SingleAsync(); + .Select(x => new { x.DeviceId, x.Device.OwnerId }).SingleAsync(cancellationToken); - await deviceUpdateService.UpdateDevice(device.OwnerId, device.DeviceId, DeviceUpdateType.ShockerUpdated, sharedWithUserId); + await deviceUpdateService.UpdateDevice(device.OwnerId, device.DeviceId, DeviceUpdateType.ShockerUpdated, sharedWithUserId, cancellationToken); return Ok(); } @@ -181,6 +187,7 @@ public async Task ShockerShareRemove( /// /// /// + /// /// Successfully updated share code /// The share code does not exist or you do not have access to it. [HttpPatch("{shockerId}/shares/{sharedWithUserId}")] @@ -193,13 +200,14 @@ public async Task ShockerShareCodeUpdate( [FromRoute] Guid shockerId, [FromRoute] Guid sharedWithUserId, [FromBody] ShockerPermLimitPair body, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { var affected = await _db.UserShares.Where(x => x.ShockerId == shockerId && x.SharedWithUserId == sharedWithUserId && x.Shocker.Device.OwnerId == CurrentUser.Id).Select(x => new { Share = x, x.Shocker.DeviceId, Owner = x.Shocker.Device.OwnerId }) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); if (affected is null) return Problem(ShockerError.ShockerNotFound); var share = affected.Share; @@ -211,9 +219,9 @@ public async Task ShockerShareCodeUpdate( share.MaxIntensity = body.Limits.Intensity; share.MaxDuration = body.Limits.Duration; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await deviceUpdateService.UpdateDevice(affected.Owner, affected.DeviceId, DeviceUpdateType.ShockerUpdated, sharedWithUserId); + await deviceUpdateService.UpdateDevice(affected.Owner, affected.DeviceId, DeviceUpdateType.ShockerUpdated, sharedWithUserId, cancellationToken); return Ok(); } @@ -225,6 +233,7 @@ public async Task ShockerShareCodeUpdate( /// /// /// + /// /// Successfully updated pause status share /// The share code does not exist or you do not have access to it. [HttpPost("{shockerId}/shares/{sharedWithUserId}/pause")] @@ -237,20 +246,21 @@ public async Task ShockerShareCodePause( [FromRoute] Guid shockerId, [FromRoute] Guid sharedWithUserId, [FromBody] PauseRequest body, - [FromServices] IDeviceUpdateService deviceUpdateService) + [FromServices] IDeviceUpdateService deviceUpdateService, + CancellationToken cancellationToken) { var affected = await _db.UserShares.Where(x => x.ShockerId == shockerId && x.SharedWithUserId == sharedWithUserId && x.Shocker.Device.OwnerId == CurrentUser.Id).Select(x => new { Share = x, x.Shocker.DeviceId, Owner = x.Shocker.Device.OwnerId }) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); if (affected is null) return Problem(ShockerError.ShockerNotFound); affected.Share.IsPaused = body.Pause; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await deviceUpdateService.UpdateDevice(affected.Owner, affected.DeviceId, DeviceUpdateType.ShockerUpdated, sharedWithUserId); + await deviceUpdateService.UpdateDevice(affected.Owner, affected.DeviceId, DeviceUpdateType.ShockerUpdated, sharedWithUserId, cancellationToken); return LegacyDataOk(body.Pause); } diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index a5f2289a..ac51841c 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -104,7 +104,8 @@ await webhookService.SendWebhookAsync( Please investigate further if necessary. """, - Color.OrangeRed); + Color.OrangeRed, + cancellationToken); return Ok(); } diff --git a/API/Controller/Tokens/Tokens.cs b/API/Controller/Tokens/Tokens.cs index dfce87f0..246a433e 100644 --- a/API/Controller/Tokens/Tokens.cs +++ b/API/Controller/Tokens/Tokens.cs @@ -21,9 +21,9 @@ public sealed partial class TokensController /// All tokens for the current user [HttpGet] [MapToApiVersion("1")] - public IAsyncEnumerable ListTokens([FromServices] IApiTokenService tokenService) + public IAsyncEnumerable ListTokens([FromServices] IApiTokenService tokenService, CancellationToken cancellationToken) { - return tokenService.ListTokensV1(ownerId: CurrentUser.Id); + return tokenService.ListTokensV1(ownerId: CurrentUser.Id, cancellationToken: cancellationToken); } /// @@ -32,9 +32,9 @@ public IAsyncEnumerable ListTokens([FromServices] IApiTokenServic /// All tokens for the current user [HttpGet] [MapToApiVersion("2")] - public IAsyncEnumerable ListTokensV2([FromServices] IApiTokenService tokenService) + public IAsyncEnumerable ListTokensV2([FromServices] IApiTokenService tokenService, CancellationToken cancellationToken) { - return tokenService.ListTokensV2(ownerId: CurrentUser.Id); + return tokenService.ListTokensV2(ownerId: CurrentUser.Id, cancellationToken: cancellationToken); } /// @@ -42,15 +42,16 @@ public IAsyncEnumerable ListTokensV2([FromServices] IApiTokenSe /// /// /// + /// /// The token /// The token does not exist or you do not have access to it. [HttpGet("{tokenId}")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("1")] - public async Task GetTokenById([FromRoute] Guid tokenId, [FromServices] IApiTokenService tokenService) + public async Task GetTokenById([FromRoute] Guid tokenId, [FromServices] IApiTokenService tokenService, CancellationToken cancellationToken) { - var apiToken = await tokenService.GetTokenV1(tokenId, CurrentUser.Id); + var apiToken = await tokenService.GetTokenV1(tokenId, CurrentUser.Id, cancellationToken); if (apiToken is null) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(apiToken); @@ -61,15 +62,16 @@ public async Task GetTokenById([FromRoute] Guid tokenId, [FromSer /// /// /// + /// /// The token /// The token does not exist or you do not have access to it. [HttpGet("{tokenId}")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("2")] - public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromServices] IApiTokenService tokenService) + public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromServices] IApiTokenService tokenService, CancellationToken cancellationToken) { - var apiToken = await tokenService.GetTokenV2(tokenId, CurrentUser.Id); + var apiToken = await tokenService.GetTokenV2(tokenId, CurrentUser.Id, cancellationToken); if (apiToken is null) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(apiToken); @@ -80,6 +82,7 @@ public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromS /// /// /// + /// /// The created token [HttpPost] [Consumes(MediaTypeNames.Application.Json)] @@ -87,9 +90,10 @@ public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromS [MapToApiVersion("1")] public Task CreateToken( [FromBody] CreateTokenRequest body, - [FromServices] IApiTokenService tokenService) + [FromServices] IApiTokenService tokenService, + CancellationToken cancellationToken) { - return tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + return tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body, cancellationToken); } /// @@ -98,6 +102,7 @@ public Task CreateToken( /// /// /// + /// /// The edited token /// The token does not exist or you do not have access to it. [HttpPatch("{tokenId}")] @@ -105,9 +110,9 @@ public Task CreateToken( [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("1")] - public async Task EditToken([FromRoute] Guid tokenId, [FromBody] EditTokenRequest body, [FromServices] IApiTokenService tokenService) + public async Task EditToken([FromRoute] Guid tokenId, [FromBody] EditTokenRequest body, [FromServices] IApiTokenService tokenService, CancellationToken cancellationToken) { - if (!await tokenService.EditTokenV1(tokenId, body, CurrentUser.Id)) return Problem(ApiTokenError.ApiTokenNotFound); + if (!await tokenService.EditTokenV1(tokenId, body, CurrentUser.Id, cancellationToken)) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(); } @@ -117,6 +122,7 @@ public async Task EditToken([FromRoute] Guid tokenId, [FromBody] /// /// /// + /// /// The created token [HttpPost] [Consumes(MediaTypeNames.Application.Json)] @@ -124,9 +130,10 @@ public async Task EditToken([FromRoute] Guid tokenId, [FromBody] [MapToApiVersion("2")] public Task CreateTokenV2( [FromBody] CreateTokenRequestV2 body, - [FromServices] IApiTokenService tokenService) + [FromServices] IApiTokenService tokenService, + CancellationToken cancellationToken) { - return tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body); + return tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body, cancellationToken); } /// @@ -135,6 +142,7 @@ public Task CreateTokenV2( /// /// /// + /// /// The edited token /// The token does not exist or you do not have access to it. [HttpPatch("{tokenId}")] @@ -142,9 +150,9 @@ public Task CreateTokenV2( [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("2")] - public async Task EditTokenV2([FromRoute] Guid tokenId, [FromBody] EditTokenRequestV2 body, [FromServices] IApiTokenService tokenService) + public async Task EditTokenV2([FromRoute] Guid tokenId, [FromBody] EditTokenRequestV2 body, [FromServices] IApiTokenService tokenService, CancellationToken cancellationToken) { - if (!await tokenService.EditTokenV2(tokenId, body, CurrentUser.Id)) return Problem(ApiTokenError.ApiTokenNotFound); + if (!await tokenService.EditTokenV2(tokenId, body, CurrentUser.Id, cancellationToken)) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(); } @@ -155,6 +163,7 @@ public async Task EditTokenV2([FromRoute] Guid tokenId, [FromBody /// /// /// + /// /// The now-set paused state of the token. /// The token does not exist or you do not have access to it. [HttpPatch("{tokenId}/paused")] @@ -162,9 +171,9 @@ public async Task EditTokenV2([FromRoute] Guid tokenId, [FromBody [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("2")] - public async Task SetTokenPaused([FromRoute] Guid tokenId, [FromBody] SetTokenPausedRequest body, [FromServices] IApiTokenService tokenService) + public async Task SetTokenPaused([FromRoute] Guid tokenId, [FromBody] SetTokenPausedRequest body, [FromServices] IApiTokenService tokenService, CancellationToken cancellationToken) { - var paused = await tokenService.SetTokenPaused(tokenId, body.Paused, CurrentUser.Id); + var paused = await tokenService.SetTokenPaused(tokenId, body.Paused, CurrentUser.Id, cancellationToken); if (paused is null) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(new TokenPausedResponse { Paused = paused.Value }); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 47c7ef48..a55ca174 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -78,9 +78,9 @@ private async Task NotifyEmailOutboxAsync() } } - private async Task IsUserNameBlacklisted(string username) + private async Task IsUserNameBlacklisted(string username, CancellationToken cancellationToken) { - await foreach (var entry in _db.UserNameBlacklists.AsNoTracking().AsAsyncEnumerable()) + await foreach (var entry in _db.UserNameBlacklists.AsNoTracking().AsAsyncEnumerable().WithCancellation(cancellationToken)) { if (entry.IsMatch(username)) return true; } @@ -88,21 +88,21 @@ private async Task IsUserNameBlacklisted(string username) return false; } - private async Task IsEmailProviderBlacklisted(string email) + private async Task IsEmailProviderBlacklisted(string email, CancellationToken cancellationToken) { if (!MailAddress.TryCreate(email, out var address)) return false; var domain = address.Host.ToLowerInvariant(); - return await _db.EmailProviderBlacklists.AnyAsync(e => e.Domain == domain); + return await _db.EmailProviderBlacklists.AnyAsync(e => e.Domain == domain, cancellationToken); } - private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation, Guid? createdByAutomationTokenId) + private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation, Guid? createdByAutomationTokenId, CancellationToken cancellationToken) { email = email.ToLowerInvariant(); - if (await IsUserNameBlacklisted(username) || await IsEmailProviderBlacklisted(email)) + if (await IsUserNameBlacklisted(username, cancellationToken) || await IsEmailProviderBlacklisted(email, cancellationToken)) return new AccountWithEmailOrUsernameExists(); - if (await _db.Users.AnyAsync(x => x.Email == email || x.Name == username)) + if (await _db.Users.AnyAsync(x => x.Email == email || x.Name == username, cancellationToken)) return new AccountWithEmailOrUsernameExists(); var user = new User @@ -115,14 +115,14 @@ private async Task CreateAccount(string email, string use }; _db.Users.Add(user); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); // Use date created by the database to keep timing consistent if (verifyOnCreation) { await _db.Users .Where(u => u.Id == user.Id) - .ExecuteUpdateAsync(spc => spc.SetProperty(u => u.ActivatedAt, u => u.CreatedAt)); + .ExecuteUpdateAsync(spc => spc.SetProperty(u => u.ActivatedAt, u => u.CreatedAt), cancellationToken); } return user; @@ -140,7 +140,7 @@ public async Task CreateAccountWithActivationFlowAsync(st await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); // Created with an automation token, the account is linked to it on insert, which makes it an automated account. - var accountCreate = await CreateAccount(email, username, password, !mailEnabled, _automationTokens.Current?.Id); + var accountCreate = await CreateAccount(email, username, password, !mailEnabled, _automationTokens.Current?.Id, cancellationToken); if (accountCreate.Value is not User user) return accountCreate; if (mailEnabled) @@ -179,7 +179,8 @@ public async Task CreateOAuthOnlyAccountAsync( string provider, string providerAccountId, string? providerAccountName, - bool isEmailTrusted) + bool isEmailTrusted, + CancellationToken cancellationToken) { email = email.ToLowerInvariant(); provider = provider.ToLowerInvariant(); @@ -189,14 +190,14 @@ public async Task CreateOAuthOnlyAccountAsync( isEmailTrusted |= !_mailOptions.IsEnabled; // Reuse your existing guards - if (await IsUserNameBlacklisted(username) || await IsEmailProviderBlacklisted(email)) + if (await IsUserNameBlacklisted(username, cancellationToken) || await IsEmailProviderBlacklisted(email, cancellationToken)) return new AccountWithEmailOrUsernameExists(); // Fast uniqueness check (optimistic; race handled by unique constraints below) - var exists = await _db.Users.AnyAsync(u => u.Email == email || u.Name == username); + var exists = await _db.Users.AnyAsync(u => u.Email == email || u.Name == username, cancellationToken); if (exists) return new AccountWithEmailOrUsernameExists(); - await using var tx = await _db.Database.BeginTransactionAsync(); + await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken); try { @@ -213,7 +214,7 @@ public async Task CreateOAuthOnlyAccountAsync( }; _db.Users.Add(user); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); // If email isn't trusted, create an activation request and durably enqueue the activation // email. The token itself is minted by the outbox delivery job at send time. @@ -228,7 +229,7 @@ public async Task CreateOAuthOnlyAccountAsync( _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username)); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); } // Link external identity @@ -241,9 +242,9 @@ public async Task CreateOAuthOnlyAccountAsync( CreatedAt = creationTime, }); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); - await tx.CommitAsync(); + await tx.CommitAsync(cancellationToken); // Notify the outbox delivery job only after a successful commit. if (!isEmailTrusted) @@ -255,7 +256,7 @@ public async Task CreateOAuthOnlyAccountAsync( } catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) { - await tx.RollbackAsync(); + await tx.RollbackAsync(cancellationToken); return new AccountWithEmailOrUsernameExists(); } } @@ -280,7 +281,7 @@ public async Task TryActivateAccountAsync(string secret, CancellationToken } /// - public async Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater, string? reason = null) + public async Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater, string? reason = null, CancellationToken cancellationToken = default) { if (executingUserId != userId) { @@ -290,14 +291,14 @@ public async Task r == RoleType.Staff || r == RoleType.Admin || - r == RoleType.System); + r == RoleType.System, cancellationToken); if (!isPrivileged) { return new Unauthorized(); } } - var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(u => u.Id == userId); + var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); if (user is null) return new NotFound(); if (user.Roles.Any(r => r is RoleType.Admin or RoleType.System)) @@ -310,7 +311,7 @@ public async Task - public async Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null) + public async Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null, CancellationToken cancellationToken = default) { - var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(u => u.Id == userId && u.UserDeactivation != null); + var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(u => u.Id == userId && u.UserDeactivation != null, cancellationToken); if (user is null) return new NotFound(); var deactivation = user.UserDeactivation!; @@ -357,33 +359,34 @@ public async Task> ReactivateAccountAsyn .AnyAsync(r => r == RoleType.Staff || r == RoleType.Admin || - r == RoleType.System); + r == RoleType.System, cancellationToken); if (!isPrivileged) { return new Unauthorized(); } } - await using var transaction = await _db.Database.BeginTransactionAsync(); + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); _db.Remove(deactivation); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); await _auditService.LogAsync( userId, action: AuditAction.AccountReactivated, actorId: executingUserId, - reason: reason + reason: reason, + cancellationToken: cancellationToken ); - await transaction.CommitAsync(); + await transaction.CommitAsync(cancellationToken); return new Success(); } /// - public async Task> DeleteAccountAsync(Guid executingUserId, Guid userId, string? reason = null) + public async Task> DeleteAccountAsync(Guid executingUserId, Guid userId, string? reason = null, CancellationToken cancellationToken = default) { var isPrivileged = await _db.Users .Where(u => u.Id == executingUserId) @@ -391,13 +394,13 @@ public async Task r == RoleType.Staff || r == RoleType.Admin || - r == RoleType.System); + r == RoleType.System, cancellationToken); if (!isPrivileged) { return new Unauthorized(); } - var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(u => u.Id == userId); + var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); if (user is null) return new NotFound(); if (user.Roles.Any(r => r is RoleType.Admin or RoleType.System)) @@ -407,20 +410,21 @@ public async Task> CheckPasswordResetEx } /// - public async Task> CreatePasswordResetFlowAsync(string email) + public async Task> CreatePasswordResetFlowAsync(string email, CancellationToken cancellationToken) { var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; var lowerCaseEmail = email.ToLowerInvariant(); @@ -494,7 +498,7 @@ public async Task y.UsedAt == null && y.CreatedAt >= validSince) }) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); if (user is null) return new NotFound(); if (user.User.ActivatedAt is null) return new AccountNotActivated(); if (user.IsDeactivated) return new AccountDeactivated(); @@ -512,7 +516,7 @@ public async Task public async Task> CompletePasswordResetFlowAsync(Guid passwordResetId, - string secret, string newPassword) + string secret, string newPassword, + CancellationToken cancellationToken) { var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; @@ -533,7 +538,7 @@ public async Task x.Reset.Id == passwordResetId && x.Reset.UsedAt == null && x.Reset.CreatedAt >= validSince - && x.Reset.SecurityStampAtCreate == x.UserSecurityStamp); + && x.Reset.SecurityStampAtCreate == x.UserSecurityStamp, cancellationToken); if (reset is null) return new NotFound(); if (reset.UserActivatedAt is null) return new AccountNotActivated(); if (reset.IsDeactivated) return new AccountDeactivated(); @@ -541,7 +546,7 @@ public async Task u.Id == reset.Reset.UserId && u.SecurityStamp == reset.Reset.SecurityStampAtCreate) .ExecuteUpdateAsync(s => s .SetProperty(u => u.PasswordHash, newPasswordHash) - .SetProperty(u => u.SecurityStamp, newStamp)); + .SetProperty(u => u.SecurityStamp, newStamp), cancellationToken); if (userRows == 0) return new NotFound(); var now = DateTime.UtcNow; await _db.UserPasswordResets .Where(r => r.Id == reset.Reset.Id && r.UsedAt == null) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.UsedAt, now)); + .ExecuteUpdateAsync(s => s.SetProperty(r => r.UsedAt, now), cancellationToken); // Unauthenticated email-token flow proving control of the account, so the actor is the account itself. await _auditService.LogAsync( reset.Reset.UserId, action: AuditAction.PasswordChanged, - actorId: reset.Reset.UserId + actorId: reset.Reset.UserId, + cancellationToken: cancellationToken ); - await transaction.CommitAsync(); + await transaction.CommitAsync(cancellationToken); return new Success(); } @@ -590,7 +596,7 @@ public async Task> CheckUsernameAv if (validationResult.Value is UsernameError usernameError) return usernameError; - if (await IsUserNameBlacklisted(username)) + if (await IsUserNameBlacklisted(username, cancellationToken)) return new UsernameTaken(); // Don't inform the user about when the blacklist is hit var isTaken = await _db.Users.AnyAsync(x => x.Name == username, cancellationToken: cancellationToken); @@ -710,33 +716,34 @@ await _auditService.LogAsync( /// - public async Task> ChangePasswordAsync(Guid userId, string newPassword, Guid? actorId) + public async Task> ChangePasswordAsync(Guid userId, string newPassword, Guid? actorId, CancellationToken cancellationToken) { - var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(x => x.Id == userId); + var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); if (user is null) return new NotFound(); if (user.ActivatedAt is null) return new AccountNotActivated(); if (user.UserDeactivation is not null) return new AccountDeactivated(); - await using var transaction = await _db.Database.BeginTransactionAsync(); + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); user.PasswordHash = HashingUtils.HashPassword(newPassword); user.SecurityStamp = Guid.CreateVersion7(); // Any outstanding reset/email-change row for this user has a stale SecurityStampAtCreate after this; predicate handles invalidation. - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); await _auditService.LogAsync( userId, action: AuditAction.PasswordChanged, - actorId: actorId ?? userId + actorId: actorId ?? userId, + cancellationToken: cancellationToken ); - await transaction.CommitAsync(); + await transaction.CommitAsync(cancellationToken); return new Success(); } /// - public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail, Guid? actorId) + public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail, Guid? actorId, CancellationToken cancellationToken) { var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; @@ -748,18 +755,18 @@ public async Task y.UsedAt == null && y.CreatedAt >= validSince) }) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); if (data is null) return new NotFound(); if (data.User.ActivatedAt is null) return new AccountNotActivated(); if (data.IsDeactivated) return new AccountDeactivated(); if (string.Equals(data.User.Email, newEmail, StringComparison.OrdinalIgnoreCase)) return new EmailUnchanged(); if (data.PendingCount >= 3) return new TooManyEmailChanges(); - if (await IsEmailProviderBlacklisted(newEmail)) + if (await IsEmailProviderBlacklisted(newEmail, cancellationToken)) return new EmailAlreadyInUse(); // Don't reveal blacklist hits var lowerCaseEmail = newEmail.ToLowerInvariant(); - if (await _db.Users.AnyAsync(x => x.Email == lowerCaseEmail)) + if (await _db.Users.AnyAsync(x => x.Email == lowerCaseEmail, cancellationToken)) return new EmailAlreadyInUse(); // The verification token is minted lazily by the outbox delivery job at send time, so no usable @@ -774,7 +781,7 @@ public async Task CheckPassword(string password, User user) + private async Task CheckPassword(string password, User user, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(user.PasswordHash)) { @@ -887,7 +895,7 @@ private async Task CheckPassword(string password, User user) // links for this user must continue to work. _logger.LogInformation("Rehashing password for user ID: [{Id}]", user.Id); user.PasswordHash = HashingUtils.HashPassword(password); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); } return true; diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 069019bb..bb9909ba 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -30,8 +30,9 @@ public interface IAccountService /// external subject/id from provider /// display name from provider /// + /// /// Success with the created user, or AccountWithEmailOrUsernameExists when taken/blocked. - Task CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName, bool isEmailTrusted); + Task CreateOAuthOnlyAccountAsync(string email, string username, string provider, string providerAccountId, string? providerAccountName, bool isEmailTrusted, CancellationToken cancellationToken = default); /// /// Returns true if the given email is already associated with an existing user account. @@ -46,11 +47,11 @@ public interface IAccountService /// Task TryActivateAccountAsync(string token, CancellationToken cancellationToken = default); - public Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater = true, string? reason = null); + public Task> DeactivateAccountAsync(Guid executingUserId, Guid userId, bool deleteLater = true, string? reason = null, CancellationToken cancellationToken = default); - public Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null); + public Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null, CancellationToken cancellationToken = default); - public Task> DeleteAccountAsync(Guid executingUserId, Guid userId, string? reason = null); + public Task> DeleteAccountAsync(Guid executingUserId, Guid userId, string? reason = null, CancellationToken cancellationToken = default); /// /// Get a user by credentials @@ -74,8 +75,9 @@ public interface IAccountService /// Creates a new password reset request and send the email if successful /// /// + /// /// - public Task> CreatePasswordResetFlowAsync(string email); + public Task> CreatePasswordResetFlowAsync(string email, CancellationToken cancellationToken = default); /// /// Completes a password reset process, sets a new password @@ -83,8 +85,9 @@ public interface IAccountService /// /// /// + /// /// - public Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword); + public Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword, CancellationToken cancellationToken = default); /// /// Check the availability of a email @@ -129,8 +132,9 @@ public interface IAccountService /// /// /// User that performed this change + /// /// - public Task> ChangePasswordAsync(Guid userId, string newPassword, Guid? actorId); + public Task> ChangePasswordAsync(Guid userId, string newPassword, Guid? actorId, CancellationToken cancellationToken = default); /// /// Creates a new email change request and sends a verification email to the new address. @@ -139,8 +143,9 @@ public interface IAccountService /// Id of the user whose email is being changed. /// Requested new email address. /// User that called this + /// /// - public Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail, Guid? actorId); + public Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail, Guid? actorId, CancellationToken cancellationToken = default); /// /// Verifies a pending email change using the supplied token. On success the user's email is updated. diff --git a/API/Services/DeviceUpdate/DeviceUpdateService.cs b/API/Services/DeviceUpdate/DeviceUpdateService.cs index a41cd31b..ee69e50f 100644 --- a/API/Services/DeviceUpdate/DeviceUpdateService.cs +++ b/API/Services/DeviceUpdate/DeviceUpdateService.cs @@ -29,7 +29,7 @@ public DeviceUpdateService( } /// - public async Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType type, Guid affectedUser) + public async Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType type, Guid affectedUser, CancellationToken cancellationToken) { var task1 = _redisPubService.SendDeviceUpdate(deviceId); var task2 = _hubContext.Clients.Users(ownerId.ToString(), affectedUser.ToString()) @@ -38,7 +38,7 @@ public async Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType typ } /// - public async Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType type) + public async Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType type, CancellationToken cancellationToken) { var task1 = _redisPubService.SendDeviceUpdate(deviceId); var task2 = _hubContext.Clients.Users(ownerId.ToString()) @@ -47,11 +47,11 @@ public async Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType typ } /// - public async Task UpdateDeviceForAllShared(Guid ownerId, Guid deviceId, DeviceUpdateType type) + public async Task UpdateDeviceForAllShared(Guid ownerId, Guid deviceId, DeviceUpdateType type, CancellationToken cancellationToken) { var task1 = _redisPubService.SendDeviceUpdate(deviceId); - var sharedWith = await _deviceService.GetSharedUserIdsAsync(deviceId); + var sharedWith = await _deviceService.GetSharedUserIdsAsync(deviceId, cancellationToken); sharedWith.Add(ownerId); // Add the owner to the list of users to send to var task2 = _hubContext.Clients.Users(sharedWith.Select(x => x.ToString())) .DeviceUpdate(deviceId, type); diff --git a/API/Services/DeviceUpdate/IDeviceUpdateService.cs b/API/Services/DeviceUpdate/IDeviceUpdateService.cs index b9c4170c..131d008b 100644 --- a/API/Services/DeviceUpdate/IDeviceUpdateService.cs +++ b/API/Services/DeviceUpdate/IDeviceUpdateService.cs @@ -11,8 +11,9 @@ public interface IDeviceUpdateService /// /// /// + /// /// - public Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType type, Guid affectedUser); + public Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType type, Guid affectedUser, CancellationToken cancellationToken = default); /// /// When anything about the device or its shockers is updated, sent to owner, usually only used on device creation @@ -20,8 +21,9 @@ public interface IDeviceUpdateService /// /// /// + /// /// - public Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType type); + public Task UpdateDevice(Guid ownerId, Guid deviceId, DeviceUpdateType type, CancellationToken cancellationToken = default); /// /// When anything about the device or its shockers is updated, sent to all shared users and owner @@ -29,6 +31,7 @@ public interface IDeviceUpdateService /// /// /// + /// /// - public Task UpdateDeviceForAllShared(Guid ownerId, Guid deviceId, DeviceUpdateType type); + public Task UpdateDeviceForAllShared(Guid ownerId, Guid deviceId, DeviceUpdateType type, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/API/Services/Token/ApiTokenService.cs b/API/Services/Token/ApiTokenService.cs index 317f55d2..ac91f448 100644 --- a/API/Services/Token/ApiTokenService.cs +++ b/API/Services/Token/ApiTokenService.cs @@ -88,31 +88,31 @@ private IQueryable Tokens(Guid? ownerId) } /// - public IAsyncEnumerable ListTokensV1(Guid ownerId) => Tokens(ownerId) + public IAsyncEnumerable ListTokensV1(Guid ownerId, CancellationToken cancellationToken) => Tokens(ownerId) .OrderBy(x => x.CreatedAt) .Select(ToTokenResponse) .AsAsyncEnumerable(); /// - public IAsyncEnumerable ListTokensV2(Guid ownerId) => Tokens(ownerId) + public IAsyncEnumerable ListTokensV2(Guid ownerId, CancellationToken cancellationToken) => Tokens(ownerId) .OrderBy(x => x.CreatedAt) .Select(ToTokenResponseV2) .AsAsyncEnumerable(); /// - public Task GetTokenV1(Guid tokenId, Guid? ownerId = null) => Tokens(ownerId) + public Task GetTokenV1(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default) => Tokens(ownerId) .Where(x => x.Id == tokenId) .Select(ToTokenResponse) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); /// - public Task GetTokenV2(Guid tokenId, Guid? ownerId = null) => Tokens(ownerId) + public Task GetTokenV2(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default) => Tokens(ownerId) .Where(x => x.Id == tokenId) .Select(ToTokenResponseV2) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); /// - public async Task CreateTokenV1(Guid userId, IPAddress createdByIp, CreateTokenRequest body) + public async Task CreateTokenV1(Guid userId, IPAddress createdByIp, CreateTokenRequest body, CancellationToken cancellationToken) { var secret = CryptoUtils.RandomString(AuthConstants.ApiTokenLength); @@ -127,19 +127,20 @@ public async Task CreateTokenV1(Guid userId, IPAddress cre ValidUntil = body.ValidUntil?.ToUniversalTime() }; - await using (var transaction = await _db.Database.BeginTransactionAsync()) + await using (var transaction = await _db.Database.BeginTransactionAsync(cancellationToken)) { _db.ApiTokens.Add(tokenDto); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); await _auditService.LogAsync( userId, action: AuditAction.ApiTokenCreated, actorId: userId, - metadata: new ApiTokenCreatedMetadata(tokenDto.Id, tokenDto.Name, tokenDto.Permissions.Select(p => PermissionTypeBindings.PermissionTypeToName[p].Name).ToList()) + metadata: new ApiTokenCreatedMetadata(tokenDto.Id, tokenDto.Name, tokenDto.Permissions.Select(p => PermissionTypeBindings.PermissionTypeToName[p].Name).ToList()), + cancellationToken: cancellationToken ); - await transaction.CommitAsync(); + await transaction.CommitAsync(cancellationToken); } return new TokenCreatedResponse @@ -155,7 +156,7 @@ await _auditService.LogAsync( } /// - public async Task CreateTokenV2(Guid userId, IPAddress createdByIp, CreateTokenRequestV2 body) + public async Task CreateTokenV2(Guid userId, IPAddress createdByIp, CreateTokenRequestV2 body, CancellationToken cancellationToken) { var secret = CryptoUtils.RandomString(AuthConstants.ApiTokenLength); @@ -171,19 +172,20 @@ public async Task CreateTokenV2(Guid userId, IPAddress c }; body.ShockerControl.ApplyTo(tokenDto); - await using (var transaction = await _db.Database.BeginTransactionAsync()) + await using (var transaction = await _db.Database.BeginTransactionAsync(cancellationToken)) { _db.ApiTokens.Add(tokenDto); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); await _auditService.LogAsync( userId, action: AuditAction.ApiTokenCreated, actorId: userId, - metadata: new ApiTokenCreatedMetadata(tokenDto.Id, tokenDto.Name, tokenDto.Permissions.Select(p => PermissionTypeBindings.PermissionTypeToName[p].Name).ToList()) + metadata: new ApiTokenCreatedMetadata(tokenDto.Id, tokenDto.Name, tokenDto.Permissions.Select(p => PermissionTypeBindings.PermissionTypeToName[p].Name).ToList()), + cancellationToken: cancellationToken ); - await transaction.CommitAsync(); + await transaction.CommitAsync(cancellationToken); } return new TokenCreatedResponseV2 @@ -200,14 +202,14 @@ await _auditService.LogAsync( } /// - public async Task EditTokenV1(Guid tokenId, EditTokenRequest body, Guid? ownerId = null) + public async Task EditTokenV1(Guid tokenId, EditTokenRequest body, Guid? ownerId = null, CancellationToken cancellationToken = default) { - var token = await Tokens(ownerId).FirstOrDefaultAsync(x => x.Id == tokenId); + var token = await Tokens(ownerId).FirstOrDefaultAsync(x => x.Id == tokenId, cancellationToken); if (token is null) return false; token.Name = body.Name; token.Permissions = body.Permissions.Distinct().ToList(); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); await _redisPubService.SendApiTokenUpdate(tokenId); @@ -215,15 +217,15 @@ public async Task EditTokenV1(Guid tokenId, EditTokenRequest body, Guid? o } /// - public async Task EditTokenV2(Guid tokenId, EditTokenRequestV2 body, Guid? ownerId = null) + public async Task EditTokenV2(Guid tokenId, EditTokenRequestV2 body, Guid? ownerId = null, CancellationToken cancellationToken = default) { - var token = await Tokens(ownerId).FirstOrDefaultAsync(x => x.Id == tokenId); + var token = await Tokens(ownerId).FirstOrDefaultAsync(x => x.Id == tokenId, cancellationToken); if (token is null) return false; token.Name = body.Name; token.Permissions = body.Permissions.Distinct().ToList(); body.ShockerControl.ApplyTo(token); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); await _redisPubService.SendApiTokenUpdate(tokenId); @@ -231,11 +233,11 @@ public async Task EditTokenV2(Guid tokenId, EditTokenRequestV2 body, Guid? } /// - public async Task SetTokenPaused(Guid tokenId, bool paused, Guid? ownerId = null) + public async Task SetTokenPaused(Guid tokenId, bool paused, Guid? ownerId = null, CancellationToken cancellationToken = default) { var nUpdated = await Tokens(ownerId) .Where(x => x.Id == tokenId) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.ShockerControlPaused, paused)); + .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.ShockerControlPaused, paused), cancellationToken); if (nUpdated <= 0) return null; await _redisPubService.SendApiTokenUpdate(tokenId); diff --git a/API/Services/Token/IApiTokenService.cs b/API/Services/Token/IApiTokenService.cs index a34e76db..9952b512 100644 --- a/API/Services/Token/IApiTokenService.cs +++ b/API/Services/Token/IApiTokenService.cs @@ -17,40 +17,40 @@ public interface IApiTokenService /// /// List all tokens owned by . /// - IAsyncEnumerable ListTokensV1(Guid ownerId); + IAsyncEnumerable ListTokensV1(Guid ownerId, CancellationToken cancellationToken = default); /// - IAsyncEnumerable ListTokensV2(Guid ownerId); + IAsyncEnumerable ListTokensV2(Guid ownerId, CancellationToken cancellationToken = default); /// /// Get a single token by id (optionally restricted to an owner), or null if it does not exist. /// - Task GetTokenV1(Guid tokenId, Guid? ownerId = null); + Task GetTokenV1(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); /// - Task GetTokenV2(Guid tokenId, Guid? ownerId = null); + Task GetTokenV2(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); /// /// Create a new token owned by and return the response including the raw token secret. /// - Task CreateTokenV1(Guid userId, IPAddress createdByIp, CreateTokenRequest body); + Task CreateTokenV1(Guid userId, IPAddress createdByIp, CreateTokenRequest body, CancellationToken cancellationToken = default); /// - Task CreateTokenV2(Guid userId, IPAddress createdByIp, CreateTokenRequestV2 body); + Task CreateTokenV2(Guid userId, IPAddress createdByIp, CreateTokenRequestV2 body, CancellationToken cancellationToken = default); /// /// Edit a token by id (optionally restricted to an owner). Returns false if the token does not exist. /// - Task EditTokenV1(Guid tokenId, EditTokenRequest body, Guid? ownerId = null); + Task EditTokenV1(Guid tokenId, EditTokenRequest body, Guid? ownerId = null, CancellationToken cancellationToken = default); /// - Task EditTokenV2(Guid tokenId, EditTokenRequestV2 body, Guid? ownerId = null); + Task EditTokenV2(Guid tokenId, EditTokenRequestV2 body, Guid? ownerId = null, CancellationToken cancellationToken = default); /// /// Set the paused state of a token by id (optionally restricted to an owner). /// Returns the now-set paused state, or null if the token does not exist. /// - Task SetTokenPaused(Guid tokenId, bool paused, Guid? ownerId = null); + Task SetTokenPaused(Guid tokenId, bool paused, Guid? ownerId = null, CancellationToken cancellationToken = default); /// /// Delete a token by id (optionally restricted to an owner) and write the deletion audit log entry. diff --git a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs index 3ea19f27..9a7f22d6 100644 --- a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs @@ -49,7 +49,7 @@ protected override async Task HandleAuthenticateAsync() var tokenDto = await _db.ApiTokens .Include(x => x.User) .Include(x => x.User.UserDeactivation) - .FirstOrDefaultAsync(x => x.TokenHash == tokenHash && (x.ValidUntil == null || x.ValidUntil >= DateTime.UtcNow)); + .FirstOrDefaultAsync(x => x.TokenHash == tokenHash && (x.ValidUntil == null || x.ValidUntil >= DateTime.UtcNow), Context.RequestAborted); if (tokenDto is null) return Fail(AuthResultError.TokenInvalid); if (tokenDto.User.UserDeactivation is not null) { diff --git a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs index 6f4f0bd7..e3500595 100644 --- a/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/HubAuthentication.cs @@ -51,7 +51,7 @@ protected override async Task HandleAuthenticateAsync() x.OwnerId, IsDeactivated = x.Owner.UserDeactivation != null }) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(Context.RequestAborted); if (hub is null) { return Fail(AuthResultError.TokenInvalid); diff --git a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs index 83218a75..ee028795 100644 --- a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs @@ -57,7 +57,7 @@ protected override async Task HandleAuthenticateAsync() _batchUpdateService.UpdateSessionLastUsed(sessionToken, DateTimeOffset.UtcNow); - var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(user => user.Id == session.UserId); + var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(user => user.Id == session.UserId, Context.RequestAborted); if (user is null) return Fail(AuthResultError.SessionInvalid); if (user.ActivatedAt is null) { diff --git a/Common/Hubs/PublicShareHub.cs b/Common/Hubs/PublicShareHub.cs index faaaf475..757d2f50 100644 --- a/Common/Hubs/PublicShareHub.cs +++ b/Common/Hubs/PublicShareHub.cs @@ -59,7 +59,7 @@ public override async Task OnConnectedAsync() } } - var exists = await _db.PublicShares.AnyAsync(x => x.Id == id && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow)); + var exists = await _db.PublicShares.AnyAsync(x => x.Id == id && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow), Context.ConnectionAborted); if (!exists) { _logger.LogDebug("Aborting connection... public share not found"); @@ -115,7 +115,7 @@ public override async Task OnConnectedAsync() AdditionalItems = additionalItems } }; - await Groups.AddToGroupAsync(Context.ConnectionId, $"share-link-{param}"); + await Groups.AddToGroupAsync(Context.ConnectionId, $"share-link-{param}", Context.ConnectionAborted); await Clients.Caller.Welcome(user is null ? AuthType.Guest : AuthType.Authenticated); } @@ -124,7 +124,7 @@ public Task Control(IReadOnlyList shocks) if (!TokenPermissions.IsAllowedAllowOrNull(PermissionType.Shockers_Use)) return Task.CompletedTask; return _controlSender.ControlPublicShare(shocks, CustomData.CachedControlLogSender, _userHub.Clients, - CustomData.PublicShareId); + CustomData.PublicShareId, Context.ConnectionAborted); } private CustomDataHolder CustomData => (CustomDataHolder)Context.Items[PublicShareCustomData]!; @@ -140,7 +140,7 @@ public Task Control(IReadOnlyList shocks) Id = x.Id, Image = x.GetImageUrl(), Name = x.Name - }).FirstAsync(user => user.Id == session.UserId); + }).FirstAsync(user => user.Id == session.UserId, Context.ConnectionAborted); } private sealed class CustomDataHolder diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index 74226d31..8a846e55 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -47,7 +47,7 @@ public override async Task OnConnectedAsync() var devicesOnline = _provider.RedisCollection(false); var sharedDevices = await _db.Devices .Where(x => x.Shockers.Any(y => y.UserShares.Any(z => z.SharedWithUserId == UserId))) - .Select(x => x.Id.ToString()).ToArrayAsync(); + .Select(x => x.Id.ToString()).ToArrayAsync(Context.ConnectionAborted); var own = devicesOnline.Where(x => x.Owner == UserId).ToListAsync(); var shared = devicesOnline.FindByIdsAsync(sharedDevices); @@ -92,7 +92,7 @@ public async Task ControlV2(IReadOnlyList shocks, ConnectionId = Context.ConnectionId, AdditionalItems = additionalItems, CustomName = customName - }).FirstAsync(); + }).FirstAsync(Context.ConnectionAborted); ApiTokenControlLimits? tokenLimits = null; if (Context.GetHttpContext()?.GetItemByType() is { } apiToken) @@ -103,7 +103,7 @@ public async Task ControlV2(IReadOnlyList shocks, tokenLimits = ApiTokenControlLimits.FromToken(apiToken); } - await _controlSender.ControlByUser(shocks, sender, Clients, tokenLimits); + await _controlSender.ControlByUser(shocks, sender, Clients, tokenLimits, Context.ConnectionAborted); } public async Task CaptivePortal(Guid deviceId, bool enabled) @@ -112,7 +112,7 @@ public async Task CaptivePortal(Guid deviceId, bool enabled) if (TokenPermissions is not null) return; var devices = await _db.Devices.Where(x => x.OwnerId == UserId) - .AnyAsync(x => x.Id == deviceId); + .AnyAsync(x => x.Id == deviceId, Context.ConnectionAborted); if (!devices) return; await _redisPubService.SendDeviceCaptivePortal(deviceId, enabled); @@ -124,7 +124,7 @@ public async Task EmergencyStop(Guid deviceId) if (TokenPermissions is not null) return; var devices = await _db.Devices.Where(x => x.OwnerId == UserId) - .AnyAsync(x => x.Id == deviceId); + .AnyAsync(x => x.Id == deviceId, Context.ConnectionAborted); if (!devices) return; await _redisPubService.SendDeviceEmergencyStop(deviceId); @@ -136,7 +136,7 @@ public async Task OtaInstall(Guid deviceId, SemVersion version) if (TokenPermissions is not null) return; var devices = await _db.Devices.Where(x => x.OwnerId == UserId) - .AnyAsync(x => x.Id == deviceId); + .AnyAsync(x => x.Id == deviceId, Context.ConnectionAborted); if (!devices) return; await _redisPubService.SendDeviceOtaInstall(deviceId, version); @@ -148,7 +148,7 @@ public async Task Reboot(Guid deviceId) if (TokenPermissions is not null) return; var devices = await _db.Devices.Where(x => x.OwnerId == UserId) - .AnyAsync(x => x.Id == deviceId); + .AnyAsync(x => x.Id == deviceId, Context.ConnectionAborted); if (!devices) return; await _redisPubService.SendDeviceReboot(deviceId); diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index f64c85ce..c2f60715 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -54,7 +54,7 @@ protected OkObjectResult LegacyEmptyOk(string message = "") } [NonAction] - protected async Task CreateSession(Guid accountId, string domain) + protected async Task CreateSession(Guid accountId, string domain, CancellationToken cancellationToken) { var frontendOptions = HttpContext.RequestServices.GetRequiredService(); var sessionService = HttpContext.RequestServices.GetRequiredService(); @@ -64,7 +64,7 @@ protected async Task CreateSession(Guid accountId, string domain) var userAgent = HttpContext.GetUserAgent(); var enrichment = enrichmentService.Enrich(remoteIp); - var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp, actorId: accountId, enrichment: enrichment); + var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp, actorId: accountId, enrichment: enrichment, cancellationToken: cancellationToken); HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, new CookieOptions diff --git a/Common/Services/Configuration/ConfigurationService.cs b/Common/Services/Configuration/ConfigurationService.cs index 5c910c43..72e56809 100644 --- a/Common/Services/Configuration/ConfigurationService.cs +++ b/Common/Services/Configuration/ConfigurationService.cs @@ -59,7 +59,7 @@ private static bool IsValidValueFormat(ConfigurationValueType type, string value }; } - public async Task> TryAddItemAsync(string name, string description, ConfigurationValueType type, string value) + public async Task> TryAddItemAsync(string name, string description, ConfigurationValueType type, string value, CancellationToken cancellationToken) { // Validate name (only uppercase letters and underscores) if (!IsValidName(name)) @@ -88,7 +88,7 @@ public async Task> TryUpdateItemAsync(string name, string? description, string? value) + public async Task> TryUpdateItemAsync(string name, string? description, string? value, CancellationToken cancellationToken) { // Validate name if (!IsValidName(name)) @@ -112,7 +112,7 @@ public async Task ci.Name == name); + var item = await _db.Configuration.FirstOrDefaultAsync(ci => ci.Name == name, cancellationToken); if (item is null) return new NotFound(); @@ -133,55 +133,56 @@ public async Task> TryDeleteItemAsync(string name) + public async Task> TryDeleteItemAsync(string name, CancellationToken cancellationToken) { // Find the item - var item = await _db.Configuration.FirstOrDefaultAsync(ci => ci.Name == name); + var item = await _db.Configuration.FirstOrDefaultAsync(ci => ci.Name == name, cancellationToken); if (item is null) return new NotFound(); // Remove and persist _db.Configuration.Remove(item); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); // Evict from cache - await _cache.RemoveAsync(name); + await _cache.RemoveAsync(name, cancellationToken); return new Success(); } private sealed record TypeValuePair(ConfigurationValueType Type, string Value); - private async Task TryGetTypeValuePair(string name) + private async Task TryGetTypeValuePair(string name, CancellationToken cancellationToken) { name = name.ToUpperInvariant(); - return await _cache.GetOrCreateAsync(name, async cancellationToken => + return await _cache.GetOrCreateAsync(name, async ct => { var pair = await GetAllItemsQuery() .Where(ci => ci.Name == name) .Select(ci => new TypeValuePair(ci.Type, ci.Value)) - .FirstOrDefaultAsync(cancellationToken); + .FirstOrDefaultAsync(ct); return pair; - }); + }, cancellationToken: cancellationToken); } - private async Task SetValueAsync(string name, string newValue, ConfigurationValueType type) + private async Task SetValueAsync(string name, string newValue, ConfigurationValueType type, CancellationToken cancellationToken) { - var item = await _db.Configuration.FirstOrDefaultAsync(c => c.Name == name); + var item = await _db.Configuration.FirstOrDefaultAsync(c => c.Name == name, cancellationToken); if (item is null) { @@ -217,25 +218,25 @@ private async Task SetValueAsync(string name, string newValue, Configurati item.UpdatedAt = DateTime.UtcNow; } - await _db.SaveChangesAsync(); - await _cache.SetAsync(name, new TypeValuePair(type, newValue)); + await _db.SaveChangesAsync(cancellationToken); + await _cache.SetAsync(name, new TypeValuePair(type, newValue), cancellationToken: cancellationToken); return true; } - public async Task> TryGetStringAsync(string name) + public async Task> TryGetStringAsync(string name, CancellationToken cancellationToken) { - var pair = await TryGetTypeValuePair(name); + var pair = await TryGetTypeValuePair(name, cancellationToken); if (pair is null) return ConfigGetResult.NotFound(); if (pair.Type != ConfigurationValueType.String) return ConfigGetResult.InvalidValueType(); return pair.Value; } - public Task TrySetStringAsync(string name, string value) => - SetValueAsync(name, value, ConfigurationValueType.String); + public Task TrySetStringAsync(string name, string value, CancellationToken cancellationToken) => + SetValueAsync(name, value, ConfigurationValueType.String, cancellationToken); - public async Task> TryGetBoolAsync(string name) + public async Task> TryGetBoolAsync(string name, CancellationToken cancellationToken) { - var pair = await TryGetTypeValuePair(name); + var pair = await TryGetTypeValuePair(name, cancellationToken); if (pair is null) return ConfigGetResult.NotFound(); if (pair.Type != ConfigurationValueType.Bool) return ConfigGetResult.InvalidValueType(); if (!bool.TryParse(pair.Value, out var value)) @@ -246,12 +247,12 @@ public async Task> TryGetBoolAsync(string name) return value; } - public Task TrySetBoolAsync(string name, bool value) => - SetValueAsync(name, value.ToString(), ConfigurationValueType.Bool); + public Task TrySetBoolAsync(string name, bool value, CancellationToken cancellationToken) => + SetValueAsync(name, value.ToString(), ConfigurationValueType.Bool, cancellationToken); - public async Task> TryGetIntAsync(string name) + public async Task> TryGetIntAsync(string name, CancellationToken cancellationToken) { - var pair = await TryGetTypeValuePair(name); + var pair = await TryGetTypeValuePair(name, cancellationToken); if (pair is null) return ConfigGetResult.NotFound(); if (pair.Type != ConfigurationValueType.Int) return ConfigGetResult.InvalidValueType(); if (!int.TryParse(pair.Value, out var value)) @@ -262,12 +263,12 @@ public async Task> TryGetIntAsync(string name) return value; } - public Task TrySetIntAsync(string name, int value) => - SetValueAsync(name, value.ToString(), ConfigurationValueType.Int); + public Task TrySetIntAsync(string name, int value, CancellationToken cancellationToken) => + SetValueAsync(name, value.ToString(), ConfigurationValueType.Int, cancellationToken); - public async Task> TryGetFloatAsync(string name) + public async Task> TryGetFloatAsync(string name, CancellationToken cancellationToken) { - var pair = await TryGetTypeValuePair(name); + var pair = await TryGetTypeValuePair(name, cancellationToken); if (pair is null) return ConfigGetResult.NotFound(); if (pair.Type != ConfigurationValueType.Float) return ConfigGetResult.InvalidValueType(); if (!float.TryParse(pair.Value, out var value)) @@ -278,12 +279,12 @@ public async Task> TryGetFloatAsync(string name) return value; } - public Task TrySetFloatAsync(string name, float value) => - SetValueAsync(name, value.ToString("R"), ConfigurationValueType.Float); + public Task TrySetFloatAsync(string name, float value, CancellationToken cancellationToken) => + SetValueAsync(name, value.ToString("R"), ConfigurationValueType.Float, cancellationToken); - public async Task> TryGetJsonAsync(string name) + public async Task> TryGetJsonAsync(string name, CancellationToken cancellationToken) { - var pair = await TryGetTypeValuePair(name); + var pair = await TryGetTypeValuePair(name, cancellationToken); if (pair is null) return ConfigGetResult.NotFound(); if (pair.Type != ConfigurationValueType.Json) return ConfigGetResult.InvalidValueType(); @@ -299,12 +300,12 @@ public async Task> TryGetJsonAsync(string name) } } - public async Task TrySetJsonAsync(string name, T value) + public async Task TrySetJsonAsync(string name, T value, CancellationToken cancellationToken) { try { var json = JsonSerializer.Serialize(value); - return await SetValueAsync(name, json, ConfigurationValueType.Json); + return await SetValueAsync(name, json, ConfigurationValueType.Json, cancellationToken); } catch (JsonException ex) { diff --git a/Common/Services/Configuration/IConfigurationService.cs b/Common/Services/Configuration/IConfigurationService.cs index ec8e7e07..d34ce2c3 100644 --- a/Common/Services/Configuration/IConfigurationService.cs +++ b/Common/Services/Configuration/IConfigurationService.cs @@ -7,24 +7,24 @@ public interface IConfigurationService { IQueryable GetAllItemsQuery(); - Task> TryAddItemAsync(string name, string description, ConfigurationValueType type, string value); - Task> TryUpdateItemAsync(string name, string? description, string? value); - Task> TryDeleteItemAsync(string name); + Task> TryAddItemAsync(string name, string description, ConfigurationValueType type, string value, CancellationToken cancellationToken = default); + Task> TryUpdateItemAsync(string name, string? description, string? value, CancellationToken cancellationToken = default); + Task> TryDeleteItemAsync(string name, CancellationToken cancellationToken = default); - Task> TryGetStringAsync(string name); - Task TrySetStringAsync(string name, string value); + Task> TryGetStringAsync(string name, CancellationToken cancellationToken = default); + Task TrySetStringAsync(string name, string value, CancellationToken cancellationToken = default); - Task> TryGetBoolAsync(string name); - Task TrySetBoolAsync(string name, bool value); + Task> TryGetBoolAsync(string name, CancellationToken cancellationToken = default); + Task TrySetBoolAsync(string name, bool value, CancellationToken cancellationToken = default); - Task> TryGetIntAsync(string name); - Task TrySetIntAsync(string name, int value); + Task> TryGetIntAsync(string name, CancellationToken cancellationToken = default); + Task TrySetIntAsync(string name, int value, CancellationToken cancellationToken = default); - Task> TryGetFloatAsync(string name); - Task TrySetFloatAsync(string name, float value); + Task> TryGetFloatAsync(string name, CancellationToken cancellationToken = default); + Task TrySetFloatAsync(string name, float value, CancellationToken cancellationToken = default); - Task> TryGetJsonAsync(string name); - Task TrySetJsonAsync(string name, T value); + Task> TryGetJsonAsync(string name, CancellationToken cancellationToken = default); + Task TrySetJsonAsync(string name, T value, CancellationToken cancellationToken = default); } public enum ConfigGetOutcome : byte diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index a73d9bb0..fb5bcce2 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -32,7 +32,7 @@ public ControlSender(OpenShockContext db, IRedisPubService publisher, ControlMet _metrics = metrics; } - public async Task ControlByUser(IReadOnlyList controls,ControlLogSender sender, IHubClients hubClients, ApiTokenControlLimits? tokenLimits = null) + public async Task ControlByUser(IReadOnlyList controls,ControlLogSender sender, IHubClients hubClients, ApiTokenControlLimits? tokenLimits = null, CancellationToken cancellationToken = default) { var shockers = await _db.Shockers .AsNoTracking() @@ -62,12 +62,12 @@ public async Task ControlByUser(IReadOnlyList con Live = x.Share.AllowLiveControl } }) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); - return await ControlInternal(controls, sender, hubClients, shockers, ControlMetrics.Source.User, tokenLimits); + return await ControlInternal(controls, sender, hubClients, shockers, ControlMetrics.Source.User, tokenLimits, cancellationToken); } - public async Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId) + public async Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId, CancellationToken cancellationToken) { var publicShareShockers = await _db.PublicShareShockerMappings .AsNoTracking() @@ -91,9 +91,9 @@ public async Task ControlPublicShare(IReadOnlyList ControlInternal(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers, string source, ApiTokenControlLimits? tokenLimits = null) + private async Task ControlInternal(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers, string source, ApiTokenControlLimits? tokenLimits = null, CancellationToken cancellationToken = default) { var shockersById = allowedShockers.ToDictionary(s => s.ShockerId, s => s); @@ -180,7 +180,7 @@ private async Task ControlInternal(IReadOnlyList } // Save all db changes before continuing - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); // Then send all network events await Task.WhenAll([ diff --git a/Common/Services/Device/DeviceService.cs b/Common/Services/Device/DeviceService.cs index 002a215d..7889109c 100644 --- a/Common/Services/Device/DeviceService.cs +++ b/Common/Services/Device/DeviceService.cs @@ -17,11 +17,11 @@ public DeviceService(OpenShockContext db) } /// - public async Task> GetSharedUserIdsAsync(Guid deviceId) + public async Task> GetSharedUserIdsAsync(Guid deviceId, CancellationToken cancellationToken) { var sharedUsers = await _db.UserShares.AsNoTracking().Where(x => x.Shocker.DeviceId == deviceId).GroupBy(x => x.SharedWithUserId) .Select(x => x.Key) - .ToListAsync(); + .ToListAsync(cancellationToken); return sharedUsers; } } \ No newline at end of file diff --git a/Common/Services/Device/IDeviceService.cs b/Common/Services/Device/IDeviceService.cs index 7c23ef92..9f759ecc 100644 --- a/Common/Services/Device/IDeviceService.cs +++ b/Common/Services/Device/IDeviceService.cs @@ -6,6 +6,7 @@ public interface IDeviceService /// Get all users that have a share (for a shocker) within the device /// /// + /// /// - public Task> GetSharedUserIdsAsync(Guid deviceId); + public Task> GetSharedUserIdsAsync(Guid deviceId, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Common/Services/IControlSender.cs b/Common/Services/IControlSender.cs index e7301c55..bca0e8fb 100644 --- a/Common/Services/IControlSender.cs +++ b/Common/Services/IControlSender.cs @@ -8,9 +8,9 @@ namespace OpenShock.Common.Services; public interface IControlSender { - public Task ControlByUser(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ApiTokenControlLimits? tokenLimits = null); + public Task ControlByUser(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ApiTokenControlLimits? tokenLimits = null, CancellationToken cancellationToken = default); - public Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId); + public Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId, CancellationToken cancellationToken = default); } /// diff --git a/Common/Services/Ota/IOtaService.cs b/Common/Services/Ota/IOtaService.cs index 40f4970d..fffc9a9e 100644 --- a/Common/Services/Ota/IOtaService.cs +++ b/Common/Services/Ota/IOtaService.cs @@ -11,16 +11,18 @@ public interface IOtaService /// /// /// + /// /// - public Task Started(Guid deviceId, int updateId, SemVersion version); + public Task Started(Guid deviceId, int updateId, SemVersion version, CancellationToken cancellationToken = default); /// /// When the first progress is reported, set the state to running /// /// /// + /// /// - public Task Progress(Guid deviceId, int updateId); + public Task Progress(Guid deviceId, int updateId, CancellationToken cancellationToken = default); /// /// When an error or rollback occured during the update @@ -29,29 +31,33 @@ public interface IOtaService /// /// /// + /// /// - public Task Error(Guid deviceId, int updateId, bool fatal, string message); + public Task Error(Guid deviceId, int updateId, bool fatal, string message, CancellationToken cancellationToken = default); /// /// Updated succeeded /// /// /// + /// /// - public Task Success(Guid deviceId, int updateId); + public Task Success(Guid deviceId, int updateId, CancellationToken cancellationToken = default); /// /// Check for an unfinished update /// /// /// + /// /// - public Task UpdateUnfinished(Guid deviceId, int updateId); + public Task UpdateUnfinished(Guid deviceId, int updateId, CancellationToken cancellationToken = default); /// /// Get all updates for a device /// /// + /// /// - public Task> GetUpdates(Guid deviceId); + public Task> GetUpdates(Guid deviceId, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Common/Services/Ota/OtaService.cs b/Common/Services/Ota/OtaService.cs index 648c5372..6ad809a7 100644 --- a/Common/Services/Ota/OtaService.cs +++ b/Common/Services/Ota/OtaService.cs @@ -19,7 +19,7 @@ public OtaService(OpenShockContext db) } /// - public Task Started(Guid deviceId, int updateId, SemVersion version) + public Task Started(Guid deviceId, int updateId, SemVersion version, CancellationToken cancellationToken) { _db.DeviceOtaUpdates.Add(new DeviceOtaUpdate { @@ -29,46 +29,46 @@ public Task Started(Guid deviceId, int updateId, SemVersion version) Version = version.ToString() }); - return _db.SaveChangesAsync(); + return _db.SaveChangesAsync(cancellationToken); } /// - public async Task Progress(Guid deviceId, int updateId) + public async Task Progress(Guid deviceId, int updateId, CancellationToken cancellationToken) { var updateTask = await _db.DeviceOtaUpdates - .FirstOrDefaultAsync(x => x.DeviceId == deviceId && x.UpdateId == updateId); + .FirstOrDefaultAsync(x => x.DeviceId == deviceId && x.UpdateId == updateId, cancellationToken); if (updateTask is null) return; updateTask.Status = OtaUpdateStatus.Running; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); } /// - public async Task Error(Guid deviceId, int updateId, bool fatal, string message) + public async Task Error(Guid deviceId, int updateId, bool fatal, string message, CancellationToken cancellationToken) { var updateTask = await _db.DeviceOtaUpdates - .FirstOrDefaultAsync(x => x.DeviceId == deviceId && x.UpdateId == updateId); + .FirstOrDefaultAsync(x => x.DeviceId == deviceId && x.UpdateId == updateId, cancellationToken); if (updateTask is null) return; updateTask.Status = OtaUpdateStatus.Error; updateTask.Message = message; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); } /// - public async Task Success(Guid deviceId, int updateId) + public async Task Success(Guid deviceId, int updateId, CancellationToken cancellationToken) { var updateTask = await _db.DeviceOtaUpdates - .FirstOrDefaultAsync(x => x.DeviceId == deviceId && x.UpdateId == updateId); + .FirstOrDefaultAsync(x => x.DeviceId == deviceId && x.UpdateId == updateId, cancellationToken); if (updateTask is null) return false; updateTask.Status = OtaUpdateStatus.Finished; - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return true; } /// - public async Task> GetUpdates(Guid deviceId) + public async Task> GetUpdates(Guid deviceId, CancellationToken cancellationToken) { return await _db.DeviceOtaUpdates.AsNoTracking() .Where(x => x.DeviceId == deviceId) @@ -80,15 +80,15 @@ public async Task> GetUpdates(Guid deviceId) Status = x.Status, Version = SemVersion.Parse(x.Version), Message = x.Message - }).ToArrayAsync(); + }).ToArrayAsync(cancellationToken); } /// - public async Task UpdateUnfinished(Guid deviceId, int updateId) + public async Task UpdateUnfinished(Guid deviceId, int updateId, CancellationToken cancellationToken) { return await _db.DeviceOtaUpdates.AsNoTracking() .AnyAsync(x => x.DeviceId == deviceId && x.UpdateId == updateId && - (x.Status == OtaUpdateStatus.Running || x.Status == OtaUpdateStatus.Started)); + (x.Status == OtaUpdateStatus.Running || x.Status == OtaUpdateStatus.Started), cancellationToken); } } \ No newline at end of file diff --git a/Common/Services/Session/ISessionService.cs b/Common/Services/Session/ISessionService.cs index c0e7ec44..0f722a6c 100644 --- a/Common/Services/Session/ISessionService.cs +++ b/Common/Services/Session/ISessionService.cs @@ -6,7 +6,7 @@ namespace OpenShock.Common.Services.Session; public interface ISessionService { - public Task CreateSessionAsync(Guid userId, string userAgent, IPAddress ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null); + public Task CreateSessionAsync(Guid userId, string userAgent, IPAddress ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null, CancellationToken cancellationToken = default); public IAsyncEnumerable ListSessionsByUserIdAsync(Guid userId); @@ -24,7 +24,7 @@ public interface ISessionService public Task DeleteSessionAsync(LoginSession loginSession); - public Task LogoutSessionAsync(LoginSession loginSession); + public Task LogoutSessionAsync(LoginSession loginSession, CancellationToken cancellationToken = default); } public sealed record CreateSessionResult(Guid Id, string Token); \ No newline at end of file diff --git a/Common/Services/Session/SessionService.cs b/Common/Services/Session/SessionService.cs index bd023a1b..b51ee37d 100644 --- a/Common/Services/Session/SessionService.cs +++ b/Common/Services/Session/SessionService.cs @@ -34,7 +34,7 @@ public SessionService(IRedisConnectionProvider redisConnectionProvider, IAuditSe _auditService = auditService; } - public async Task CreateSessionAsync(Guid userId, string userAgent, IPAddress ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null) + public async Task CreateSessionAsync(Guid userId, string userAgent, IPAddress ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null, CancellationToken cancellationToken = default) { Guid id = Guid.CreateVersion7(); string token = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); @@ -58,7 +58,8 @@ await _auditService.LogAsync( userId, action: AuditAction.Login, actorId: actorId ?? userId, - metadata: new LoginMetadata(id) + metadata: new LoginMetadata(id), + cancellationToken: cancellationToken ); return new CreateSessionResult(id, token); @@ -128,7 +129,7 @@ public async Task DeleteSessionAsync(LoginSession loginSession) await _loginSessions.DeleteAsync(loginSession); } - public async Task LogoutSessionAsync(LoginSession loginSession) + public async Task LogoutSessionAsync(LoginSession loginSession, CancellationToken cancellationToken) { // Record the logout before destroying the session. The session delete is idempotent, so if // this throws the caller can safely retry; deleting first would leave the session gone while @@ -136,7 +137,8 @@ public async Task LogoutSessionAsync(LoginSession loginSession) await _auditService.LogAsync( loginSession.UserId, action: AuditAction.Logout, - actorId: loginSession.UserId + actorId: loginSession.UserId, + cancellationToken: cancellationToken ); await _loginSessions.DeleteAsync(loginSession); diff --git a/Common/Services/Webhook/IWebhookService.cs b/Common/Services/Webhook/IWebhookService.cs index f3618cb6..fe77347f 100644 --- a/Common/Services/Webhook/IWebhookService.cs +++ b/Common/Services/Webhook/IWebhookService.cs @@ -6,11 +6,11 @@ namespace OpenShock.Common.Services.Webhook; public interface IWebhookService { - public Task, UnsupportedWebhookUrl>> AddWebhookAsync(string name, Uri webhookUrl); - public Task RemoveWebhookAsync(Guid webhookId); - public Task GetWebhooksAsync(); + public Task, UnsupportedWebhookUrl>> AddWebhookAsync(string name, Uri webhookUrl, CancellationToken cancellationToken = default); + public Task RemoveWebhookAsync(Guid webhookId, CancellationToken cancellationToken = default); + public Task GetWebhooksAsync(CancellationToken cancellationToken = default); - public Task> SendWebhookAsync(string webhookName, string title, string content, Color color); + public Task> SendWebhookAsync(string webhookName, string title, string content, Color color, CancellationToken cancellationToken = default); } public struct UnsupportedWebhookUrl; diff --git a/Common/Services/Webhook/WebhookService.cs b/Common/Services/Webhook/WebhookService.cs index 06cad8d8..0671ae3e 100644 --- a/Common/Services/Webhook/WebhookService.cs +++ b/Common/Services/Webhook/WebhookService.cs @@ -20,7 +20,7 @@ public WebhookService(OpenShockContext db, HttpClient httpClient) private static string GetWebhookUrl(long webhookId, string webhookToken) => $"https://discord.com/api/webhooks/{webhookId}/{webhookToken}"; - public async Task, UnsupportedWebhookUrl>> AddWebhookAsync(string name, Uri webhookUrl) + public async Task, UnsupportedWebhookUrl>> AddWebhookAsync(string name, Uri webhookUrl, CancellationToken cancellationToken) { if (webhookUrl is not { @@ -44,7 +44,7 @@ public async Task, UnsupportedWebhookUrl>> AddWebhook _db.DiscordWebhooks.Add(webhook); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return new Success(new WebhookDto { @@ -55,13 +55,13 @@ public async Task, UnsupportedWebhookUrl>> AddWebhook }); } - public async Task RemoveWebhookAsync(Guid webhookId) + public async Task RemoveWebhookAsync(Guid webhookId, CancellationToken cancellationToken) { - var nDeleted = await _db.DiscordWebhooks.Where(w => w.Id == webhookId).ExecuteDeleteAsync(); + var nDeleted = await _db.DiscordWebhooks.Where(w => w.Id == webhookId).ExecuteDeleteAsync(cancellationToken); return nDeleted > 0; } - public async Task GetWebhooksAsync() + public async Task GetWebhooksAsync(CancellationToken cancellationToken) { return await _db.DiscordWebhooks .OrderByDescending(w => w.CreatedAt) @@ -72,14 +72,14 @@ public async Task GetWebhooksAsync() Url = GetWebhookUrl(w.WebhookId, w.WebhookToken), CreatedAt = w.CreatedAt }) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); } - public async Task> SendWebhookAsync(string webhookName, string title, string content, Color color) + public async Task> SendWebhookAsync(string webhookName, string title, string content, Color color, CancellationToken cancellationToken) { var webhook = await _db.DiscordWebhooks .Where(w => w.Name == webhookName) - .FirstOrDefaultAsync(); + .FirstOrDefaultAsync(cancellationToken); if (webhook is null) return new NotFound(); @@ -100,7 +100,8 @@ public async Task> SendWebhookA { using var response = await _httpClient.PostAsJsonAsync( GetWebhookUrl(webhook.WebhookId, webhook.WebhookToken), - payload); + payload, + cancellationToken); if (!response.IsSuccessStatusCode) return new Error(); // Consider inspecting status code if you want more granularity diff --git a/Cron/DashboardAdminAuth.cs b/Cron/DashboardAdminAuth.cs index 2484f7bf..e8944b78 100644 --- a/Cron/DashboardAdminAuth.cs +++ b/Cron/DashboardAdminAuth.cs @@ -18,7 +18,7 @@ public async Task AuthorizeAsync(DashboardContext context) if (httpContext.TryGetUserSessionToken(out var userSessionCookie)) { - if (await SessionAuthAdmin(userSessionCookie, sessionService, db)) + if (await SessionAuthAdmin(userSessionCookie, sessionService, db, httpContext.RequestAborted)) { return true; } @@ -29,12 +29,12 @@ public async Task AuthorizeAsync(DashboardContext context) return false; } - private static async Task SessionAuthAdmin(string sessionKey, ISessionService sessionService, OpenShockContext db) + private static async Task SessionAuthAdmin(string sessionKey, ISessionService sessionService, OpenShockContext db, CancellationToken cancellationToken) { var session = await sessionService.GetSessionByTokenAsync(sessionKey); if (session is null) return false; - var retrievedUser = await db.Users.FirstAsync(user => user.Id == session.UserId); + var retrievedUser = await db.Users.FirstAsync(user => user.Id == session.UserId, cancellationToken); return retrievedUser.IsRole(RoleType.Admin); } } \ No newline at end of file diff --git a/Cron/Jobs/ClearOldAuditLogsJob.cs b/Cron/Jobs/ClearOldAuditLogsJob.cs index ff3a7f70..94b41089 100644 --- a/Cron/Jobs/ClearOldAuditLogsJob.cs +++ b/Cron/Jobs/ClearOldAuditLogsJob.cs @@ -9,20 +9,23 @@ namespace OpenShock.Cron.Jobs; public sealed class ClearOldAuditLogsJob { private readonly OpenShockContext _db; + private readonly IHostApplicationLifetime _lifetime; private readonly ILogger _logger; - public ClearOldAuditLogsJob(OpenShockContext db, ILogger logger) + public ClearOldAuditLogsJob(OpenShockContext db, IHostApplicationLifetime lifetime, ILogger logger) { _db = db; + _lifetime = lifetime; _logger = logger; } public async Task Execute() { + var cancellationToken = _lifetime.ApplicationStopping; var cutoff = DateTime.UtcNow - Duration.AuditRetentionTime; var deleted = await _db.UserAuditLogs .Where(x => x.CreatedAt < cutoff) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); _logger.LogInformation("Deleted {Count} audit log entries older than {Cutoff}", deleted, cutoff); return deleted; diff --git a/Cron/Jobs/ClearOldEmailChangesJob.cs b/Cron/Jobs/ClearOldEmailChangesJob.cs index fe102843..4fc9962e 100644 --- a/Cron/Jobs/ClearOldEmailChangesJob.cs +++ b/Cron/Jobs/ClearOldEmailChangesJob.cs @@ -12,22 +12,25 @@ namespace OpenShock.Cron.Jobs; public sealed class ClearOldEmailChangesJob { private readonly OpenShockContext _db; + private readonly IHostApplicationLifetime _lifetime; private readonly ILogger _logger; - public ClearOldEmailChangesJob(OpenShockContext db, ILogger logger) + public ClearOldEmailChangesJob(OpenShockContext db, IHostApplicationLifetime lifetime, ILogger logger) { _db = db; + _lifetime = lifetime; _logger = logger; } public async Task Execute() { + var cancellationToken = _lifetime.ApplicationStopping; var expiredAtUtc = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; var earliestCreatedOnUtc = expiredAtUtc - Duration.AuditRetentionTime; int nDeleted = await _db.UserEmailChanges .Where(x => x.UsedAt == null && x.CreatedAt < earliestCreatedOnUtc) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); _logger.LogInformation("Deleted {deletedCount} expired email change requests since {earliestCreatedOnUtc}", nDeleted, earliestCreatedOnUtc); diff --git a/Cron/Jobs/ClearOldPasswordResetsJob.cs b/Cron/Jobs/ClearOldPasswordResetsJob.cs index a5fd29be..8998d6df 100644 --- a/Cron/Jobs/ClearOldPasswordResetsJob.cs +++ b/Cron/Jobs/ClearOldPasswordResetsJob.cs @@ -12,28 +12,32 @@ namespace OpenShock.Cron.Jobs; public sealed class ClearOldPasswordResetsJob { private readonly OpenShockContext _db; + private readonly IHostApplicationLifetime _lifetime; private readonly ILogger _logger; /// /// DI constructor /// /// + /// Its stopping token cancels the job when the server shuts down. /// - public ClearOldPasswordResetsJob(OpenShockContext db, ILogger logger) + public ClearOldPasswordResetsJob(OpenShockContext db, IHostApplicationLifetime lifetime, ILogger logger) { _db = db; + _lifetime = lifetime; _logger = logger; } public async Task Execute() { + var cancellationToken = _lifetime.ApplicationStopping; var expiredAtUtc = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; var earliestCreatedOnUtc = expiredAtUtc - Duration.AuditRetentionTime; // Run the delete query int nDeleted = await _db.UserPasswordResets .Where(x => x.UsedAt == null && x.CreatedAt < earliestCreatedOnUtc) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); _logger.LogInformation("Deleted {deletedCount} expired password resets since {earliestCreatedOnUtc}", nDeleted, earliestCreatedOnUtc); diff --git a/Cron/Jobs/ClearOldShockerControlLogs.cs b/Cron/Jobs/ClearOldShockerControlLogs.cs index 47424e67..34aec712 100644 --- a/Cron/Jobs/ClearOldShockerControlLogs.cs +++ b/Cron/Jobs/ClearOldShockerControlLogs.cs @@ -13,23 +13,27 @@ namespace OpenShock.Cron.Jobs; public sealed class ClearOldShockerControlLogs { private readonly OpenShockContext _db; + private readonly IHostApplicationLifetime _lifetime; private readonly ILogger _logger; /// /// DI constructor /// /// + /// Its stopping token cancels the job when the server shuts down. /// - public ClearOldShockerControlLogs(OpenShockContext db, ILogger logger) + public ClearOldShockerControlLogs(OpenShockContext db, IHostApplicationLifetime lifetime, ILogger logger) { _db = db; + _lifetime = lifetime; _logger = logger; } public async Task Execute() { + var cancellationToken = _lifetime.ApplicationStopping; var deletedUserLimits = - await _db.DeleteControlLogsBeyondPerUserLimitAsync(HardLimits.MaxShockerControlLogsPerUser); + await _db.DeleteControlLogsBeyondPerUserLimitAsync(HardLimits.MaxShockerControlLogsPerUser, cancellationToken); _logger.LogInformation("Deleted {deletedUserLimits} shocker control logs exceeding the per-user limit", deletedUserLimits); diff --git a/Cron/Jobs/DeleteDeactivatedAccounts.cs b/Cron/Jobs/DeleteDeactivatedAccounts.cs index 32dc6f33..834fde1b 100644 --- a/Cron/Jobs/DeleteDeactivatedAccounts.cs +++ b/Cron/Jobs/DeleteDeactivatedAccounts.cs @@ -12,27 +12,31 @@ namespace OpenShock.Cron.Jobs; public sealed class DeleteDeactivatedAccounts { private readonly OpenShockContext _db; + private readonly IHostApplicationLifetime _lifetime; private readonly ILogger _logger; /// /// DI constructor /// /// + /// Its stopping token cancels the job when the server shuts down. /// - public DeleteDeactivatedAccounts(OpenShockContext db, ILogger logger) + public DeleteDeactivatedAccounts(OpenShockContext db, IHostApplicationLifetime lifetime, ILogger logger) { _db = db; + _lifetime = lifetime; _logger = logger; } public async Task Execute() { + var cancellationToken = _lifetime.ApplicationStopping; var earliestCreatedAtUtc = DateTime.UtcNow - Duration.DeactivatedAccountRetentionTime; // Run the delete query int nDeleted = await _db.Users .Where(x => x.UserDeactivation != null && x.UserDeactivation.DeleteLater && x.UserDeactivation.CreatedAt <= earliestCreatedAtUtc) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(cancellationToken); _logger.LogInformation("Deleted {deletedCount} deactivated accounts since {earliestCreatedOnUtc}", nDeleted, earliestCreatedAtUtc); diff --git a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs index fd364a85..72d0bc61 100644 --- a/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs +++ b/Cron/Jobs/DeleteExpiredAutomatedAccountsJob.cs @@ -14,16 +14,19 @@ namespace OpenShock.Cron.Jobs; public sealed class DeleteExpiredAutomatedAccountsJob { private readonly OpenShockContext _db; + private readonly IHostApplicationLifetime _lifetime; private readonly ILogger _logger; - public DeleteExpiredAutomatedAccountsJob(OpenShockContext db, ILogger logger) + public DeleteExpiredAutomatedAccountsJob(OpenShockContext db, IHostApplicationLifetime lifetime, ILogger logger) { _db = db; + _lifetime = lifetime; _logger = logger; } public async Task Execute() { + var cancellationToken = _lifetime.ApplicationStopping; var now = DateTime.UtcNow; // Selecting and deleting in one statement means an account promoted between a read and the delete can't @@ -44,7 +47,7 @@ USING t AND u.created_at + t.auto_cleanup_after < {now} AND NOT (u.roles && {PrivilegedRoles.All}) RETURNING u.id AS "UserId", u.created_by_automation_token_id AS "AutomationTokenId" - """).ToListAsync(); + """).ToListAsync(cancellationToken); if (deleted.Count == 0) { diff --git a/Cron/Jobs/EmailOutboxDeliveryJob.cs b/Cron/Jobs/EmailOutboxDeliveryJob.cs index c970be61..e75ab0da 100644 --- a/Cron/Jobs/EmailOutboxDeliveryJob.cs +++ b/Cron/Jobs/EmailOutboxDeliveryJob.cs @@ -28,6 +28,7 @@ public sealed class EmailOutboxDeliveryJob private const int BatchSize = EmailOutboxQueries.ClaimBatchSize; private readonly OpenShockContext _db; + private readonly IHostApplicationLifetime _lifetime; private readonly IEmailOutboxDispatcher _dispatcher; private readonly ILogger _logger; @@ -35,28 +36,36 @@ public sealed class EmailOutboxDeliveryJob /// DI constructor /// /// + /// Its stopping token cancels the job when the server shuts down. /// /// - public EmailOutboxDeliveryJob(OpenShockContext db, IEmailOutboxDispatcher dispatcher, ILogger logger) + public EmailOutboxDeliveryJob(OpenShockContext db, IHostApplicationLifetime lifetime, IEmailOutboxDispatcher dispatcher, ILogger logger) { _db = db; + _lifetime = lifetime; _dispatcher = dispatcher; _logger = logger; } public async Task Execute() { + var cancellationToken = _lifetime.ApplicationStopping; int claimed; do { - var ids = await ClaimDueBatchAsync(); + var ids = await ClaimDueBatchAsync(cancellationToken); claimed = ids.Count; foreach (var id in ids) { try { - await DeliverAsync(id); + await DeliverAsync(id, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Shutting down: stop the batch. Claimed rows stay Sending and are retried when their lease lapses. + throw; } catch (DbUpdateConcurrencyException) { @@ -82,17 +91,17 @@ public async Task Execute() /// is held only for this short transaction; the sends happen afterwards, outside it. Returns the ids /// now owned by this run. /// - private async Task> ClaimDueBatchAsync() + private async Task> ClaimDueBatchAsync(CancellationToken cancellationToken) { _db.ChangeTracker.Clear(); - await using var transaction = await _db.Database.BeginTransactionAsync(); + await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken); - var due = await _db.EmailOutbox.DueForDelivery(BatchSize).ToListAsync(); + var due = await _db.EmailOutbox.DueForDelivery(BatchSize).ToListAsync(cancellationToken); if (due.Count == 0) { - await transaction.CommitAsync(); + await transaction.CommitAsync(cancellationToken); return []; } @@ -112,8 +121,8 @@ private async Task> ClaimDueBatchAsync() } } - await _db.SaveChangesAsync(); - await transaction.CommitAsync(); + await _db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); return claimed; } @@ -123,11 +132,11 @@ private async Task> ClaimDueBatchAsync() /// carries the current concurrency token; if the row is no longer /// another run already resolved it and this one bows out. /// - private async Task DeliverAsync(Guid messageId) + private async Task DeliverAsync(Guid messageId, CancellationToken cancellationToken) { _db.ChangeTracker.Clear(); - var message = await _db.EmailOutbox.FirstOrDefaultAsync(m => m.Id == messageId); + var message = await _db.EmailOutbox.FirstOrDefaultAsync(m => m.Id == messageId, cancellationToken); if (message is null) { _logger.LogWarning("Email outbox message {MessageId} no longer exists; nothing to send", messageId); @@ -147,17 +156,17 @@ private async Task DeliverAsync(Guid messageId) .Where(m => m.CoalesceKey == message.CoalesceKey) .OrderByDescending(m => m.CreatedAt).ThenByDescending(m => m.Id) .Select(m => m.Id) - .FirstAsync(); + .FirstAsync(cancellationToken); if (newestId != message.Id) { EmailOutboxStateMachine.ApplyResult(message, EmailDispatchResult.Skip("Superseded by a newer request"), DateTime.UtcNow); - await _db.SaveChangesAsync(); + await _db.SaveChangesAsync(cancellationToken); return; } } - var result = await _dispatcher.SendAsync(message, _db); + var result = await _dispatcher.SendAsync(message, _db, cancellationToken); if (result.Outcome == EmailDispatchOutcome.TransientFailure && message.AttemptCount >= EmailOutboxRetryPolicy.MaxAttempts) { @@ -166,6 +175,7 @@ private async Task DeliverAsync(Guid messageId) } EmailOutboxStateMachine.ApplyResult(message, result, DateTime.UtcNow); + // Not cancellable: once the send has happened, the outcome must be recorded or it will be sent again. await _db.SaveChangesAsync(); } } diff --git a/Cron/Jobs/OtaTimeoutJob.cs b/Cron/Jobs/OtaTimeoutJob.cs index a9f85020..3db51a1b 100644 --- a/Cron/Jobs/OtaTimeoutJob.cs +++ b/Cron/Jobs/OtaTimeoutJob.cs @@ -9,24 +9,28 @@ namespace OpenShock.Cron.Jobs; public sealed class OtaTimeoutJob { private readonly OpenShockContext _db; + private readonly IHostApplicationLifetime _lifetime; /// /// DI constructor /// /// - public OtaTimeoutJob(OpenShockContext db) + /// Its stopping token cancels the job when the server shuts down. + public OtaTimeoutJob(OpenShockContext db, IHostApplicationLifetime lifetime) { _db = db; + _lifetime = lifetime; } public async Task Execute() { + var cancellationToken = _lifetime.ApplicationStopping; var time = DateTime.UtcNow.Subtract(TimeSpan.FromMinutes(10)); await _db.DeviceOtaUpdates .Where(x => (x.Status == OtaUpdateStatus.Started || x.Status == OtaUpdateStatus.Running) && x.CreatedAt < time) .ExecuteUpdateAsync(calls => calls.SetProperty(x => x.Status, OtaUpdateStatus.Timeout) - .SetProperty(x => x.Message, "Timeout reached")); + .SetProperty(x => x.Message, "Timeout reached"), cancellationToken); } } \ No newline at end of file diff --git a/LiveControlGateway/Controllers/HubV1Controller.cs b/LiveControlGateway/Controllers/HubV1Controller.cs index 9d3a067f..2e702f67 100644 --- a/LiveControlGateway/Controllers/HubV1Controller.cs +++ b/LiveControlGateway/Controllers/HubV1Controller.cs @@ -53,7 +53,7 @@ ILogger logger private IUserHub HcOwner => _userHubContext.Clients.User(CurrentHubOwnerId.ToString()); /// - protected override async Task Handle(HubToGatewayMessage data) + protected override async Task Handle(HubToGatewayMessage data, CancellationToken cancellationToken) { if(!data.Payload.HasValue) return false; var payload = data.Payload.Value; @@ -81,7 +81,8 @@ await otaService.Started( CurrentHubId, payload.OtaInstallStarted.UpdateId, - SemVersion.FromFbs(payload.OtaInstallStarted.Version!)); + SemVersion.FromFbs(payload.OtaInstallStarted.Version!), + cancellationToken); break; case HubToGatewayMessagePayload.ItemKind.OtaInstallProgress: @@ -94,7 +95,7 @@ await HcOwner.OtaInstallProgress( if (_lastStatus == OtaUpdateStatus.Started) { _lastStatus = OtaUpdateStatus.Running; - await otaService.Progress(CurrentHubId, payload.OtaInstallProgress.UpdateId); + await otaService.Progress(CurrentHubId, payload.OtaInstallProgress.UpdateId, cancellationToken); } break; @@ -107,7 +108,8 @@ await HcOwner.OtaInstallFailed( payload.OtaInstallFailed.Message!); await otaService.Error(CurrentHubId, payload.OtaInstallFailed.UpdateId, - payload.OtaInstallFailed.Fatal, payload.OtaInstallFailed.Message!); + payload.OtaInstallFailed.Fatal, payload.OtaInstallFailed.Message!, + cancellationToken); _lastStatus = OtaUpdateStatus.Error; break; @@ -118,7 +120,7 @@ await otaService.Error(CurrentHubId, payload.OtaInstallFailed.UpdateId, await HcOwner.OtaInstallSucceeded( CurrentHubId, payload.BootStatus.OtaUpdateId); - await otaService.Success(CurrentHubId, payload.BootStatus.OtaUpdateId); + await otaService.Success(CurrentHubId, payload.BootStatus.OtaUpdateId, cancellationToken); _lastStatus = OtaUpdateStatus.Finished; break; } @@ -129,7 +131,8 @@ await HcOwner.OtaRollback( CurrentHubId, payload.BootStatus.OtaUpdateId); await otaService.Error(CurrentHubId, payload.BootStatus.OtaUpdateId, false, - "Hub booted with firmware rollback"); + "Hub booted with firmware rollback", + cancellationToken); _lastStatus = OtaUpdateStatus.Error; break; } @@ -139,7 +142,8 @@ await otaService.Error(CurrentHubId, payload.BootStatus.OtaUpdateId, false, if (payload.BootStatus.OtaUpdateId == 0) break; var unfinished = await otaService.UpdateUnfinished(CurrentHubId, - payload.BootStatus.OtaUpdateId); + payload.BootStatus.OtaUpdateId, + cancellationToken); if (!unfinished) break; @@ -149,7 +153,8 @@ await HcOwner.OtaRollback( CurrentHubId, payload.BootStatus.OtaUpdateId); await otaService.Error(CurrentHubId, payload.BootStatus.OtaUpdateId, false, - "Hub booted with normal boot, update seems unfinished"); + "Hub booted with normal boot, update seems unfinished", + cancellationToken); _lastStatus = OtaUpdateStatus.Error; } diff --git a/LiveControlGateway/Controllers/HubV2Controller.cs b/LiveControlGateway/Controllers/HubV2Controller.cs index a7dc7d4d..695fe348 100644 --- a/LiveControlGateway/Controllers/HubV2Controller.cs +++ b/LiveControlGateway/Controllers/HubV2Controller.cs @@ -76,7 +76,7 @@ await QueueMessage(new GatewayToHubMessage private IUserHub HcOwner => _userHubContext.Clients.User(CurrentHubOwnerId.ToString()); /// - protected override async Task Handle(HubToGatewayMessage data) + protected override async Task Handle(HubToGatewayMessage data, CancellationToken cancellationToken) { var payload = data.Payload; @@ -112,7 +112,8 @@ await HcOwner.OtaInstallStarted( await otaService.Started( CurrentHubId, payload.OtaUpdateStarted.UpdateId, - SemVersion.FromFbs(payload.OtaUpdateStarted.Version)); + SemVersion.FromFbs(payload.OtaUpdateStarted.Version), + cancellationToken); break; case HubToGatewayMessagePayload.ItemKind.OtaUpdateProgress: @@ -125,7 +126,7 @@ await HcOwner.OtaInstallProgress( if (_lastStatus == OtaUpdateStatus.Started) { _lastStatus = OtaUpdateStatus.Running; - await otaService.Progress(CurrentHubId, payload.OtaUpdateProgress.UpdateId); + await otaService.Progress(CurrentHubId, payload.OtaUpdateProgress.UpdateId, cancellationToken); } break; @@ -138,7 +139,8 @@ await HcOwner.OtaInstallFailed( payload.OtaUpdateFailed.Message!); await otaService.Error(CurrentHubId, payload.OtaUpdateFailed.UpdateId, - payload.OtaUpdateFailed.Fatal, payload.OtaUpdateFailed.Message!); + payload.OtaUpdateFailed.Fatal, payload.OtaUpdateFailed.Message!, + cancellationToken); _lastStatus = OtaUpdateStatus.Error; break; @@ -149,7 +151,7 @@ await otaService.Error(CurrentHubId, payload.OtaUpdateFailed.UpdateId, await HcOwner.OtaInstallSucceeded( CurrentHubId, payload.BootStatus.OtaUpdateId); - await otaService.Success(CurrentHubId, payload.BootStatus.OtaUpdateId); + await otaService.Success(CurrentHubId, payload.BootStatus.OtaUpdateId, cancellationToken); _lastStatus = OtaUpdateStatus.Finished; break; } @@ -160,7 +162,8 @@ await HcOwner.OtaRollback( CurrentHubId, payload.BootStatus.OtaUpdateId); await otaService.Error(CurrentHubId, payload.BootStatus.OtaUpdateId, false, - "Hub booted with firmware rollback"); + "Hub booted with firmware rollback", + cancellationToken); _lastStatus = OtaUpdateStatus.Error; break; } @@ -170,7 +173,8 @@ await otaService.Error(CurrentHubId, payload.BootStatus.OtaUpdateId, false, if (payload.BootStatus.OtaUpdateId == 0) break; var unfinished = await otaService.UpdateUnfinished(CurrentHubId, - payload.BootStatus.OtaUpdateId); + payload.BootStatus.OtaUpdateId, + cancellationToken); if (!unfinished) break; @@ -180,7 +184,8 @@ await HcOwner.OtaRollback( CurrentHubId, payload.BootStatus.OtaUpdateId); await otaService.Error(CurrentHubId, payload.BootStatus.OtaUpdateId, false, - "Hub booted with normal boot, update seems unfinished"); + "Hub booted with normal boot, update seems unfinished", + cancellationToken); _lastStatus = OtaUpdateStatus.Error; } diff --git a/LiveControlGateway/Websocket/FlatbuffersWebsocketBaseController.cs b/LiveControlGateway/Websocket/FlatbuffersWebsocketBaseController.cs index 78c23e42..0fbcf92d 100644 --- a/LiveControlGateway/Websocket/FlatbuffersWebsocketBaseController.cs +++ b/LiveControlGateway/Websocket/FlatbuffersWebsocketBaseController.cs @@ -45,8 +45,9 @@ protected override Task /// Handle the incoming message /// /// + /// /// false if message is invalid - protected abstract Task Handle(TIn data); + protected abstract Task Handle(TIn data, CancellationToken cancellationToken); /// protected override async Task HandleReceive(CancellationToken cancellationToken) @@ -66,7 +67,7 @@ await FlatbufferWebSocketUtils.ReceiveFullMessageAsyncNonAlloc(WebSocket!, Logger.LogTrace("Client sent closure"); return false; case TIn data: - return await Handle(data); + return await Handle(data, cancellationToken); default: return false; }