diff --git a/.dockerignore b/.dockerignore index e06e0181..88e98f8a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,4 +9,4 @@ API/SmtpTemplates/dist/ **/appsettings.Development.json Dockerfile* **/*.md -dev/ \ No newline at end of file +Dev/ \ No newline at end of file diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 4220b5fd..26737b36 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -32,7 +32,7 @@ name: ci-build env: REGISTRY: ghcr.io - DOTNET_VERSION: 10.0.x + DOTNET_VERSION: 11.0.x jobs: diff --git a/.github/workflows/ci-tag.yml b/.github/workflows/ci-tag.yml index 9f78b9f9..1dacbadb 100644 --- a/.github/workflows/ci-tag.yml +++ b/.github/workflows/ci-tag.yml @@ -7,7 +7,7 @@ on: name: ci-tag env: - DOTNET_VERSION: 10.0.x + DOTNET_VERSION: 11.0.x REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository_owner }}/api diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 64705761..962e2473 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,7 +9,7 @@ on: - cron: '0 6 * * 1' env: - DOTNET_VERSION: 10.x.x + DOTNET_VERSION: 11.0.x jobs: analyze: diff --git a/.github/workflows/update-cloudflare-proxies.yml b/.github/workflows/update-cloudflare-proxies.yml index e3e4f295..c12ca3cb 100644 --- a/.github/workflows/update-cloudflare-proxies.yml +++ b/.github/workflows/update-cloudflare-proxies.yml @@ -1,5 +1,3 @@ -name: Update Cloudflare Proxies - on: schedule: - cron: '0 0 1 * *' # runs at 00:00 UTC on the 1st day of every month @@ -9,6 +7,11 @@ on: - '.github/workflows/update-cloudflare-proxies.yml' - 'Common/CloudflareIPs.targets' +name: Update Cloudflare Proxies + +env: + DOTNET_VERSION: 11.0.x + jobs: update-proxies: runs-on: ubuntu-latest diff --git a/API/Controller/Account/Authenticated/ChangeEmail.cs b/API/Controller/Account/Authenticated/ChangeEmail.cs index 4538b769..487570f2 100644 --- a/API/Controller/Account/Authenticated/ChangeEmail.cs +++ b/API/Controller/Account/Authenticated/ChangeEmail.cs @@ -2,9 +2,11 @@ using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; +using OpenShock.API.Services.Account; using OpenShock.Common.Errors; using OpenShock.Common.Problems; using OpenShock.Common.Utils; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account.Authenticated; @@ -38,13 +40,16 @@ public async Task ChangeEmail([FromBody] ChangeEmailRequest body) var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id); - return result.Match( - success => Ok(), - alreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse), - unchanged => Problem(AccountError.EmailChangeUnchanged), - tooMany => Problem(AccountError.EmailChangeTooMany), - notActivated => throw new UnreachableException("Authenticated user is not activated"), - deactivated => throw new UnreachableException("Authenticated user is deactivated"), - notFound => throw new UnreachableException("Authenticated user not found in database")); + return result switch + { + Results.Success => Ok(), + EmailAlreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse), + EmailUnchanged => Problem(AccountError.EmailChangeUnchanged), + TooManyEmailChanges => Problem(AccountError.EmailChangeTooMany), + AccountNotActivated => throw new UnreachableException("Authenticated user is not activated"), + AccountDeactivated => throw new UnreachableException("Authenticated user is deactivated"), + Results.NotFound => throw new UnreachableException("Authenticated user not found in database"), + _ => throw new UnreachableException() + }; } } diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index bdd4ae61..22358e45 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -2,9 +2,11 @@ using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; +using OpenShock.API.Services.Account; using OpenShock.Common.Errors; using OpenShock.Common.Problems; using OpenShock.Common.Utils; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account.Authenticated; @@ -38,10 +40,13 @@ public async Task ChangePassword([FromBody] ChangePasswordRequest var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id); - return result.Match( - success => Ok(), - notActivated => throw new UnreachableException("Authenticated user is not activated"), - deactivated => throw new UnreachableException("Authenticated user is deactivated"), - notFound => throw new UnreachableException("Authenticated user not found in database")); + return result switch + { + Results.Success => Ok(), + AccountNotActivated => throw new UnreachableException("Authenticated user is not activated"), + AccountDeactivated => throw new UnreachableException("Authenticated user is deactivated"), + Results.NotFound => throw new UnreachableException("Authenticated user not found in database"), + _ => throw new UnreachableException() + }; } } diff --git a/API/Controller/Account/Authenticated/ChangeUsername.cs b/API/Controller/Account/Authenticated/ChangeUsername.cs index da212791..9d03af33 100644 --- a/API/Controller/Account/Authenticated/ChangeUsername.cs +++ b/API/Controller/Account/Authenticated/ChangeUsername.cs @@ -1,9 +1,13 @@ -using System.Net.Mime; +using System.Diagnostics; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; +using OpenShock.API.Services.Account; using OpenShock.Common.Errors; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; +using OpenShock.Common.Validation; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account.Authenticated; @@ -26,12 +30,15 @@ public async Task ChangeUsername([FromBody] ChangeUsernameRequest 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)); - return result.Match( - success => Ok(), - usernametaken => Problem(AccountError.UsernameTaken), - usernameerror => Problem(AccountError.UsernameInvalid(usernameerror)), - recentlychanged => Problem(AccountError.UsernameRecentlyChanged), - accountdeactivated => Problem(AccountError.AccountDeactivated), - notfound => throw new Exception("Unexpected result, apparently our current user does not exist...")); + return result switch + { + Results.Success => Ok(), + UsernameTaken => Problem(AccountError.UsernameTaken), + UsernameError usernameError => Problem(AccountError.UsernameInvalid(usernameError)), + RecentlyChanged => Problem(AccountError.UsernameRecentlyChanged), + AccountDeactivated => Problem(AccountError.AccountDeactivated), + Results.NotFound => throw new Exception("Unexpected result, apparently our current user does not exist..."), + _ => throw new UnreachableException() + }; } } \ No newline at end of file diff --git a/API/Controller/Account/Authenticated/Deactivate.cs b/API/Controller/Account/Authenticated/Deactivate.cs index 6e7e8efc..0a0ada1a 100644 --- a/API/Controller/Account/Authenticated/Deactivate.cs +++ b/API/Controller/Account/Authenticated/Deactivate.cs @@ -1,7 +1,11 @@ using Microsoft.AspNetCore.Mvc; +using System.Diagnostics; using System.Net.Mime; +using OpenShock.API.Services.Account; using OpenShock.Common.Errors; using OpenShock.Common.Problems; +using AccountSvc = OpenShock.API.Services.Account; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account.Authenticated; @@ -17,12 +21,14 @@ public sealed partial class AuthenticatedAccountController public async Task Deactivate() { var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true); - return deactivationResult.Match( - success => NoContent(), - cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => throw new Exception("This is not supposed to happen, wtf?") - ); + return deactivationResult switch + { + Results.Success => NoContent(), + CannotDeactivatePrivilegedAccount => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), + AccountDeactivationAlreadyInProgress => Problem(AccountActivationError.AlreadyDeactivated), + AccountSvc.Unauthorized => Problem(AccountActivationError.Unauthorized), + Results.NotFound => throw new Exception("This is not supposed to happen, wtf?"), + _ => throw new UnreachableException() + }; } } \ No newline at end of file diff --git a/API/Controller/Account/CheckUsername.cs b/API/Controller/Account/CheckUsername.cs index 60df27fa..b533c87e 100644 --- a/API/Controller/Account/CheckUsername.cs +++ b/API/Controller/Account/CheckUsername.cs @@ -1,7 +1,10 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; +using OpenShock.API.Services.Account; +using OpenShock.Common.Results; using OpenShock.Common.Validation; namespace OpenShock.API.Controller.Account; @@ -20,11 +23,13 @@ public async Task CheckUsername([FromBody] ChangeUsername { var result = await _accountService.CheckUsernameAvailabilityAsync(body.Username, cancellationToken); - return result.Match( - success => new UsernameCheckResponse(UsernameAvailability.Available), - taken => new UsernameCheckResponse(UsernameAvailability.Taken), - invalid => new UsernameCheckResponse(UsernameAvailability.Invalid, invalid) - ); + return result switch + { + Success => new UsernameCheckResponse(UsernameAvailability.Available), + UsernameTaken => new UsernameCheckResponse(UsernameAvailability.Taken), + UsernameError invalid => new UsernameCheckResponse(UsernameAvailability.Invalid, invalid), + _ => throw new UnreachableException() + }; } } diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 43ae9c7c..c32413bf 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -1,12 +1,16 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; +using System.Diagnostics; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Services.Account; using OpenShock.Common.Errors; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; using OpenShock.API.Models.Response; using OpenShock.API.Services.Turnstile; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account; @@ -36,14 +40,16 @@ public async Task LoginV2( if (turnstileError is not null) return turnstileError; var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); - if (!getAccountResult.TryPickT0(out var account, out var errors)) + if (getAccountResult is not User account) { - return errors.Match( - notFound => Problem(LoginError.InvalidCredentials), - deactivated => Problem(AccountError.AccountDeactivated), - notActivated => Problem(AccountError.AccountNotActivated), - oauthOnly => Problem(AccountError.AccountOAuthOnly) - ); + return getAccountResult switch + { + Results.NotFound => Problem(LoginError.InvalidCredentials), + AccountDeactivated => Problem(AccountError.AccountDeactivated), + AccountNotActivated => Problem(AccountError.AccountNotActivated), + AccountIsOAuthOnly => Problem(AccountError.AccountOAuthOnly), + _ => throw new UnreachableException() + }; } await CreateSession(account.Id, cookieDomain); diff --git a/API/Controller/Account/PasswordResetCheckValid.cs b/API/Controller/Account/PasswordResetCheckValid.cs index b5f1312f..3397f0ad 100644 --- a/API/Controller/Account/PasswordResetCheckValid.cs +++ b/API/Controller/Account/PasswordResetCheckValid.cs @@ -1,11 +1,14 @@ using System; +using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Services.Account; using OpenShock.Common.Errors; using OpenShock.Common.Problems; using OpenShock.Common.Models; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account; @@ -39,10 +42,12 @@ public IActionResult PasswordResetCheckValidLegacy([FromRoute] Guid passwordRese public async Task PasswordResetCheckValid([FromRoute] Guid passwordResetId, [FromRoute] string secret, CancellationToken cancellationToken) { var passwordResetExists = await _accountService.CheckPasswordResetExistsAsync(passwordResetId, secret, cancellationToken); - return passwordResetExists.Match( - success => LegacyEmptyOk("Valid password reset process"), - notFound => Problem(PasswordResetError.PasswordResetNotFound), - invalid => Problem(PasswordResetError.PasswordResetNotFound) - ); + return passwordResetExists switch + { + Results.Success => LegacyEmptyOk("Valid password reset process"), + Results.NotFound => Problem(PasswordResetError.PasswordResetNotFound), + SecretInvalid => Problem(PasswordResetError.PasswordResetNotFound), + _ => throw new UnreachableException() + }; } } \ No newline at end of file diff --git a/API/Controller/Account/PasswordResetComplete.cs b/API/Controller/Account/PasswordResetComplete.cs index 78f6f615..e0e174c2 100644 --- a/API/Controller/Account/PasswordResetComplete.cs +++ b/API/Controller/Account/PasswordResetComplete.cs @@ -1,11 +1,14 @@ using System; +using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; +using OpenShock.API.Services.Account; using OpenShock.Common.Errors; using OpenShock.Common.Problems; using OpenShock.Common.Models; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account; @@ -42,13 +45,15 @@ public async Task PasswordResetComplete([FromRoute] Guid password { var passwordResetComplete = await _accountService.CompletePasswordResetFlowAsync(passwordResetId, secret, body.Password); - return passwordResetComplete.Match( - success => LegacyEmptyOk("Password successfully changed"), - notFound => Problem(PasswordResetError.PasswordResetNotFound), - notActivated => Problem(AccountError.AccountNotActivated), - deactivated => Problem(AccountError.AccountDeactivated), - invalid => Problem(PasswordResetError.PasswordResetNotFound) - ); + return passwordResetComplete switch + { + Results.Success => LegacyEmptyOk("Password successfully changed"), + Results.NotFound => Problem(PasswordResetError.PasswordResetNotFound), + AccountNotActivated => Problem(AccountError.AccountNotActivated), + AccountDeactivated => Problem(AccountError.AccountDeactivated), + SecretInvalid => Problem(PasswordResetError.PasswordResetNotFound), + _ => throw new UnreachableException() + }; } diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index cf3c2f72..e1ce9597 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -1,12 +1,16 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; +using OpenShock.API.Services.Account; +using System.Diagnostics; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Errors; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Results; namespace OpenShock.API.Controller.Account; @@ -41,9 +45,11 @@ public async Task SignUpV2( if (turnstileError is not null) return turnstileError; var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); - return creationAction.Match( - _ => Ok(), - _ => Problem(SignupError.UsernameOrEmailExists) - ); + return creationAction switch + { + User _ => Ok(), + AccountWithEmailOrUsernameExists => Problem(SignupError.UsernameOrEmailExists), + _ => throw new UnreachableException() + }; } } \ No newline at end of file diff --git a/API/Controller/Account/VerifyEmail.cs b/API/Controller/Account/VerifyEmail.cs index 93b840d0..d3733bc3 100644 --- a/API/Controller/Account/VerifyEmail.cs +++ b/API/Controller/Account/VerifyEmail.cs @@ -1,9 +1,12 @@ using System; +using System.Diagnostics; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; +using OpenShock.API.Services.Account; using OpenShock.Common.Errors; using OpenShock.Common.Problems; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Account; @@ -42,9 +45,12 @@ private async Task VerifyPendingEmailChange(string token, Cancell { var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken); - return result.Match( - success => Ok(), - notFound => Problem(AccountError.EmailChangeNotFound), - emailTaken => Problem(AccountError.EmailChangeAlreadyInUse)); + return result switch + { + VerifyEmailSuccess => Ok(), + Results.NotFound => Problem(AccountError.EmailChangeNotFound), + EmailAlreadyInUse => Problem(AccountError.EmailChangeAlreadyInUse), + _ => throw new UnreachableException() + }; } } \ No newline at end of file diff --git a/API/Controller/Account/_Turnstile.cs b/API/Controller/Account/_Turnstile.cs index d01baaaa..df13424f 100644 --- a/API/Controller/Account/_Turnstile.cs +++ b/API/Controller/Account/_Turnstile.cs @@ -3,6 +3,7 @@ using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Problems; +using OpenShock.Common.Results; using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account; @@ -16,9 +17,8 @@ public sealed partial class AccountController private async Task VerifyTurnstileAsync(ICloudflareTurnstileService turnstileService, string turnstileResponse, CancellationToken cancellationToken) { var turnStile = await turnstileService.VerifyUserResponseTokenAsync(turnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); - if (turnStile.IsT0) return null; + if (turnStile is not CloudflareTurnstileError[] cfErrors) return null; - var cfErrors = turnStile.AsT1.Value; if (cfErrors.All(err => err.IsClientError())) return Problem(TurnstileError.InvalidTurnstile); diff --git a/API/Controller/Admin/Configuration.cs b/API/Controller/Admin/Configuration.cs index 07b43995..ba5c3784 100644 --- a/API/Controller/Admin/Configuration.cs +++ b/API/Controller/Admin/Configuration.cs @@ -5,6 +5,7 @@ using OpenShock.Common.Problems; using OpenShock.Common.Services.Configuration; using System.Net.Mime; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Admin; @@ -58,12 +59,13 @@ public async Task ConfigurationAdd([FromBody] ConfigurationAddIte body.Value ); - return result.Match( - success => Ok(), - alreadyExists => Problem(ConfigurationError.AlreadyExists(body.Name)), - invalidName => Problem(ConfigurationError.InvalidNameFormat(body.Name)), - invalidValue => Problem(ConfigurationError.InvalidValueFormat(body.Value)) - ); + return result switch + { + Results.Success => Ok(), + AlreadyExists => Problem(ConfigurationError.AlreadyExists(body.Name)), + InvalidNameFormat => Problem(ConfigurationError.InvalidNameFormat(body.Name)), + InvalidValueFormat => Problem(ConfigurationError.InvalidValueFormat(body.Value)) + }; } /// @@ -89,12 +91,13 @@ public async Task ConfigurationUpdate([FromBody] ConfigurationUpd body.Value ); - return result.Match( - success => Ok(), - notFound => Problem(ConfigurationError.NotFound(body.Name)), - invalidName => Problem(ConfigurationError.InvalidNameFormat(body.Name)), - invalidValue => Problem(ConfigurationError.InvalidValueFormat(body.Value!)) - ); + return result switch + { + Results.Success => Ok(), + Results.NotFound => Problem(ConfigurationError.NotFound(body.Name)), + InvalidNameFormat => Problem(ConfigurationError.InvalidNameFormat(body.Name)), + InvalidValueFormat => Problem(ConfigurationError.InvalidValueFormat(body.Value!)) + }; } /// @@ -114,10 +117,11 @@ public async Task ConfigurationDelete([FromRoute] string name, [F var result = await configurationService.TryDeleteItemAsync(name); - return result.Match( - success => Ok(), - notFound => Problem(ConfigurationError.NotFound(name)), - invalidName => Problem(ConfigurationError.InvalidNameFormat(name)) - ); + return result switch + { + Results.Success => Ok(), + Results.NotFound => Problem(ConfigurationError.NotFound(name)), + InvalidNameFormat => Problem(ConfigurationError.InvalidNameFormat(name)) + }; } } \ No newline at end of file diff --git a/API/Controller/Admin/DeactivateUser.cs b/API/Controller/Admin/DeactivateUser.cs index 25a5e7ce..95659130 100644 --- a/API/Controller/Admin/DeactivateUser.cs +++ b/API/Controller/Admin/DeactivateUser.cs @@ -1,8 +1,11 @@ using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Constants; using OpenShock.Common.Errors; +using AccountSvc = OpenShock.API.Services.Account; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Admin; @@ -24,12 +27,14 @@ public async Task DeactivateUser( PedanticallyEnsureAdmin(); var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater, reason); - return deactivationResult.Match( - success => Ok("Account deactivated"), - cannotDeactivatePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - alreadyDeactivated => Problem(AccountActivationError.AlreadyDeactivated), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") - ); + return deactivationResult switch + { + Results.Success => Ok("Account deactivated"), + CannotDeactivatePrivilegedAccount => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), + AccountDeactivationAlreadyInProgress => Problem(AccountActivationError.AlreadyDeactivated), + AccountSvc.Unauthorized => Problem(AccountActivationError.Unauthorized), + Results.NotFound => NotFound("User not found"), + _ => throw new UnreachableException() + }; } } \ No newline at end of file diff --git a/API/Controller/Admin/DeleteUser.cs b/API/Controller/Admin/DeleteUser.cs index 45349527..12911dcc 100644 --- a/API/Controller/Admin/DeleteUser.cs +++ b/API/Controller/Admin/DeleteUser.cs @@ -1,8 +1,11 @@ using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Constants; using OpenShock.Common.Errors; +using AccountSvc = OpenShock.API.Services.Account; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Admin; @@ -23,11 +26,13 @@ public async Task DeleteUser( PedanticallyEnsureAdmin(); var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId, reason); - return result.Match( - success => Ok("Account deleted"), - cannotDeletePrivledged => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") - ); + return result switch + { + Results.Success => Ok("Account deleted"), + CannotDeletePrivilegedAccount => Problem(AccountActivationError.CannotDeactivateOrDeletePrivledgedAccount), + AccountSvc.Unauthorized => Problem(AccountActivationError.Unauthorized), + Results.NotFound => NotFound("User not found"), + _ => throw new UnreachableException() + }; } } \ No newline at end of file diff --git a/API/Controller/Admin/ReactivateUser.cs b/API/Controller/Admin/ReactivateUser.cs index 190eacb5..4f230179 100644 --- a/API/Controller/Admin/ReactivateUser.cs +++ b/API/Controller/Admin/ReactivateUser.cs @@ -1,8 +1,11 @@ using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Services.Account; using OpenShock.Common.Constants; using OpenShock.Common.Errors; +using AccountSvc = OpenShock.API.Services.Account; +using Results = OpenShock.Common.Results; namespace OpenShock.API.Controller.Admin; @@ -23,10 +26,12 @@ public async Task ReactivateUser( PedanticallyEnsureAdmin(); var reactivationResult = await accountService.ReactivateAccountAsync(CurrentUser.Id, userId, reason); - return reactivationResult.Match( - success => Ok("Account reactivated"), - unauthorized => Problem(AccountActivationError.Unauthorized), - notFound => NotFound("User not found") - ); + return reactivationResult switch + { + Results.Success => Ok("Account reactivated"), + AccountSvc.Unauthorized => Problem(AccountActivationError.Unauthorized), + Results.NotFound => NotFound("User not found"), + _ => throw new UnreachableException() + }; } } \ No newline at end of file diff --git a/API/Controller/Admin/WebhookAdd.cs b/API/Controller/Admin/WebhookAdd.cs index 1123e659..e8bee8bc 100644 --- a/API/Controller/Admin/WebhookAdd.cs +++ b/API/Controller/Admin/WebhookAdd.cs @@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Controller.Admin.DTOs; using OpenShock.Common.Errors; +using OpenShock.Common.Models; +using OpenShock.Common.Results; using OpenShock.Common.Services.Webhook; namespace OpenShock.API.Controller.Admin; @@ -22,9 +24,10 @@ public async Task AddWebhook([FromBody] AddWebhookDto body, [From PedanticallyEnsureAdmin(); var result = await webhookService.AddWebhookAsync(body.Name, body.Url); - return result.Match( - success => Ok(success.Value), - unsupported => Problem(AdminError.WebhookOnlyDiscord) - ); + return result switch + { + Success success => Ok(success.Value), + UnsupportedWebhookUrl => Problem(AdminError.WebhookOnlyDiscord) + }; } } \ No newline at end of file diff --git a/API/Controller/Devices/DevicesController.cs b/API/Controller/Devices/DevicesController.cs index 38cfacb5..551e508d 100644 --- a/API/Controller/Devices/DevicesController.cs +++ b/API/Controller/Devices/DevicesController.cs @@ -3,7 +3,6 @@ using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using OneOf; using OpenShock.API.Models.Requests; using OpenShock.API.Models.Response; using OpenShock.API.Services.DeviceUpdate; @@ -15,6 +14,7 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; using OpenShock.Common.Redis; +using OpenShock.Common.Results; using OpenShock.Common.Utils; using Redis.OM; @@ -237,7 +237,7 @@ public async Task GetPairCode([FromRoute] Guid deviceId) public async Task GetLiveControlGatewayInfo([FromRoute] Guid deviceId) { var result = await ResolveDeviceGatewayAsync(deviceId); - if (result.TryPickT1(out var problem, out var gateway)) return Problem(problem); + if (result is not LcgNode gateway) return Problem((OpenShockProblem)result.Value!); return LegacyDataOk(new LcgResponse { @@ -259,7 +259,7 @@ public async Task GetLiveControlGatewayInfo([FromRoute] Guid devi public async Task GetLiveControlGatewayInfoV2([FromRoute] Guid deviceId) { var result = await ResolveDeviceGatewayAsync(deviceId); - if (result.TryPickT1(out var problem, out var gateway)) return Problem(problem); + if (result is not LcgNode gateway) return Problem((OpenShockProblem)result.Value!); return Ok(new LcgResponseV2 { @@ -275,7 +275,7 @@ 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) { // Check if user owns device or has a share var deviceExistsAndYouHaveAccess = await _db.Devices.AnyAsync(x => diff --git a/API/Controller/OAuth/HandOff.cs b/API/Controller/OAuth/HandOff.cs index 2e31b499..ccff2fc3 100644 --- a/API/Controller/OAuth/HandOff.cs +++ b/API/Controller/OAuth/HandOff.cs @@ -29,8 +29,9 @@ public async Task OAuthHandOff( CancellationToken cancellationToken) { var result = await ValidateOAuthFlowAsync(); - if (!result.TryPickT0(out var auth, out var error)) + if (result is not ValidatedFlowContext auth) { + var error = (OAuthValidationError)result.Value!; return error switch { OAuthValidationError.FlowStateMissing => RedirectFrontendError("oauthFlowNotStarted"), diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index 9aedbeaa..3acee31c 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -7,7 +7,9 @@ using OpenShock.API.Services.OAuthConnection; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; +using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; +using OpenShock.Common.Results; using System.Security.Claims; namespace OpenShock.API.Controller.OAuth; @@ -48,8 +50,9 @@ public async Task OAuthSignupFinalize( } var result = await ValidateOAuthFlowAsync(); - if (!result.TryPickT0(out var auth, out var error)) + if (result is not ValidatedFlowContext auth) { + var error = (OAuthValidationError)result.Value!; return error switch { OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound), @@ -99,7 +102,7 @@ public async Task OAuthSignupFinalize( isEmailTrusted ); - if (!created.TryPickT0(out var newUser, out _)) + if (created is not User newUser) { // Username or email already exists — conflict. // Do NOT clear the flow cookie so the frontend can retry with a different username. @@ -107,15 +110,15 @@ public async Task OAuthSignupFinalize( } // Authenticate the client if its activated (create session and set session cookie) - if (newUser.Value.ActivatedAt is not null) + if (newUser.ActivatedAt is not null) { - await CreateSession(newUser.Value.Id, domain); + await CreateSession(newUser.Id, domain); } // Clear the temporary OAuth flow cookie. await HttpContext.SignOutAsync(OAuthConstants.FlowScheme); - return Ok(LoginV2OkResponse.FromUser(newUser.Value)); + return Ok(LoginV2OkResponse.FromUser(newUser)); static bool IsTruthy(string? value) { diff --git a/API/Controller/OAuth/SignupGetData.cs b/API/Controller/OAuth/SignupGetData.cs index 0bfc3b47..7fcb82cb 100644 --- a/API/Controller/OAuth/SignupGetData.cs +++ b/API/Controller/OAuth/SignupGetData.cs @@ -31,8 +31,9 @@ public async Task OAuthSignupGetData([FromRoute] string provider) } var result = await ValidateOAuthFlowAsync(); - if (!result.TryPickT0(out var auth, out var error)) + if (result is not ValidatedFlowContext auth) { + var error = (OAuthValidationError)result.Value!; return error switch { OAuthValidationError.FlowStateMissing => Problem(OAuthError.FlowNotFound), diff --git a/API/Controller/OAuth/_ApiController.cs b/API/Controller/OAuth/_ApiController.cs index 05486d31..e575d196 100644 --- a/API/Controller/OAuth/_ApiController.cs +++ b/API/Controller/OAuth/_ApiController.cs @@ -2,10 +2,10 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; -using OneOf; using OpenShock.API.OAuth; using OpenShock.API.Services.Account; using OpenShock.Common; +using OpenShock.Common.Results; namespace OpenShock.API.Controller.OAuth; @@ -39,7 +39,7 @@ private enum OAuthValidationError /// Validates: provider exists, temp cookie auth present, scheme matches, flow parsable. /// On success returns ValidatedFlowContext; on failure returns IActionResult with proper problem details. /// - private async Task> ValidateOAuthFlowAsync() + private async Task> ValidateOAuthFlowAsync() { // 1) authenticate temp cookie var auth = await HttpContext.AuthenticateAsync(OAuthConstants.FlowScheme); diff --git a/API/Controller/Sessions/SessionSelf.cs b/API/Controller/Sessions/SessionSelf.cs index 9e7a7cf4..bf54fb18 100644 --- a/API/Controller/Sessions/SessionSelf.cs +++ b/API/Controller/Sessions/SessionSelf.cs @@ -17,7 +17,7 @@ public sealed partial class SessionsController [HttpGet("self")] public LoginSessionResponse GetSelfSession([FromServices] IUserReferenceService userReferenceService) { - if (!userReferenceService.AuthReference.TryPickT0(out LoginSession? session, out _)) + if (userReferenceService.AuthReference is not LoginSession session) throw new UnreachableException("the [UserSessionOnly] attribute should have blocked caller"); return LoginSessionResponse.MapFrom(session); diff --git a/API/Controller/Shockers/SendControl.cs b/API/Controller/Shockers/SendControl.cs index df039fa2..9822bf5a 100644 --- a/API/Controller/Shockers/SendControl.cs +++ b/API/Controller/Shockers/SendControl.cs @@ -1,4 +1,5 @@ - using System.Net.Mime; + using System.Diagnostics; +using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; @@ -10,6 +11,7 @@ using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; +using OpenShock.Common.Results; using OpenShock.Common.Services; namespace OpenShock.API.Controller.Shockers; @@ -45,7 +47,7 @@ public async Task SendControl( }; ApiTokenControlLimits? tokenLimits = null; - if (userReferenceService.AuthReference.TryPickT1(out ApiToken? apiToken, out _)) + if (userReferenceService.AuthReference is ApiToken apiToken) { // A paused token may not control shockers. if (apiToken.ShockerControlPaused) return Problem(ApiTokenError.ApiTokenPaused); @@ -54,11 +56,14 @@ public async Task SendControl( } var controlAction = await controlSender.ControlByUser(body.Shocks, sender, userHub.Clients, tokenLimits); - return controlAction.Match( - success => LegacyEmptyOk("Successfully sent control messages"), - notFound => Problem(ShockerControlError.ShockerControlNotFound(notFound.Value)), - paused => Problem(ShockerControlError.ShockerControlPaused(paused.Value)), - noPermission => Problem(ShockerControlError.ShockerControlNoPermission(noPermission.Value))); + return controlAction switch + { + Success => LegacyEmptyOk("Successfully sent control messages"), + NotFound notFound => Problem(ShockerControlError.ShockerControlNotFound(notFound.Value)), + ShockerPaused paused => Problem(ShockerControlError.ShockerControlPaused(paused.Value)), + ShockerNoPermission noPermission => Problem(ShockerControlError.ShockerControlNoPermission(noPermission.Value)), + _ => throw new UnreachableException() + }; } /// diff --git a/API/Controller/Tokens/GetTokenSelf.cs b/API/Controller/Tokens/GetTokenSelf.cs index a250f918..d20d3779 100644 --- a/API/Controller/Tokens/GetTokenSelf.cs +++ b/API/Controller/Tokens/GetTokenSelf.cs @@ -57,7 +57,7 @@ public TokenResponseV2 GetSelfTokenV2([FromServices] IUserReferenceService userR private static ApiToken GetSelfTokenDto(IUserReferenceService userReferenceService) { - if (!userReferenceService.AuthReference.TryPickT1(out ApiToken? apiToken, out _)) + if (userReferenceService.AuthReference is not ApiToken apiToken) throw new UnreachableException("the [TokenOnly] attribute should have blocked caller"); return apiToken; diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index b91c2956..a7d76d06 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; +using OpenShock.Common.Results; using OpenShock.Common.Services.Webhook; namespace OpenShock.API.Controller.Tokens; @@ -36,9 +37,8 @@ public async Task ReportTokens( var remoteIP = HttpContext.GetRemoteIP(); var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIP, cancellationToken); - if (!turnStile.IsT0) + if (turnStile is CloudflareTurnstileError[] cfErrors) { - var cfErrors = turnStile.AsT1.Value; if (cfErrors.All(err => err == CloudflareTurnstileError.InvalidResponse)) return Problem(TurnstileError.InvalidTurnstile); diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 1383acf1..31919fca 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -1,12 +1,11 @@ using System.Net.Mail; using Microsoft.EntityFrameworkCore; using Npgsql; -using OneOf; -using OneOf.Types; using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; +using OpenShock.Common.Results; using OpenShock.Common.Services.Audit; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Services.Session; @@ -89,7 +88,7 @@ private async Task IsEmailProviderBlacklisted(string email) return await _db.EmailProviderBlacklists.AnyAsync(e => e.Domain == domain); } - private async Task, AccountWithEmailOrUsernameExists>> CreateAccount(string email, string username, string password, bool verifyOnCreation) + private async Task CreateAccount(string email, string username, string password, bool verifyOnCreation) { email = email.ToLowerInvariant(); @@ -118,20 +117,18 @@ await _db.Users .ExecuteUpdateAsync(spc => spc.SetProperty(u => u.ActivatedAt, u => u.CreatedAt)); } - return new Success(user); + return user; } /// - public async Task, AccountWithEmailOrUsernameExists>> CreateAccountWithActivationFlowAsync(string email, string username, string password) + public async Task CreateAccountWithActivationFlowAsync(string email, string username, string password) { // With mail disabled the activation email is never delivered, so an activation flow would leave // the account permanently unusable. Activate on creation instead. var mailEnabled = _mailOptions.IsEnabled; var accountCreate = await CreateAccount(email, username, password, !mailEnabled); - if (accountCreate.IsT1 || !mailEnabled) return accountCreate; - - var user = accountCreate.AsT0.Value; + if (accountCreate.Value is not User user || !mailEnabled) return accountCreate; // The real activation token is minted by the outbox delivery job at send time; here we record the // request (with a seeded hash) and durably enqueue the email. @@ -146,7 +143,7 @@ public async Task, AccountWithEmailOrUsernameExists>> Create await _db.SaveChangesAsync(); await NotifyEmailOutboxAsync(); - return new Success(user); + return user; } public Task IsEmailRegisteredAsync(string email, CancellationToken cancellationToken = default) @@ -155,7 +152,7 @@ public Task IsEmailRegisteredAsync(string email, CancellationToken cancell return _db.Users.AnyAsync(u => u.Email == email, cancellationToken); } - public async Task, AccountWithEmailOrUsernameExists>> CreateOAuthOnlyAccountAsync( + public async Task CreateOAuthOnlyAccountAsync( string email, string username, string provider, @@ -233,7 +230,7 @@ public async Task, AccountWithEmailOrUsernameExists>> Create await NotifyEmailOutboxAsync(); } - return new Success(user); + return user; } catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) { @@ -262,7 +259,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) { if (executingUserId != userId) { @@ -321,7 +318,7 @@ await _auditService.LogAsync( } /// - public async Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null) + public async Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null) { var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(u => u.Id == userId && u.UserDeactivation != null); if (user is null) return new NotFound(); @@ -365,7 +362,7 @@ await _auditService.LogAsync( } /// - public async Task> DeleteAccountAsync(Guid executingUserId, Guid userId, string? reason = null) + public async Task> DeleteAccountAsync(Guid executingUserId, Guid userId, string? reason = null) { var isPrivileged = await _db.Users .Where(u => u.Id == executingUserId) @@ -408,7 +405,7 @@ await _auditService.LogAsync( } /// - public async Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken) + public async Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken) { var lowercaseUsernameOrEmail = usernameOrEmail.ToLowerInvariant(); var user = await _db.Users @@ -446,7 +443,7 @@ public async Task - public async Task> CheckPasswordResetExistsAsync(Guid passwordResetId, string secret, + public async Task> CheckPasswordResetExistsAsync(Guid passwordResetId, string secret, CancellationToken cancellationToken = default) { var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; @@ -464,7 +461,7 @@ public async Task> CheckPasswordResetExi } /// - public async Task> CreatePasswordResetFlowAsync(string email) + public async Task> CreatePasswordResetFlowAsync(string email) { var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; var lowerCaseEmail = email.ToLowerInvariant(); @@ -501,7 +498,7 @@ public async Task - public async Task> CompletePasswordResetFlowAsync(Guid passwordResetId, + public async Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword) { var validSince = DateTime.UtcNow - Duration.PasswordResetRequestLifetime; @@ -555,12 +552,12 @@ await _auditService.LogAsync( } /// - public async Task> CheckUsernameAvailabilityAsync(string username, + public async Task> CheckUsernameAvailabilityAsync(string username, CancellationToken cancellationToken = default) { var validationResult = UsernameValidator.Validate(username); - if (validationResult.IsT1) - return validationResult.AsT1; + if (validationResult.Value is UsernameError usernameError) + return usernameError; if (await IsUserNameBlacklisted(username)) return new UsernameTaken(); // Don't inform the user about when the blacklist is hit @@ -572,7 +569,7 @@ public async Task> CheckUsernameAva } /// - public async Task> ChangeUsernameAsync(Guid userId, string username, Guid? actorId, bool ignoreLimit = false, CancellationToken cancellationToken = default) + public async Task> ChangeUsernameAsync(Guid userId, string username, Guid? actorId, bool ignoreLimit = false, CancellationToken cancellationToken = default) { if (!ignoreLimit) { @@ -584,8 +581,11 @@ public async Task u.UserDeactivation).FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); if (user is null) return new NotFound(); @@ -622,7 +622,7 @@ await _auditService.LogAsync( /// - public async Task> ChangePasswordAsync(Guid userId, string newPassword, Guid? actorId) + public async Task> ChangePasswordAsync(Guid userId, string newPassword, Guid? actorId) { var user = await _db.Users.Include(u => u.UserDeactivation).FirstOrDefaultAsync(x => x.Id == userId); if (user is null) return new NotFound(); @@ -648,7 +648,7 @@ await _auditService.LogAsync( } /// - public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail, Guid? actorId) + public async Task> CreateEmailChangeFlowAsync(Guid userId, string newEmail, Guid? actorId) { var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; @@ -715,7 +715,7 @@ await _auditService.LogAsync( return new Success(); } - public async Task, NotFound, EmailAlreadyInUse>> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) + public async Task> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default) { var hash = HashingUtils.HashToken(token); var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; @@ -774,7 +774,7 @@ await _auditService.LogAsync( await transaction.CommitAsync(cancellationToken); - return new Success<(Guid, string, string)>((change.UserId, change.OldEmail, change.NewEmail)); + return new VerifyEmailSuccess(change.UserId, change.OldEmail, change.NewEmail); } private async Task CheckPassword(string password, User user) diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 71c6fce7..5b75f8c2 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -1,6 +1,5 @@ -using OneOf; -using OneOf.Types; -using OpenShock.Common.OpenShockDb; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Results; using OpenShock.Common.Validation; namespace OpenShock.API.Services.Account; @@ -17,7 +16,7 @@ public interface IAccountService /// /// /// - public Task, AccountWithEmailOrUsernameExists>> CreateAccountWithActivationFlowAsync(string email, string username, string password); + public Task CreateAccountWithActivationFlowAsync(string email, string username, string password); /// /// Creates an OAuth-only (passwordless) account and links the external identity in a single transaction. @@ -31,7 +30,7 @@ public interface IAccountService /// display name from provider /// /// Success with the created user, or AccountWithEmailOrUsernameExists when taken/blocked. - Task, AccountWithEmailOrUsernameExists>> 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); /// /// Returns true if the given email is already associated with an existing user account. @@ -46,11 +45,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); - public Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null); + public Task> ReactivateAccountAsync(Guid executingUserId, Guid userId, string? reason = null); - public Task> DeleteAccountAsync(Guid executingUserId, Guid userId, string? reason = null); + public Task> DeleteAccountAsync(Guid executingUserId, Guid userId, string? reason = null); /// /// Get a user by credentials @@ -59,7 +58,7 @@ public interface IAccountService /// /// /// - public Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken = default); + public Task> GetAccountByCredentialsAsync(string usernameOrEmail, string password, CancellationToken cancellationToken = default); /// /// Check if a password reset request exists and the secret is valid @@ -68,14 +67,14 @@ public interface IAccountService /// /// /// - public Task> CheckPasswordResetExistsAsync(Guid passwordResetId, string secret, CancellationToken cancellationToken = default); + public Task> CheckPasswordResetExistsAsync(Guid passwordResetId, string secret, CancellationToken cancellationToken = default); /// /// Creates a new password reset request and send the email if successful /// /// /// - public Task> CreatePasswordResetFlowAsync(string email); + public Task> CreatePasswordResetFlowAsync(string email); /// /// Completes a password reset process, sets a new password @@ -84,7 +83,7 @@ public interface IAccountService /// /// /// - public Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword); + public Task> CompletePasswordResetFlowAsync(Guid passwordResetId, string secret, string newPassword); /// /// Check the availability of a username @@ -92,7 +91,7 @@ public interface IAccountService /// /// /// - public Task> CheckUsernameAvailabilityAsync(string username, CancellationToken cancellationToken = default); + public Task> CheckUsernameAvailabilityAsync(string username, CancellationToken cancellationToken = default); /// /// Change the username of a user @@ -102,8 +101,8 @@ public interface IAccountService /// User that performed this change /// Ignore the username change limit, set this to true when an admin is changing the username /// - /// only returns when the result is != Available - public Task> ChangeUsernameAsync(Guid userId, string username, Guid? actorId, bool ignoreLimit = false, CancellationToken cancellationToken = default); + /// Success, or the reason the username couldn't be changed (taken, invalid, changed too recently, account deactivated, or user not found) + public Task> ChangeUsernameAsync(Guid userId, string username, Guid? actorId, bool ignoreLimit = false, CancellationToken cancellationToken = default); /// /// Change the password of a user @@ -112,7 +111,7 @@ 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); /// /// Creates a new email change request and sends a verification email to the new address. @@ -122,7 +121,7 @@ public interface IAccountService /// 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); /// /// Verifies a pending email change using the supplied token. On success the user's email is updated. @@ -133,24 +132,28 @@ public interface IAccountService /// /// /// - Task, NotFound, EmailAlreadyInUse>> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); + Task> TryVerifyEmailAsync(string token, CancellationToken cancellationToken = default); } -public readonly struct AccountIsOAuthOnly; -public readonly struct AccountNotActivated; -public readonly struct AccountDeactivated; -public readonly struct AccountWithEmailOrUsernameExists; -public readonly struct CannotDeactivatePrivilegedAccount; -public readonly struct AccountDeactivationAlreadyInProgress; -public readonly struct CannotDeletePrivilegedAccount; -public readonly struct TooManyPasswordResets; -public readonly struct SecretInvalid; -public readonly struct Unauthorized; +public union AccountCreationResult(User, AccountWithEmailOrUsernameExists); -public readonly struct UsernameTaken; +public sealed record VerifyEmailSuccess(Guid UserId, string OldEmail, string NewEmail); -public readonly struct RecentlyChanged; +public sealed class AccountIsOAuthOnly; +public sealed class AccountNotActivated; +public sealed class AccountDeactivated; +public sealed class AccountWithEmailOrUsernameExists; +public sealed class CannotDeactivatePrivilegedAccount; +public sealed class AccountDeactivationAlreadyInProgress; +public sealed class CannotDeletePrivilegedAccount; +public sealed class TooManyPasswordResets; +public sealed class SecretInvalid; +public sealed class Unauthorized; -public readonly struct EmailAlreadyInUse; -public readonly struct EmailUnchanged; -public readonly struct TooManyEmailChanges; \ No newline at end of file +public sealed class UsernameTaken; + +public sealed class RecentlyChanged; + +public sealed class EmailAlreadyInUse; +public sealed class EmailUnchanged; +public sealed class TooManyEmailChanges; \ No newline at end of file diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs index 0b53e23e..0df2e762 100644 --- a/API/Services/Turnstile/CloudflareTurnstileService.cs +++ b/API/Services/Turnstile/CloudflareTurnstileService.cs @@ -1,7 +1,6 @@ using System.Net; -using OneOf; -using OneOf.Types; using OpenShock.API.Options; +using OpenShock.Common.Results; namespace OpenShock.API.Services.Turnstile; @@ -22,10 +21,7 @@ public CloudflareTurnstileService(HttpClient httpClient, TurnstileOptions option _logger = logger; } - private static Error CreateError(params ReadOnlySpan errors) - { - return new Error(errors.ToArray()); - } + private static CloudflareTurnstileError[] CreateError(params CloudflareTurnstileError[] errors) => errors; private static CloudflareTurnstileError MapCfError(string error) { @@ -43,7 +39,7 @@ private static CloudflareTurnstileError MapCfError(string error) } /// - public async Task>> VerifyUserResponseTokenAsync( + public async Task> VerifyUserResponseTokenAsync( string responseToken, IPAddress? remoteIpAddress, CancellationToken cancellationToken = default) { if (!_options.Enabled) return new Success(); diff --git a/API/Services/Turnstile/ICloudflareTurnstileService.cs b/API/Services/Turnstile/ICloudflareTurnstileService.cs index 0b167058..d740b951 100644 --- a/API/Services/Turnstile/ICloudflareTurnstileService.cs +++ b/API/Services/Turnstile/ICloudflareTurnstileService.cs @@ -1,5 +1,5 @@ using System.Net; -using OneOf.Types; +using OpenShock.Common.Results; namespace OpenShock.API.Services.Turnstile; @@ -12,6 +12,6 @@ public interface ICloudflareTurnstileService /// /// /// Success, No response token was supplied, internal error in cloudflare turnstile, business logic error on turnstile validation - public Task>> VerifyUserResponseTokenAsync( + public Task> VerifyUserResponseTokenAsync( string responseToken, IPAddress? remoteIpAddress, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Common.Tests/Validation/UsernameValidatorTests.cs b/Common.Tests/Validation/UsernameValidatorTests.cs index d91dc7e6..e40550eb 100644 --- a/Common.Tests/Validation/UsernameValidatorTests.cs +++ b/Common.Tests/Validation/UsernameValidatorTests.cs @@ -1,4 +1,5 @@ -using OpenShock.Common.Validation; +using OpenShock.Common.Results; +using OpenShock.Common.Validation; namespace OpenShock.Common.Tests.Validation; @@ -14,7 +15,7 @@ public async Task Validate_ValidUsername_ReturnsSuccess() var result = UsernameValidator.Validate(username); // Assert - await Assert.That(result.IsT0).IsTrue(); + await Assert.That(result is Success).IsTrue(); } [Test] @@ -27,8 +28,8 @@ public async Task Validate_TooShort_ReturnsError() var result = UsernameValidator.Validate(username); // Assert - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.TooShort); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.TooShort); } [Test] @@ -41,8 +42,8 @@ public async Task Validate_TooLong_ReturnsError() var result = UsernameValidator.Validate(username); // Assert - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.TooLong); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.TooLong); } [Test] @@ -55,8 +56,8 @@ public async Task Validate_StartWithWhitespace_ReturnsError() var result = UsernameValidator.Validate(username); // Assert - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.StartOrEndWithWhitespace); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.StartOrEndWithWhitespace); } [Test] @@ -69,8 +70,8 @@ public async Task Validate_EndWithWhitespace_ReturnsError() var result = UsernameValidator.Validate(username); // Assert - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.StartOrEndWithWhitespace); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.StartOrEndWithWhitespace); } [Test] @@ -83,8 +84,8 @@ public async Task Validate_ResembleEmail_ReturnsError() var result = UsernameValidator.Validate(username); // Assert - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ResembleEmail); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.ResembleEmail); } [Test] @@ -97,8 +98,8 @@ public async Task Validate_ContainsObnoxiousCharacters_ReturnsError() var result = UsernameValidator.Validate(username); // Assert - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); } // --- Boundary value tests --- @@ -108,7 +109,7 @@ public async Task Validate_ExactMinLength_ReturnsSuccess() { // HardLimits.UsernameMinLength = 3 var result = UsernameValidator.Validate("abc"); - await Assert.That(result.IsT0).IsTrue(); + await Assert.That(result is Success).IsTrue(); } [Test] @@ -116,8 +117,8 @@ public async Task Validate_OneBelowMinLength_ReturnsTooShort() { // 2 chars = below min of 3 var result = UsernameValidator.Validate("ab"); - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.TooShort); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.TooShort); } [Test] @@ -125,7 +126,7 @@ public async Task Validate_ExactMaxLength_ReturnsSuccess() { // HardLimits.UsernameMaxLength = 32 var result = UsernameValidator.Validate(new string('a', 32)); - await Assert.That(result.IsT0).IsTrue(); + await Assert.That(result is Success).IsTrue(); } [Test] @@ -133,22 +134,22 @@ public async Task Validate_OneAboveMaxLength_ReturnsTooLong() { // 33 chars = above max of 32 var result = UsernameValidator.Validate(new string('a', 33)); - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.TooLong); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.TooLong); } [Test] public async Task Validate_WithHyphensAndUnderscores_ReturnsSuccess() { var result = UsernameValidator.Validate("test-user_123"); - await Assert.That(result.IsT0).IsTrue(); + await Assert.That(result is Success).IsTrue(); } [Test] public async Task Validate_WithDots_ReturnsSuccess() { var result = UsernameValidator.Validate("test.user"); - await Assert.That(result.IsT0).IsTrue(); + await Assert.That(result is Success).IsTrue(); } [Test] @@ -156,23 +157,23 @@ public async Task Validate_WithMiddleSpaces_ReturnsSuccess() { // Middle spaces are allowed, only leading/trailing are rejected var result = UsernameValidator.Validate("test user"); - await Assert.That(result.IsT0).IsTrue(); + await Assert.That(result is Success).IsTrue(); } [Test] public async Task Validate_TabAtStart_ReturnsWhitespaceError() { var result = UsernameValidator.Validate("\tTestUser"); - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.StartOrEndWithWhitespace); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.StartOrEndWithWhitespace); } [Test] public async Task Validate_AtSignInMiddle_ReturnsResembleEmail() { var result = UsernameValidator.Validate("user@name"); - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ResembleEmail); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.ResembleEmail); } [Test] @@ -180,8 +181,8 @@ public async Task Validate_ZeroWidthJoiner_ReturnsObnoxious() { // Zero-width joiner U+200D var result = UsernameValidator.Validate("test\u200Duser"); - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); } [Test] @@ -189,7 +190,7 @@ public async Task Validate_RightToLeftOverride_ReturnsObnoxious() { // U+202E Right-to-left override var result = UsernameValidator.Validate("test\u202Euser"); - await Assert.That(result.IsT1).IsTrue(); - await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); + await Assert.That(result is UsernameError).IsTrue(); + await Assert.That(((UsernameError)result.Value!).Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); } } diff --git a/Common/Authentication/Attributes/TokenPermissionAttribute.cs b/Common/Authentication/Attributes/TokenPermissionAttribute.cs index 438af984..b8f04156 100644 --- a/Common/Authentication/Attributes/TokenPermissionAttribute.cs +++ b/Common/Authentication/Attributes/TokenPermissionAttribute.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Mvc.Filters; -using OneOf.Types; using OpenShock.Common.Authentication.Services; using OpenShock.Common.Errors; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; using OpenShock.Common.Redis; +using OpenShock.Common.Results; namespace OpenShock.Common.Authentication.Attributes; @@ -43,7 +43,12 @@ public void OnAuthorization(AuthorizationFilterContext context) { var userReference = context.HttpContext.RequestServices.GetRequiredService(); - var problem = userReference.AuthReference.Match(LoginSessionMatch, ApiTokenMatch, NoneMatch); + var problem = userReference.AuthReference switch + { + LoginSession loginSession => LoginSessionMatch(loginSession), + ApiToken apiToken => ApiTokenMatch(apiToken), + None none => NoneMatch(none) + }; if (problem is not null) { diff --git a/Common/Authentication/ControllerBase/AuthenticatedSessionControllerBase.cs b/Common/Authentication/ControllerBase/AuthenticatedSessionControllerBase.cs index 421abd91..56e1c62c 100644 --- a/Common/Authentication/ControllerBase/AuthenticatedSessionControllerBase.cs +++ b/Common/Authentication/ControllerBase/AuthenticatedSessionControllerBase.cs @@ -1,11 +1,11 @@ using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using OneOf.Types; using OpenShock.Common.Authentication.Services; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; +using OpenShock.Common.Results; namespace OpenShock.Common.Authentication.ControllerBase; @@ -28,10 +28,11 @@ public void OnActionExecuted(ActionExecutedContext context) protected bool IsAllowed(PermissionType requiredType) { var userReferenceService = HttpContext.RequestServices.GetRequiredService(); - return userReferenceService.AuthReference.Match( - (LoginSession _) => true, // We are in a session - (ApiToken apiToken) => requiredType.IsAllowed(apiToken.Permissions), - (None _) => throw new UnreachableException("User should be authenticated here") - ); + return userReferenceService.AuthReference switch + { + LoginSession => true, // We are in a session + ApiToken apiToken => requiredType.IsAllowed(apiToken.Permissions), + None => throw new UnreachableException("User should be authenticated here") + }; } } \ No newline at end of file diff --git a/Common/Authentication/Services/UserReferenceService.cs b/Common/Authentication/Services/UserReferenceService.cs index 5b689bae..bd654ca1 100644 --- a/Common/Authentication/Services/UserReferenceService.cs +++ b/Common/Authentication/Services/UserReferenceService.cs @@ -1,16 +1,15 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; -using OneOf; -using OneOf.Types; +using OpenShock.Common.Results; namespace OpenShock.Common.Authentication.Services; public interface IUserReferenceService { - public OneOf AuthReference { get; set; } + public Union3 AuthReference { get; set; } } public sealed class UserReferenceService : IUserReferenceService { - public OneOf AuthReference { get; set; } = new None(); + public Union3 AuthReference { get; set; } = new None(); } \ No newline at end of file diff --git a/Common/Common.csproj b/Common/Common.csproj index a1eb241f..dabf1022 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -28,7 +28,6 @@ - diff --git a/Common/DataAnnotations/UsernameAttribute.cs b/Common/DataAnnotations/UsernameAttribute.cs index c0b366d7..47f552e4 100644 --- a/Common/DataAnnotations/UsernameAttribute.cs +++ b/Common/DataAnnotations/UsernameAttribute.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using OpenShock.Common.DataAnnotations.Interfaces; +using OpenShock.Common.Results; using OpenShock.Common.Validation; namespace OpenShock.Common.DataAnnotations; @@ -44,11 +46,13 @@ public sealed class UsernameAttribute : ValidationAttribute, IParameterAttribute if (value is not string displayName) return new ValidationResult(ErrMsgMustBeString); var result = UsernameValidator.Validate(displayName); - - return result.Match( - _ => ValidationResult.Success, - error => new ValidationResult($"{error.Type} - {error.Message}") - ); + + return result switch + { + Success => ValidationResult.Success, + UsernameError error => new ValidationResult($"{error.Type} - {error.Message}"), + _ => throw new UnreachableException() + }; } /// diff --git a/Common/DeviceControl/NotAllShockersSucceeded.cs b/Common/DeviceControl/NotAllShockersSucceeded.cs index 4bf63a63..29bbaf9c 100644 --- a/Common/DeviceControl/NotAllShockersSucceeded.cs +++ b/Common/DeviceControl/NotAllShockersSucceeded.cs @@ -1,3 +1,3 @@ namespace OpenShock.Common.DeviceControl; -public readonly struct NotAllShockersSucceeded; \ No newline at end of file +public sealed class NotAllShockersSucceeded; \ No newline at end of file diff --git a/Common/Hubs/PublicShareHub.cs b/Common/Hubs/PublicShareHub.cs index 15fcf4f4..a559dd7a 100644 --- a/Common/Hubs/PublicShareHub.cs +++ b/Common/Hubs/PublicShareHub.cs @@ -54,7 +54,7 @@ public override async Task OnConnectedAsync() } } - _tokenPermissions = _userReferenceService.AuthReference.TryPickT1(out ApiToken? apiToken, out _) ? apiToken.Permissions : null; + _tokenPermissions = _userReferenceService.AuthReference is ApiToken apiToken ? apiToken.Permissions : null; var exists = await _db.PublicShares.AnyAsync(x => x.Id == id && (x.ExpiresAt == null || x.ExpiresAt > DateTime.UtcNow)); if (!exists) diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index 940868ac..76136015 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -39,7 +39,7 @@ public UserHub(ILogger logger, OpenShockContext db, IRedisConnectionPro public override async Task OnConnectedAsync() { - _tokenPermissions = _userReferenceService.AuthReference.TryPickT1(out ApiToken? apiToken, out _) ? apiToken.Permissions : null; + _tokenPermissions = _userReferenceService.AuthReference is ApiToken apiToken ? apiToken.Permissions : null; await Clients.Caller.Welcome(Context.ConnectionId); var devicesOnline = _provider.RedisCollection(false); @@ -93,7 +93,7 @@ public async Task ControlV2(IReadOnlyList shocks, }).FirstAsync(); ApiTokenControlLimits? tokenLimits = null; - if (_userReferenceService.AuthReference.TryPickT1(out ApiToken? apiToken, out _)) + if (_userReferenceService.AuthReference is ApiToken apiToken) { // A paused token may not control shockers. if (apiToken.ShockerControlPaused) return; diff --git a/Common/Results/CommonResultCases.cs b/Common/Results/CommonResultCases.cs new file mode 100644 index 00000000..9af8c7d5 --- /dev/null +++ b/Common/Results/CommonResultCases.cs @@ -0,0 +1,27 @@ +namespace OpenShock.Common.Results; + +// Shared case types for use in C# union declarations, replacing the equivalent +// marker types formerly provided by OneOf.Types (OneOf package). + +public sealed class Success; + +public sealed class Success(T value) +{ + public T Value { get; } = value; +} + +public sealed class NotFound; + +public sealed class NotFound(T value) +{ + public T Value { get; } = value; +} + +public sealed class Error; + +public sealed class None; + +/// +/// When the websocket sent a close frame +/// +public sealed class WebsocketClosure; \ No newline at end of file diff --git a/Common/Results/Unions.cs b/Common/Results/Unions.cs new file mode 100644 index 00000000..92a4122c --- /dev/null +++ b/Common/Results/Unions.cs @@ -0,0 +1,27 @@ +using OpenShock.Common.Problems; + +namespace OpenShock.Common.Results; + +// Generic structural unions used throughout the codebase in place of OneOf. +// These compose arbitrary existing types into a closed, exhaustively-matchable set, +// mirroring how OneOf was used. See: https://github.com/dotnet/csharplang/blob/main/proposals/unions.md + +public union Union2(T0, T1); + +public union Union3(T0, T1, T2); + +public union Union4(T0, T1, T2, T3); + +public union Union5(T0, T1, T2, T3, T4); + +public union Union6(T0, T1, T2, T3, T4, T5); + +public union Union7(T0, T1, T2, T3, T4, T5, T6); + +public union Union8(T0, T1, T2, T3, T4, T5, T6, T7); + +public union SuccessOrError(Success, T); +public union SuccessOrProblem(Success, OpenShockProblem); +public union SuccessOrNotFound(Success, NotFound); + +public union ValueOrProblem(T, OpenShockProblem); diff --git a/Common/Services/Configuration/ConfigurationService.cs b/Common/Services/Configuration/ConfigurationService.cs index 5a2757ad..5c910c43 100644 --- a/Common/Services/Configuration/ConfigurationService.cs +++ b/Common/Services/Configuration/ConfigurationService.cs @@ -1,13 +1,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; using Npgsql; -using OneOf; -using OneOf.Types; using OpenShock.Common.OpenShockDb; using System.Buffers; using System.Globalization; using System.Text.Json; using OpenShock.Common.JsonSerialization; +using OpenShock.Common.Results; namespace OpenShock.Common.Services.Configuration; @@ -60,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) { // Validate name (only uppercase letters and underscores) if (!IsValidName(name)) @@ -102,7 +101,7 @@ public async Task> TryUpdateItemAsync(string name, string? description, string? value) + public async Task> TryUpdateItemAsync(string name, string? description, string? value) { // Validate name if (!IsValidName(name)) @@ -148,7 +147,7 @@ await _cache.SetAsync( return new Success(); } - public async Task> TryDeleteItemAsync(string name) + public async Task> TryDeleteItemAsync(string name) { // Find the item var item = await _db.Configuration.FirstOrDefaultAsync(ci => ci.Name == name); @@ -223,26 +222,26 @@ private async Task SetValueAsync(string name, string newValue, Configurati return true; } - public async Task> TryGetStringAsync(string name) + public async Task> TryGetStringAsync(string name) { var pair = await TryGetTypeValuePair(name); - if (pair is null) return new NotFound(); - if (pair.Type != ConfigurationValueType.String) return new InvalidValueType(); + 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 async Task> TryGetBoolAsync(string name) + public async Task> TryGetBoolAsync(string name) { var pair = await TryGetTypeValuePair(name); - if (pair is null) return new NotFound(); - if (pair.Type != ConfigurationValueType.Bool) return new InvalidValueType(); + if (pair is null) return ConfigGetResult.NotFound(); + if (pair.Type != ConfigurationValueType.Bool) return ConfigGetResult.InvalidValueType(); if (!bool.TryParse(pair.Value, out var value)) { _logger.LogWarning("Failed to parse bool for '{Name}': Value='{Value}'", name, pair.Value); - return new InvalidValueFormat(); + return ConfigGetResult.InvalidValueFormat(); } return value; } @@ -250,15 +249,15 @@ public async Task> T public Task TrySetBoolAsync(string name, bool value) => SetValueAsync(name, value.ToString(), ConfigurationValueType.Bool); - public async Task> TryGetIntAsync(string name) + public async Task> TryGetIntAsync(string name) { var pair = await TryGetTypeValuePair(name); - if (pair is null) return new NotFound(); - if (pair.Type != ConfigurationValueType.Int) return new InvalidValueType(); + if (pair is null) return ConfigGetResult.NotFound(); + if (pair.Type != ConfigurationValueType.Int) return ConfigGetResult.InvalidValueType(); if (!int.TryParse(pair.Value, out var value)) { _logger.LogWarning("Failed to parse int for '{Name}': Value='{Value}'", name, pair.Value); - return new InvalidValueFormat(); + return ConfigGetResult.InvalidValueFormat(); } return value; } @@ -266,15 +265,15 @@ public async Task> Tr public Task TrySetIntAsync(string name, int value) => SetValueAsync(name, value.ToString(), ConfigurationValueType.Int); - public async Task> TryGetFloatAsync(string name) + public async Task> TryGetFloatAsync(string name) { var pair = await TryGetTypeValuePair(name); - if (pair is null) return new NotFound(); - if (pair.Type != ConfigurationValueType.Float) return new InvalidValueType(); + if (pair is null) return ConfigGetResult.NotFound(); + if (pair.Type != ConfigurationValueType.Float) return ConfigGetResult.InvalidValueType(); if (!float.TryParse(pair.Value, out var value)) { _logger.LogWarning("Failed to parse float for '{Name}': Value='{Value}'", name, pair.Value); - return new InvalidValueFormat(); + return ConfigGetResult.InvalidValueFormat(); } return value; } @@ -282,21 +281,21 @@ public async Task> public Task TrySetFloatAsync(string name, float value) => SetValueAsync(name, value.ToString("R"), ConfigurationValueType.Float); - public async Task> TryGetJsonAsync(string name) + public async Task> TryGetJsonAsync(string name) { var pair = await TryGetTypeValuePair(name); - if (pair is null) return new NotFound(); - if (pair.Type != ConfigurationValueType.Json) return new InvalidValueType(); + if (pair is null) return ConfigGetResult.NotFound(); + if (pair.Type != ConfigurationValueType.Json) return ConfigGetResult.InvalidValueType(); try { var obj = JsonSerializer.Deserialize(pair.Value, JsonOptions.Default); - return obj is not null ? obj : new InvalidValueFormat(); + return obj is not null ? obj : ConfigGetResult.InvalidValueFormat(); } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to deserialize JSON for '{Name}'", name); - return new InvalidValueFormat(); + return ConfigGetResult.InvalidValueFormat(); } } diff --git a/Common/Services/Configuration/IConfigurationService.cs b/Common/Services/Configuration/IConfigurationService.cs index 89356c6d..ec8e7e07 100644 --- a/Common/Services/Configuration/IConfigurationService.cs +++ b/Common/Services/Configuration/IConfigurationService.cs @@ -1,6 +1,5 @@ -using OneOf; -using OneOf.Types; -using OpenShock.Common.OpenShockDb; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Results; namespace OpenShock.Common.Services.Configuration; @@ -8,27 +7,63 @@ 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); + Task> TryUpdateItemAsync(string name, string? description, string? value); + Task> TryDeleteItemAsync(string name); - Task> TryGetStringAsync(string name); + Task> TryGetStringAsync(string name); Task TrySetStringAsync(string name, string value); - Task> TryGetBoolAsync(string name); + Task> TryGetBoolAsync(string name); Task TrySetBoolAsync(string name, bool value); - Task> TryGetIntAsync(string name); + Task> TryGetIntAsync(string name); Task TrySetIntAsync(string name, int value); - Task> TryGetFloatAsync(string name); + Task> TryGetFloatAsync(string name); Task TrySetFloatAsync(string name, float value); - Task> TryGetJsonAsync(string name); + Task> TryGetJsonAsync(string name); Task TrySetJsonAsync(string name, T value); } -public readonly struct AlreadyExists; -public readonly struct InvalidNameFormat; -public readonly struct InvalidValueType; -public readonly struct InvalidValueFormat; \ No newline at end of file +public enum ConfigGetOutcome : byte +{ + Value, + NotFound, + InvalidValueType, + InvalidValueFormat +} + +/// +/// Result of reading a typed configuration value: the value, or why it couldn't be read. +/// Stores the payload directly (tag + typed field) instead of boxing through object? +/// +public readonly record struct ConfigGetResult +{ + public ConfigGetOutcome Outcome { get; } + public T Value { get; } + + private ConfigGetResult(ConfigGetOutcome outcome) + { + Outcome = outcome; + Value = default!; + } + + public ConfigGetResult(T value) + { + Outcome = ConfigGetOutcome.Value; + Value = value; + } + + public static implicit operator ConfigGetResult(T value) => new(value); + + public static ConfigGetResult NotFound() => new(ConfigGetOutcome.NotFound); + public static ConfigGetResult InvalidValueType() => new(ConfigGetOutcome.InvalidValueType); + public static ConfigGetResult InvalidValueFormat() => new(ConfigGetOutcome.InvalidValueFormat); +} + +public sealed class AlreadyExists; +public sealed class InvalidNameFormat; +public sealed class InvalidValueType; +public sealed class InvalidValueFormat; \ No newline at end of file diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index 92698913..a30efd43 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -1,7 +1,5 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; -using OneOf; -using OneOf.Types; using OpenShock.Common.Constants; using OpenShock.Common.DeviceControl; using OpenShock.Common.Extensions; @@ -10,6 +8,7 @@ using OpenShock.Common.Models.WebSocket.User; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis.PubSub; +using OpenShock.Common.Results; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; @@ -26,7 +25,7 @@ public ControlSender(OpenShockContext db, IRedisPubService publisher) _publisher = publisher; } - 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) { var shockers = await _db.Shockers .AsNoTracking() @@ -61,7 +60,7 @@ public async Task> ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId) + public async Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId) { var publicShareShockers = await _db.PublicShareShockerMappings .AsNoTracking() @@ -99,7 +98,7 @@ private static void Clamp(Control control, SharePermsAndLimits? limits) control.Duration = Math.Clamp(control.Duration, HardLimits.MinControlDuration, durationMax); } - private async Task> ControlInternal(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers, ApiTokenControlLimits? tokenLimits = null) + private async Task ControlInternal(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers, ApiTokenControlLimits? tokenLimits = null) { var shockersById = allowedShockers.ToDictionary(s => s.ShockerId, s => s); @@ -111,7 +110,7 @@ private async Task x.Id)) { if (!shockersById.TryGetValue(control.Id, out var shocker)) - return new ShockerNotFoundOrNoAccess(control.Id); + return new NotFound(control.Id); if (shocker.Paused) return new ShockerPaused(control.Id); diff --git a/Common/Services/IControlSender.cs b/Common/Services/IControlSender.cs index 28c25d1d..e7301c55 100644 --- a/Common/Services/IControlSender.cs +++ b/Common/Services/IControlSender.cs @@ -1,21 +1,24 @@ using Microsoft.AspNetCore.SignalR; -using OneOf; -using OneOf.Types; using OpenShock.Common.Hubs; using OpenShock.Common.Models; using OpenShock.Common.Models.WebSocket.User; +using OpenShock.Common.Results; 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); - public Task> ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId); + public Task ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId); } -public readonly record struct ShockerNotFoundOrNoAccess(Guid Value); +/// +/// Result of a shocker control attempt. The error cases are reference types, so storing them in +/// the union's internal object? slot is a plain reference assignment, not a boxing conversion. +/// +public union ShockerControlResult(Success, NotFound, ShockerPaused, ShockerNoPermission); -public readonly record struct ShockerPaused(Guid Value); +public sealed record ShockerPaused(Guid Value); -public readonly record struct ShockerNoPermission(Guid Value); \ No newline at end of file +public sealed record ShockerNoPermission(Guid Value); \ No newline at end of file diff --git a/Common/Services/Webhook/IWebhookService.cs b/Common/Services/Webhook/IWebhookService.cs index 10dd7603..f3618cb6 100644 --- a/Common/Services/Webhook/IWebhookService.cs +++ b/Common/Services/Webhook/IWebhookService.cs @@ -1,17 +1,16 @@ using System.Drawing; -using OneOf; -using OneOf.Types; using OpenShock.Common.Models; +using OpenShock.Common.Results; namespace OpenShock.Common.Services.Webhook; public interface IWebhookService { - public Task, UnsupportedWebhookUrl>> AddWebhookAsync(string name, Uri webhookUrl); + public Task, UnsupportedWebhookUrl>> AddWebhookAsync(string name, Uri webhookUrl); public Task RemoveWebhookAsync(Guid webhookId); public Task GetWebhooksAsync(); - public Task> SendWebhookAsync(string webhookName, string title, string content, Color color); + public Task> SendWebhookAsync(string webhookName, string title, string content, Color color); } public struct UnsupportedWebhookUrl; diff --git a/Common/Services/Webhook/WebhookService.cs b/Common/Services/Webhook/WebhookService.cs index d1acf67b..06cad8d8 100644 --- a/Common/Services/Webhook/WebhookService.cs +++ b/Common/Services/Webhook/WebhookService.cs @@ -1,9 +1,8 @@ using System.Drawing; using Microsoft.EntityFrameworkCore; -using OneOf; -using OneOf.Types; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Results; namespace OpenShock.Common.Services.Webhook; @@ -21,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) { if (webhookUrl is not { @@ -76,7 +75,7 @@ public async Task GetWebhooksAsync() .ToArrayAsync(); } - public async Task> SendWebhookAsync(string webhookName, string title, string content, Color color) + public async Task> SendWebhookAsync(string webhookName, string title, string content, Color color) { var webhook = await _db.DiscordWebhooks .Where(w => w.Name == webhookName) diff --git a/Common/Utils/JsonWebSocketUtils.cs b/Common/Utils/JsonWebSocketUtils.cs index 9dc519e2..8456867d 100644 --- a/Common/Utils/JsonWebSocketUtils.cs +++ b/Common/Utils/JsonWebSocketUtils.cs @@ -4,16 +4,17 @@ using Microsoft.IO; using OpenShock.Common.JsonSerialization; using OpenShock.Common.Models.WebSocket; +using OpenShock.Common.Results; namespace OpenShock.Common.Utils; public static class JsonWebSocketUtils { private const uint MaxMessageSize = 512_000; // 512 000 bytes - + public static readonly RecyclableMemoryStreamManager RecyclableMemory = new(); - public static async Task> ReceiveFullMessageAsyncNonAlloc(WebSocket socket, CancellationToken cancellationToken = default) + public static async Task> ReceiveFullMessageAsyncNonAlloc(WebSocket socket, CancellationToken cancellationToken = default) { var buffer = ArrayPool.Shared.Rent(4096); try @@ -70,9 +71,4 @@ public static async Task SendFullMessageBytes(byte[] msg, WebSocket socket, Canc /// /// When json deserialization fails /// -public readonly record struct DeserializeFailed(Exception Exception); - -/// -/// When the websocket sent a close frame -/// -public readonly struct WebsocketClosure; \ No newline at end of file +public readonly record struct DeserializeFailed(Exception Exception); \ No newline at end of file diff --git a/Common/Validation/UsernameValidator.cs b/Common/Validation/UsernameValidator.cs index 2faca0a0..e83d8c9b 100644 --- a/Common/Validation/UsernameValidator.cs +++ b/Common/Validation/UsernameValidator.cs @@ -1,13 +1,12 @@ using System.Diagnostics.CodeAnalysis; -using OneOf; -using OneOf.Types; using OpenShock.Common.Constants; +using OpenShock.Common.Results; namespace OpenShock.Common.Validation; public static class UsernameValidator { - public static OneOf Validate(string username) + public static Union2 Validate(string username) { if (username.Length < HardLimits.UsernameMinLength) { diff --git a/Common/Websocket/WebsockBaseController.cs b/Common/Websocket/WebsockBaseController.cs index 78b29601..06fa6dc7 100644 --- a/Common/Websocket/WebsockBaseController.cs +++ b/Common/Websocket/WebsockBaseController.cs @@ -2,10 +2,9 @@ using System.Text.Json; using System.Threading.Channels; using Microsoft.AspNetCore.Mvc; -using OneOf; -using OneOf.Types; using OpenShock.Common.Errors; using OpenShock.Common.Problems; +using OpenShock.Common.Results; using OpenShock.Common.Utils; namespace OpenShock.Common.Websocket; @@ -120,9 +119,9 @@ public async Task Get([FromServices] IHostApplicationLifetime lifetime, Cancella } var connectionPrecondition = await ConnectionPrecondition(); - if (connectionPrecondition.IsT1) + if (connectionPrecondition.Value is OpenShockProblem connectionError) { - await connectionPrecondition.AsT1.Value.WriteAsJsonAsync(HttpContext, LinkedToken); + await connectionError.WriteAsJsonAsync(HttpContext, LinkedToken); return; } @@ -292,6 +291,6 @@ protected async Task ForceClose(WebSocketCloseStatus closeStatus, string? status /// Action when the websocket connection is destroyed to unregister the connection to a websocket manager /// [NonAction] - protected virtual Task>> ConnectionPrecondition() => - Task.FromResult(OneOf>.FromT0(new Success())); + protected virtual Task ConnectionPrecondition() => + Task.FromResult(new Success()); } \ No newline at end of file diff --git a/Cron/Services/Email/EmailServiceExtension.cs b/Cron/Services/Email/EmailServiceExtension.cs index 0fba5e64..7df97604 100644 --- a/Cron/Services/Email/EmailServiceExtension.cs +++ b/Cron/Services/Email/EmailServiceExtension.cs @@ -53,10 +53,10 @@ private static WebApplicationBuilder AddSenderContactConfiguration(this WebAppli private static async Task AddEmailServiceTemplates(this WebApplicationBuilder builder) { - var accountActivation = EmailTemplate.ParseFromFileThrow("SmtpTemplates/AccountActivation.liquid"); - var passwordReset = EmailTemplate.ParseFromFileThrow("SmtpTemplates/PasswordReset.liquid"); - var emailVerification = EmailTemplate.ParseFromFileThrow("SmtpTemplates/EmailVerification.liquid"); - var emailChangeNotice = EmailTemplate.ParseFromFileThrow("SmtpTemplates/EmailChangeNotice.liquid"); + var accountActivation = EmailTemplate.ParseFromFileOrThrow("SmtpTemplates/AccountActivation.liquid"); + var passwordReset = EmailTemplate.ParseFromFileOrThrow("SmtpTemplates/PasswordReset.liquid"); + var emailVerification = EmailTemplate.ParseFromFileOrThrow("SmtpTemplates/EmailVerification.liquid"); + var emailChangeNotice = EmailTemplate.ParseFromFileOrThrow("SmtpTemplates/EmailChangeNotice.liquid"); await Task.WhenAll(accountActivation, passwordReset, emailVerification, emailChangeNotice); diff --git a/Cron/Services/Email/EmailTemplate.cs b/Cron/Services/Email/EmailTemplate.cs index aa01b548..854efa2c 100644 --- a/Cron/Services/Email/EmailTemplate.cs +++ b/Cron/Services/Email/EmailTemplate.cs @@ -1,5 +1,7 @@ +using System.Diagnostics; using System.Text.Encodings.Web; using Fluid; +using OpenShock.Common.Results; using OpenShock.Cron.Services.Email.Mailjet.Mail; namespace OpenShock.Cron.Services.Email; @@ -27,24 +29,29 @@ private static TemplateOptions CreateOptions() return (subject, htmlBody); } - public static async Task ParseFromFileThrow(string filePath) + public static async Task ParseFromFileOrThrow(string filePath) { var result = await ParseFromFile(filePath); - return result.IsT0 ? result.AsT0 : throw new InvalidDataException(result.AsT1); + return result switch + { + EmailTemplate template => template, + TemplateParseError error => throw new InvalidDataException(error.Value), + _ => throw new UnreachableException() + }; } - private static Task> ParseFromFile(string filePath) => + public static Task> ParseFromFile(string filePath) => ParseFromFile(File.OpenRead(filePath)); - private static async Task> ParseFromFile(FileStream fileStream) + private static async Task> ParseFromFile(FileStream fileStream) { using var streamReader = new StreamReader(fileStream); var subject = await streamReader.ReadLineAsync(); - if (subject is null) throw new InvalidDataException("Subject is null"); + if (subject is null) return new TemplateParseError("Subject is null"); - if (!Parser.TryParse(subject, out var subjectTemplate, out var errorSubject)) return errorSubject; + if (!Parser.TryParse(subject, out var subjectTemplate, out var errorSubject)) return new TemplateParseError(errorSubject); var body = await streamReader.ReadToEndAsync(); - if (!Parser.TryParse(body, out var bodyTemplate, out var errorBody)) return errorBody; + if (!Parser.TryParse(body, out var bodyTemplate, out var errorBody)) return new TemplateParseError(errorBody); return new EmailTemplate { @@ -53,3 +60,8 @@ public static async Task ParseFromFileThrow(string filePath) }; } } + +/// +/// Union case for when a Fluid template fails to parse +/// +public sealed record TemplateParseError(string Value); diff --git a/Directory.Build.props b/Directory.Build.props index a92375ae..f1b72012 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,12 +22,12 @@ - net10.0 + net11.0 enable - Latest + preview enable $(Product) $(Product.Replace(" ", "_")) @@ -35,6 +35,9 @@ $(Version) a2109c1e-fb11-44d7-8127-346ef60cb9a5 true + + $(Features);runtime-async=on diff --git a/Directory.Packages.props b/Directory.Packages.props index e3d5f82c..f192c661 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,16 +17,15 @@ - + - + - @@ -44,4 +43,10 @@ + + + + \ No newline at end of file diff --git a/LiveControlGateway/Controllers/HubControllerBase.cs b/LiveControlGateway/Controllers/HubControllerBase.cs index aab9f028..995152b8 100644 --- a/LiveControlGateway/Controllers/HubControllerBase.cs +++ b/LiveControlGateway/Controllers/HubControllerBase.cs @@ -2,12 +2,12 @@ using FlatSharp; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using OneOf; -using OneOf.Types; using OpenShock.Common.Constants; using OpenShock.Common.Errors; using OpenShock.Common.Problems; +using OpenShock.Common.Results; using OpenShock.Common.Utils; +using OpenShock.Common.Websocket; using OpenShock.LiveControlGateway.LifetimeManager; using OpenShock.LiveControlGateway.Options; using OpenShock.LiveControlGateway.Websocket; @@ -123,7 +123,7 @@ ILogger> logger private SemVersion? _firmwareVersion; /// - protected override async Task>> ConnectionPrecondition() + protected override async Task ConnectionPrecondition() { _connected = DateTimeOffset.UtcNow; @@ -134,27 +134,25 @@ protected override async Task>> Connectio } else { - var err = new Error(WebsocketError.WebsocketHubFirmwareVersionInvalid); - return err; + return WebsocketError.WebsocketHubFirmwareVersionInvalid; } _userAgent = HttpContext.Request.Headers.UserAgent.ToString().Truncate(256); var hubLifetimeResult = await _hubLifetimeManager.TryAddDeviceConnection(5, this, LinkedToken); - if (hubLifetimeResult.IsT1) + switch (hubLifetimeResult) { - Logger.LogWarning("Hub lifetime busy, closing connection"); - return new Error(WebsocketError.WebsocketHubLifetimeBusy); + case HubLifetimeManager.Busy: + Logger.LogWarning("Hub lifetime busy, closing connection"); + return WebsocketError.WebsocketHubLifetimeBusy; + case Error: + Logger.LogError("Hub lifetime error, closing connection"); + return ExceptionError.Exception; + case LifetimeManager.HubLifetime hubLifetime: + HubLifetime = hubLifetime; + break; } - - if (hubLifetimeResult.IsT2) - { - Logger.LogError("Hub lifetime error, closing connection"); - return new Error(ExceptionError.Exception); - } - - HubLifetime = hubLifetimeResult.AsT0; - + return new Success(); } diff --git a/LiveControlGateway/Controllers/LiveControlController.cs b/LiveControlGateway/Controllers/LiveControlController.cs index 44d9e0b9..addf3cb9 100644 --- a/LiveControlGateway/Controllers/LiveControlController.cs +++ b/LiveControlGateway/Controllers/LiveControlController.cs @@ -5,8 +5,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.EntityFrameworkCore; -using OneOf; -using OneOf.Types; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.Attributes; using OpenShock.Common.Authentication.Services; @@ -23,6 +21,7 @@ using OpenShock.LiveControlGateway.Models; using OpenShock.LiveControlGateway.PubSub; using JsonOptions = OpenShock.Common.JsonSerialization.JsonOptions; +using Results = OpenShock.Common.Results; using Timer = System.Timers.Timer; namespace OpenShock.LiveControlGateway.Controllers; @@ -177,11 +176,11 @@ public async Task UpdatePermissions(OpenShockContext db) /// We get the id from the route, check if its valid, check if the user has access to the shocker / hub /// /// - protected override async Task>> ConnectionPrecondition() + protected override async Task ConnectionPrecondition() { if (HttpContext.GetRouteValue("hubId") is not string param || !Guid.TryParse(param, out var id)) { - return new OneOf.Types.Error(WebsocketError.WebsocketLiveControlHubIdInvalid); + return WebsocketError.WebsocketLiveControlHubIdInvalid; } HubId = id; @@ -194,7 +193,7 @@ public async Task UpdatePermissions(OpenShockContext db) if (!hubExistsAndYouHaveAccess) { - return new OneOf.Types.Error(WebsocketError.WebsocketLiveControlHubNotFound); + return WebsocketError.WebsocketLiveControlHubNotFound; } _device = await db.Devices.FirstOrDefaultAsync(x => x.Id == HubId); @@ -216,22 +215,20 @@ public async Task UpdatePermissions(OpenShockContext db) var hubLifetimeResult = await _hubLifetimeManager.AddLiveControlConnection(this); - if (hubLifetimeResult.IsT1) + switch (hubLifetimeResult) { - _logger.LogDebug("No such hub with id [{HubId}] connected", HubId); - return new OneOf.Types.Error(WebsocketError.WebsocketLiveControlHubNotConnected); + case Results.NotFound: + _logger.LogDebug("No such hub with id [{HubId}] connected", HubId); + return WebsocketError.WebsocketLiveControlHubNotConnected; + case LifetimeManager.HubLifetimeManager.Busy: + _logger.LogDebug("Hub is busy, cannot connect [{HubId}]", HubId); + return WebsocketError.WebsocketLiveControlHubLifetimeBusy; + case LifetimeManager.HubLifetime hubLifetime: + _hubLifetime = hubLifetime; + break; } - if (hubLifetimeResult.IsT2) - { - _logger.LogDebug("Hub is busy, cannot connect [{HubId}]", HubId); - return new OneOf.Types.Error(WebsocketError.WebsocketLiveControlHubLifetimeBusy); - } - - _hubLifetime = hubLifetimeResult.AsT0; - - - return new Success(); + return new Results.Success(); } /// @@ -245,7 +242,7 @@ public void OnActionExecuting(ActionExecutingContext context) // When authenticated via an API token, the token may scope/pause shocker control. // Session auth carries no such limits. - if (_userReferenceService.AuthReference.TryPickT1(out var apiToken, out _)) + if (_userReferenceService.AuthReference is ApiToken apiToken) { _tokenId = apiToken.Id; _tokenPaused = apiToken.ShockerControlPaused; @@ -312,8 +309,17 @@ protected override async Task HandleReceive(CancellationToken cancellation LinkedToken ); - var continueLoop = await message.Match(async request => - { + switch (message) + { + case DeserializeFailed failed: + Logger.LogWarning(failed.Exception, "Deserialization failed for websocket message"); + await ForceClose(WebSocketCloseStatus.InvalidPayloadData, "Invalid json message received"); + return false; + case Results.WebsocketClosure: + Logger.LogTrace("Client sent closure"); + return false; + default: + var request = (BaseRequest?)message.Value; if (request?.Data is null) { Logger.LogWarning("Received null data from client"); @@ -324,19 +330,7 @@ protected override async Task HandleReceive(CancellationToken cancellation await ProcessResult(request); return true; - }, - async failed => - { - Logger.LogWarning(failed.Exception, "Deserialization failed for websocket message"); - await ForceClose(WebSocketCloseStatus.InvalidPayloadData, "Invalid json message received"); - return false; - }, closure => - { - Logger.LogTrace("Client sent closure"); - return Task.FromResult(false); - }); - - return continueLoop; + } } private Task ProcessResult(BaseRequest request) @@ -479,18 +473,20 @@ await QueueMessage(new LiveControlResponse } var permCheck = CheckFramePermissions(frame.Shocker, frame.Type); - if (!permCheck.TryPickT0(out var perms, out var error)) + if (permCheck is not SharePermsAndLimits perms) { await QueueMessage(new LiveControlResponse { - ResponseType = error.Match( - notFound => LiveResponseType.ShockerNotFound, - liveNotEnabled => LiveResponseType.ShockerMissingLivePermission, - noPermission => LiveResponseType.ShockerMissingPermission, - shockerPaused => LiveResponseType.ShockerPaused - ) + ResponseType = permCheck switch + { + Results.NotFound => LiveResponseType.ShockerNotFound, + LiveNotEnabled => LiveResponseType.ShockerMissingLivePermission, + NoPermission => LiveResponseType.ShockerMissingPermission, + ShockerPaused => LiveResponseType.ShockerPaused, + _ => throw new UnreachableException() + } }); - + return; } @@ -501,27 +497,30 @@ await QueueMessage(new LiveControlResponse var result = HubLifetime.ReceiveFrame(frame.Shocker, frame.Type, intensity, _tps); - await result.Match( - _ => - { + switch (result) + { + case Results.Success: Logger.LogTrace("Successfully received frame"); - return ValueTask.CompletedTask; - }, - _ => QueueMessage(new LiveControlResponse - { - ResponseType = LiveResponseType.ShockerNotFound - }), - shockerExclusive => QueueMessage(new LiveControlResponse - { - ResponseType = LiveResponseType.ShockerExclusive, - Data = shockerExclusive.Until - }) - ); + break; + case Results.NotFound: + await QueueMessage(new LiveControlResponse + { + ResponseType = LiveResponseType.ShockerNotFound + }); + break; + case ShockerExclusive shockerExclusive: + await QueueMessage(new LiveControlResponse + { + ResponseType = LiveResponseType.ShockerExclusive, + Data = shockerExclusive.Until + }); + break; + } } - private OneOf CheckFramePermissions(Guid shocker, ControlType controlType) + private Results.Union5 CheckFramePermissions(Guid shocker, ControlType controlType) { - if (!_sharedShockers.TryGetValue(shocker, out var shockerShare)) return new NotFound(); + if (!_sharedShockers.TryGetValue(shocker, out var shockerShare)) return new Results.NotFound(); if (shockerShare.Paused) return new ShockerPaused(); if (!PermissionUtils.IsAllowed(controlType, true, shockerShare.PermsAndLimits)) return new NoPermission(); @@ -605,16 +604,16 @@ protected override async ValueTask DisposeControllerAsync() } /// -/// OneOf +/// Union case /// -public readonly struct LiveNotEnabled; +public sealed class LiveNotEnabled; /// -/// OneOf +/// Union case /// -public readonly struct NoPermission; +public sealed class NoPermission; /// -/// OneOf +/// Union case /// -public readonly struct ShockerPaused; \ No newline at end of file +public sealed class ShockerPaused; \ No newline at end of file diff --git a/LiveControlGateway/LifetimeManager/HubLifetime.cs b/LiveControlGateway/LifetimeManager/HubLifetime.cs index bd912706..277dd87a 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetime.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetime.cs @@ -1,13 +1,12 @@ using MessagePack; using Microsoft.EntityFrameworkCore; -using OneOf; -using OneOf.Types; using OpenShock.Common.Constants; using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; using OpenShock.Common.Redis.PubSub; +using OpenShock.Common.Results; using OpenShock.Common.Services.RedisPubSub; using OpenShock.Common.Utils; using OpenShock.LiveControlGateway.Controllers; @@ -392,7 +391,7 @@ private async Task UpdateShockers(OpenShockContext db, CancellationToken cancell /// /// /// - public OneOf ReceiveFrame(Guid shocker, ControlType type, byte intensity, + public Union3 ReceiveFrame(Guid shocker, ControlType type, byte intensity, byte tps) { if (!_shockerStates.TryGetValue(shocker, out var state)) return new NotFound(); @@ -465,7 +464,7 @@ public ValueTask Control(IReadOnlyList commands) /// /// /// - public async Task> Online(Guid device, SelfOnlineData data) + public async Task> Online(Guid device, SelfOnlineData data) { var deviceOnline = _redisConnectionProvider.RedisCollection(); var deviceId = device.ToString(); @@ -560,7 +559,7 @@ public async ValueTask DisposeAsync() /// /// Online state updated /// -public readonly struct OnlineStateUpdated; +public sealed class OnlineStateUpdated; /// /// Self online data struct diff --git a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs index 8493a18e..eafea8bf 100644 --- a/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs +++ b/LiveControlGateway/LifetimeManager/HubLifetimeManager.cs @@ -1,10 +1,10 @@ using System.Diagnostics.Metrics; using Microsoft.EntityFrameworkCore; -using OneOf.Types; using OpenShock.Common.Extensions; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis.PubSub; +using OpenShock.Common.Results; using OpenShock.Common.Services.RedisPubSub; using OpenShock.LiveControlGateway.Controllers; using Redis.OM.Contracts; @@ -66,7 +66,7 @@ public HubLifetimeManager( /// /// When the hub lifetime is busy, we cannot add a new device connection /// - public readonly struct Busy; + public sealed class Busy; /// /// Add device to lifetime manager, called on successful connect of device @@ -75,7 +75,7 @@ public HubLifetimeManager( /// /// /// - public async Task> TryAddDeviceConnection(byte tps, IHubController hubController, + public async Task> TryAddDeviceConnection(byte tps, IHubController hubController, CancellationToken cancellationToken) { _logger.LogDebug("Adding hub lifetime [{HubId}]", hubController.Id); @@ -201,7 +201,7 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> AddLiveControlConnection(LiveControlController liveControlController) + public async Task> AddLiveControlConnection(LiveControlController liveControlController) { if (!liveControlController.HubId.HasValue) throw new ArgumentException("LiveControlController does not have a hubId", nameof(liveControlController)); @@ -225,9 +225,9 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> UpdateDevice(Guid device) + public async Task UpdateDevice(Guid device) { - if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); + if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new NotFound(); await deviceLifetime.UpdateDevice(); return new Success(); } @@ -238,9 +238,9 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> Control(Guid device, IReadOnlyList shocks) + public async Task Control(Guid device, IReadOnlyList shocks) { - if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); + if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new NotFound(); await deviceLifetime.Control(shocks); return new Success(); } @@ -251,9 +251,9 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> ControlCaptive(Guid device, bool enabled) + public async Task ControlCaptive(Guid device, bool enabled) { - if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); + if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new NotFound(); await deviceLifetime.ControlCaptive(enabled); return new Success(); } @@ -263,9 +263,9 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> EmergencyStop(Guid device) + public async Task> EmergencyStop(Guid device) { - if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); + if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new NotFound(); bool ok = await deviceLifetime.EmergencyStop(); return ok ? new Success() : new DeviceMissingFeature(); } @@ -276,9 +276,9 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> OtaInstall(Guid device, SemVersion version) + public async Task OtaInstall(Guid device, SemVersion version) { - if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); + if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new NotFound(); await deviceLifetime.OtaInstall(version); return new Success(); } @@ -288,9 +288,9 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> Reboot(Guid device) + public async Task> Reboot(Guid device) { - if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); + if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new NotFound(); bool ok = await deviceLifetime.Reboot(); return ok ? new Success() : new DeviceMissingFeature(); } @@ -300,25 +300,20 @@ public async Task RemoveDeviceConnection(IHubController hubController) /// /// /// - public async Task> DeviceOnline(Guid device, SelfOnlineData data) + public async Task DeviceOnline(Guid device, SelfOnlineData data) { - if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new DeviceNotFound(); + if (!_lifetimes.TryGetValue(device, out var deviceLifetime)) return new NotFound(); await deviceLifetime.Online(device, data); return new Success(); } } /// -/// OneOf -/// -public readonly struct DeviceNotFound; - -/// -/// OneOf +/// Union case /// public readonly record struct ShockerExclusive(DateTimeOffset Until); /// /// This hub is too outdated to use this command /// -public readonly struct DeviceMissingFeature; \ No newline at end of file +public sealed class DeviceMissingFeature; \ No newline at end of file diff --git a/LiveControlGateway/Websocket/FlatbufferWebSocketUtils.cs b/LiveControlGateway/Websocket/FlatbufferWebSocketUtils.cs index 82a47ff8..adb064be 100644 --- a/LiveControlGateway/Websocket/FlatbufferWebSocketUtils.cs +++ b/LiveControlGateway/Websocket/FlatbufferWebSocketUtils.cs @@ -1,7 +1,7 @@ using System.Buffers; using System.Net.WebSockets; using FlatSharp; -using OneOf; +using OpenShock.Common.Results; using OpenShock.Common.Utils; namespace OpenShock.LiveControlGateway.Websocket; @@ -22,7 +22,7 @@ public static class FlatbufferWebSocketUtils /// /// /// - public static async Task> ReceiveFullMessageAsyncNonAlloc( + public static async Task> ReceiveFullMessageAsyncNonAlloc( WebSocket socket, ISerializer serializer, CancellationToken cancellationToken) where T : class, IFlatBufferSerializable { @@ -109,9 +109,4 @@ private static async Task SendFullMessageBytes(ReadOnlyMemory msg, WebSock /// /// When flatbuffers deserialization fails /// -public readonly record struct DeserializeFailed(Exception Exception); - -/// -/// When the websocket sent a close frame -/// -public readonly struct WebsocketClosure; \ No newline at end of file +public readonly record struct DeserializeFailed(Exception Exception); \ No newline at end of file diff --git a/LiveControlGateway/Websocket/FlatbuffersWebsocketBaseController.cs b/LiveControlGateway/Websocket/FlatbuffersWebsocketBaseController.cs index d73e05e3..179911b5 100644 --- a/LiveControlGateway/Websocket/FlatbuffersWebsocketBaseController.cs +++ b/LiveControlGateway/Websocket/FlatbuffersWebsocketBaseController.cs @@ -1,5 +1,6 @@ using System.Net.WebSockets; using FlatSharp; +using OpenShock.Common.Results; using OpenShock.Common.Websocket; namespace OpenShock.LiveControlGateway.Websocket; @@ -53,20 +54,19 @@ protected override async Task HandleReceive(CancellationToken cancellation var message = await FlatbufferWebSocketUtils.ReceiveFullMessageAsyncNonAlloc(WebSocket!, _incomingSerializer, cancellationToken); - - var continueLoop = await message.Match( - Handle, - async _ => - { + + switch (message) + { + case DeserializeFailed: await ForceClose(WebSocketCloseStatus.InvalidPayloadData, "Invalid flatbuffers message"); return false; - }, - _ => - { + case WebsocketClosure: Logger.LogTrace("Client sent closure"); - return Task.FromResult(false); - }); - - return continueLoop; + return false; + case TIn data: + return await Handle(data); + default: + return false; + } } } \ No newline at end of file diff --git a/docker/API.Dockerfile b/docker/API.Dockerfile index 038bc18a..b58c76b0 100644 --- a/docker/API.Dockerfile +++ b/docker/API.Dockerfile @@ -10,7 +10,7 @@ COPY --link API/. API/ RUN dotnet publish --no-restore -c Release API/API.csproj -o /app # final is the final runtime stage for running the app -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final-api +FROM mcr.microsoft.com/dotnet/aspnet:11.0.0-preview.6-alpine3.24 AS final-api WORKDIR /app COPY docker/entrypoint.sh /entrypoint.sh diff --git a/docker/Base.Dockerfile b/docker/Base.Dockerfile index 0768d286..56484a62 100644 --- a/docker/Base.Dockerfile +++ b/docker/Base.Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build-common +FROM mcr.microsoft.com/dotnet/sdk:11.0.100-preview.6-alpine3.24 AS build-common WORKDIR /src COPY --link Common/*.csproj Common/ diff --git a/docker/Cron.Dockerfile b/docker/Cron.Dockerfile index 1836c4b9..a15e3865 100644 --- a/docker/Cron.Dockerfile +++ b/docker/Cron.Dockerfile @@ -10,7 +10,7 @@ COPY --link Cron/. Cron/ RUN dotnet publish --no-restore -c Release Cron/Cron.csproj -o /app # final is the final runtime stage for running the app -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final-cron +FROM mcr.microsoft.com/dotnet/aspnet:11.0.0-preview.6-alpine3.24 AS final-cron WORKDIR /app COPY docker/entrypoint.sh /entrypoint.sh diff --git a/docker/LiveControlGateway.Dockerfile b/docker/LiveControlGateway.Dockerfile index 5d4a8107..b19cd5ef 100644 --- a/docker/LiveControlGateway.Dockerfile +++ b/docker/LiveControlGateway.Dockerfile @@ -10,7 +10,7 @@ COPY --link LiveControlGateway/. LiveControlGateway/ RUN dotnet publish --no-restore -c Release LiveControlGateway/LiveControlGateway.csproj -o /app # final is the final runtime stage for running the app -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final-gateway +FROM mcr.microsoft.com/dotnet/aspnet:11.0.0-preview.6-alpine3.24 AS final-gateway WORKDIR /app COPY docker/entrypoint.sh /entrypoint.sh diff --git a/global.json b/global.json index 7cceb992..4cb6a54c 100644 --- a/global.json +++ b/global.json @@ -1,8 +1,8 @@ { "sdk": { - "version": "10.0.100", + "version": "11.0.100-preview.6.26359.118", "rollForward": "latestMinor", - "allowPrerelease": false + "allowPrerelease": true }, "test": { "runner": "Microsoft.Testing.Platform"