From fbfc6990e6ac9c4823cd27bb43909a259fd1074e Mon Sep 17 00:00:00 2001 From: FrigaT Date: Tue, 14 Apr 2026 01:39:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AccountController.cs | 6 +- .../Controllers/OpenIdController.cs | 6 +- .../Controllers/SharedPlaylistController.cs | 29 +- .../Data/ApplicationDbContext.cs | 43 +- ...413221546_AddSessionCacheTable.Designer.cs | 426 ++++++++++++++ .../20260413221546_AddSessionCacheTable.cs | 32 ++ ...3451_AddUserSessionsAndLogging.Designer.cs | 544 ++++++++++++++++++ ...0260413223451_AddUserSessionsAndLogging.cs | 151 +++++ .../ApplicationDbContextModelSnapshot.cs | 132 ++++- .../Entities/ApplicationUser.cs | 2 +- ...redPlaylistEntity.cs => SharedPlaylist.cs} | 6 +- .../Entities/TrackAdditionLog.cs | 16 + .../Entities/TrackAdditionLogEntity.cs | 15 - .../Entities/TrackRemovalLog.cs | 15 + PlaylistShared.Api/Entities/UserSession.cs | 15 + .../Mapping/AppMappingProfile.cs | 2 +- PlaylistShared.Api/PlaylistShared.Api.csproj | 1 + PlaylistShared.Api/Program.cs | 22 +- .../Services/SharedPlaylistService.cs | 16 +- .../Services/TrackAdditionLogService.cs | 12 +- .../Services/TrackRemovalLogService.cs | 22 + .../Services/UserSessionService.cs | 47 ++ 22 files changed, 1509 insertions(+), 51 deletions(-) create mode 100644 PlaylistShared.Api/Data/Migrations/20260413221546_AddSessionCacheTable.Designer.cs create mode 100644 PlaylistShared.Api/Data/Migrations/20260413221546_AddSessionCacheTable.cs create mode 100644 PlaylistShared.Api/Data/Migrations/20260413223451_AddUserSessionsAndLogging.Designer.cs create mode 100644 PlaylistShared.Api/Data/Migrations/20260413223451_AddUserSessionsAndLogging.cs rename PlaylistShared.Api/Entities/{SharedPlaylistEntity.cs => SharedPlaylist.cs} (77%) create mode 100644 PlaylistShared.Api/Entities/TrackAdditionLog.cs delete mode 100644 PlaylistShared.Api/Entities/TrackAdditionLogEntity.cs create mode 100644 PlaylistShared.Api/Entities/TrackRemovalLog.cs create mode 100644 PlaylistShared.Api/Entities/UserSession.cs create mode 100644 PlaylistShared.Api/Services/TrackRemovalLogService.cs create mode 100644 PlaylistShared.Api/Services/UserSessionService.cs diff --git a/PlaylistShared.Api/Controllers/AccountController.cs b/PlaylistShared.Api/Controllers/AccountController.cs index da68f1b..eca67cf 100644 --- a/PlaylistShared.Api/Controllers/AccountController.cs +++ b/PlaylistShared.Api/Controllers/AccountController.cs @@ -13,12 +13,14 @@ public class AccountController : ControllerBase private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly JwtService _jwtService; + private readonly UserSessionService _userSessionService; - public AccountController(UserManager userManager, SignInManager signInManager, JwtService jwtService) + public AccountController(UserManager userManager, SignInManager signInManager, JwtService jwtService, UserSessionService userSessionService) { _userManager = userManager; _signInManager = signInManager; _jwtService = jwtService; + _userSessionService = userSessionService; } [HttpPost("register")] @@ -56,6 +58,8 @@ public class AccountController : ControllerBase private async Task>> GenerateTokenResponse(ApplicationUser user) { + await _userSessionService.GetOrCreateCurrentSessionAsync(user.Id); + var (token, refreshToken, expiration) = await _jwtService.GenerateTokenAsync(user); return Ok(ApiResponse.Ok(new LoginResponse { diff --git a/PlaylistShared.Api/Controllers/OpenIdController.cs b/PlaylistShared.Api/Controllers/OpenIdController.cs index cfd7667..a789e31 100644 --- a/PlaylistShared.Api/Controllers/OpenIdController.cs +++ b/PlaylistShared.Api/Controllers/OpenIdController.cs @@ -14,17 +14,20 @@ public class OpenIdController : ControllerBase private readonly UserManager _userManager; private readonly JwtService _jwtService; private readonly IConfiguration _configuration; + private readonly UserSessionService _userSessionService; public OpenIdController( SignInManager signInManager, UserManager userManager, JwtService jwtService, - IConfiguration configuration) + IConfiguration configuration, + UserSessionService userSessionService) { _signInManager = signInManager; _userManager = userManager; _jwtService = jwtService; _configuration = configuration; + _userSessionService = userSessionService; } [HttpGet("login")] @@ -70,6 +73,7 @@ public class OpenIdController : ControllerBase } await _signInManager.SignInAsync(user, isPersistent: false); + await _userSessionService.GetOrCreateCurrentSessionAsync(user.Id); var (token, refreshToken, _) = await _jwtService.GenerateTokenAsync(user); return Redirect($"{_configuration["Client:BaseUrl"]}/auth-callback?token={token}&refreshToken={refreshToken}"); } diff --git a/PlaylistShared.Api/Controllers/SharedPlaylistController.cs b/PlaylistShared.Api/Controllers/SharedPlaylistController.cs index b16493a..ba86fea 100644 --- a/PlaylistShared.Api/Controllers/SharedPlaylistController.cs +++ b/PlaylistShared.Api/Controllers/SharedPlaylistController.cs @@ -16,18 +16,24 @@ public class SharedPlaylistController : ControllerBase private readonly SharedPlaylistService _sharedService; private readonly YandexMusicService _yandexService; private readonly UserManager _userManager; - private readonly TrackAdditionLogService _trackLogService; + private readonly UserSessionService _userSessionService; + private readonly TrackAdditionLogService _trackAdditionLogService; + private readonly TrackRemovalLogService _trackRemovalLogService; public SharedPlaylistController( SharedPlaylistService sharedService, YandexMusicService yandexService, UserManager userManager, - TrackAdditionLogService trackLogService) + TrackAdditionLogService trackAdditionLogService, + TrackRemovalLogService trackRemovalLogService, + UserSessionService userSessionService) { _sharedService = sharedService; _yandexService = yandexService; _userManager = userManager; - _trackLogService = trackLogService; + _trackAdditionLogService = trackAdditionLogService; + _trackRemovalLogService = trackRemovalLogService; + _userSessionService = userSessionService; } // GET /api/sharedplaylist/{token} @@ -113,6 +119,13 @@ public class SharedPlaylistController : ControllerBase if (updatedPlaylist == null) return StatusCode(500, ApiResponse.Fail(new ErrorResponse { StatusCode = 500, Message = "Ошибка при добавлении треков" })); + var session = await _userSessionService.GetOrCreateCurrentSessionAsync(currentUserId); + var sessionId = session.SessionId; + foreach (var trackId in request.TrackIds) + { + await _trackAdditionLogService.LogAdditionAsync(playlist.Id, trackId, currentUserId, sessionId); + } + return Ok(ApiResponse.Ok(new { message = "Треки добавлены" })); } @@ -125,9 +138,12 @@ public class SharedPlaylistController : ControllerBase if (playlist == null) return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); + var session = await _userSessionService.GetOrCreateCurrentSessionAsync(currentUserId); + var sessionId = session.SessionId; + foreach (var trackId in request.TrackIds) { - if (!await _sharedService.CanRemoveTrackAsync(playlist, currentUserId, trackId)) + if (!await _sharedService.CanRemoveTrackAsync(playlist, currentUserId, trackId, sessionId)) return StatusCode(403, ApiResponse.Fail(new ErrorResponse { StatusCode = 403, Message = $"Недостаточно прав для удаления трека {trackId}" })); } @@ -140,7 +156,10 @@ public class SharedPlaylistController : ControllerBase return StatusCode(500, ApiResponse.Fail(new ErrorResponse { StatusCode = 500, Message = "Ошибка при удалении треков" })); foreach (var trackId in request.TrackIds) - await _trackLogService.RemoveLogsForTrackAsync(playlist.Id, trackId); + { + await _trackRemovalLogService.LogRemovalAsync(playlist.Id, trackId, currentUserId, sessionId); + await _trackAdditionLogService.RemoveLogsForTrackAsync(playlist.Id, trackId); + } return Ok(ApiResponse.Ok(new { message = "Треки удалены" })); } diff --git a/PlaylistShared.Api/Data/ApplicationDbContext.cs b/PlaylistShared.Api/Data/ApplicationDbContext.cs index ac45e2e..419df82 100644 --- a/PlaylistShared.Api/Data/ApplicationDbContext.cs +++ b/PlaylistShared.Api/Data/ApplicationDbContext.cs @@ -9,14 +9,16 @@ public class ApplicationDbContext : IdentityDbContext options) : base(options) { } - public DbSet SharedPlaylists => Set(); - public DbSet TrackAdditionLogs => Set(); + public DbSet SharedPlaylists => Set(); + public DbSet TrackAdditionLogs => Set(); + public DbSet UserSessions => Set(); + public DbSet TrackRemovalLogs => Set(); protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); - builder.Entity(entity => + builder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => e.ShareToken).IsUnique(); @@ -29,7 +31,18 @@ public class ApplicationDbContext : IdentityDbContext e.Title).IsRequired().HasMaxLength(255); }); - builder.Entity(entity => + builder.Entity(entity => + { + entity.HasKey(e => e.SessionId); + entity.Property(e => e.SessionId).HasMaxLength(449); + entity.HasIndex(e => e.AssociatedUserId); + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.AssociatedUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.SharedPlaylistId, e.TrackId }); @@ -41,6 +54,28 @@ public class ApplicationDbContext : IdentityDbContext e.AddedByUserId) .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.Session) + .WithMany(s => s.TrackAdditionLogs) + .HasForeignKey(e => e.SessionId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.SharedPlaylistId, e.TrackId }); + entity.HasOne(e => e.SharedPlaylist) + .WithMany() + .HasForeignKey(e => e.SharedPlaylistId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.RemovedByUser) + .WithMany() + .HasForeignKey(e => e.RemovedByUserId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.Session) + .WithMany(s => s.TrackRemovalLogs) + .HasForeignKey(e => e.SessionId) + .OnDelete(DeleteBehavior.Restrict); }); } } \ No newline at end of file diff --git a/PlaylistShared.Api/Data/Migrations/20260413221546_AddSessionCacheTable.Designer.cs b/PlaylistShared.Api/Data/Migrations/20260413221546_AddSessionCacheTable.Designer.cs new file mode 100644 index 0000000..54fe475 --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/20260413221546_AddSessionCacheTable.Designer.cs @@ -0,0 +1,426 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PlaylistShared.Api.Data; + +#nullable disable + +namespace PlaylistShared.Api.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260413221546_AddSessionCacheTable")] + partial class AddSessionCacheTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("RefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("RefreshTokenExpiryUtc") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("YandexAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexId") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexRefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexTokenExpiryUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddPermission") + .HasColumnType("int"); + + b.Property("CoverUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatorUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("RemovePermission") + .HasColumnType("int"); + + b.Property("ShareToken") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("ViewPermission") + .HasColumnType("int"); + + b.Property("YandexPlaylistKind") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("YandexPlaylistOwnerUid") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatorUserId"); + + b.HasIndex("ShareToken") + .IsUnique(); + + b.ToTable("SharedPlaylists"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddedAtUtc") + .HasColumnType("datetime2"); + + b.Property("AddedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("SharedPlaylistId") + .HasColumnType("uniqueidentifier"); + + b.Property("TrackId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("AddedByUserId"); + + b.HasIndex("SharedPlaylistId", "TrackId"); + + b.ToTable("TrackAdditionLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylistEntity", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "Creator") + .WithMany("OwnedPlaylists") + .HasForeignKey("CreatorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLogEntity", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "AddedByUser") + .WithMany() + .HasForeignKey("AddedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylistEntity", "SharedPlaylist") + .WithMany("TrackAdditionLogs") + .HasForeignKey("SharedPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AddedByUser"); + + b.Navigation("SharedPlaylist"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b => + { + b.Navigation("OwnedPlaylists"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylistEntity", b => + { + b.Navigation("TrackAdditionLogs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PlaylistShared.Api/Data/Migrations/20260413221546_AddSessionCacheTable.cs b/PlaylistShared.Api/Data/Migrations/20260413221546_AddSessionCacheTable.cs new file mode 100644 index 0000000..ad09897 --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/20260413221546_AddSessionCacheTable.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PlaylistShared.Api.Data.Migrations +{ + /// + public partial class AddSessionCacheTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + CREATE TABLE [dbo].[SessionCache] ( + [Id] NVARCHAR(449) NOT NULL, + [Value] VARBINARY(MAX) NOT NULL, + [ExpiresAtTime] DATETIMEOFFSET NOT NULL, + [SlidingExpirationInSeconds] BIGINT NULL, + [AbsoluteExpiration] DATETIMEOFFSET NULL, + CONSTRAINT [pk_SessionCache] PRIMARY KEY ([Id]) + ); + CREATE NONCLUSTERED INDEX [Index_ExpiresAtTime] ON [dbo].[SessionCache] ([ExpiresAtTime]); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP TABLE [dbo].[SessionCache]"); + } + } +} diff --git a/PlaylistShared.Api/Data/Migrations/20260413223451_AddUserSessionsAndLogging.Designer.cs b/PlaylistShared.Api/Data/Migrations/20260413223451_AddUserSessionsAndLogging.Designer.cs new file mode 100644 index 0000000..4fac978 --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/20260413223451_AddUserSessionsAndLogging.Designer.cs @@ -0,0 +1,544 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PlaylistShared.Api.Data; + +#nullable disable + +namespace PlaylistShared.Api.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260413223451_AddUserSessionsAndLogging")] + partial class AddUserSessionsAndLogging + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("RefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("RefreshTokenExpiryUtc") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("YandexAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexId") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexRefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexTokenExpiryUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddPermission") + .HasColumnType("int"); + + b.Property("CoverUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatorUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("RemovePermission") + .HasColumnType("int"); + + b.Property("ShareToken") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("ViewPermission") + .HasColumnType("int"); + + b.Property("YandexPlaylistKind") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("YandexPlaylistOwnerUid") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatorUserId"); + + b.HasIndex("ShareToken") + .IsUnique(); + + b.ToTable("SharedPlaylists"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddedAtUtc") + .HasColumnType("datetime2"); + + b.Property("AddedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("nvarchar(449)"); + + b.Property("SharedPlaylistId") + .HasColumnType("uniqueidentifier"); + + b.Property("TrackId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("AddedByUserId"); + + b.HasIndex("SessionId"); + + b.HasIndex("SharedPlaylistId", "TrackId"); + + b.ToTable("TrackAdditionLogs"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.Property("SessionId") + .HasMaxLength(449) + .HasColumnType("nvarchar(449)"); + + b.Property("AssociatedUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClientIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstSeenUtc") + .HasColumnType("datetime2"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2"); + + b.Property("UserAgent") + .HasColumnType("nvarchar(max)"); + + b.HasKey("SessionId"); + + b.HasIndex("AssociatedUserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("TrackRemovalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("RemovedAtUtc") + .HasColumnType("datetime2"); + + b.Property("RemovedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("nvarchar(449)"); + + b.Property("SharedPlaylistId") + .HasColumnType("uniqueidentifier"); + + b.Property("TrackId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SessionId"); + + b.HasIndex("SharedPlaylistId", "TrackId"); + + b.ToTable("TrackRemovalLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "Creator") + .WithMany("OwnedPlaylists") + .HasForeignKey("CreatorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLog", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "AddedByUser") + .WithMany() + .HasForeignKey("AddedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PlaylistShared.Api.Entities.UserSession", "Session") + .WithMany("TrackAdditionLogs") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylist", "SharedPlaylist") + .WithMany("TrackAdditionLogs") + .HasForeignKey("SharedPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AddedByUser"); + + b.Navigation("Session"); + + b.Navigation("SharedPlaylist"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "User") + .WithMany() + .HasForeignKey("AssociatedUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TrackRemovalLog", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PlaylistShared.Api.Entities.UserSession", "Session") + .WithMany("TrackRemovalLogs") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylist", "SharedPlaylist") + .WithMany() + .HasForeignKey("SharedPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RemovedByUser"); + + b.Navigation("Session"); + + b.Navigation("SharedPlaylist"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b => + { + b.Navigation("OwnedPlaylists"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => + { + b.Navigation("TrackAdditionLogs"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.Navigation("TrackAdditionLogs"); + + b.Navigation("TrackRemovalLogs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PlaylistShared.Api/Data/Migrations/20260413223451_AddUserSessionsAndLogging.cs b/PlaylistShared.Api/Data/Migrations/20260413223451_AddUserSessionsAndLogging.cs new file mode 100644 index 0000000..2b9347c --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/20260413223451_AddUserSessionsAndLogging.cs @@ -0,0 +1,151 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PlaylistShared.Api.Data.Migrations +{ + /// + public partial class AddUserSessionsAndLogging : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "AddedByUserId", + table: "TrackAdditionLogs", + type: "uniqueidentifier", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uniqueidentifier"); + + migrationBuilder.AddColumn( + name: "SessionId", + table: "TrackAdditionLogs", + type: "nvarchar(449)", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "UserSessions", + columns: table => new + { + SessionId = table.Column(type: "nvarchar(449)", maxLength: 449, nullable: false), + ClientIpAddress = table.Column(type: "nvarchar(max)", nullable: true), + UserAgent = table.Column(type: "nvarchar(max)", nullable: true), + FirstSeenUtc = table.Column(type: "datetime2", nullable: false), + LastSeenUtc = table.Column(type: "datetime2", nullable: false), + AssociatedUserId = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.SessionId); + table.ForeignKey( + name: "FK_UserSessions_AspNetUsers_AssociatedUserId", + column: x => x.AssociatedUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "TrackRemovalLogs", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + SharedPlaylistId = table.Column(type: "uniqueidentifier", nullable: false), + TrackId = table.Column(type: "nvarchar(450)", nullable: false), + RemovedByUserId = table.Column(type: "uniqueidentifier", nullable: true), + RemovedAtUtc = table.Column(type: "datetime2", nullable: false), + SessionId = table.Column(type: "nvarchar(449)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackRemovalLogs", x => x.Id); + table.ForeignKey( + name: "FK_TrackRemovalLogs_AspNetUsers_RemovedByUserId", + column: x => x.RemovedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TrackRemovalLogs_SharedPlaylists_SharedPlaylistId", + column: x => x.SharedPlaylistId, + principalTable: "SharedPlaylists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TrackRemovalLogs_UserSessions_SessionId", + column: x => x.SessionId, + principalTable: "UserSessions", + principalColumn: "SessionId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_TrackAdditionLogs_SessionId", + table: "TrackAdditionLogs", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackRemovalLogs_RemovedByUserId", + table: "TrackRemovalLogs", + column: "RemovedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackRemovalLogs_SessionId", + table: "TrackRemovalLogs", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackRemovalLogs_SharedPlaylistId_TrackId", + table: "TrackRemovalLogs", + columns: new[] { "SharedPlaylistId", "TrackId" }); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_AssociatedUserId", + table: "UserSessions", + column: "AssociatedUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_TrackAdditionLogs_UserSessions_SessionId", + table: "TrackAdditionLogs", + column: "SessionId", + principalTable: "UserSessions", + principalColumn: "SessionId", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TrackAdditionLogs_UserSessions_SessionId", + table: "TrackAdditionLogs"); + + migrationBuilder.DropTable( + name: "TrackRemovalLogs"); + + migrationBuilder.DropTable( + name: "UserSessions"); + + migrationBuilder.DropIndex( + name: "IX_TrackAdditionLogs_SessionId", + table: "TrackAdditionLogs"); + + migrationBuilder.DropColumn( + name: "SessionId", + table: "TrackAdditionLogs"); + + migrationBuilder.AlterColumn( + name: "AddedByUserId", + table: "TrackAdditionLogs", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uniqueidentifier", + oldNullable: true); + } + } +} diff --git a/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs index e2599e0..26f5e59 100644 --- a/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -237,7 +237,7 @@ namespace PlaylistShared.Api.Data.Migrations b.ToTable("AspNetUsers", (string)null); }); - modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylistEntity", b => + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -299,7 +299,7 @@ namespace PlaylistShared.Api.Data.Migrations b.ToTable("SharedPlaylists"); }); - modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLogEntity", b => + modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLog", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -308,9 +308,13 @@ namespace PlaylistShared.Api.Data.Migrations b.Property("AddedAtUtc") .HasColumnType("datetime2"); - b.Property("AddedByUserId") + b.Property("AddedByUserId") .HasColumnType("uniqueidentifier"); + b.Property("SessionId") + .IsRequired() + .HasColumnType("nvarchar(449)"); + b.Property("SharedPlaylistId") .HasColumnType("uniqueidentifier"); @@ -322,11 +326,75 @@ namespace PlaylistShared.Api.Data.Migrations b.HasIndex("AddedByUserId"); + b.HasIndex("SessionId"); + b.HasIndex("SharedPlaylistId", "TrackId"); b.ToTable("TrackAdditionLogs"); }); + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.Property("SessionId") + .HasMaxLength(449) + .HasColumnType("nvarchar(449)"); + + b.Property("AssociatedUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClientIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstSeenUtc") + .HasColumnType("datetime2"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2"); + + b.Property("UserAgent") + .HasColumnType("nvarchar(max)"); + + b.HasKey("SessionId"); + + b.HasIndex("AssociatedUserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("TrackRemovalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("RemovedAtUtc") + .HasColumnType("datetime2"); + + b.Property("RemovedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("nvarchar(449)"); + + b.Property("SharedPlaylistId") + .HasColumnType("uniqueidentifier"); + + b.Property("TrackId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SessionId"); + + b.HasIndex("SharedPlaylistId", "TrackId"); + + b.ToTable("TrackRemovalLogs"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -378,7 +446,7 @@ namespace PlaylistShared.Api.Data.Migrations .IsRequired(); }); - modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylistEntity", b => + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => { b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "Creator") .WithMany("OwnedPlaylists") @@ -389,15 +457,20 @@ namespace PlaylistShared.Api.Data.Migrations b.Navigation("Creator"); }); - modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLogEntity", b => + modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLog", b => { b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "AddedByUser") .WithMany() .HasForeignKey("AddedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PlaylistShared.Api.Entities.UserSession", "Session") + .WithMany("TrackAdditionLogs") + .HasForeignKey("SessionId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("PlaylistShared.Api.Entities.SharedPlaylistEntity", "SharedPlaylist") + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylist", "SharedPlaylist") .WithMany("TrackAdditionLogs") .HasForeignKey("SharedPlaylistId") .OnDelete(DeleteBehavior.Cascade) @@ -405,6 +478,44 @@ namespace PlaylistShared.Api.Data.Migrations b.Navigation("AddedByUser"); + b.Navigation("Session"); + + b.Navigation("SharedPlaylist"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "User") + .WithMany() + .HasForeignKey("AssociatedUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TrackRemovalLog", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PlaylistShared.Api.Entities.UserSession", "Session") + .WithMany("TrackRemovalLogs") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylist", "SharedPlaylist") + .WithMany() + .HasForeignKey("SharedPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RemovedByUser"); + + b.Navigation("Session"); + b.Navigation("SharedPlaylist"); }); @@ -413,10 +524,17 @@ namespace PlaylistShared.Api.Data.Migrations b.Navigation("OwnedPlaylists"); }); - modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylistEntity", b => + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => { b.Navigation("TrackAdditionLogs"); }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.Navigation("TrackAdditionLogs"); + + b.Navigation("TrackRemovalLogs"); + }); #pragma warning restore 612, 618 } } diff --git a/PlaylistShared.Api/Entities/ApplicationUser.cs b/PlaylistShared.Api/Entities/ApplicationUser.cs index 9ef4a81..64af85b 100644 --- a/PlaylistShared.Api/Entities/ApplicationUser.cs +++ b/PlaylistShared.Api/Entities/ApplicationUser.cs @@ -24,5 +24,5 @@ public class ApplicationUser : IdentityUser public DateTime RefreshTokenExpiryUtc { get; set; } /// Плейлисты, созданные пользователем. - public ICollection OwnedPlaylists { get; set; } = new List(); + public ICollection OwnedPlaylists { get; set; } = new List(); } \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/SharedPlaylistEntity.cs b/PlaylistShared.Api/Entities/SharedPlaylist.cs similarity index 77% rename from PlaylistShared.Api/Entities/SharedPlaylistEntity.cs rename to PlaylistShared.Api/Entities/SharedPlaylist.cs index b7c70c7..5ca053a 100644 --- a/PlaylistShared.Api/Entities/SharedPlaylistEntity.cs +++ b/PlaylistShared.Api/Entities/SharedPlaylist.cs @@ -2,8 +2,8 @@ namespace PlaylistShared.Api.Entities; -/// Сущность шеринг-плейлиста (таблица в БД). -public class SharedPlaylistEntity +/// Сущность шеринг-плейлиста. +public class SharedPlaylist { public Guid Id { get; set; } public Guid CreatorUserId { get; set; } @@ -22,5 +22,5 @@ public class SharedPlaylistEntity // Навигационные свойства public ApplicationUser Creator { get; set; } = null!; - public ICollection TrackAdditionLogs { get; set; } = new List(); + public ICollection TrackAdditionLogs { get; set; } = new List(); } \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/TrackAdditionLog.cs b/PlaylistShared.Api/Entities/TrackAdditionLog.cs new file mode 100644 index 0000000..a5ffd2a --- /dev/null +++ b/PlaylistShared.Api/Entities/TrackAdditionLog.cs @@ -0,0 +1,16 @@ +namespace PlaylistShared.Api.Entities; + +/// Лог добавления трека. +public class TrackAdditionLog +{ + public Guid Id { get; set; } + public Guid SharedPlaylistId { get; set; } + public string TrackId { get; set; } = null!; + public Guid? AddedByUserId { get; set; } + public DateTime AddedAtUtc { get; set; } + public string SessionId { get; set; } = null!; + + public SharedPlaylist SharedPlaylist { get; set; } = null!; + public ApplicationUser? AddedByUser { get; set; } + public UserSession Session { get; set; } = null!; +} \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/TrackAdditionLogEntity.cs b/PlaylistShared.Api/Entities/TrackAdditionLogEntity.cs deleted file mode 100644 index 71c8ba1..0000000 --- a/PlaylistShared.Api/Entities/TrackAdditionLogEntity.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace PlaylistShared.Api.Entities; - -/// Лог добавления трека (таблица в БД). -public class TrackAdditionLogEntity -{ - public Guid Id { get; set; } - public Guid SharedPlaylistId { get; set; } - public string TrackId { get; set; } = null!; - public Guid AddedByUserId { get; set; } - public DateTime AddedAtUtc { get; set; } - - // Навигационные свойства - public SharedPlaylistEntity SharedPlaylist { get; set; } = null!; - public ApplicationUser AddedByUser { get; set; } = null!; -} \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/TrackRemovalLog.cs b/PlaylistShared.Api/Entities/TrackRemovalLog.cs new file mode 100644 index 0000000..224b774 --- /dev/null +++ b/PlaylistShared.Api/Entities/TrackRemovalLog.cs @@ -0,0 +1,15 @@ +using PlaylistShared.Api.Entities; + +public class TrackRemovalLog +{ + public Guid Id { get; set; } + public Guid SharedPlaylistId { get; set; } + public string TrackId { get; set; } = null!; + public Guid? RemovedByUserId { get; set; } + public DateTime RemovedAtUtc { get; set; } + public string SessionId { get; set; } = null!; + + public SharedPlaylist SharedPlaylist { get; set; } = null!; + public ApplicationUser? RemovedByUser { get; set; } + public UserSession Session { get; set; } = null!; +} \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/UserSession.cs b/PlaylistShared.Api/Entities/UserSession.cs new file mode 100644 index 0000000..dc41e76 --- /dev/null +++ b/PlaylistShared.Api/Entities/UserSession.cs @@ -0,0 +1,15 @@ +namespace PlaylistShared.Api.Entities; + +public class UserSession +{ + public string SessionId { get; set; } = null!; // HttpContext.Session.Id + public string? ClientIpAddress { get; set; } + public string? UserAgent { get; set; } + public DateTime FirstSeenUtc { get; set; } + public DateTime LastSeenUtc { get; set; } + public Guid? AssociatedUserId { get; set; } // если позже залогинился + + public ApplicationUser? User { get; set; } + public ICollection TrackAdditionLogs { get; set; } = new List(); + public ICollection TrackRemovalLogs { get; set; } = new List(); +} \ No newline at end of file diff --git a/PlaylistShared.Api/Mapping/AppMappingProfile.cs b/PlaylistShared.Api/Mapping/AppMappingProfile.cs index a18f814..9e400de 100644 --- a/PlaylistShared.Api/Mapping/AppMappingProfile.cs +++ b/PlaylistShared.Api/Mapping/AppMappingProfile.cs @@ -9,7 +9,7 @@ public class AppMappingProfile : Profile { public AppMappingProfile() { - CreateMap() + CreateMap() .ForMember(dest => dest.Creator, opt => opt.MapFrom(src => src.Creator)); CreateMap(); } diff --git a/PlaylistShared.Api/PlaylistShared.Api.csproj b/PlaylistShared.Api/PlaylistShared.Api.csproj index 3f4f3c7..9d47615 100644 --- a/PlaylistShared.Api/PlaylistShared.Api.csproj +++ b/PlaylistShared.Api/PlaylistShared.Api.csproj @@ -22,6 +22,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/PlaylistShared.Api/Program.cs b/PlaylistShared.Api/Program.cs index 11f5909..d0acef5 100644 --- a/PlaylistShared.Api/Program.cs +++ b/PlaylistShared.Api/Program.cs @@ -35,6 +35,22 @@ public class Program .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + // Session + builder.Services.AddDistributedSqlServerCache(options => + { + options.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection"); + options.SchemaName = "dbo"; + options.TableName = "SessionCache"; + }); + + builder.Services.AddSession(options => + { + options.IdleTimeout = TimeSpan.FromDays(30); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.Cookie.SameSite = SameSiteMode.Lax; + }); + // JWT var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); builder.Services.AddAuthentication(options => @@ -70,12 +86,15 @@ public class Program options.SignInScheme = IdentityConstants.ExternalScheme; }); + builder.Services.AddHttpContextAccessor(); builder.Services.AddAuthorization(); builder.Services.AddAutoMapper(t => t.AddProfile()); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddDataProtection(); builder.Services.AddHttpClient(); @@ -123,6 +142,7 @@ public class Program app.UseCors("Production"); } + app.UseSession(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/PlaylistShared.Api/Services/SharedPlaylistService.cs b/PlaylistShared.Api/Services/SharedPlaylistService.cs index 932b63c..7d529ff 100644 --- a/PlaylistShared.Api/Services/SharedPlaylistService.cs +++ b/PlaylistShared.Api/Services/SharedPlaylistService.cs @@ -23,7 +23,7 @@ public class SharedPlaylistService public async Task CreateAsync(Guid creatorUserId, SharePlaylistDto dto) { - var entity = new SharedPlaylistEntity + var entity = new SharedPlaylist { Id = Guid.NewGuid(), CreatorUserId = creatorUserId, @@ -51,7 +51,7 @@ public class SharedPlaylistService return entity == null ? null : _mapper.Map(entity); } - public async Task GetEntityByTokenAsync(string token) + public async Task GetEntityByTokenAsync(string token) { return await _db.SharedPlaylists .Include(sp => sp.Creator) @@ -80,21 +80,21 @@ public class SharedPlaylistService return true; } - public async Task CanViewAsync(SharedPlaylistEntity playlist, Guid? currentUserId) + public async Task CanViewAsync(SharedPlaylist playlist, Guid? currentUserId) { if (currentUserId == playlist.CreatorUserId) return true; return playlist.ViewPermission == ViewPermission.Everyone || (playlist.ViewPermission == ViewPermission.AuthorizedOnly && currentUserId.HasValue); } - public async Task CanAddTrackAsync(SharedPlaylistEntity playlist, Guid? currentUserId) + public async Task CanAddTrackAsync(SharedPlaylist playlist, Guid? currentUserId) { if (currentUserId == playlist.CreatorUserId) return true; return playlist.AddPermission == EditPermission.Everyone || (playlist.AddPermission == EditPermission.AuthorizedOnly && currentUserId.HasValue); } - public async Task CanRemoveTrackAsync(SharedPlaylistEntity playlist, Guid? currentUserId, string trackId) + public async Task CanRemoveTrackAsync(SharedPlaylist playlist, Guid? currentUserId, string trackId, string sessionId) { if (currentUserId == playlist.CreatorUserId) return true; return playlist.RemovePermission switch @@ -102,7 +102,9 @@ public class SharedPlaylistService EditPermission.Everyone => true, EditPermission.AuthorizedOnly => currentUserId.HasValue, EditPermission.AddedByUserOnly when currentUserId.HasValue => - await _trackLogService.IsTrackAddedByUserAsync(playlist.Id, trackId, currentUserId.Value), + await _trackLogService.IsTrackAddedByCurrentUserOrSessionAsync(playlist.Id, trackId, currentUserId, sessionId), + EditPermission.AddedByUserOnly when !currentUserId.HasValue => + await _trackLogService.IsTrackAddedByCurrentUserOrSessionAsync(playlist.Id, trackId, null, sessionId), _ => false }; } @@ -121,7 +123,7 @@ public class SharedPlaylistService .TrimEnd('='); } - public async Task> GetAllByUserAsync(Guid userId) + public async Task> GetAllByUserAsync(Guid userId) { return await _db.SharedPlaylists .Where(sp => sp.CreatorUserId == userId && !sp.IsDeleted) diff --git a/PlaylistShared.Api/Services/TrackAdditionLogService.cs b/PlaylistShared.Api/Services/TrackAdditionLogService.cs index 35172b1..c59624f 100644 --- a/PlaylistShared.Api/Services/TrackAdditionLogService.cs +++ b/PlaylistShared.Api/Services/TrackAdditionLogService.cs @@ -13,24 +13,26 @@ public class TrackAdditionLogService _db = db; } - public async Task LogAdditionAsync(Guid sharedPlaylistId, string trackId, Guid addedByUserId) + public async Task LogAdditionAsync(Guid sharedPlaylistId, string trackId, Guid? addedByUserId, string sessionId) { - var log = new TrackAdditionLogEntity + var log = new TrackAdditionLog { Id = Guid.NewGuid(), SharedPlaylistId = sharedPlaylistId, TrackId = trackId, AddedByUserId = addedByUserId, - AddedAtUtc = DateTime.UtcNow + AddedAtUtc = DateTime.UtcNow, + SessionId = sessionId }; _db.TrackAdditionLogs.Add(log); await _db.SaveChangesAsync(); } - public async Task IsTrackAddedByUserAsync(Guid sharedPlaylistId, string trackId, Guid userId) + public async Task IsTrackAddedByCurrentUserOrSessionAsync(Guid sharedPlaylistId, string trackId, Guid? userId, string sessionId) { return await _db.TrackAdditionLogs - .AnyAsync(l => l.SharedPlaylistId == sharedPlaylistId && l.TrackId == trackId && l.AddedByUserId == userId); + .AnyAsync(l => l.SharedPlaylistId == sharedPlaylistId && l.TrackId == trackId && + (userId != null ? l.AddedByUserId == userId : l.SessionId == sessionId)); } public async Task RemoveLogsForTrackAsync(Guid sharedPlaylistId, string trackId) diff --git a/PlaylistShared.Api/Services/TrackRemovalLogService.cs b/PlaylistShared.Api/Services/TrackRemovalLogService.cs new file mode 100644 index 0000000..4314df6 --- /dev/null +++ b/PlaylistShared.Api/Services/TrackRemovalLogService.cs @@ -0,0 +1,22 @@ +using PlaylistShared.Api.Data; + +public class TrackRemovalLogService +{ + private readonly ApplicationDbContext _db; + public TrackRemovalLogService(ApplicationDbContext db) => _db = db; + + public async Task LogRemovalAsync(Guid sharedPlaylistId, string trackId, Guid? removedByUserId, string sessionId) + { + var log = new TrackRemovalLog + { + Id = Guid.NewGuid(), + SharedPlaylistId = sharedPlaylistId, + TrackId = trackId, + RemovedByUserId = removedByUserId, + RemovedAtUtc = DateTime.UtcNow, + SessionId = sessionId + }; + _db.TrackRemovalLogs.Add(log); + await _db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Services/UserSessionService.cs b/PlaylistShared.Api/Services/UserSessionService.cs new file mode 100644 index 0000000..6be3049 --- /dev/null +++ b/PlaylistShared.Api/Services/UserSessionService.cs @@ -0,0 +1,47 @@ +using PlaylistShared.Api.Data; +using PlaylistShared.Api.Entities; + +public class UserSessionService +{ + private readonly ApplicationDbContext _db; + private readonly IHttpContextAccessor _httpContextAccessor; + + public UserSessionService(ApplicationDbContext db, IHttpContextAccessor httpContextAccessor) + { + _db = db; + _httpContextAccessor = httpContextAccessor; + } + + public async Task GetOrCreateCurrentSessionAsync(Guid? associatedUserId = null) + { + var httpContext = _httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("No HttpContext available"); + + var sessionId = httpContext.Session.Id; + var now = DateTime.UtcNow; + + var session = await _db.UserSessions.FindAsync(sessionId); + if (session == null) + { + session = new UserSession + { + SessionId = sessionId, + ClientIpAddress = httpContext.Connection.RemoteIpAddress?.ToString(), + UserAgent = httpContext.Request.Headers["User-Agent"].ToString(), + FirstSeenUtc = now, + LastSeenUtc = now, + AssociatedUserId = associatedUserId + }; + _db.UserSessions.Add(session); + } + else + { + session.LastSeenUtc = now; + if (session.AssociatedUserId == null && associatedUserId != null) + session.AssociatedUserId = associatedUserId; + _db.UserSessions.Update(session); + } + await _db.SaveChangesAsync(); + return session; + } +} \ No newline at end of file