From eb2bb52be32eac9fe3238f95cd3d8ba7f183b92b Mon Sep 17 00:00:00 2001 From: LucHeart Date: Tue, 2 Jun 2026 01:25:45 +0200 Subject: [PATCH 1/6] v2 token endpoints --- API/Controller/Tokens/GetTokenSelf.cs | 48 +- API/Controller/Tokens/Tokens.cs | 107 +- API/Controller/Tokens/_ApiController.cs | 4 +- API/Models/Response/TokenResponseV2.cs | 18 + ...1_MakeApiTokenLastUsedNullable.Designer.cs | 1475 +++++++++++++++++ ...0601005411_MakeApiTokenLastUsedNullable.cs | 44 + .../OpenShockContextModelSnapshot.cs | 8 +- Common/OpenShockDb/ApiToken.cs | 2 +- Common/OpenShockDb/OpenShockContext.cs | 1 - 9 files changed, 1663 insertions(+), 44 deletions(-) create mode 100644 API/Models/Response/TokenResponseV2.cs create mode 100644 Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.Designer.cs create mode 100644 Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs diff --git a/API/Controller/Tokens/GetTokenSelf.cs b/API/Controller/Tokens/GetTokenSelf.cs index 65d51be7..7bd1a487 100644 --- a/API/Controller/Tokens/GetTokenSelf.cs +++ b/API/Controller/Tokens/GetTokenSelf.cs @@ -1,9 +1,11 @@ -using Microsoft.AspNetCore.Authorization; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Response; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.ControllerBase; using OpenShock.Common.Authentication.Services; +using OpenShock.Common.OpenShockDb; namespace OpenShock.API.Controller.Tokens; @@ -11,6 +13,7 @@ namespace OpenShock.API.Controller.Tokens; [Tags("API Tokens")] [Route("/{version:apiVersion}/tokens")] [Authorize(AuthenticationSchemes = OpenShockAuthSchemes.ApiToken)] +[ApiVersion("1"), ApiVersion("2")] public sealed partial class TokensSelfController : AuthenticatedSessionControllerBase { /// @@ -20,16 +23,35 @@ public sealed partial class TokensSelfController : AuthenticatedSessionControlle /// /// [HttpGet("self")] + [MapToApiVersion("1")] public TokenResponse GetSelfToken([FromServices] IUserReferenceService userReferenceService) { - var x = userReferenceService.AuthReference; - - if (x is null) throw new Exception("This should not be reachable due to AuthenticatedSession requirement"); - if (!x.Value.IsT1) throw new Exception("This should not be reachable due to the [TokenOnly] attribute"); - - var token = x.Value.AsT1; - + var token = GetSelfTokenDto(userReferenceService); + return new TokenResponse + { + CreatedOn = token.CreatedAt, + ValidUntil = token.ValidUntil, + LastUsed = token.LastUsed ?? default, + Permissions = token.Permissions, + Name = token.Name, + Id = token.Id + }; + } + + /// + /// Gets information about the current token used to access this endpoint + /// + /// + /// + /// + [HttpGet("self")] + [MapToApiVersion("2")] + public TokenResponseV2 GetSelfTokenV2([FromServices] IUserReferenceService userReferenceService) + { + var token = GetSelfTokenDto(userReferenceService); + + return new TokenResponseV2 { CreatedOn = token.CreatedAt, ValidUntil = token.ValidUntil, @@ -39,4 +61,14 @@ public TokenResponse GetSelfToken([FromServices] IUserReferenceService userRefer Id = token.Id }; } + + private static ApiToken GetSelfTokenDto(IUserReferenceService userReferenceService) + { + var x = userReferenceService.AuthReference; + + if (x is null) throw new Exception("This should not be reachable due to AuthenticatedSession requirement"); + if (!x.Value.IsT1) throw new Exception("This should not be reachable due to the [TokenOnly] attribute"); + + return x.Value.AsT1; + } } \ No newline at end of file diff --git a/API/Controller/Tokens/Tokens.cs b/API/Controller/Tokens/Tokens.cs index 9eee7163..aab77d82 100644 --- a/API/Controller/Tokens/Tokens.cs +++ b/API/Controller/Tokens/Tokens.cs @@ -1,4 +1,6 @@ +using System.Linq.Expressions; using System.Net.Mime; +using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OpenShock.API.Models.Requests; @@ -13,25 +15,57 @@ namespace OpenShock.API.Controller.Tokens; public sealed partial class TokensController { + private static readonly Expression> ToTokenResponse = x => new TokenResponse + { + CreatedOn = x.CreatedAt, + ValidUntil = x.ValidUntil, + LastUsed = x.LastUsed ?? default, + Permissions = x.Permissions, + Name = x.Name, + Id = x.Id + }; + + private static readonly Expression> ToTokenResponseV2 = x => new TokenResponseV2 + { + CreatedOn = x.CreatedAt, + ValidUntil = x.ValidUntil, + LastUsed = x.LastUsed, + Permissions = x.Permissions, + Name = x.Name, + Id = x.Id + }; + + /// + /// Tokens belonging to the current user that have not expired. + /// + private IQueryable CurrentUserValidTokens => _db.ApiTokens + .Where(x => x.UserId == CurrentUser.Id && (x.ValidUntil == null || x.ValidUntil > DateTime.UtcNow)); + /// /// List all tokens for the current user /// /// All tokens for the current user [HttpGet] + [MapToApiVersion("1")] public IAsyncEnumerable ListTokens() { - return _db.ApiTokens - .Where(x => x.UserId == CurrentUser.Id && (x.ValidUntil == null || x.ValidUntil > DateTime.UtcNow)) + return CurrentUserValidTokens + .OrderBy(x => x.CreatedAt) + .Select(ToTokenResponse) + .AsAsyncEnumerable(); + } + + /// + /// List all tokens for the current user + /// + /// All tokens for the current user + [HttpGet] + [MapToApiVersion("2")] + public IAsyncEnumerable ListTokensV2() + { + return CurrentUserValidTokens .OrderBy(x => x.CreatedAt) - .Select(x => new TokenResponse - { - CreatedOn = x.CreatedAt, - ValidUntil = x.ValidUntil, - LastUsed = x.LastUsed, - Permissions = x.Permissions, - Name = x.Name, - Id = x.Id - }) + .Select(ToTokenResponseV2) .AsAsyncEnumerable(); } @@ -43,23 +77,39 @@ public IAsyncEnumerable ListTokens() /// The token does not exist or you do not have access to it. [HttpGet("{tokenId}")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound + [MapToApiVersion("1")] public async Task GetTokenById([FromRoute] Guid tokenId) { - var apiToken = await _db.ApiTokens - .Where(x => x.UserId == CurrentUser.Id && x.Id == tokenId && (x.ValidUntil == null || x.ValidUntil > DateTime.UtcNow)) - .Select(x => new TokenResponse - { - CreatedOn = x.CreatedAt, - ValidUntil = x.ValidUntil, - Permissions = x.Permissions, - LastUsed = x.LastUsed, - Name = x.Name, - Id = x.Id - }).FirstOrDefaultAsync(); - + var apiToken = await CurrentUserValidTokens + .Where(x => x.Id == tokenId) + .Select(ToTokenResponse) + .FirstOrDefaultAsync(); + if (apiToken is null) return Problem(ApiTokenError.ApiTokenNotFound); - + + return Ok(apiToken); + } + + /// + /// Get a token by id + /// + /// + /// The token + /// The token does not exist or you do not have access to it. + [HttpGet("{tokenId}")] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound + [MapToApiVersion("2")] + public async Task GetTokenByIdV2([FromRoute] Guid tokenId) + { + var apiToken = await CurrentUserValidTokens + .Where(x => x.Id == tokenId) + .Select(ToTokenResponseV2) + .FirstOrDefaultAsync(); + + if (apiToken is null) return Problem(ApiTokenError.ApiTokenNotFound); + return Ok(apiToken); } @@ -71,6 +121,7 @@ public async Task GetTokenById([FromRoute] Guid tokenId) [HttpPost] [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] + [MapToApiVersion("1")] public async Task CreateToken([FromBody] CreateTokenRequest body) { var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.ApiTokenLength); @@ -95,7 +146,7 @@ public async Task CreateToken([FromBody] CreateTokenReques Token = token, CreatedAt = tokenDto.CreatedAt, ValidUntil = tokenDto.ValidUntil, - LastUsed = tokenDto.LastUsed, + LastUsed = tokenDto.LastUsed ?? default, Permissions = tokenDto.Permissions }; } @@ -111,10 +162,10 @@ public async Task CreateToken([FromBody] CreateTokenReques [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound + [MapToApiVersion("1")] public async Task EditToken([FromRoute] Guid tokenId, [FromBody] EditTokenRequest body) { - var token = await _db.ApiTokens - .FirstOrDefaultAsync(x => x.UserId == CurrentUser.Id && x.Id == tokenId && (x.ValidUntil == null || x.ValidUntil > DateTime.UtcNow)); + var token = await CurrentUserValidTokens.FirstOrDefaultAsync(x => x.Id == tokenId); if (token is null) return Problem(ApiTokenError.ApiTokenNotFound); token.Name = body.Name; diff --git a/API/Controller/Tokens/_ApiController.cs b/API/Controller/Tokens/_ApiController.cs index 2b7aabe3..a45545f1 100644 --- a/API/Controller/Tokens/_ApiController.cs +++ b/API/Controller/Tokens/_ApiController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authorization; +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.ControllerBase; @@ -10,6 +11,7 @@ namespace OpenShock.API.Controller.Tokens; [Tags("API Tokens")] [Route("/{version:apiVersion}/tokens")] [Authorize(AuthenticationSchemes = OpenShockAuthSchemes.UserSessionCookie)] +[ApiVersion("1"), ApiVersion("2")] public sealed partial class TokensController : AuthenticatedSessionControllerBase { private readonly OpenShockContext _db; diff --git a/API/Models/Response/TokenResponseV2.cs b/API/Models/Response/TokenResponseV2.cs new file mode 100644 index 00000000..b277f42a --- /dev/null +++ b/API/Models/Response/TokenResponseV2.cs @@ -0,0 +1,18 @@ +using OpenShock.Common.Models; + +namespace OpenShock.API.Models.Response; + +public sealed class TokenResponseV2 +{ + public required Guid Id { get; init; } + + public required string Name { get; init; } + + public required DateTime CreatedOn { get; init; } + + public required DateTime? ValidUntil { get; init; } + + public required DateTime? LastUsed { get; init; } + + public required List Permissions { get; init; } +} \ No newline at end of file diff --git a/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.Designer.cs b/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.Designer.cs new file mode 100644 index 00000000..61aa72b8 --- /dev/null +++ b/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.Designer.cs @@ -0,0 +1,1475 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260601005411_MakeApiTokenLastUsedNullable")] + partial class MakeApiTokenLastUsedNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs b/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs new file mode 100644 index 00000000..c9dad55d --- /dev/null +++ b/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class MakeApiTokenLastUsedNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "last_used", + table: "api_tokens", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValueSql: "'-infinity'::timestamp without time zone"); + + // Tokens that were never used were stored with the '-infinity' sentinel; represent that as NULL now. + migrationBuilder.Sql("UPDATE api_tokens SET last_used = NULL WHERE last_used = '-infinity';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Restore the '-infinity' sentinel before reinstating the NOT NULL constraint. + migrationBuilder.Sql("UPDATE api_tokens SET last_used = '-infinity' WHERE last_used IS NULL;"); + + migrationBuilder.AlterColumn( + name: "last_used", + table: "api_tokens", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "'-infinity'::timestamp without time zone", + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 0a030050..b2e0fb8b 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -21,7 +21,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); @@ -152,11 +152,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("inet") .HasColumnName("created_by_ip"); - b.Property("LastUsed") - .ValueGeneratedOnAdd() + b.Property("LastUsed") .HasColumnType("timestamp with time zone") - .HasColumnName("last_used") - .HasDefaultValueSql("'-infinity'::timestamp without time zone"); + .HasColumnName("last_used"); b.Property("Name") .IsRequired() diff --git a/Common/OpenShockDb/ApiToken.cs b/Common/OpenShockDb/ApiToken.cs index 76c5dbd1..f574a39b 100644 --- a/Common/OpenShockDb/ApiToken.cs +++ b/Common/OpenShockDb/ApiToken.cs @@ -21,7 +21,7 @@ public sealed class ApiToken public DateTime CreatedAt { get; set; } - public DateTime LastUsed { get; set; } + public DateTime? LastUsed { get; set; } // Navigations public User User { get; set; } = null!; diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 2742d711..91549a42 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -170,7 +170,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasDefaultValueSql("CURRENT_TIMESTAMP") .HasColumnName("created_at"); entity.Property(e => e.LastUsed) - .HasDefaultValueSql("'-infinity'::timestamp without time zone") .HasColumnName("last_used"); entity.Property(e => e.Name) .HasMaxLength(HardLimits.ApiKeyNameMaxLength) From 6b0bc3a1e86cb1dcbf8af9c8198182e866d5fc7c Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 3 Jun 2026 11:00:39 +0200 Subject: [PATCH 2/6] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs b/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs index c9dad55d..b8d706a3 100644 --- a/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs +++ b/Common/Migrations/20260601005411_MakeApiTokenLastUsedNullable.cs @@ -21,14 +21,14 @@ protected override void Up(MigrationBuilder migrationBuilder) oldDefaultValueSql: "'-infinity'::timestamp without time zone"); // Tokens that were never used were stored with the '-infinity' sentinel; represent that as NULL now. - migrationBuilder.Sql("UPDATE api_tokens SET last_used = NULL WHERE last_used = '-infinity';"); + migrationBuilder.Sql("UPDATE api_tokens SET last_used = NULL WHERE last_used = '-infinity'::timestamptz;"); } /// protected override void Down(MigrationBuilder migrationBuilder) { // Restore the '-infinity' sentinel before reinstating the NOT NULL constraint. - migrationBuilder.Sql("UPDATE api_tokens SET last_used = '-infinity' WHERE last_used IS NULL;"); + migrationBuilder.Sql("UPDATE api_tokens SET last_used = '-infinity'::timestamptz WHERE last_used IS NULL;"); migrationBuilder.AlterColumn( name: "last_used", From a27a6c895223f6174d3cca970debb777255f081b Mon Sep 17 00:00:00 2001 From: LucHeart Date: Wed, 3 Jun 2026 15:20:25 +0200 Subject: [PATCH 3/6] feat: add token limits and pausing --- API.IntegrationTests/Tests/TokensTests.cs | 105 ++ API/Controller/Shockers/SendControl.cs | 15 +- API/Controller/Tokens/GetTokenSelf.cs | 6 +- API/Controller/Tokens/Tokens.cs | 87 +- API/Models/Requests/CreateTokenRequestV2.cs | 6 + API/Models/Requests/EditTokenRequestV2.cs | 20 + API/Models/Response/TokenCreatedResponseV2.cs | 23 + API/Models/Response/TokenResponseV2.cs | 13 +- API/Models/ShockerControlSettings.cs | 117 ++ .../Models/ApiTokenControlLimitsTests.cs | 87 + Common/Hubs/UserHub.cs | 6 +- ...5919_AddApiTokenShockerControl.Designer.cs | 1518 +++++++++++++++++ ...0260603105919_AddApiTokenShockerControl.cs | 138 ++ .../OpenShockContextModelSnapshot.cs | 43 + Common/Models/ApiTokenControlLimits.cs | 61 + Common/Models/ControlLimitMode.cs | 17 + Common/OpenShockDb/ApiToken.cs | 19 + Common/OpenShockDb/OpenShockContext.cs | 26 + Common/Services/ControlSender.cs | 18 +- Common/Services/IControlSender.cs | 2 +- 20 files changed, 2309 insertions(+), 18 deletions(-) create mode 100644 API/Models/Requests/CreateTokenRequestV2.cs create mode 100644 API/Models/Requests/EditTokenRequestV2.cs create mode 100644 API/Models/Response/TokenCreatedResponseV2.cs create mode 100644 API/Models/ShockerControlSettings.cs create mode 100644 Common.Tests/Models/ApiTokenControlLimitsTests.cs create mode 100644 Common/Migrations/20260603105919_AddApiTokenShockerControl.Designer.cs create mode 100644 Common/Migrations/20260603105919_AddApiTokenShockerControl.cs create mode 100644 Common/Models/ApiTokenControlLimits.cs create mode 100644 Common/Models/ControlLimitMode.cs diff --git a/API.IntegrationTests/Tests/TokensTests.cs b/API.IntegrationTests/Tests/TokensTests.cs index 5e99e85e..c924110c 100644 --- a/API.IntegrationTests/Tests/TokensTests.cs +++ b/API.IntegrationTests/Tests/TokensTests.cs @@ -33,6 +33,111 @@ public async Task CreateToken_Success_ReturnsTokenString() await Assert.That(root.TryGetProperty("id", out _)).IsTrue(); } + // --- Create Token V2 (shocker control) --- + + [Test] + public async Task CreateTokenV2_DefaultShockerControl_IsPermissive() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokv2def", "tokv2def@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/2/tokens", TestHelper.JsonContent(new + { + name = "DefaultsToken", + permissions = new[] { "shockers.use" } + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var sc = doc.RootElement.GetProperty("shockerControl"); + await Assert.That(sc.GetProperty("enabled").GetBoolean()).IsTrue(); + await Assert.That(sc.GetProperty("intensity").GetProperty("min").GetInt32()).IsEqualTo(0); + await Assert.That(sc.GetProperty("intensity").GetProperty("max").GetInt32()).IsEqualTo(100); + await Assert.That(sc.GetProperty("intensity").GetProperty("mode").GetString()).IsEqualTo("Clamp"); + await Assert.That(sc.GetProperty("duration").GetProperty("min").GetInt32()).IsEqualTo(300); + await Assert.That(sc.GetProperty("duration").GetProperty("max").GetInt32()).IsEqualTo(65535); + } + + [Test] + public async Task CreateTokenV2_CustomShockerControl_RoundTrips() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokv2custom", "tokv2custom@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var createResponse = await client.PostAsync("/2/tokens", TestHelper.JsonContent(new + { + name = "CustomToken", + permissions = new[] { "shockers.use" }, + shockerControl = new + { + enabled = false, + intensity = new { min = 10, max = 50, mode = "Lerp" }, + duration = new { min = 500, max = 2000, mode = "Clamp" } + } + })); + + await Assert.That(createResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + var createJson = await createResponse.Content.ReadAsStringAsync(); + using var createDoc = JsonDocument.Parse(createJson); + var tokenId = createDoc.RootElement.GetProperty("id").GetString(); + + // Read it back via v2 GET and confirm the configuration persisted. + var getResponse = await client.GetAsync($"/2/tokens/{tokenId}"); + var json = await getResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var sc = doc.RootElement.GetProperty("shockerControl"); + await Assert.That(sc.GetProperty("enabled").GetBoolean()).IsFalse(); + await Assert.That(sc.GetProperty("intensity").GetProperty("min").GetInt32()).IsEqualTo(10); + await Assert.That(sc.GetProperty("intensity").GetProperty("max").GetInt32()).IsEqualTo(50); + await Assert.That(sc.GetProperty("intensity").GetProperty("mode").GetString()).IsEqualTo("Lerp"); + await Assert.That(sc.GetProperty("duration").GetProperty("min").GetInt32()).IsEqualTo(500); + await Assert.That(sc.GetProperty("duration").GetProperty("max").GetInt32()).IsEqualTo(2000); + } + + [Test] + public async Task CreateTokenV2_MinGreaterThanMax_Returns400() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokv2minmax", "tokv2minmax@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/2/tokens", TestHelper.JsonContent(new + { + name = "BadToken", + permissions = new[] { "shockers.use" }, + shockerControl = new + { + enabled = true, + intensity = new { min = 80, max = 20, mode = "Clamp" }, + duration = new { min = 300, max = 65535, mode = "Clamp" } + } + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task CreateTokenV2_IntensityOutOfRange_Returns400() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokv2range", "tokv2range@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/2/tokens", TestHelper.JsonContent(new + { + name = "OutOfRange", + permissions = new[] { "shockers.use" }, + shockerControl = new + { + enabled = true, + intensity = new { min = 0, max = 200, mode = "Clamp" }, + duration = new { min = 300, max = 65535, mode = "Clamp" } + } + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + // --- List Tokens --- [Test] diff --git a/API/Controller/Shockers/SendControl.cs b/API/Controller/Shockers/SendControl.cs index d6cecae9..5dc3df8d 100644 --- a/API/Controller/Shockers/SendControl.cs +++ b/API/Controller/Shockers/SendControl.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using OpenShock.Common.Authentication.Attributes; +using OpenShock.Common.Authentication.Services; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; using OpenShock.Common.Hubs; @@ -29,7 +30,8 @@ public sealed partial class ShockerController public async Task SendControl( [FromBody] ControlRequest body, [FromServices] IHubContext userHub, - [FromServices] IControlSender controlSender) + [FromServices] IControlSender controlSender, + [FromServices] IUserReferenceService userReferenceService) { var sender = new ControlLogSender { @@ -41,7 +43,11 @@ public async Task SendControl( CustomName = body.CustomName }; - var controlAction = await controlSender.ControlByUser(body.Shocks, sender, userHub.Clients); + ApiTokenControlLimits? tokenLimits = null; + if (userReferenceService.AuthReference is { IsT1: true } authReference) + tokenLimits = ApiTokenControlLimits.FromToken(authReference.AsT1); + + 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)), @@ -64,12 +70,13 @@ public async Task SendControl( public Task SendControl_DEPRECATED( [FromBody] IReadOnlyList body, [FromServices] IHubContext userHub, - [FromServices] IControlSender controlSender) + [FromServices] IControlSender controlSender, + [FromServices] IUserReferenceService userReferenceService) { return SendControl(new ControlRequest { Shocks = body, CustomName = null - }, userHub, controlSender); + }, userHub, controlSender, userReferenceService); } } \ No newline at end of file diff --git a/API/Controller/Tokens/GetTokenSelf.cs b/API/Controller/Tokens/GetTokenSelf.cs index 7bd1a487..e473f03f 100644 --- a/API/Controller/Tokens/GetTokenSelf.cs +++ b/API/Controller/Tokens/GetTokenSelf.cs @@ -1,6 +1,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Models; using OpenShock.API.Models.Response; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.ControllerBase; @@ -32,7 +33,7 @@ public TokenResponse GetSelfToken([FromServices] IUserReferenceService userRefer { CreatedOn = token.CreatedAt, ValidUntil = token.ValidUntil, - LastUsed = token.LastUsed ?? default, + LastUsed = token.LastUsed ?? DateTime.MinValue, Permissions = token.Permissions, Name = token.Name, Id = token.Id @@ -58,7 +59,8 @@ public TokenResponseV2 GetSelfTokenV2([FromServices] IUserReferenceService userR LastUsed = token.LastUsed, Permissions = token.Permissions, Name = token.Name, - Id = token.Id + Id = token.Id, + ShockerControl = ShockerControlSettings.FromToken(token) }; } diff --git a/API/Controller/Tokens/Tokens.cs b/API/Controller/Tokens/Tokens.cs index aab77d82..3075c2e3 100644 --- a/API/Controller/Tokens/Tokens.cs +++ b/API/Controller/Tokens/Tokens.cs @@ -3,6 +3,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using OpenShock.API.Models; using OpenShock.API.Models.Requests; using OpenShock.API.Models.Response; using OpenShock.Common.Constants; @@ -32,7 +33,23 @@ public sealed partial class TokensController LastUsed = x.LastUsed, Permissions = x.Permissions, Name = x.Name, - Id = x.Id + Id = x.Id, + ShockerControl = new ShockerControlSettings + { + Enabled = x.ShockerControlEnabled, + Intensity = new IntensityLimitSettings + { + Min = x.ShockerControlIntensityMin, + Max = x.ShockerControlIntensityMax, + Mode = x.ShockerControlIntensityMode + }, + Duration = new DurationLimitSettings + { + Min = x.ShockerControlDurationMin, + Max = x.ShockerControlDurationMax, + Mode = x.ShockerControlDurationMode + } + } }; /// @@ -174,4 +191,72 @@ public async Task EditToken([FromRoute] Guid tokenId, [FromBody] return Ok(); } + + /// + /// Create a new token + /// + /// + /// The created token + [HttpPost] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + [MapToApiVersion("2")] + public async Task CreateTokenV2([FromBody] CreateTokenRequestV2 body) + { + var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.ApiTokenLength); + + var shockerControl = body.ShockerControl ?? ShockerControlSettings.Default; + + var tokenDto = new ApiToken + { + Id = Guid.CreateVersion7(), + UserId = CurrentUser.Id, + Name = body.Name, + TokenHash = HashingUtils.HashToken(token), + CreatedByIp = HttpContext.GetRemoteIP(), + Permissions = body.Permissions.Distinct().ToList(), + ValidUntil = body.ValidUntil?.ToUniversalTime() + }; + shockerControl.ApplyTo(tokenDto); + + _db.ApiTokens.Add(tokenDto); + await _db.SaveChangesAsync(); + + return new TokenCreatedResponseV2 + { + Id = tokenDto.Id, + Name = tokenDto.Name, + Token = token, + CreatedAt = tokenDto.CreatedAt, + ValidUntil = tokenDto.ValidUntil, + LastUsed = tokenDto.LastUsed, + Permissions = tokenDto.Permissions, + ShockerControl = ShockerControlSettings.FromToken(tokenDto) + }; + } + + /// + /// Edit a token + /// + /// + /// + /// The edited token + /// The token does not exist or you do not have access to it. + [HttpPatch("{tokenId}")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound + [MapToApiVersion("2")] + public async Task EditTokenV2([FromRoute] Guid tokenId, [FromBody] EditTokenRequestV2 body) + { + var token = await CurrentUserValidTokens.FirstOrDefaultAsync(x => x.Id == tokenId); + if (token is null) return Problem(ApiTokenError.ApiTokenNotFound); + + token.Name = body.Name; + token.Permissions = body.Permissions.Distinct().ToList(); + (body.ShockerControl ?? ShockerControlSettings.Default).ApplyTo(token); + await _db.SaveChangesAsync(); + + return Ok(); + } } \ No newline at end of file diff --git a/API/Models/Requests/CreateTokenRequestV2.cs b/API/Models/Requests/CreateTokenRequestV2.cs new file mode 100644 index 00000000..36421156 --- /dev/null +++ b/API/Models/Requests/CreateTokenRequestV2.cs @@ -0,0 +1,6 @@ +namespace OpenShock.API.Models.Requests; + +public sealed class CreateTokenRequestV2 : EditTokenRequestV2 +{ + public DateTime? ValidUntil { get; set; } = null; +} diff --git a/API/Models/Requests/EditTokenRequestV2.cs b/API/Models/Requests/EditTokenRequestV2.cs new file mode 100644 index 00000000..a96b5165 --- /dev/null +++ b/API/Models/Requests/EditTokenRequestV2.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using OpenShock.API.Models; +using OpenShock.Common.Constants; +using OpenShock.Common.Models; + +namespace OpenShock.API.Models.Requests; + +public class EditTokenRequestV2 +{ + [StringLength(HardLimits.ApiKeyNameMaxLength, MinimumLength = 1, ErrorMessage = "API token length must be between {1} and {2}")] + public required string Name { get; set; } + + [MaxLength(HardLimits.ApiKeyMaxPermissions, ErrorMessage = "API token permissions must be between {1} and {2}")] + public List Permissions { get; set; } = [PermissionType.Shockers_Use]; + + /// + /// Per-token shocker control configuration. When omitted, the permissive default is used. + /// + public ShockerControlSettings? ShockerControl { get; set; } = null; +} diff --git a/API/Models/Response/TokenCreatedResponseV2.cs b/API/Models/Response/TokenCreatedResponseV2.cs new file mode 100644 index 00000000..9ee5ca3b --- /dev/null +++ b/API/Models/Response/TokenCreatedResponseV2.cs @@ -0,0 +1,23 @@ +using OpenShock.API.Models; +using OpenShock.Common.Models; + +namespace OpenShock.API.Models.Response; + +public sealed class TokenCreatedResponseV2 +{ + public required Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Token { get; init; } + + public required DateTime CreatedAt { get; init; } + + public required DateTime? ValidUntil { get; init; } + + public required DateTime? LastUsed { get; init; } + + public required IReadOnlyList Permissions { get; init; } + + public required ShockerControlSettings ShockerControl { get; init; } +} diff --git a/API/Models/Response/TokenResponseV2.cs b/API/Models/Response/TokenResponseV2.cs index b277f42a..9256b575 100644 --- a/API/Models/Response/TokenResponseV2.cs +++ b/API/Models/Response/TokenResponseV2.cs @@ -1,4 +1,5 @@ -using OpenShock.Common.Models; +using OpenShock.API.Models; +using OpenShock.Common.Models; namespace OpenShock.API.Models.Response; @@ -9,10 +10,12 @@ public sealed class TokenResponseV2 public required string Name { get; init; } public required DateTime CreatedOn { get; init; } - + public required DateTime? ValidUntil { get; init; } - + public required DateTime? LastUsed { get; init; } - - public required List Permissions { get; init; } + + public required IReadOnlyList Permissions { get; init; } + + public required ShockerControlSettings ShockerControl { get; init; } } \ No newline at end of file diff --git a/API/Models/ShockerControlSettings.cs b/API/Models/ShockerControlSettings.cs new file mode 100644 index 00000000..3d3d08cf --- /dev/null +++ b/API/Models/ShockerControlSettings.cs @@ -0,0 +1,117 @@ +using System.ComponentModel.DataAnnotations; +using OpenShock.Common.Constants; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.Models; + +/// +/// Per-token shocker control configuration: a toggle plus min/max limits for intensity and duration. +/// +public sealed class ShockerControlSettings : IValidatableObject +{ + /// + /// Whether this token is allowed to send shocker control messages at all. + /// + public bool Enabled { get; init; } = true; + + public required IntensityLimitSettings Intensity { get; init; } + + public required DurationLimitSettings Duration { get; init; } + + /// + /// The permissive default: enabled, full-range clamp for both intensity and duration (a no-op). + /// + public static ShockerControlSettings Default => new() + { + Enabled = true, + Intensity = new IntensityLimitSettings + { + Min = HardLimits.MinControlIntensity, + Max = HardLimits.MaxControlIntensity, + Mode = ControlLimitMode.Clamp + }, + Duration = new DurationLimitSettings + { + Min = HardLimits.MinControlDuration, + Max = HardLimits.MaxControlDuration, + Mode = ControlLimitMode.Clamp + } + }; + + /// + /// Maps the flat shocker-control columns of an onto this DTO. + /// In-memory only; for EF projections construct the object inline so it can be translated. + /// + public static ShockerControlSettings FromToken(ApiToken token) => new() + { + Enabled = token.ShockerControlEnabled, + Intensity = new IntensityLimitSettings + { + Min = token.ShockerControlIntensityMin, + Max = token.ShockerControlIntensityMax, + Mode = token.ShockerControlIntensityMode + }, + Duration = new DurationLimitSettings + { + Min = token.ShockerControlDurationMin, + Max = token.ShockerControlDurationMax, + Mode = token.ShockerControlDurationMode + } + }; + + /// + /// Writes this configuration onto the flat shocker-control columns of an . + /// + public void ApplyTo(ApiToken token) + { + token.ShockerControlEnabled = Enabled; + token.ShockerControlIntensityMin = Intensity.Min; + token.ShockerControlIntensityMax = Intensity.Max; + token.ShockerControlIntensityMode = Intensity.Mode; + token.ShockerControlDurationMin = Duration.Min; + token.ShockerControlDurationMax = Duration.Max; + token.ShockerControlDurationMode = Duration.Mode; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (Intensity is not null && Intensity.Min > Intensity.Max) + { + yield return new ValidationResult( + "Intensity min must be less than or equal to max", + [nameof(Intensity)]); + } + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (Duration is not null && Duration.Min > Duration.Max) + { + yield return new ValidationResult( + "Duration min must be less than or equal to max", + [nameof(Duration)]); + } + } +} + +public sealed class IntensityLimitSettings +{ + [Range(HardLimits.MinControlIntensity, HardLimits.MaxControlIntensity)] + public required byte Min { get; init; } + + [Range(HardLimits.MinControlIntensity, HardLimits.MaxControlIntensity)] + public required byte Max { get; init; } + + public ControlLimitMode Mode { get; init; } = ControlLimitMode.Clamp; +} + +public sealed class DurationLimitSettings +{ + [Range(HardLimits.MinControlDuration, HardLimits.MaxControlDuration)] + public required ushort Min { get; init; } + + [Range(HardLimits.MinControlDuration, HardLimits.MaxControlDuration)] + public required ushort Max { get; init; } + + public ControlLimitMode Mode { get; init; } = ControlLimitMode.Clamp; +} diff --git a/Common.Tests/Models/ApiTokenControlLimitsTests.cs b/Common.Tests/Models/ApiTokenControlLimitsTests.cs new file mode 100644 index 00000000..1b53d7c2 --- /dev/null +++ b/Common.Tests/Models/ApiTokenControlLimitsTests.cs @@ -0,0 +1,87 @@ +using OpenShock.Common.Models; + +namespace OpenShock.Common.Tests.Models; + +public class ApiTokenControlLimitsTests +{ + private static ApiTokenControlLimits IntensityLimits(byte min, byte max, ControlLimitMode mode) => new() + { + Enabled = true, + IntensityMin = min, + IntensityMax = max, + IntensityMode = mode, + DurationMin = 300, + DurationMax = 65535, + DurationMode = ControlLimitMode.Clamp + }; + + private static ApiTokenControlLimits DurationLimits(ushort min, ushort max, ControlLimitMode mode) => new() + { + Enabled = true, + IntensityMin = 0, + IntensityMax = 100, + IntensityMode = ControlLimitMode.Clamp, + DurationMin = min, + DurationMax = max, + DurationMode = mode + }; + + // --- Intensity clamp --- + + [Test] + [Arguments(0, 100, (byte)100, (byte)100)] // full range is a no-op + [Arguments(0, 50, (byte)100, (byte)50)] // above max is capped + [Arguments(0, 50, (byte)30, (byte)30)] // within range passes through + [Arguments(20, 80, (byte)0, (byte)20)] // below min is raised + public async Task ApplyIntensity_Clamp(int min, int max, byte input, byte expected) + { + var result = IntensityLimits((byte)min, (byte)max, ControlLimitMode.Clamp).ApplyIntensity(input); + await Assert.That(result).IsEqualTo(expected); + } + + // --- Intensity lerp (input range 0-100 remapped onto [min,max]) --- + + [Test] + [Arguments(0, 50, (byte)0, (byte)0)] // input 0 -> min + [Arguments(0, 50, (byte)100, (byte)50)] // input 100 -> max + [Arguments(0, 50, (byte)50, (byte)25)] // midpoint -> midpoint + [Arguments(20, 80, (byte)0, (byte)20)] + [Arguments(20, 80, (byte)100, (byte)80)] + [Arguments(20, 80, (byte)50, (byte)50)] + public async Task ApplyIntensity_Lerp(int min, int max, byte input, byte expected) + { + var result = IntensityLimits((byte)min, (byte)max, ControlLimitMode.Lerp).ApplyIntensity(input); + await Assert.That(result).IsEqualTo(expected); + } + + // --- Duration clamp --- + + [Test] + [Arguments(300, 65535, (ushort)1000, (ushort)1000)] // full range is a no-op + [Arguments(300, 1000, (ushort)5000, (ushort)1000)] // above max is capped + [Arguments(300, 1000, (ushort)700, (ushort)700)] // within range passes through + public async Task ApplyDuration_Clamp(int min, int max, ushort input, ushort expected) + { + var result = DurationLimits((ushort)min, (ushort)max, ControlLimitMode.Clamp).ApplyDuration(input); + await Assert.That(result).IsEqualTo(expected); + } + + // --- Duration lerp (input range 300-65535 remapped onto [min,max]) --- + + [Test] + public async Task ApplyDuration_Lerp_Endpoints() + { + var limits = DurationLimits(1000, 5000, ControlLimitMode.Lerp); + await Assert.That(limits.ApplyDuration(300)).IsEqualTo((ushort)1000); // input min -> min + await Assert.That(limits.ApplyDuration(65535)).IsEqualTo((ushort)5000); // input max -> max + } + + [Test] + public async Task ApplyDuration_Lerp_Midpoint() + { + var limits = DurationLimits(1000, 5000, ControlLimitMode.Lerp); + // midpoint of input range -> midpoint of [1000,5000] == 3000 + var midInput = (ushort)((300 + 65535) / 2); + await Assert.That(limits.ApplyDuration(midInput)).IsEqualTo((ushort)3000); + } +} diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index 9bd54f9d..ce147327 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -92,7 +92,11 @@ public async Task ControlV2(IReadOnlyList shocks, CustomName = customName }).FirstAsync(); - await _controlSender.ControlByUser(shocks, sender, Clients); + ApiTokenControlLimits? tokenLimits = null; + if (_userReferenceService.AuthReference is { IsT1: true } authReference) + tokenLimits = ApiTokenControlLimits.FromToken(authReference.AsT1); + + await _controlSender.ControlByUser(shocks, sender, Clients, tokenLimits); } public async Task CaptivePortal(Guid deviceId, bool enabled) diff --git a/Common/Migrations/20260603105919_AddApiTokenShockerControl.Designer.cs b/Common/Migrations/20260603105919_AddApiTokenShockerControl.Designer.cs new file mode 100644 index 00000000..7364cfcb --- /dev/null +++ b/Common/Migrations/20260603105919_AddApiTokenShockerControl.Designer.cs @@ -0,0 +1,1518 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260603105919_AddApiTokenShockerControl")] + partial class AddApiTokenShockerControl + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("ShockerControlDurationMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(65535) + .HasColumnName("shocker_control_duration_max"); + + b.Property("ShockerControlDurationMin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(300) + .HasColumnName("shocker_control_duration_min"); + + b.Property("ShockerControlDurationMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + + b.Property("ShockerControlEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("shocker_control_enabled"); + + b.Property("ShockerControlIntensityMax") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)100) + .HasColumnName("shocker_control_intensity_max"); + + b.Property("ShockerControlIntensityMin") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)0) + .HasColumnName("shocker_control_intensity_min"); + + b.Property("ShockerControlIntensityMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20260603105919_AddApiTokenShockerControl.cs b/Common/Migrations/20260603105919_AddApiTokenShockerControl.cs new file mode 100644 index 00000000..706c65ca --- /dev/null +++ b/Common/Migrations/20260603105919_AddApiTokenShockerControl.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.Common.Models; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddApiTokenShockerControl : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + + migrationBuilder.AddColumn( + name: "shocker_control_duration_max", + table: "api_tokens", + type: "integer", + nullable: false, + defaultValue: 65535); + + migrationBuilder.AddColumn( + name: "shocker_control_duration_min", + table: "api_tokens", + type: "integer", + nullable: false, + defaultValue: 300); + + migrationBuilder.AddColumn( + name: "shocker_control_duration_mode", + table: "api_tokens", + type: "control_limit_mode", + nullable: false, + defaultValue: ControlLimitMode.Clamp); + + migrationBuilder.AddColumn( + name: "shocker_control_enabled", + table: "api_tokens", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "shocker_control_intensity_max", + table: "api_tokens", + type: "smallint", + nullable: false, + defaultValue: (byte)100); + + migrationBuilder.AddColumn( + name: "shocker_control_intensity_min", + table: "api_tokens", + type: "smallint", + nullable: false, + defaultValue: (byte)0); + + migrationBuilder.AddColumn( + name: "shocker_control_intensity_mode", + table: "api_tokens", + type: "control_limit_mode", + nullable: false, + defaultValue: ControlLimitMode.Clamp); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "shocker_control_duration_max", + table: "api_tokens"); + + migrationBuilder.DropColumn( + name: "shocker_control_duration_min", + table: "api_tokens"); + + migrationBuilder.DropColumn( + name: "shocker_control_duration_mode", + table: "api_tokens"); + + migrationBuilder.DropColumn( + name: "shocker_control_enabled", + table: "api_tokens"); + + migrationBuilder.DropColumn( + name: "shocker_control_intensity_max", + table: "api_tokens"); + + migrationBuilder.DropColumn( + name: "shocker_control_intensity_min", + table: "api_tokens"); + + migrationBuilder.DropColumn( + name: "shocker_control_intensity_mode", + table: "api_tokens"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index b2e0fb8b..fd627007 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -25,6 +25,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); @@ -167,6 +168,48 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("permission_type[]") .HasColumnName("permissions"); + b.Property("ShockerControlDurationMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(65535) + .HasColumnName("shocker_control_duration_max"); + + b.Property("ShockerControlDurationMin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(300) + .HasColumnName("shocker_control_duration_min"); + + b.Property("ShockerControlDurationMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + + b.Property("ShockerControlEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("shocker_control_enabled"); + + b.Property("ShockerControlIntensityMax") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)100) + .HasColumnName("shocker_control_intensity_max"); + + b.Property("ShockerControlIntensityMin") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)0) + .HasColumnName("shocker_control_intensity_min"); + + b.Property("ShockerControlIntensityMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(64) diff --git a/Common/Models/ApiTokenControlLimits.cs b/Common/Models/ApiTokenControlLimits.cs new file mode 100644 index 00000000..32480379 --- /dev/null +++ b/Common/Models/ApiTokenControlLimits.cs @@ -0,0 +1,61 @@ +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.Common.Models; + +/// +/// The shocker-control scoping carried by an API token, resolved at control time. +/// +public readonly struct ApiTokenControlLimits +{ + public required bool Enabled { get; init; } + + public required byte IntensityMin { get; init; } + public required byte IntensityMax { get; init; } + public required ControlLimitMode IntensityMode { get; init; } + + public required ushort DurationMin { get; init; } + public required ushort DurationMax { get; init; } + public required ControlLimitMode DurationMode { get; init; } + + public static ApiTokenControlLimits FromToken(ApiToken token) => new() + { + Enabled = token.ShockerControlEnabled, + IntensityMin = token.ShockerControlIntensityMin, + IntensityMax = token.ShockerControlIntensityMax, + IntensityMode = token.ShockerControlIntensityMode, + DurationMin = token.ShockerControlDurationMin, + DurationMax = token.ShockerControlDurationMax, + DurationMode = token.ShockerControlDurationMode + }; + + /// + /// Applies the token's intensity scoping to an incoming control intensity (input range 0-100). + /// + public byte ApplyIntensity(byte intensity) => (byte)Apply( + intensity, IntensityMin, IntensityMax, IntensityMode, + HardLimits.MinControlIntensity, HardLimits.MaxControlIntensity); + + /// + /// Applies the token's duration scoping to an incoming control duration (input range 300-65535). + /// + public ushort ApplyDuration(ushort duration) => (ushort)Apply( + duration, DurationMin, DurationMax, DurationMode, + HardLimits.MinControlDuration, HardLimits.MaxControlDuration); + + private static int Apply(int value, int min, int max, ControlLimitMode mode, int inputMin, int inputMax) + { + switch (mode) + { + case ControlLimitMode.Lerp: + var t = inputMax <= inputMin + ? 0d + : (double)(value - inputMin) / (inputMax - inputMin); + var lerped = (int)Math.Round(min + (max - min) * t, MidpointRounding.AwayFromZero); + return Math.Clamp(lerped, min, max); + case ControlLimitMode.Clamp: + default: + return Math.Clamp(value, min, max); + } + } +} diff --git a/Common/Models/ControlLimitMode.cs b/Common/Models/ControlLimitMode.cs new file mode 100644 index 00000000..b2de7f81 --- /dev/null +++ b/Common/Models/ControlLimitMode.cs @@ -0,0 +1,17 @@ +namespace OpenShock.Common.Models; + +/// +/// Determines how a per-token min/max limit is applied to an incoming control value. +/// +public enum ControlLimitMode +{ + /// + /// Clamp the incoming value into the [min, max] range. + /// + Clamp = 0, + + /// + /// Linearly remap the full input range onto [min, max]. + /// + Lerp = 1 +} diff --git a/Common/OpenShockDb/ApiToken.cs b/Common/OpenShockDb/ApiToken.cs index f574a39b..2c6a6a8c 100644 --- a/Common/OpenShockDb/ApiToken.cs +++ b/Common/OpenShockDb/ApiToken.cs @@ -1,4 +1,5 @@ using System.Net; +using OpenShock.Common.Constants; using OpenShock.Common.Models; namespace OpenShock.Common.OpenShockDb; @@ -23,6 +24,24 @@ public sealed class ApiToken public DateTime? LastUsed { get; set; } + /// + /// Whether this token is allowed to send shocker control messages at all. + /// Independent gate on top of the permission. + /// + public bool ShockerControlEnabled { get; set; } = true; + + public byte ShockerControlIntensityMin { get; set; } = HardLimits.MinControlIntensity; + + public byte ShockerControlIntensityMax { get; set; } = HardLimits.MaxControlIntensity; + + public ControlLimitMode ShockerControlIntensityMode { get; set; } = ControlLimitMode.Clamp; + + public ushort ShockerControlDurationMin { get; set; } = HardLimits.MinControlDuration; + + public ushort ShockerControlDurationMax { get; set; } = HardLimits.MaxControlDuration; + + public ControlLimitMode ShockerControlDurationMode { get; set; } = ControlLimitMode.Clamp; + // Navigations public User User { get; set; } = null!; } diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 91549a42..f679b075 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -65,6 +65,7 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde { npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); @@ -141,6 +142,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder .HasPostgresEnum("control_type", ["sound", "vibrate", "shock", "stop"]) + .HasPostgresEnum("control_limit_mode", ["clamp", "lerp"]) .HasPostgresEnum("ota_update_status", ["started", "running", "finished", "error", "timeout"]) .HasPostgresEnum("password_encryption_type", ["pbkdf2", "bcrypt_enhanced"]) .HasPostgresEnum("permission_type", @@ -183,6 +185,30 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Permissions).HasColumnType("permission_type[]").HasColumnName("permissions"); + entity.Property(e => e.ShockerControlEnabled) + .HasDefaultValue(true) + .HasColumnName("shocker_control_enabled"); + entity.Property(e => e.ShockerControlIntensityMin) + .HasDefaultValue(HardLimits.MinControlIntensity) + .HasColumnName("shocker_control_intensity_min"); + entity.Property(e => e.ShockerControlIntensityMax) + .HasDefaultValue(HardLimits.MaxControlIntensity) + .HasColumnName("shocker_control_intensity_max"); + entity.Property(e => e.ShockerControlIntensityMode) + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + entity.Property(e => e.ShockerControlDurationMin) + .HasDefaultValue(HardLimits.MinControlDuration) + .HasColumnName("shocker_control_duration_min"); + entity.Property(e => e.ShockerControlDurationMax) + .HasDefaultValue(HardLimits.MaxControlDuration) + .HasColumnName("shocker_control_duration_max"); + entity.Property(e => e.ShockerControlDurationMode) + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + entity.HasOne(d => d.User).WithMany(p => p.ApiTokens) .HasForeignKey(d => d.UserId) .HasConstraintName("fk_api_tokens_user_id"); diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index f4b898e5..3af316d3 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -26,7 +26,7 @@ public ControlSender(OpenShockContext db, IRedisPubService publisher) _publisher = publisher; } - public async Task> ControlByUser(IReadOnlyList controls,ControlLogSender sender, IHubClients hubClients) + public async Task> ControlByUser(IReadOnlyList controls,ControlLogSender sender, IHubClients hubClients, ApiTokenControlLimits? tokenLimits = null) { var shockers = await _db.Shockers .AsNoTracking() @@ -58,7 +58,7 @@ public async Task> ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId) @@ -99,12 +99,12 @@ 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) + private async Task> ControlInternal(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ControlShockerObj[] allowedShockers, ApiTokenControlLimits? tokenLimits = null) { var shockersById = allowedShockers.ToDictionary(s => s.ShockerId, s => s); var now = DateTime.UtcNow; - + var messagesByDevice = new Dictionary>(); var logsByOwner = new Dictionary>(); @@ -119,6 +119,16 @@ private async Task> ControlByUser(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients); + public Task> ControlByUser(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, ApiTokenControlLimits? tokenLimits = null); public Task> ControlPublicShare(IReadOnlyList controls, ControlLogSender sender, IHubClients hubClients, Guid publicShareId); } From 930f36da25d54c6723b95d611a5b59b83ac7e8a9 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Wed, 3 Jun 2026 15:57:49 +0200 Subject: [PATCH 4/6] more work on pausing --- API.IntegrationTests/Tests/TokensTests.cs | 73 ++++++++++++++----- API/Models/Requests/EditTokenRequestV2.cs | 5 +- API/Models/Requests/SetTokenPausedRequest.cs | 9 +++ API/Models/Response/TokenPausedResponse.cs | 9 +++ API/Program.cs | 2 + .../Models/ApiTokenControlLimitsTests.cs | 2 - Common/Errors/ApiTokenError.cs | 1 + Common/Hubs/UserHub.cs | 9 ++- ...636_AddApiTokenShockerControl.Designer.cs} | 14 ++-- ...260603134636_AddApiTokenShockerControl.cs} | 22 +++--- .../OpenShockContextModelSnapshot.cs | 12 +-- Common/Models/ApiTokenControlLimits.cs | 3 - Common/OpenShockDb/ApiToken.cs | 4 +- Common/OpenShockDb/OpenShockContext.cs | 6 +- Common/Services/ControlSender.cs | 5 +- 15 files changed, 117 insertions(+), 59 deletions(-) create mode 100644 API/Models/Requests/SetTokenPausedRequest.cs create mode 100644 API/Models/Response/TokenPausedResponse.cs rename Common/Migrations/{20260603105919_AddApiTokenShockerControl.Designer.cs => 20260603134636_AddApiTokenShockerControl.Designer.cs} (99%) rename Common/Migrations/{20260603105919_AddApiTokenShockerControl.cs => 20260603134636_AddApiTokenShockerControl.cs} (98%) diff --git a/API.IntegrationTests/Tests/TokensTests.cs b/API.IntegrationTests/Tests/TokensTests.cs index c924110c..ef01677b 100644 --- a/API.IntegrationTests/Tests/TokensTests.cs +++ b/API.IntegrationTests/Tests/TokensTests.cs @@ -35,29 +35,27 @@ public async Task CreateToken_Success_ReturnsTokenString() // --- Create Token V2 (shocker control) --- + private static object ShockerControlBody(bool paused = false) => new + { + paused, + intensity = new { min = 0, max = 100, mode = "Clamp" }, + duration = new { min = 300, max = 65535, mode = "Clamp" } + }; + [Test] - public async Task CreateTokenV2_DefaultShockerControl_IsPermissive() + public async Task CreateTokenV2_MissingShockerControl_Returns400() { var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokv2def", "tokv2def@test.org", "SecurePassword123#"); using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + // shockerControl is required on v2 create. var response = await client.PostAsync("/2/tokens", TestHelper.JsonContent(new { - name = "DefaultsToken", + name = "NoShockerControl", permissions = new[] { "shockers.use" } })); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - var sc = doc.RootElement.GetProperty("shockerControl"); - await Assert.That(sc.GetProperty("enabled").GetBoolean()).IsTrue(); - await Assert.That(sc.GetProperty("intensity").GetProperty("min").GetInt32()).IsEqualTo(0); - await Assert.That(sc.GetProperty("intensity").GetProperty("max").GetInt32()).IsEqualTo(100); - await Assert.That(sc.GetProperty("intensity").GetProperty("mode").GetString()).IsEqualTo("Clamp"); - await Assert.That(sc.GetProperty("duration").GetProperty("min").GetInt32()).IsEqualTo(300); - await Assert.That(sc.GetProperty("duration").GetProperty("max").GetInt32()).IsEqualTo(65535); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); } [Test] @@ -72,7 +70,7 @@ public async Task CreateTokenV2_CustomShockerControl_RoundTrips() permissions = new[] { "shockers.use" }, shockerControl = new { - enabled = false, + paused = true, intensity = new { min = 10, max = 50, mode = "Lerp" }, duration = new { min = 500, max = 2000, mode = "Clamp" } } @@ -88,7 +86,7 @@ public async Task CreateTokenV2_CustomShockerControl_RoundTrips() var json = await getResponse.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); var sc = doc.RootElement.GetProperty("shockerControl"); - await Assert.That(sc.GetProperty("enabled").GetBoolean()).IsFalse(); + await Assert.That(sc.GetProperty("paused").GetBoolean()).IsTrue(); await Assert.That(sc.GetProperty("intensity").GetProperty("min").GetInt32()).IsEqualTo(10); await Assert.That(sc.GetProperty("intensity").GetProperty("max").GetInt32()).IsEqualTo(50); await Assert.That(sc.GetProperty("intensity").GetProperty("mode").GetString()).IsEqualTo("Lerp"); @@ -108,7 +106,7 @@ public async Task CreateTokenV2_MinGreaterThanMax_Returns400() permissions = new[] { "shockers.use" }, shockerControl = new { - enabled = true, + paused = false, intensity = new { min = 80, max = 20, mode = "Clamp" }, duration = new { min = 300, max = 65535, mode = "Clamp" } } @@ -129,7 +127,7 @@ public async Task CreateTokenV2_IntensityOutOfRange_Returns400() permissions = new[] { "shockers.use" }, shockerControl = new { - enabled = true, + paused = false, intensity = new { min = 0, max = 200, mode = "Clamp" }, duration = new { min = 300, max = 65535, mode = "Clamp" } } @@ -138,6 +136,47 @@ public async Task CreateTokenV2_IntensityOutOfRange_Returns400() await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); } + [Test] + public async Task SetTokenPaused_TogglesAndReturnsState() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokv2pause", "tokv2pause@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var createResponse = await client.PostAsync("/2/tokens", TestHelper.JsonContent(new + { + name = "PauseMe", + permissions = new[] { "shockers.use" }, + shockerControl = ShockerControlBody(paused: false) + })); + var createJson = await createResponse.Content.ReadAsStringAsync(); + using var createDoc = JsonDocument.Parse(createJson); + var tokenId = createDoc.RootElement.GetProperty("id").GetString(); + + // Pause it; the endpoint returns the now-set state. + var pauseResponse = await client.PatchAsync($"/2/tokens/{tokenId}/paused", TestHelper.JsonContent(new { paused = true })); + await Assert.That(pauseResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + using (var pauseDoc = JsonDocument.Parse(await pauseResponse.Content.ReadAsStringAsync())) + { + await Assert.That(pauseDoc.RootElement.GetProperty("paused").GetBoolean()).IsTrue(); + } + + // Confirm it persisted. + var getResponse = await client.GetAsync($"/2/tokens/{tokenId}"); + using var getDoc = JsonDocument.Parse(await getResponse.Content.ReadAsStringAsync()); + await Assert.That(getDoc.RootElement.GetProperty("shockerControl").GetProperty("paused").GetBoolean()).IsTrue(); + } + + [Test] + public async Task SetTokenPaused_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokv2pause404", "tokv2pause404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PatchAsync($"/2/tokens/{Guid.CreateVersion7()}/paused", TestHelper.JsonContent(new { paused = true })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + // --- List Tokens --- [Test] diff --git a/API/Models/Requests/EditTokenRequestV2.cs b/API/Models/Requests/EditTokenRequestV2.cs index a96b5165..fb50f456 100644 --- a/API/Models/Requests/EditTokenRequestV2.cs +++ b/API/Models/Requests/EditTokenRequestV2.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using OpenShock.API.Models; using OpenShock.Common.Constants; using OpenShock.Common.Models; @@ -11,10 +10,10 @@ public class EditTokenRequestV2 public required string Name { get; set; } [MaxLength(HardLimits.ApiKeyMaxPermissions, ErrorMessage = "API token permissions must be between {1} and {2}")] - public List Permissions { get; set; } = [PermissionType.Shockers_Use]; + public required IReadOnlyList Permissions { get; set; } /// /// Per-token shocker control configuration. When omitted, the permissive default is used. /// - public ShockerControlSettings? ShockerControl { get; set; } = null; + public required ShockerControlSettings ShockerControl { get; set; } } diff --git a/API/Models/Requests/SetTokenPausedRequest.cs b/API/Models/Requests/SetTokenPausedRequest.cs new file mode 100644 index 00000000..c01670c9 --- /dev/null +++ b/API/Models/Requests/SetTokenPausedRequest.cs @@ -0,0 +1,9 @@ +namespace OpenShock.API.Models.Requests; + +public sealed class SetTokenPausedRequest +{ + /// + /// When true, the token is paused and may not send shocker control messages. + /// + public required bool Paused { get; set; } +} diff --git a/API/Models/Response/TokenPausedResponse.cs b/API/Models/Response/TokenPausedResponse.cs new file mode 100644 index 00000000..61e6bf0a --- /dev/null +++ b/API/Models/Response/TokenPausedResponse.cs @@ -0,0 +1,9 @@ +namespace OpenShock.API.Models.Response; + +public sealed class TokenPausedResponse +{ + /// + /// The now-set paused state of the token. + /// + public required bool Paused { get; init; } +} diff --git a/API/Program.cs b/API/Program.cs index a9ebff52..5dcf07fa 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -7,6 +7,7 @@ using OpenShock.API.Services.Email; using OpenShock.API.Services.LCGNodeProvisioner; using OpenShock.API.Services.OAuthConnection; +using OpenShock.API.Services.Token; using OpenShock.API.Services.Turnstile; using OpenShock.API.Services.UserService; using OpenShock.Common; @@ -101,6 +102,7 @@ static void DefaultOptions(RemoteAuthenticationOptions options, string provider) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.AddSwaggerExt(); diff --git a/Common.Tests/Models/ApiTokenControlLimitsTests.cs b/Common.Tests/Models/ApiTokenControlLimitsTests.cs index 1b53d7c2..6d83d69d 100644 --- a/Common.Tests/Models/ApiTokenControlLimitsTests.cs +++ b/Common.Tests/Models/ApiTokenControlLimitsTests.cs @@ -6,7 +6,6 @@ public class ApiTokenControlLimitsTests { private static ApiTokenControlLimits IntensityLimits(byte min, byte max, ControlLimitMode mode) => new() { - Enabled = true, IntensityMin = min, IntensityMax = max, IntensityMode = mode, @@ -17,7 +16,6 @@ public class ApiTokenControlLimitsTests private static ApiTokenControlLimits DurationLimits(ushort min, ushort max, ControlLimitMode mode) => new() { - Enabled = true, IntensityMin = 0, IntensityMax = 100, IntensityMode = ControlLimitMode.Clamp, diff --git a/Common/Errors/ApiTokenError.cs b/Common/Errors/ApiTokenError.cs index fe13d40a..5007feea 100644 --- a/Common/Errors/ApiTokenError.cs +++ b/Common/Errors/ApiTokenError.cs @@ -7,4 +7,5 @@ public static class ApiTokenError { public static OpenShockProblem ApiTokenNotFound => new("ApiToken.NotFound", "Api token not found", HttpStatusCode.NotFound); public static OpenShockProblem ApiTokenCanOnlyDeleteSelf => new("ApiToken.CanOnlyDeleteSelf", "You can only delete your own api token in token authentication scope", HttpStatusCode.Forbidden); + public static OpenShockProblem ApiTokenPaused => new("ApiToken.Paused", "This api token is paused and may not control shockers", HttpStatusCode.Forbidden); } \ No newline at end of file diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index ce147327..daf5bc93 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -94,7 +94,14 @@ public async Task ControlV2(IReadOnlyList shocks, ApiTokenControlLimits? tokenLimits = null; if (_userReferenceService.AuthReference is { IsT1: true } authReference) - tokenLimits = ApiTokenControlLimits.FromToken(authReference.AsT1); + { + var apiToken = authReference.AsT1; + + // A paused token may not control shockers. + if (apiToken.ShockerControlPaused) return; + + tokenLimits = ApiTokenControlLimits.FromToken(apiToken); + } await _controlSender.ControlByUser(shocks, sender, Clients, tokenLimits); } diff --git a/Common/Migrations/20260603105919_AddApiTokenShockerControl.Designer.cs b/Common/Migrations/20260603134636_AddApiTokenShockerControl.Designer.cs similarity index 99% rename from Common/Migrations/20260603105919_AddApiTokenShockerControl.Designer.cs rename to Common/Migrations/20260603134636_AddApiTokenShockerControl.Designer.cs index 7364cfcb..d27f3aee 100644 --- a/Common/Migrations/20260603105919_AddApiTokenShockerControl.Designer.cs +++ b/Common/Migrations/20260603134636_AddApiTokenShockerControl.Designer.cs @@ -15,7 +15,7 @@ namespace OpenShock.Common.Migrations { [DbContext(typeof(MigrationOpenShockContext))] - [Migration("20260603105919_AddApiTokenShockerControl")] + [Migration("20260603134636_AddApiTokenShockerControl")] partial class AddApiTokenShockerControl { /// @@ -189,12 +189,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasDefaultValue(ControlLimitMode.Clamp) .HasColumnName("shocker_control_duration_mode"); - b.Property("ShockerControlEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("shocker_control_enabled"); - b.Property("ShockerControlIntensityMax") .ValueGeneratedOnAdd() .HasColumnType("smallint") @@ -213,6 +207,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasDefaultValue(ControlLimitMode.Clamp) .HasColumnName("shocker_control_intensity_mode"); + b.Property("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(64) diff --git a/Common/Migrations/20260603105919_AddApiTokenShockerControl.cs b/Common/Migrations/20260603134636_AddApiTokenShockerControl.cs similarity index 98% rename from Common/Migrations/20260603105919_AddApiTokenShockerControl.cs rename to Common/Migrations/20260603134636_AddApiTokenShockerControl.cs index 706c65ca..d78e72a2 100644 --- a/Common/Migrations/20260603105919_AddApiTokenShockerControl.cs +++ b/Common/Migrations/20260603134636_AddApiTokenShockerControl.cs @@ -53,13 +53,6 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: ControlLimitMode.Clamp); - migrationBuilder.AddColumn( - name: "shocker_control_enabled", - table: "api_tokens", - type: "boolean", - nullable: false, - defaultValue: true); - migrationBuilder.AddColumn( name: "shocker_control_intensity_max", table: "api_tokens", @@ -80,6 +73,13 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "control_limit_mode", nullable: false, defaultValue: ControlLimitMode.Clamp); + + migrationBuilder.AddColumn( + name: "shocker_control_paused", + table: "api_tokens", + type: "boolean", + nullable: false, + defaultValue: false); } /// @@ -97,10 +97,6 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "shocker_control_duration_mode", table: "api_tokens"); - migrationBuilder.DropColumn( - name: "shocker_control_enabled", - table: "api_tokens"); - migrationBuilder.DropColumn( name: "shocker_control_intensity_max", table: "api_tokens"); @@ -113,6 +109,10 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "shocker_control_intensity_mode", table: "api_tokens"); + migrationBuilder.DropColumn( + name: "shocker_control_paused", + table: "api_tokens"); + migrationBuilder.AlterDatabase() .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index fd627007..81f0c618 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -186,12 +186,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDefaultValue(ControlLimitMode.Clamp) .HasColumnName("shocker_control_duration_mode"); - b.Property("ShockerControlEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true) - .HasColumnName("shocker_control_enabled"); - b.Property("ShockerControlIntensityMax") .ValueGeneratedOnAdd() .HasColumnType("smallint") @@ -210,6 +204,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDefaultValue(ControlLimitMode.Clamp) .HasColumnName("shocker_control_intensity_mode"); + b.Property("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(64) diff --git a/Common/Models/ApiTokenControlLimits.cs b/Common/Models/ApiTokenControlLimits.cs index 32480379..9d7dbe15 100644 --- a/Common/Models/ApiTokenControlLimits.cs +++ b/Common/Models/ApiTokenControlLimits.cs @@ -8,8 +8,6 @@ namespace OpenShock.Common.Models; /// public readonly struct ApiTokenControlLimits { - public required bool Enabled { get; init; } - public required byte IntensityMin { get; init; } public required byte IntensityMax { get; init; } public required ControlLimitMode IntensityMode { get; init; } @@ -20,7 +18,6 @@ public readonly struct ApiTokenControlLimits public static ApiTokenControlLimits FromToken(ApiToken token) => new() { - Enabled = token.ShockerControlEnabled, IntensityMin = token.ShockerControlIntensityMin, IntensityMax = token.ShockerControlIntensityMax, IntensityMode = token.ShockerControlIntensityMode, diff --git a/Common/OpenShockDb/ApiToken.cs b/Common/OpenShockDb/ApiToken.cs index 2c6a6a8c..b1e3b139 100644 --- a/Common/OpenShockDb/ApiToken.cs +++ b/Common/OpenShockDb/ApiToken.cs @@ -25,10 +25,10 @@ public sealed class ApiToken public DateTime? LastUsed { get; set; } /// - /// Whether this token is allowed to send shocker control messages at all. + /// When true, this token is paused and may not send shocker control messages. /// Independent gate on top of the permission. /// - public bool ShockerControlEnabled { get; set; } = true; + public bool ShockerControlPaused { get; set; } = false; public byte ShockerControlIntensityMin { get; set; } = HardLimits.MinControlIntensity; diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index f679b075..66de70f0 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -185,9 +185,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Permissions).HasColumnType("permission_type[]").HasColumnName("permissions"); - entity.Property(e => e.ShockerControlEnabled) - .HasDefaultValue(true) - .HasColumnName("shocker_control_enabled"); + entity.Property(e => e.ShockerControlPaused) + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); entity.Property(e => e.ShockerControlIntensityMin) .HasDefaultValue(HardLimits.MinControlIntensity) .HasColumnName("shocker_control_intensity_min"); diff --git a/Common/Services/ControlSender.cs b/Common/Services/ControlSender.cs index 3af316d3..92698913 100644 --- a/Common/Services/ControlSender.cs +++ b/Common/Services/ControlSender.cs @@ -119,12 +119,9 @@ private async Task Date: Wed, 3 Jun 2026 17:27:39 +0200 Subject: [PATCH 5/6] more work --- API/Controller/Shockers/SendControl.cs | 9 +- API/Controller/Tokens/DeleteToken.cs | 42 ++--- API/Controller/Tokens/Tokens.cs | 190 ++++++---------------- API/Models/ShockerControlSettings.cs | 32 +--- API/Services/Token/ApiTokenService.cs | 208 +++++++++++++++++++++++++ API/Services/Token/IApiTokenService.cs | 59 +++++++ 6 files changed, 343 insertions(+), 197 deletions(-) create mode 100644 API/Services/Token/ApiTokenService.cs create mode 100644 API/Services/Token/IApiTokenService.cs diff --git a/API/Controller/Shockers/SendControl.cs b/API/Controller/Shockers/SendControl.cs index 5dc3df8d..82e33a5b 100644 --- a/API/Controller/Shockers/SendControl.cs +++ b/API/Controller/Shockers/SendControl.cs @@ -45,7 +45,14 @@ public async Task SendControl( ApiTokenControlLimits? tokenLimits = null; if (userReferenceService.AuthReference is { IsT1: true } authReference) - tokenLimits = ApiTokenControlLimits.FromToken(authReference.AsT1); + { + var apiToken = authReference.AsT1; + + // A paused token may not control shockers. + if (apiToken.ShockerControlPaused) return Problem(ApiTokenError.ApiTokenPaused); + + tokenLimits = ApiTokenControlLimits.FromToken(apiToken); + } var controlAction = await controlSender.ControlByUser(body.Shocks, sender, userHub.Clients, tokenLimits); return controlAction.Match( diff --git a/API/Controller/Tokens/DeleteToken.cs b/API/Controller/Tokens/DeleteToken.cs index 4c15e13d..a109e80b 100644 --- a/API/Controller/Tokens/DeleteToken.cs +++ b/API/Controller/Tokens/DeleteToken.cs @@ -2,12 +2,11 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using OpenShock.API.Services.Token; using OpenShock.Common.Authentication; using OpenShock.Common.Authentication.ControllerBase; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; -using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; namespace OpenShock.API.Controller.Tokens; @@ -18,25 +17,14 @@ namespace OpenShock.API.Controller.Tokens; [Authorize(AuthenticationSchemes = OpenShockAuthSchemes.UserSessionApiTokenCombo)] public sealed class TokenDeleteController : AuthenticatedSessionControllerBase { - private readonly OpenShockContext _db; + private readonly IApiTokenService _tokenService; private readonly ILogger _logger; - public TokenDeleteController(OpenShockContext db, ILogger logger) + public TokenDeleteController(IApiTokenService tokenService, ILogger logger) { - _db = db; + _tokenService = tokenService; _logger = logger; } - - private async Task TryDeleteByIdAsync(Guid tokenId, CancellationToken cancellationToken) - { - int nDeleted = await _db.ApiTokens.Where(x => x.Id == tokenId).ExecuteDeleteAsync(cancellationToken); - return nDeleted > 0; - } - private async Task TryDeleteByIdAndOwnerAsync(Guid tokenId, Guid ownerId, CancellationToken cancellationToken) - { - int nDeleted = await _db.ApiTokens.Where(x => x.Id == tokenId && x.UserId == ownerId).ExecuteDeleteAsync(cancellationToken); - return nDeleted > 0; - } /// /// Revoke a token @@ -55,32 +43,32 @@ public async Task DeleteToken([FromRoute] Guid tokenId, Cancellat // If a token tries to delete itself, let it if (User.TryGetClaimValueAsGuid(OpenShockAuthClaims.ApiTokenId, out var currentApiTokenId) && currentApiTokenId == tokenId) { - if (await TryDeleteByIdAsync(tokenId, cancellationToken)) return Ok(); - + if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) return Ok(); + // If we get here, it's a race-condition or something weird! _logger.LogWarning("Token {TokenId} attempted self-deletion but no record was found (possible race-condition).", tokenId); - + return Problem(ApiTokenError.ApiTokenNotFound); } - + var userIdentity = User.TryGetOpenShockUserIdentity(); - if (userIdentity is null) return Problem(ApiTokenError.ApiTokenCanOnlyDeleteSelf); // If user is null then ApiToken must have been here, and it cant delete others - + if (userIdentity is null) return Problem(ApiTokenError.ApiTokenCanOnlyDeleteSelf); // If user is null then ApiToken must have been here, and it cant delete others + // If a privileged user is trying to delete the token, let them if (userIdentity.IsAdminOrSystem()) { - if (await TryDeleteByIdAsync(tokenId, cancellationToken)) return Ok(); - + if (await _tokenService.DeleteToken(tokenId, cancellationToken: cancellationToken)) return Ok(); + return Problem(ApiTokenError.ApiTokenNotFound); } - + // A normal user is trying to delete the token, delete it if they own it var userId = userIdentity.GetClaimValueAsGuid(ClaimTypes.NameIdentifier); - if (await TryDeleteByIdAndOwnerAsync(tokenId, userId, cancellationToken)) + if (await _tokenService.DeleteToken(tokenId, userId, cancellationToken)) { return Ok(); } - + return Problem(ApiTokenError.ApiTokenNotFound); } } \ No newline at end of file diff --git a/API/Controller/Tokens/Tokens.cs b/API/Controller/Tokens/Tokens.cs index 3075c2e3..5f3786c7 100644 --- a/API/Controller/Tokens/Tokens.cs +++ b/API/Controller/Tokens/Tokens.cs @@ -1,14 +1,10 @@ -using System.Linq.Expressions; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using OpenShock.API.Models; using OpenShock.API.Models.Requests; using OpenShock.API.Models.Response; -using OpenShock.Common.Constants; +using OpenShock.API.Services.Token; using OpenShock.Common.Errors; -using OpenShock.Common.OpenShockDb; using OpenShock.Common.Problems; using OpenShock.Common.Utils; @@ -16,60 +12,15 @@ namespace OpenShock.API.Controller.Tokens; public sealed partial class TokensController { - private static readonly Expression> ToTokenResponse = x => new TokenResponse - { - CreatedOn = x.CreatedAt, - ValidUntil = x.ValidUntil, - LastUsed = x.LastUsed ?? default, - Permissions = x.Permissions, - Name = x.Name, - Id = x.Id - }; - - private static readonly Expression> ToTokenResponseV2 = x => new TokenResponseV2 - { - CreatedOn = x.CreatedAt, - ValidUntil = x.ValidUntil, - LastUsed = x.LastUsed, - Permissions = x.Permissions, - Name = x.Name, - Id = x.Id, - ShockerControl = new ShockerControlSettings - { - Enabled = x.ShockerControlEnabled, - Intensity = new IntensityLimitSettings - { - Min = x.ShockerControlIntensityMin, - Max = x.ShockerControlIntensityMax, - Mode = x.ShockerControlIntensityMode - }, - Duration = new DurationLimitSettings - { - Min = x.ShockerControlDurationMin, - Max = x.ShockerControlDurationMax, - Mode = x.ShockerControlDurationMode - } - } - }; - - /// - /// Tokens belonging to the current user that have not expired. - /// - private IQueryable CurrentUserValidTokens => _db.ApiTokens - .Where(x => x.UserId == CurrentUser.Id && (x.ValidUntil == null || x.ValidUntil > DateTime.UtcNow)); - /// /// List all tokens for the current user /// /// All tokens for the current user [HttpGet] [MapToApiVersion("1")] - public IAsyncEnumerable ListTokens() + public IAsyncEnumerable ListTokens([FromServices] IApiTokenService tokenService) { - return CurrentUserValidTokens - .OrderBy(x => x.CreatedAt) - .Select(ToTokenResponse) - .AsAsyncEnumerable(); + return tokenService.ListTokensV1(ownerId: CurrentUser.Id); } /// @@ -78,31 +29,25 @@ public IAsyncEnumerable ListTokens() /// All tokens for the current user [HttpGet] [MapToApiVersion("2")] - public IAsyncEnumerable ListTokensV2() + public IAsyncEnumerable ListTokensV2([FromServices] IApiTokenService tokenService) { - return CurrentUserValidTokens - .OrderBy(x => x.CreatedAt) - .Select(ToTokenResponseV2) - .AsAsyncEnumerable(); + return tokenService.ListTokensV2(ownerId: CurrentUser.Id); } /// /// Get a token by id /// /// + /// /// The token /// The token does not exist or you do not have access to it. [HttpGet("{tokenId}")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("1")] - public async Task GetTokenById([FromRoute] Guid tokenId) + public async Task GetTokenById([FromRoute] Guid tokenId, [FromServices] IApiTokenService tokenService) { - var apiToken = await CurrentUserValidTokens - .Where(x => x.Id == tokenId) - .Select(ToTokenResponse) - .FirstOrDefaultAsync(); - + var apiToken = await tokenService.GetTokenV1(tokenId, CurrentUser.Id); if (apiToken is null) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(apiToken); @@ -112,19 +57,16 @@ public async Task GetTokenById([FromRoute] Guid tokenId) /// Get a token by id /// /// + /// /// The token /// The token does not exist or you do not have access to it. [HttpGet("{tokenId}")] [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("2")] - public async Task GetTokenByIdV2([FromRoute] Guid tokenId) + public async Task GetTokenByIdV2([FromRoute] Guid tokenId, [FromServices] IApiTokenService tokenService) { - var apiToken = await CurrentUserValidTokens - .Where(x => x.Id == tokenId) - .Select(ToTokenResponseV2) - .FirstOrDefaultAsync(); - + var apiToken = await tokenService.GetTokenV2(tokenId, CurrentUser.Id); if (apiToken is null) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(apiToken); @@ -134,38 +76,15 @@ public async Task GetTokenByIdV2([FromRoute] Guid tokenId) /// Create a new token /// /// + /// /// The created token [HttpPost] [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [MapToApiVersion("1")] - public async Task CreateToken([FromBody] CreateTokenRequest body) + public Task CreateToken([FromBody] CreateTokenRequest body, [FromServices] IApiTokenService tokenService) { - var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.ApiTokenLength); - - var tokenDto = new ApiToken - { - Id = Guid.CreateVersion7(), - UserId = CurrentUser.Id, - Name = body.Name, - TokenHash = HashingUtils.HashToken(token), - CreatedByIp = HttpContext.GetRemoteIP(), - Permissions = body.Permissions.Distinct().ToList(), - ValidUntil = body.ValidUntil?.ToUniversalTime() - }; - _db.ApiTokens.Add(tokenDto); - await _db.SaveChangesAsync(); - - return new TokenCreatedResponse - { - Id = tokenDto.Id, - Name = body.Name, - Token = token, - CreatedAt = tokenDto.CreatedAt, - ValidUntil = tokenDto.ValidUntil, - LastUsed = tokenDto.LastUsed ?? default, - Permissions = tokenDto.Permissions - }; + return tokenService.CreateTokenV1(CurrentUser.Id, HttpContext.GetRemoteIP(), body); } /// @@ -173,21 +92,17 @@ public async Task CreateToken([FromBody] CreateTokenReques /// /// /// + /// /// The edited token /// The token does not exist or you do not have access to it. [HttpPatch("{tokenId}")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("1")] - public async Task EditToken([FromRoute] Guid tokenId, [FromBody] EditTokenRequest body) + public async Task EditToken([FromRoute] Guid tokenId, [FromBody] EditTokenRequest body, [FromServices] IApiTokenService tokenService) { - var token = await CurrentUserValidTokens.FirstOrDefaultAsync(x => x.Id == tokenId); - if (token is null) return Problem(ApiTokenError.ApiTokenNotFound); - - token.Name = body.Name; - token.Permissions = body.Permissions.Distinct().ToList(); - await _db.SaveChangesAsync(); + if (!await tokenService.EditTokenV1(tokenId, body, CurrentUser.Id)) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(); } @@ -196,43 +111,15 @@ public async Task EditToken([FromRoute] Guid tokenId, [FromBody] /// Create a new token /// /// + /// /// The created token [HttpPost] [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [MapToApiVersion("2")] - public async Task CreateTokenV2([FromBody] CreateTokenRequestV2 body) + public Task CreateTokenV2([FromBody] CreateTokenRequestV2 body, [FromServices] IApiTokenService tokenService) { - var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.ApiTokenLength); - - var shockerControl = body.ShockerControl ?? ShockerControlSettings.Default; - - var tokenDto = new ApiToken - { - Id = Guid.CreateVersion7(), - UserId = CurrentUser.Id, - Name = body.Name, - TokenHash = HashingUtils.HashToken(token), - CreatedByIp = HttpContext.GetRemoteIP(), - Permissions = body.Permissions.Distinct().ToList(), - ValidUntil = body.ValidUntil?.ToUniversalTime() - }; - shockerControl.ApplyTo(tokenDto); - - _db.ApiTokens.Add(tokenDto); - await _db.SaveChangesAsync(); - - return new TokenCreatedResponseV2 - { - Id = tokenDto.Id, - Name = tokenDto.Name, - Token = token, - CreatedAt = tokenDto.CreatedAt, - ValidUntil = tokenDto.ValidUntil, - LastUsed = tokenDto.LastUsed, - Permissions = tokenDto.Permissions, - ShockerControl = ShockerControlSettings.FromToken(tokenDto) - }; + return tokenService.CreateTokenV2(CurrentUser.Id, HttpContext.GetRemoteIP(), body); } /// @@ -240,6 +127,7 @@ public async Task CreateTokenV2([FromBody] CreateTokenRe /// /// /// + /// /// The edited token /// The token does not exist or you do not have access to it. [HttpPatch("{tokenId}")] @@ -247,16 +135,32 @@ public async Task CreateTokenV2([FromBody] CreateTokenRe [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound [MapToApiVersion("2")] - public async Task EditTokenV2([FromRoute] Guid tokenId, [FromBody] EditTokenRequestV2 body) + public async Task EditTokenV2([FromRoute] Guid tokenId, [FromBody] EditTokenRequestV2 body, [FromServices] IApiTokenService tokenService) { - var token = await CurrentUserValidTokens.FirstOrDefaultAsync(x => x.Id == tokenId); - if (token is null) return Problem(ApiTokenError.ApiTokenNotFound); - - token.Name = body.Name; - token.Permissions = body.Permissions.Distinct().ToList(); - (body.ShockerControl ?? ShockerControlSettings.Default).ApplyTo(token); - await _db.SaveChangesAsync(); + if (!await tokenService.EditTokenV2(tokenId, body, CurrentUser.Id)) return Problem(ApiTokenError.ApiTokenNotFound); return Ok(); } -} \ No newline at end of file + + /// + /// Set whether a token is paused. A paused token may not send shocker control messages. + /// + /// + /// + /// + /// The now-set paused state of the token. + /// The token does not exist or you do not have access to it. + [HttpPatch("{tokenId}/paused")] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound + [MapToApiVersion("2")] + public async Task SetTokenPaused([FromRoute] Guid tokenId, [FromBody] SetTokenPausedRequest body, [FromServices] IApiTokenService tokenService) + { + var paused = await tokenService.SetTokenPaused(tokenId, body.Paused, CurrentUser.Id); + if (paused is null) return Problem(ApiTokenError.ApiTokenNotFound); + + return Ok(new TokenPausedResponse { Paused = paused.Value }); + } +} diff --git a/API/Models/ShockerControlSettings.cs b/API/Models/ShockerControlSettings.cs index 3d3d08cf..35e6ad66 100644 --- a/API/Models/ShockerControlSettings.cs +++ b/API/Models/ShockerControlSettings.cs @@ -11,41 +11,21 @@ namespace OpenShock.API.Models; public sealed class ShockerControlSettings : IValidatableObject { /// - /// Whether this token is allowed to send shocker control messages at all. + /// When true, this token is paused and may not send shocker control messages. /// - public bool Enabled { get; init; } = true; + public required bool Paused { get; init; } public required IntensityLimitSettings Intensity { get; init; } public required DurationLimitSettings Duration { get; init; } - /// - /// The permissive default: enabled, full-range clamp for both intensity and duration (a no-op). - /// - public static ShockerControlSettings Default => new() - { - Enabled = true, - Intensity = new IntensityLimitSettings - { - Min = HardLimits.MinControlIntensity, - Max = HardLimits.MaxControlIntensity, - Mode = ControlLimitMode.Clamp - }, - Duration = new DurationLimitSettings - { - Min = HardLimits.MinControlDuration, - Max = HardLimits.MaxControlDuration, - Mode = ControlLimitMode.Clamp - } - }; - /// /// Maps the flat shocker-control columns of an onto this DTO. /// In-memory only; for EF projections construct the object inline so it can be translated. /// public static ShockerControlSettings FromToken(ApiToken token) => new() { - Enabled = token.ShockerControlEnabled, + Paused = token.ShockerControlPaused, Intensity = new IntensityLimitSettings { Min = token.ShockerControlIntensityMin, @@ -65,7 +45,7 @@ public sealed class ShockerControlSettings : IValidatableObject /// public void ApplyTo(ApiToken token) { - token.ShockerControlEnabled = Enabled; + token.ShockerControlPaused = Paused; token.ShockerControlIntensityMin = Intensity.Min; token.ShockerControlIntensityMax = Intensity.Max; token.ShockerControlIntensityMode = Intensity.Mode; @@ -102,7 +82,7 @@ public sealed class IntensityLimitSettings [Range(HardLimits.MinControlIntensity, HardLimits.MaxControlIntensity)] public required byte Max { get; init; } - public ControlLimitMode Mode { get; init; } = ControlLimitMode.Clamp; + public required ControlLimitMode Mode { get; init; } } public sealed class DurationLimitSettings @@ -113,5 +93,5 @@ public sealed class DurationLimitSettings [Range(HardLimits.MinControlDuration, HardLimits.MaxControlDuration)] public required ushort Max { get; init; } - public ControlLimitMode Mode { get; init; } = ControlLimitMode.Clamp; + public ControlLimitMode Mode { get; init; } } diff --git a/API/Services/Token/ApiTokenService.cs b/API/Services/Token/ApiTokenService.cs new file mode 100644 index 00000000..5fc944ad --- /dev/null +++ b/API/Services/Token/ApiTokenService.cs @@ -0,0 +1,208 @@ +using System.Linq.Expressions; +using System.Net; +using Microsoft.EntityFrameworkCore; +using OpenShock.API.Models; +using OpenShock.API.Models.Requests; +using OpenShock.API.Models.Response; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Services.Token; + +/// +/// Default implementation of . +/// +public sealed class ApiTokenService : IApiTokenService +{ + private readonly OpenShockContext _db; + + /// + /// DI Constructor + /// + /// + public ApiTokenService(OpenShockContext db) + { + _db = db; + } + + private static readonly Expression> ToTokenResponse = x => new TokenResponse + { + CreatedOn = x.CreatedAt, + ValidUntil = x.ValidUntil, + LastUsed = x.LastUsed ?? default, + Permissions = x.Permissions, + Name = x.Name, + Id = x.Id + }; + + private static readonly Expression> ToTokenResponseV2 = x => new TokenResponseV2 + { + CreatedOn = x.CreatedAt, + ValidUntil = x.ValidUntil, + LastUsed = x.LastUsed, + Permissions = x.Permissions, + Name = x.Name, + Id = x.Id, + ShockerControl = new ShockerControlSettings + { + Paused = x.ShockerControlPaused, + Intensity = new IntensityLimitSettings + { + Min = x.ShockerControlIntensityMin, + Max = x.ShockerControlIntensityMax, + Mode = x.ShockerControlIntensityMode + }, + Duration = new DurationLimitSettings + { + Min = x.ShockerControlDurationMin, + Max = x.ShockerControlDurationMax, + Mode = x.ShockerControlDurationMode + } + } + }; + + /// + /// The set of live (non-expired) tokens, optionally restricted to a single owner. + /// Expired tokens are excluded everywhere - they can no longer authenticate and are + /// treated as deleted. Callers pass the current user's id for owner-scoped (IDOR-safe) access, + /// or null for unrestricted access (admin/system). + /// + private IQueryable Tokens(Guid? ownerId) + { + // Mirror the expiry check in ApiTokenAuthentication (valid while ValidUntil >= now) so a token + // is hidden here exactly when it can no longer authenticate. + var query = _db.ApiTokens.Where(x => x.ValidUntil == null || x.ValidUntil >= DateTime.UtcNow); + return ownerId is { } owner ? query.Where(x => x.UserId == owner) : query; + } + + /// + public IAsyncEnumerable ListTokensV1(Guid ownerId) => Tokens(ownerId) + .OrderBy(x => x.CreatedAt) + .Select(ToTokenResponse) + .AsAsyncEnumerable(); + + /// + public IAsyncEnumerable ListTokensV2(Guid ownerId) => Tokens(ownerId) + .OrderBy(x => x.CreatedAt) + .Select(ToTokenResponseV2) + .AsAsyncEnumerable(); + + /// + public Task GetTokenV1(Guid tokenId, Guid? ownerId = null) => Tokens(ownerId) + .Where(x => x.Id == tokenId) + .Select(ToTokenResponse) + .FirstOrDefaultAsync(); + + /// + public Task GetTokenV2(Guid tokenId, Guid? ownerId = null) => Tokens(ownerId) + .Where(x => x.Id == tokenId) + .Select(ToTokenResponseV2) + .FirstOrDefaultAsync(); + + /// + public async Task CreateTokenV1(Guid userId, IPAddress createdByIp, CreateTokenRequest body) + { + var secret = CryptoUtils.RandomAlphaNumericString(AuthConstants.ApiTokenLength); + + var tokenDto = new ApiToken + { + Id = Guid.CreateVersion7(), + UserId = userId, + Name = body.Name, + TokenHash = HashingUtils.HashToken(secret), + CreatedByIp = createdByIp, + Permissions = body.Permissions.Distinct().ToList(), + ValidUntil = body.ValidUntil?.ToUniversalTime() + }; + _db.ApiTokens.Add(tokenDto); + await _db.SaveChangesAsync(); + + return new TokenCreatedResponse + { + Id = tokenDto.Id, + Name = tokenDto.Name, + Token = secret, + CreatedAt = tokenDto.CreatedAt, + ValidUntil = tokenDto.ValidUntil, + LastUsed = tokenDto.LastUsed ?? default, + Permissions = tokenDto.Permissions + }; + } + + /// + public async Task CreateTokenV2(Guid userId, IPAddress createdByIp, CreateTokenRequestV2 body) + { + var secret = CryptoUtils.RandomAlphaNumericString(AuthConstants.ApiTokenLength); + + var tokenDto = new ApiToken + { + Id = Guid.CreateVersion7(), + UserId = userId, + Name = body.Name, + TokenHash = HashingUtils.HashToken(secret), + CreatedByIp = createdByIp, + Permissions = body.Permissions.Distinct().ToList(), + ValidUntil = body.ValidUntil?.ToUniversalTime() + }; + body.ShockerControl.ApplyTo(tokenDto); + + _db.ApiTokens.Add(tokenDto); + await _db.SaveChangesAsync(); + + return new TokenCreatedResponseV2 + { + Id = tokenDto.Id, + Name = tokenDto.Name, + Token = secret, + CreatedAt = tokenDto.CreatedAt, + ValidUntil = tokenDto.ValidUntil, + LastUsed = tokenDto.LastUsed, + Permissions = tokenDto.Permissions, + ShockerControl = ShockerControlSettings.FromToken(tokenDto) + }; + } + + /// + public async Task EditTokenV1(Guid tokenId, EditTokenRequest body, Guid? ownerId = null) + { + var token = await Tokens(ownerId).FirstOrDefaultAsync(x => x.Id == tokenId); + if (token is null) return false; + + token.Name = body.Name; + token.Permissions = body.Permissions.Distinct().ToList(); + await _db.SaveChangesAsync(); + + return true; + } + + /// + public async Task EditTokenV2(Guid tokenId, EditTokenRequestV2 body, Guid? ownerId = null) + { + var token = await Tokens(ownerId).FirstOrDefaultAsync(x => x.Id == tokenId); + if (token is null) return false; + + token.Name = body.Name; + token.Permissions = body.Permissions.Distinct().ToList(); + body.ShockerControl.ApplyTo(token); + await _db.SaveChangesAsync(); + + return true; + } + + /// + public async Task SetTokenPaused(Guid tokenId, bool paused, Guid? ownerId = null) + { + var nUpdated = await Tokens(ownerId) + .Where(x => x.Id == tokenId) + .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.ShockerControlPaused, paused)); + return nUpdated > 0 ? paused : null; + } + + /// + public async Task DeleteToken(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default) + { + var nDeleted = await Tokens(ownerId).Where(x => x.Id == tokenId).ExecuteDeleteAsync(cancellationToken); + return nDeleted > 0; + } +} diff --git a/API/Services/Token/IApiTokenService.cs b/API/Services/Token/IApiTokenService.cs new file mode 100644 index 00000000..032dcdf7 --- /dev/null +++ b/API/Services/Token/IApiTokenService.cs @@ -0,0 +1,59 @@ +using System.Net; +using OpenShock.API.Models.Requests; +using OpenShock.API.Models.Response; + +namespace OpenShock.API.Services.Token; + +/// +/// Handles persistence and mapping for user API tokens. +/// +/// +/// Read/mutate operations take an optional ownerId restriction. Pass the current user's id +/// for owner-scoped (IDOR-safe) access, or null for unrestricted access (admin/system). +/// The caller (controller) decides which to use. +/// +public interface IApiTokenService +{ + /// + /// List all tokens owned by . + /// + IAsyncEnumerable ListTokensV1(Guid ownerId); + + /// + IAsyncEnumerable ListTokensV2(Guid ownerId); + + /// + /// Get a single token by id (optionally restricted to an owner), or null if it does not exist. + /// + Task GetTokenV1(Guid tokenId, Guid? ownerId = null); + + /// + Task GetTokenV2(Guid tokenId, Guid? ownerId = null); + + /// + /// Create a new token owned by and return the response including the raw token secret. + /// + Task CreateTokenV1(Guid userId, IPAddress createdByIp, CreateTokenRequest body); + + /// + Task CreateTokenV2(Guid userId, IPAddress createdByIp, CreateTokenRequestV2 body); + + /// + /// Edit a token by id (optionally restricted to an owner). Returns false if the token does not exist. + /// + Task EditTokenV1(Guid tokenId, EditTokenRequest body, Guid? ownerId = null); + + /// + Task EditTokenV2(Guid tokenId, EditTokenRequestV2 body, Guid? ownerId = null); + + /// + /// Set the paused state of a token by id (optionally restricted to an owner). + /// Returns the now-set paused state, or null if the token does not exist. + /// + Task SetTokenPaused(Guid tokenId, bool paused, Guid? ownerId = null); + + /// + /// Delete a token by id (optionally restricted to an owner). Returns false if no token was deleted. + /// + Task DeleteToken(Guid tokenId, Guid? ownerId = null, CancellationToken cancellationToken = default); +} \ No newline at end of file From d2229ca657788859110d5ef2a8b99632c00ba4b3 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Wed, 3 Jun 2026 21:15:28 +0200 Subject: [PATCH 6/6] more fixes --- API/Controller/Sessions/SessionSelf.cs | 7 ++++--- API/Controller/Shockers/SendControl.cs | 6 ++---- API/Controller/Tokens/GetTokenSelf.cs | 19 +++++-------------- API/Models/Response/TokenResponse.cs | 10 ++++++++++ Common/Hubs/UserHub.cs | 4 +--- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/API/Controller/Sessions/SessionSelf.cs b/API/Controller/Sessions/SessionSelf.cs index 53995c33..25c6c009 100644 --- a/API/Controller/Sessions/SessionSelf.cs +++ b/API/Controller/Sessions/SessionSelf.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using System.Diagnostics; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Response; using OpenShock.Common.Authentication.Services; @@ -17,8 +18,8 @@ public LoginSessionResponse GetSelfSession([FromServices] IUserReferenceService { var x = userReferenceService.AuthReference; - if (x is null) throw new Exception("This should not be reachable due to AuthenticatedSession requirement"); - if (!x.Value.IsT0) throw new Exception("This should not be reachable due to the [UserSessionOnly] attribute"); + if (x is null) throw new UnreachableException("AuthenticatedSession requirement"); + if (!x.Value.IsT0) throw new UnreachableException("the [UserSessionOnly] attribute"); var session = x.Value.AsT0; diff --git a/API/Controller/Shockers/SendControl.cs b/API/Controller/Shockers/SendControl.cs index 82e33a5b..def4299f 100644 --- a/API/Controller/Shockers/SendControl.cs +++ b/API/Controller/Shockers/SendControl.cs @@ -1,4 +1,4 @@ -using System.Net.Mime; + using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; @@ -44,10 +44,8 @@ public async Task SendControl( }; ApiTokenControlLimits? tokenLimits = null; - if (userReferenceService.AuthReference is { IsT1: true } authReference) + if (userReferenceService.AuthReference is { } authReference && authReference.TryPickT1(out var apiToken, out _)) { - var apiToken = authReference.AsT1; - // A paused token may not control shockers. if (apiToken.ShockerControlPaused) return Problem(ApiTokenError.ApiTokenPaused); diff --git a/API/Controller/Tokens/GetTokenSelf.cs b/API/Controller/Tokens/GetTokenSelf.cs index e473f03f..130aa1c0 100644 --- a/API/Controller/Tokens/GetTokenSelf.cs +++ b/API/Controller/Tokens/GetTokenSelf.cs @@ -1,4 +1,5 @@ -using Asp.Versioning; +using System.Diagnostics; +using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models; @@ -27,17 +28,7 @@ public sealed partial class TokensSelfController : AuthenticatedSessionControlle [MapToApiVersion("1")] public TokenResponse GetSelfToken([FromServices] IUserReferenceService userReferenceService) { - var token = GetSelfTokenDto(userReferenceService); - - return new TokenResponse - { - CreatedOn = token.CreatedAt, - ValidUntil = token.ValidUntil, - LastUsed = token.LastUsed ?? DateTime.MinValue, - Permissions = token.Permissions, - Name = token.Name, - Id = token.Id - }; + return TokenResponse.MapFrom(GetSelfTokenV2(userReferenceService)); } /// @@ -68,8 +59,8 @@ private static ApiToken GetSelfTokenDto(IUserReferenceService userReferenceServi { var x = userReferenceService.AuthReference; - if (x is null) throw new Exception("This should not be reachable due to AuthenticatedSession requirement"); - if (!x.Value.IsT1) throw new Exception("This should not be reachable due to the [TokenOnly] attribute"); + if (x is null) throw new UnreachableException("AuthenticatedSession requirement"); + if (!x.Value.IsT1) throw new UnreachableException("the [TokenOnly] attribute"); return x.Value.AsT1; } diff --git a/API/Models/Response/TokenResponse.cs b/API/Models/Response/TokenResponse.cs index de049c36..73676b0e 100644 --- a/API/Models/Response/TokenResponse.cs +++ b/API/Models/Response/TokenResponse.cs @@ -15,4 +15,14 @@ public sealed class TokenResponse public required DateTime LastUsed { get; init; } public required List Permissions { get; init; } + + public static TokenResponse MapFrom(TokenResponseV2 token) => new() + { + Id = token.Id, + Name = token.Name, + CreatedOn = token.CreatedOn, + ValidUntil = token.ValidUntil, + LastUsed = token.LastUsed ?? DateTime.MinValue, + Permissions = [.. token.Permissions] + }; } \ No newline at end of file diff --git a/Common/Hubs/UserHub.cs b/Common/Hubs/UserHub.cs index daf5bc93..71e83437 100644 --- a/Common/Hubs/UserHub.cs +++ b/Common/Hubs/UserHub.cs @@ -93,10 +93,8 @@ public async Task ControlV2(IReadOnlyList shocks, }).FirstAsync(); ApiTokenControlLimits? tokenLimits = null; - if (_userReferenceService.AuthReference is { IsT1: true } authReference) + if (_userReferenceService.AuthReference is { } authReference && authReference.TryPickT1(out var apiToken, out _)) { - var apiToken = authReference.AsT1; - // A paused token may not control shockers. if (apiToken.ShockerControlPaused) return;