diff --git a/API/Controller/Admin/DTOs/AddWebhookDto.cs b/API/Controller/Admin/DTOs/AddWebhookDto.cs index 0a271d27a..bdab02238 100644 --- a/API/Controller/Admin/DTOs/AddWebhookDto.cs +++ b/API/Controller/Admin/DTOs/AddWebhookDto.cs @@ -1,7 +1,11 @@ -namespace OpenShock.API.Controller.Admin.DTOs; +using System.ComponentModel.DataAnnotations; +using OpenShock.Common.Constants; + +namespace OpenShock.API.Controller.Admin.DTOs; public sealed class AddWebhookDto { + [StringLength(ApiHardLimits.DiscordWebhookNameMaxLength)] public required string Name { get; init; } public required Uri Url { get; init; } } diff --git a/API/Controller/Admin/DTOs/ConfigurationAddItemRequest.cs b/API/Controller/Admin/DTOs/ConfigurationAddItemRequest.cs index d934f314d..a88979dec 100644 --- a/API/Controller/Admin/DTOs/ConfigurationAddItemRequest.cs +++ b/API/Controller/Admin/DTOs/ConfigurationAddItemRequest.cs @@ -1,5 +1,6 @@ -using OpenShock.Common.OpenShockDb; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; namespace OpenShock.API.Controller.Admin.DTOs; @@ -13,12 +14,14 @@ public sealed class ConfigurationAddItemRequest /// Must consist of uppercase letters and underscores only. /// [Required(AllowEmptyStrings = false)] + [StringLength(ApiHardLimits.ConfigurationNameMaxLength)] public required string Name { get; init; } /// /// A human-readable description of the configuration item’s purpose. /// [Required(AllowEmptyStrings = true)] + [StringLength(ApiHardLimits.ConfigurationDescriptionMaxLength)] public required string Description { get; init; } /// @@ -32,5 +35,6 @@ public sealed class ConfigurationAddItemRequest /// Must conform to the format required by . /// [Required(AllowEmptyStrings = true)] + [StringLength(ApiHardLimits.ConfigurationValueMaxLength)] public required string Value { get; init; } } diff --git a/API/Controller/Admin/DTOs/ConfigurationUpdateItemRequest.cs b/API/Controller/Admin/DTOs/ConfigurationUpdateItemRequest.cs index bc157ed9b..34a252794 100644 --- a/API/Controller/Admin/DTOs/ConfigurationUpdateItemRequest.cs +++ b/API/Controller/Admin/DTOs/ConfigurationUpdateItemRequest.cs @@ -1,5 +1,6 @@ -using OpenShock.Common.OpenShockDb; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using OpenShock.Common.Constants; +using OpenShock.Common.OpenShockDb; namespace OpenShock.API.Controller.Admin.DTOs; @@ -15,16 +16,19 @@ public sealed class ConfigurationUpdateItemRequest /// Must consist only of uppercase letters and underscores. /// [Required(AllowEmptyStrings = false)] + [StringLength(ApiHardLimits.ConfigurationNameMaxLength)] public required string Name { get; init; } /// /// (Optional) New description for the configuration item. /// + [StringLength(ApiHardLimits.ConfigurationDescriptionMaxLength)] public string? Description { get; init; } /// /// (Optional) New value for the configuration item. /// Must match the format expected by its existing . /// + [StringLength(ApiHardLimits.ConfigurationValueMaxLength)] public string? Value { get; init; } } diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index da28b8db3..0453e1a6a 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -234,7 +234,7 @@ public async Task CreateOAuthOnlyAccountAsync( UserId = user.Id, ProviderKey = provider, ExternalId = providerAccountId, - DisplayName = providerAccountName, + DisplayName = providerAccountName?.Truncate(ApiHardLimits.OAuthDisplayNameMaxLength), CreatedAt = creationTime, }); diff --git a/API/Services/OAuthConnection/OAuthConnectionService.cs b/API/Services/OAuthConnection/OAuthConnectionService.cs index e01f2d601..0d0b779b2 100644 --- a/API/Services/OAuthConnection/OAuthConnectionService.cs +++ b/API/Services/OAuthConnection/OAuthConnectionService.cs @@ -1,8 +1,10 @@ using Microsoft.EntityFrameworkCore; using Npgsql; +using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Services.Audit; +using OpenShock.Common.Utils; namespace OpenShock.API.Services.OAuthConnection; @@ -58,7 +60,7 @@ public async Task TryAddConnectionAsync(Guid userId, string provider, stri UserId = userId, ProviderKey = provider.ToLowerInvariant(), ExternalId = providerAccountId, - DisplayName = providerAccountName + DisplayName = providerAccountName?.Truncate(ApiHardLimits.OAuthDisplayNameMaxLength) }); await _db.SaveChangesAsync(); } diff --git a/Common.Tests/Utils/StringUtilsTests.cs b/Common.Tests/Utils/StringUtilsTests.cs index 8c4caa6c8..a3e12dd75 100644 --- a/Common.Tests/Utils/StringUtilsTests.cs +++ b/Common.Tests/Utils/StringUtilsTests.cs @@ -45,4 +45,18 @@ public async Task Truncate_MaxOne_ReturnsSingleChar() var result = "hello".Truncate(1); await Assert.That(result).IsEqualTo("h"); } + + [Test] + public async Task Truncate_CutInsideSurrogatePair_DropsWholePair() + { + var result = "abc😀".Truncate(4); + await Assert.That(result).IsEqualTo("abc"); + } + + [Test] + public async Task Truncate_CutAfterSurrogatePair_KeepsPair() + { + var result = "abc😀def".Truncate(5); + await Assert.That(result).IsEqualTo("abc😀"); + } } diff --git a/Common/Constants/ApiHardLimits.cs b/Common/Constants/ApiHardLimits.cs index 23e01a901..d3bd51a45 100644 --- a/Common/Constants/ApiHardLimits.cs +++ b/Common/Constants/ApiHardLimits.cs @@ -1,5 +1,3 @@ -using OpenShock.Internal.Common.Constants; - namespace OpenShock.Common.Constants; /// @@ -27,6 +25,17 @@ public static class ApiHardLimits public const int PasswordResetSecretMaxLength = 100; public const int AuditReasonMaxLength = 512; + public const int ConfigurationNameMaxLength = 64; + public const int ConfigurationDescriptionMaxLength = 255; + public const int ConfigurationValueMaxLength = 4096; + + public const int OAuthProviderKeyMaxLength = 16; + public const int OAuthExternalIdMaxLength = 255; + public const int OAuthDisplayNameMaxLength = 64; + + public const int DiscordWebhookNameMaxLength = 64; + public const int DiscordWebhookTokenMaxLength = 255; + public const int MaxTurnstileResponseTokenLength = 2048; // https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ // Don't allow any firmware prior to 2024. diff --git a/Common/Migrations/20261113184758_AddStringLengthLimits.Designer.cs b/Common/Migrations/20261113184758_AddStringLengthLimits.Designer.cs new file mode 100644 index 000000000..49d4b587d --- /dev/null +++ b/Common/Migrations/20261113184758_AddStringLengthLimits.Designer.cs @@ -0,0 +1,1923 @@ +// +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.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +[DbContext(typeof(MigrationOpenShockContext))] +[Migration("20261113184758_AddStringLengthLimits")] +partial class _20261113184758_AddStringLengthLimits +{ + /// + 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", "11.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "automation_token_type", new[] { "turnstile", "rate_limit", "control_stats_read" }); + 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[] { "stop", "shock", "vibrate", "sound" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" }); + 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[] { "bcrypt_enhanced", "pbkdf2" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth", "usershares.edit", "usershares.pause", "publicshares.pause", "publicshares.edit" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "cai_xianlin", "petrainer", "petrainer_998dr", "wellturn_t330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(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("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + + 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") + .ValueGeneratedOnAdd() + .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.Property>("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("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("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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") + .HasMaxLength(4) + .HasColumnType("character varying(4)") + .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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AutoCleanupAfter") + .HasColumnType("interval") + .HasColumnName("auto_cleanup_after"); + + b.Property("AutoCleanupUsers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("auto_cleanup_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastRotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_rotated_at"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property>("Types") + .IsRequired() + .HasColumnType("automation_token_type[]") + .HasColumnName("types"); + + b.Property("UseCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("use_count"); + + b.HasKey("Id") + .HasName("automation_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("automation_tokens"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .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() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStat", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bucket") + .HasColumnType("timestamp with time zone") + .HasColumnName("bucket"); + + b.Property("ControllerUserId") + .HasColumnType("uuid") + .HasColumnName("controller_user_id"); + + b.Property("Count") + .HasColumnType("bigint") + .HasColumnName("count"); + + b.Property("DurationMs") + .HasColumnType("bigint") + .HasColumnName("duration_ms"); + + b.Property("IntensityMs") + .HasColumnType("bigint") + .HasColumnName("intensity_ms"); + + b.Property("Live") + .HasColumnType("boolean") + .HasColumnName("live"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("OwnerUserId") + .HasColumnType("uuid") + .HasColumnName("owner_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("control_stats_pkey"); + + b.HasIndex("ShockerId"); + + b.HasIndex("UpdatedAt"); + + b.HasIndex("ControllerUserId", "Bucket"); + + b.HasIndex("OwnerUserId", "Bucket"); + + var index = b.HasIndex("OwnerUserId", "ShockerId", "ControllerUserId", "Bucket", "Type", "Live") + .IsUnique() + .HasDatabaseName("ix_control_stats_upsert_key") + .HasFilter("shocker_id IS NOT NULL"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(index, false); + + b.ToTable("control_stats"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStatBatch", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("applied_at"); + + b.HasKey("Id") + .HasName("control_stat_batches_pkey"); + + b.HasIndex("AppliedAt"); + + b.ToTable("control_stat_batches"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ControlStatRemoval", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("removed_at"); + + b.HasKey("UserId") + .HasName("control_stat_removals_pkey"); + + b.HasIndex("RemovedAt"); + + b.ToTable("control_stat_removals"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .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() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt_count"); + + b.Property("CoalesceKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("coalesce_key"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("last_error"); + + b.Property("NextAttemptAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property>("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("recipient"); + + b.Property("RecipientName") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("recipient_name"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .HasColumnType("email_status") + .HasColumnName("status"); + + b.Property("Type") + .HasColumnType("email_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("email_outbox_pkey"); + + b.HasIndex("CoalesceKey"); + + b.HasIndex("Recipient"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("email_outbox"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + + var index = b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("CreatedByAutomationTokenId") + .HasColumnType("uuid") + .HasColumnName("created_by_automation_token_id"); + + 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.Property>("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("CreatedByAutomationTokenId"); + + b.HasIndex("Email") + .IsUnique(); + + var index = b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("users"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("audit_action") + .HasColumnName("action"); + + b.Property("ActorId") + .HasColumnType("uuid") + .HasColumnName("actor_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property("Reason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("reason"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_audit_logs_pkey"); + + b.HasIndex("ActorId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_audit_logs"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + + var index = b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(index, new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + var id = b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + }); + + 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"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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"); + }); + + 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"); + }); + + 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.ControlStat", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("ControlStats") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_control_stats_owner_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ControlStats") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_control_stats_shocker_id"); + + b.Navigation("Owner"); + + b.Navigation("Shocker"); + }); + + 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.User", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.AutomationToken", "CreatedByAutomationToken") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedByAutomationTokenId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_users_created_by_automation_token_id"); + + b.Navigation("CreatedByAutomationToken"); + }); + + 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.UserAuditLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Actor") + .WithMany("ActorAuditLogs") + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_user_audit_logs_actor_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("AuditLogs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_audit_logs_user_id"); + + b.Navigation("Actor"); + + 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.AutomationToken", b => + { + b.Navigation("CreatedUsers"); + }); + + 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("ControlStats"); + + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ActorAuditLogs"); + + b.Navigation("ApiTokens"); + + b.Navigation("AuditLogs"); + + b.Navigation("ControlStats"); + + 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/20261113184758_AddStringLengthLimits.cs b/Common/Migrations/20261113184758_AddStringLengthLimits.cs new file mode 100644 index 000000000..fe16169ce --- /dev/null +++ b/Common/Migrations/20261113184758_AddStringLengthLimits.cs @@ -0,0 +1,213 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations; + +/// +public partial class _20261113184758_AddStringLengthLimits : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // The columns below were unbounded text. A truncated credential is useless, so abort with a clear + // error if any existing webhook token exceeds the new limit. + migrationBuilder.Sql(MigrationQueries.FailIfLonger("discord_webhooks", "webhook_token", 255)); + + // Truncate anything else longer than the new limit first, otherwise the type change fails on existing rows. + migrationBuilder.Sql("UPDATE user_oauth_connections SET display_name = left(display_name, 64) WHERE char_length(display_name) > 64;"); + + migrationBuilder.AlterColumn( + name: "display_name", + table: "user_oauth_connections", + type: "character varying(64)", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.Sql("UPDATE user_oauth_connections SET external_id = left(external_id, 255) WHERE char_length(external_id) > 255;"); + + migrationBuilder.AlterColumn( + name: "external_id", + table: "user_oauth_connections", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.Sql("UPDATE user_oauth_connections SET provider_key = left(provider_key, 16) WHERE char_length(provider_key) > 16;"); + + migrationBuilder.AlterColumn( + name: "provider_key", + table: "user_oauth_connections", + type: "character varying(16)", + maxLength: 16, + nullable: false, + collation: "C", + oldClrType: typeof(string), + oldType: "text", + oldCollation: "C"); + + migrationBuilder.AlterColumn( + name: "webhook_token", + table: "discord_webhooks", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.Sql("UPDATE discord_webhooks SET name = left(name, 64) WHERE char_length(name) > 64;"); + + migrationBuilder.AlterColumn( + name: "name", + table: "discord_webhooks", + type: "character varying(64)", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.Sql("UPDATE configuration SET value = left(value, 4096) WHERE char_length(value) > 4096;"); + + migrationBuilder.AlterColumn( + name: "value", + table: "configuration", + type: "character varying(4096)", + maxLength: 4096, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.Sql("UPDATE configuration SET description = left(description, 255) WHERE char_length(description) > 255;"); + + migrationBuilder.AlterColumn( + name: "description", + table: "configuration", + type: "character varying(255)", + maxLength: 255, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.Sql("UPDATE configuration SET name = left(name, 64) WHERE char_length(name) > 64;"); + + migrationBuilder.AlterColumn( + name: "name", + table: "configuration", + type: "character varying(64)", + maxLength: 64, + nullable: false, + collation: "C", + oldClrType: typeof(string), + oldType: "text", + oldCollation: "C"); + + migrationBuilder.Sql("UPDATE api_token_reports SET ip_country = left(ip_country, 4) WHERE char_length(ip_country) > 4;"); + + migrationBuilder.AlterColumn( + name: "ip_country", + table: "api_token_reports", + type: "character varying(4)", + maxLength: 4, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "display_name", + table: "user_oauth_connections", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "external_id", + table: "user_oauth_connections", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "provider_key", + table: "user_oauth_connections", + type: "text", + nullable: false, + collation: "C", + oldClrType: typeof(string), + oldType: "character varying(16)", + oldMaxLength: 16, + oldCollation: "C"); + + migrationBuilder.AlterColumn( + name: "webhook_token", + table: "discord_webhooks", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "name", + table: "discord_webhooks", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "value", + table: "configuration", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(4096)", + oldMaxLength: 4096); + + migrationBuilder.AlterColumn( + name: "description", + table: "configuration", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(255)", + oldMaxLength: 255); + + migrationBuilder.AlterColumn( + name: "name", + table: "configuration", + type: "text", + nullable: false, + collation: "C", + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64, + oldCollation: "C"); + + migrationBuilder.AlterColumn( + name: "ip_country", + table: "api_token_reports", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(4)", + oldMaxLength: 4, + oldNullable: true); + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 296a8fb56..b7777ff19 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -18,14 +18,14 @@ partial class OpenShockContextModelSnapshot : ModelSnapshot // If you encounter a merge conflict in the line below, it means you need to // discard one of the migration branches and recreate its migrations on top of // the other branch. See https://aka.ms/efcore-docs-migrations-conflicts for more info. - public override string LastMigrationId => "20261016142540_AddControlStats"; + public override string LastMigrationId => "20261113184758_AddStringLengthLimits"; 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", "11.0.0-rc.1.26425.128") + .HasAnnotation("ProductVersion", "11.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "audit_action", new[] { "login", "logout", "password_changed", "email_change_requested", "email_changed", "username_changed", "api_token_created", "api_token_deleted", "oauth_connected", "oauth_disconnected", "account_deactivated", "account_reactivated", "account_deleted", "automation_token_created", "automation_token_updated", "automation_token_rotated", "automation_token_deleted", "automation_token_used" }); @@ -151,6 +151,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -253,6 +254,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -272,7 +274,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("ip_address"); b.Property("IpCountry") - .HasColumnType("text") + .HasMaxLength(4) + .HasColumnType("character varying(4)") .HasColumnName("ip_country"); b.Property("SubmittedCount") @@ -294,6 +297,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.AutomationToken", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -357,7 +361,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => { b.Property("Name") - .HasColumnType("text") + .HasMaxLength(64) + .HasColumnType("character varying(64)") .HasColumnName("name") .UseCollation("C"); @@ -369,7 +374,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Description") .IsRequired() - .HasColumnType("text") + .HasMaxLength(255) + .HasColumnType("character varying(255)") .HasColumnName("description"); b.Property("Type") @@ -382,7 +388,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Value") .IsRequired() - .HasColumnType("text") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") .HasColumnName("value"); b.HasKey("Name") @@ -501,6 +508,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -580,7 +588,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => { b.Property("Name") - .HasColumnType("text") + .HasMaxLength(64) + .HasColumnType("character varying(64)") .HasColumnName("name"); b.Property("CreatedAt") @@ -599,7 +608,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("WebhookToken") .IsRequired() - .HasColumnType("text") + .HasMaxLength(255) + .HasColumnType("character varying(255)") .HasColumnName("webhook_token"); b.HasKey("Name") @@ -611,6 +621,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -690,6 +701,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -720,6 +732,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -814,6 +827,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -858,6 +872,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -911,6 +926,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -973,6 +989,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -1071,6 +1088,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserAuditLog", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -1158,6 +1176,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -1213,6 +1232,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -1284,12 +1304,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => { b.Property("ProviderKey") - .HasColumnType("text") + .HasMaxLength(16) + .HasColumnType("character varying(16)") .HasColumnName("provider_key") .UseCollation("C"); b.Property("ExternalId") - .HasColumnType("text") + .HasMaxLength(255) + .HasColumnType("character varying(255)") .HasColumnName("external_id"); b.Property("CreatedAt") @@ -1299,7 +1321,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDefaultValueSql("CURRENT_TIMESTAMP"); b.Property("DisplayName") - .HasColumnType("text") + .HasMaxLength(64) + .HasColumnType("character varying(64)") .HasColumnName("display_name"); b.Property("UserId") @@ -1317,6 +1340,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); @@ -1420,6 +1444,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("uuid") .HasColumnName("id"); diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 234f69209..b28813927 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; using OpenShock.Common.Models; - using OpenShock.Internal.Common.Constants; namespace OpenShock.Common.OpenShockDb; @@ -232,6 +231,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.IpAddress) .HasColumnName("ip_address"); entity.Property(e => e.IpCountry) + .HasMaxLength(4) .HasColumnName("ip_country"); entity.Property(e => e.CreatedAt) .HasDefaultValueSql("CURRENT_TIMESTAMP") @@ -729,10 +729,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("user_id"); entity.Property(e => e.ProviderKey) .UseCollation("C") + .HasMaxLength(ApiHardLimits.OAuthProviderKeyMaxLength) .HasColumnName("provider_key"); entity.Property(e => e.ExternalId) + .HasMaxLength(ApiHardLimits.OAuthExternalIdMaxLength) .HasColumnName("external_id"); entity.Property(e => e.DisplayName) + .HasMaxLength(ApiHardLimits.OAuthDisplayNameMaxLength) .HasColumnName("display_name"); entity.Property(e => e.CreatedAt) .HasDefaultValueSql("CURRENT_TIMESTAMP") @@ -869,10 +872,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Id) .HasColumnName("id"); entity.Property(e => e.Name) + .HasMaxLength(ApiHardLimits.DiscordWebhookNameMaxLength) .HasColumnName("name"); entity.Property(e => e.WebhookId) .HasColumnName("webhook_id"); entity.Property(e => e.WebhookToken) + .HasMaxLength(ApiHardLimits.DiscordWebhookTokenMaxLength) .HasColumnName("webhook_token"); entity.Property(e => e.CreatedAt) .HasDefaultValueSql("CURRENT_TIMESTAMP") @@ -887,12 +892,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Name) .UseCollation("C") + .HasMaxLength(ApiHardLimits.ConfigurationNameMaxLength) .HasColumnName("name"); entity.Property(e => e.Description) + .HasMaxLength(ApiHardLimits.ConfigurationDescriptionMaxLength) .HasColumnName("description"); entity.Property(e => e.Type) .HasColumnName("type"); entity.Property(e => e.Value) + .HasMaxLength(ApiHardLimits.ConfigurationValueMaxLength) .HasColumnName("value"); entity.Property(e => e.UpdatedAt) .HasColumnName("updated_at"); diff --git a/Common/OpenShockDb/Queries/MigrationQueries.cs b/Common/OpenShockDb/Queries/MigrationQueries.cs new file mode 100644 index 000000000..fc88a6751 --- /dev/null +++ b/Common/OpenShockDb/Queries/MigrationQueries.cs @@ -0,0 +1,24 @@ +namespace OpenShock.Common.OpenShockDb; + +/// +/// Reusable raw SQL for migrations. +/// +public static class MigrationQueries +{ + /// + /// SQL that aborts the migration with a clear error if any existing value in is longer + /// than characters. Run it before narrowing a column whose values must not be + /// truncated, like credentials. Table and column names are inlined into the SQL, only pass constants. + /// + public static string FailIfLonger(string table, string column, int maxLength) => + $""" + DO $$ + DECLARE offending bigint; + BEGIN + SELECT count(*) INTO offending FROM {table} WHERE char_length({column}) > {maxLength}; + IF offending > 0 THEN + RAISE EXCEPTION '% row(s) in {table}.{column} exceed the new limit of {maxLength} characters. Fix or remove them manually before re-running this migration.', offending; + END IF; + END $$; + """; +} diff --git a/Common/Services/Webhook/WebhookService.cs b/Common/Services/Webhook/WebhookService.cs index d4cca11df..c555d03d6 100644 --- a/Common/Services/Webhook/WebhookService.cs +++ b/Common/Services/Webhook/WebhookService.cs @@ -1,5 +1,6 @@ using System.Drawing; using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Results; @@ -28,7 +29,8 @@ public async Task, UnsupportedWebhookUrl>> AddWebhook DnsSafeHost: "discord.com", Segments: ["/", "api/", "webhooks/", {} webhookIdStr, {} webhookToken] } || - !long.TryParse(webhookIdStr[..^1], out var webhookId) + !long.TryParse(webhookIdStr[..^1], out var webhookId) || + webhookToken.Length > ApiHardLimits.DiscordWebhookTokenMaxLength ) { return new UnsupportedWebhookUrl(); diff --git a/Common/Utils/StringUtils.cs b/Common/Utils/StringUtils.cs index e569d4f6c..797ba763d 100644 --- a/Common/Utils/StringUtils.cs +++ b/Common/Utils/StringUtils.cs @@ -4,6 +4,11 @@ public static class StringUtils { public static string Truncate(this string input, int maxLength) { - return input.Length <= maxLength ? input : input[..maxLength]; + if (input.Length <= maxLength) return input; + + // Never cut a surrogate pair in half, that would leave an invalid UTF-16 string. + if (maxLength > 0 && char.IsHighSurrogate(input[maxLength - 1])) maxLength--; + + return input[..maxLength]; } }