diff --git a/src/Nullinside.Api.Common/Nullinside.Api.Common.csproj b/src/Nullinside.Api.Common/Nullinside.Api.Common.csproj index 725115c..f1c4e1f 100644 --- a/src/Nullinside.Api.Common/Nullinside.Api.Common.csproj +++ b/src/Nullinside.Api.Common/Nullinside.Api.Common.csproj @@ -16,10 +16,10 @@ - + - + diff --git a/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs b/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs index bcb3ca9..ab1fddb 100644 --- a/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs +++ b/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs @@ -14,7 +14,7 @@ public interface ITwitchApiProxy { /// The Twitch access token. These are the credentials used for all requests. /// TwitchAccessToken? OAuth { get; set; } - + /// /// The Twitch app configuration. These are used for all requests. /// diff --git a/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs b/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs index fc0f708..9e5b63b 100644 --- a/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs +++ b/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs @@ -33,7 +33,7 @@ public class TwitchApiProxy : ITwitchApiProxy { /// Initializes a new instance of the class. /// public TwitchApiProxy() { - TwitchAppConfig = new() { + TwitchAppConfig = new TwitchAppConfig { ClientId = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID"), ClientSecret = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET"), ClientRedirect = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT") @@ -46,21 +46,27 @@ public TwitchApiProxy() { /// The access token. /// The refresh token. /// When the token expires (utc). - /// The client id of the registered twitch app, uses environment variable - /// "TWITCH_BOT_CLIENT_ID" when null. - /// The client secret of the registered twitch app, uses environment variable - /// "TWITCH_BOT_CLIENT_SECRET" when null. - /// The url to redirect to from the registered twitch app, uses environment variable - /// "TWITCH_BOT_CLIENT_REDIRECT" when null. - public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, string? clientId = null, + /// + /// The client id of the registered twitch app, uses environment variable + /// "TWITCH_BOT_CLIENT_ID" when null. + /// + /// + /// The client secret of the registered twitch app, uses environment variable + /// "TWITCH_BOT_CLIENT_SECRET" when null. + /// + /// + /// The url to redirect to from the registered twitch app, uses environment variable + /// "TWITCH_BOT_CLIENT_REDIRECT" when null. + /// + public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, string? clientId = null, string? clientSecret = null, string? clientRedirect = null) { OAuth = new TwitchAccessToken { AccessToken = token, RefreshToken = refreshToken, ExpiresUtc = tokenExpires }; - - TwitchAppConfig = new() { + + TwitchAppConfig = new TwitchAppConfig { ClientId = clientId ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID"), ClientSecret = clientSecret ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET"), ClientRedirect = clientRedirect ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT") @@ -74,14 +80,14 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, /// public virtual TwitchAccessToken? OAuth { get; set; } - + /// public virtual TwitchAppConfig? TwitchAppConfig { get; set; } /// public virtual async Task CreateAccessToken(string code, CancellationToken token = new()) { ITwitchAPI api = GetApi(); - AuthCodeResponse? response = await api.Auth.GetAccessTokenFromCodeAsync(code, TwitchAppConfig?.ClientSecret, + AuthCodeResponse? response = await api.Auth.GetAccessTokenFromCodeAsync(code, TwitchAppConfig?.ClientSecret, TwitchAppConfig?.ClientRedirect); if (null == response) { return null; @@ -101,7 +107,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, if (string.IsNullOrWhiteSpace(TwitchAppConfig?.ClientSecret) || string.IsNullOrWhiteSpace(TwitchAppConfig?.ClientId)) { return null; } - + ITwitchAPI api = GetApi(); RefreshResponse? response = await api.Auth.RefreshAuthTokenAsync(OAuth?.RefreshToken, TwitchAppConfig?.ClientSecret, TwitchAppConfig?.ClientId); if (null == response) { @@ -145,7 +151,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, } /// - public virtual async Task GetUserEmail(CancellationToken token = new()) { + public virtual async Task GetUserEmail(CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); GetUsersResponse? response = await api.Helix.Users.GetUsersAsync(); @@ -158,7 +164,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, } /// - public virtual async Task> GetUserModChannels(string userId) { + public virtual async Task> GetUserModChannels(string userId) { using var client = new HttpClient(); var ret = new List(); @@ -189,7 +195,7 @@ public virtual async Task> GetUserModChanne } /// - public virtual async Task> BanChannelUsers(string channelId, string botId, + public virtual async Task> BanChannelUsers(string channelId, string botId, IEnumerable<(string Id, string Username)> users, string reason, CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); @@ -223,7 +229,7 @@ public virtual async Task> BanChannelUsers(string chann } /// - public virtual async Task> GetChannelUsers(string channelId, string botId, + public virtual async Task> GetChannelUsers(string channelId, string botId, CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); @@ -247,7 +253,7 @@ public virtual async Task> GetChannelUsers(string channelI } /// - public virtual async Task> GetChannelsLive(IEnumerable userIds) { + public virtual async Task> GetChannelsLive(IEnumerable userIds) { ITwitchAPI api = GetApi(); // We can only query 100 at a time, so throttle the search. @@ -271,7 +277,7 @@ public virtual async Task> GetChannelsLive(IEnumerable - public virtual async Task> GetChannelMods(string channelId, CancellationToken token = new()) { + public virtual async Task> GetChannelMods(string channelId, CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); @@ -298,7 +304,7 @@ public virtual async Task> GetChannelsLive(IEnumerable - public virtual async Task AddChannelMod(string channelId, string userId, CancellationToken token = new()) { + public virtual async Task AddChannelMod(string channelId, string userId, CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); await api.Helix.Moderation.AddChannelModeratorAsync(channelId, userId); diff --git a/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs b/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs index 4e5222c..ef9460e 100644 --- a/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs +++ b/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs @@ -1,21 +1,21 @@ namespace Nullinside.Api.Common.Twitch; /// -/// The configuration for a twitch app that provides OAuth tokens. +/// The configuration for a twitch app that provides OAuth tokens. /// public class TwitchAppConfig { /// - /// The client id. + /// The client id. /// public string? ClientId { get; set; } - + /// - /// The client secret. + /// The client secret. /// public string? ClientSecret { get; set; } - + /// - /// A registered URL that the Twitch API is allowed to redirect to on our website. + /// A registered URL that the Twitch API is allowed to redirect to on our website. /// public string? ClientRedirect { get; set; } } \ No newline at end of file diff --git a/src/Nullinside.Api.Model/Ddl/User.cs b/src/Nullinside.Api.Model/Ddl/User.cs index aad7a38..c5d4c00 100644 --- a/src/Nullinside.Api.Model/Ddl/User.cs +++ b/src/Nullinside.Api.Model/Ddl/User.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + using Microsoft.EntityFrameworkCore; namespace Nullinside.Api.Model.Ddl; @@ -60,6 +62,7 @@ public class User : ITableModel { /// /// The last timestamp of when the user logged into the site. /// + [Timestamp] public DateTime UpdatedOn { get; set; } /// @@ -92,6 +95,8 @@ public void OnModelCreating(ModelBuilder modelBuilder) { .HasMaxLength(255); entity.Property(e => e.Token) .HasMaxLength(255); + entity.Property(e => e.UpdatedOn) + .IsRowVersion(); // TODO: Add the other strings in this file with lengths }); } diff --git a/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.Designer.cs b/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.Designer.cs new file mode 100644 index 0000000..292ab93 --- /dev/null +++ b/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.Designer.cs @@ -0,0 +1,314 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nullinside.Api.Model; + +#nullable disable + +namespace Nullinside.Api.Model.Migrations +{ + [DbContext(typeof(NullinsideContext))] + [Migration("20250228164901_UserTableTimestamp")] + partial class UserTableTimestamp + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.DockerDeployments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("IsDockerComposeProject") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Notes") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ServerDir") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("DisplayName"); + + b.HasIndex("IsDockerComposeProject", "Name"); + + b.ToTable("DockerDeployments"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.FeatureToggle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Feature") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("FeatureToggle"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BannedUserTwitchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("TwitchBan"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("TwitchId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TwitchUsername") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("TwitchId") + .IsUnique(); + + b.ToTable("TwitchUser"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserBannedOutsideOfBotLogs", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Channel") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Reason") + .HasColumnType("longtext"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TwitchId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TwitchUsername") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.ToTable("TwitchUserBannedOutsideOfBotLogs"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserChatLogs", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Channel") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TwitchId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TwitchUsername") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.ToTable("TwitchUserChatLogs"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BanKnownBots") + .HasColumnType("tinyint(1)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("UpdatedOn") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("TwitchUserConfig"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsBanned") + .HasColumnType("tinyint(1)"); + + b.Property("Token") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TwitchId") + .HasColumnType("longtext"); + + b.Property("TwitchLastScanned") + .HasColumnType("datetime(6)"); + + b.Property("TwitchRefreshToken") + .HasColumnType("longtext"); + + b.Property("TwitchToken") + .HasColumnType("longtext"); + + b.Property("TwitchTokenExpiration") + .HasColumnType("datetime(6)"); + + b.Property("TwitchUsername") + .HasColumnType("longtext"); + + b.Property("UpdatedOn") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("RoleAdded") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRoles"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserConfig", b => + { + b.HasOne("Nullinside.Api.Model.Ddl.User", null) + .WithOne("TwitchConfig") + .HasForeignKey("Nullinside.Api.Model.Ddl.TwitchUserConfig", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.UserRole", b => + { + b.HasOne("Nullinside.Api.Model.Ddl.User", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.User", b => + { + b.Navigation("Roles"); + + b.Navigation("TwitchConfig"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.cs b/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.cs new file mode 100644 index 0000000..600728d --- /dev/null +++ b/src/Nullinside.Api.Model/Migrations/20250228164901_UserTableTimestamp.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using MySql.EntityFrameworkCore.Metadata; + +#nullable disable + +namespace Nullinside.Api.Model.Migrations +{ + /// + public partial class UserTableTimestamp : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "UpdatedOn", + table: "Users", + type: "datetime(6)", + rowVersion: true, + nullable: false, + oldClrType: typeof(DateTime), + oldType: "datetime(6)") + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.ComputedColumn); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "UpdatedOn", + table: "Users", + type: "datetime(6)", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "datetime(6)", + oldRowVersion: true) + .OldAnnotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.ComputedColumn); + } + } +} diff --git a/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs b/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs index c739d53..040f4d1 100644 --- a/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs +++ b/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs @@ -16,7 +16,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("ProductVersion", "8.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("Nullinside.Api.Model.Ddl.DockerDeployments", b => @@ -247,6 +247,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("longtext"); b.Property("UpdatedOn") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() .HasColumnType("datetime(6)"); b.HasKey("Id"); diff --git a/src/Nullinside.Api.Model/Shared/DatabaseLock.cs b/src/Nullinside.Api.Model/Shared/DatabaseLock.cs new file mode 100644 index 0000000..64e2e02 --- /dev/null +++ b/src/Nullinside.Api.Model/Shared/DatabaseLock.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; + +using Nullinside.Api.Model; + +namespace Nullinside.Api.Common; + +/// +/// Handles acquiring a lock at the database level. +/// +/// Requires a transaction wrapping its functionality. +public class DatabaseLock : IDisposable { + /// + /// The database context. + /// + private INullinsideContext _mysqlDbContext; + /// + /// The lock name used on the previous lock. + /// + private string? _name; + + /// + /// Initializes a new instance of the class. + /// + /// The database context. + /// Must provide parameter. + public DatabaseLock(INullinsideContext mysqlDbContext) { + _mysqlDbContext = mysqlDbContext ?? throw new ArgumentNullException(nameof(mysqlDbContext)); + } + + /// + /// Acquires the lock. + /// + /// Waits indefinitely. + /// The name of the lock. MUST BE A HARD CODED VALUE + /// The cancellation token. + /// True if successful, false otherwise. + public async Task GetLock(string name, CancellationToken cancellationToken = new()) { + if (string.IsNullOrWhiteSpace(name)) { + return false; + } + + // This is only used with hard coded names. +#pragma warning disable EF1002 + await _mysqlDbContext.Database.ExecuteSqlRawAsync($"SELECT GET_LOCK('{name}', -1)", cancellationToken: cancellationToken); +#pragma warning restore EF1002 + _name = name; + return true; + } + + /// + /// Releases the lock. + /// + /// The name of the lock. MUST BE A HARD CODED VALUE + /// The cancellation token. + public async Task ReleaseLock(string name, CancellationToken cancellationToken = new()) { + if (!string.IsNullOrWhiteSpace(name) && !name.Equals(_name, StringComparison.InvariantCultureIgnoreCase)) { + // This is only used with hard coded names. +#pragma warning disable EF1002 + await _mysqlDbContext.Database.ExecuteSqlRawAsync($"SELECT RELEASE_LOCK('{_name}')", cancellationToken: cancellationToken); +#pragma warning restore EF1002 + } + + // This is only used with hard coded names. +#pragma warning disable EF1002 + await _mysqlDbContext.Database.ExecuteSqlRawAsync($"SELECT RELEASE_LOCK('{name}')", cancellationToken: cancellationToken); +#pragma warning restore EF1002 + _name = null; + } + + /// + /// Disposes of resources. + /// + /// True if dispose called, false if finalizer. + protected virtual void Dispose(bool disposing) { + if (disposing) { + if (!string.IsNullOrWhiteSpace(_name)) { + Task.WaitAll(this.ReleaseLock(_name)); + } + } + } + + /// + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Finalizes instance of the class. + /// + ~DatabaseLock() { + Dispose(false); + } +} \ No newline at end of file