diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/PlaylistShared.Api/Controllers/AccountController.cs b/PlaylistShared.Api/Controllers/AccountController.cs new file mode 100644 index 0000000..0a6be70 --- /dev/null +++ b/PlaylistShared.Api/Controllers/AccountController.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PlaylistShared.Api.Entities; +using PlaylistShared.Api.Services; +using PlaylistShared.Shared.DTO; + +[ApiController] +[Route("api/[controller]")] +public class AccountController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly JwtService _jwtService; + + public AccountController(UserManager userManager, SignInManager signInManager, JwtService jwtService) + { + _userManager = userManager; + _signInManager = signInManager; + _jwtService = jwtService; + } + + [HttpPost("register")] + public async Task>> Register(RegisterRequest request) + { + var user = new ApplicationUser + { + UserName = request.Username, + Email = request.Email + }; + var result = await _userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + return BadRequest(ApiResponse.Fail(new ErrorResponse + { + StatusCode = 400, + Message = string.Join(", ", result.Errors.Select(e => e.Description)) + })); + + return await GenerateTokenResponse(user); + } + + [HttpPost("login")] + public async Task>> Login(LoginRequest request) + { + var user = await _userManager.FindByNameAsync(request.Username); + if (user == null) + return Unauthorized(ApiResponse.Fail(new ErrorResponse { StatusCode = 401, Message = "Неверное имя пользователя или пароль" })); + + var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false); + if (!result.Succeeded) + return Unauthorized(ApiResponse.Fail(new ErrorResponse { StatusCode = 401, Message = "Неверное имя пользователя или пароль" })); + + return await GenerateTokenResponse(user); + } + + private async Task>> GenerateTokenResponse(ApplicationUser user) + { + var (token, refreshToken, expiration) = await _jwtService.GenerateTokenAsync(user); + return Ok(ApiResponse.Ok(new LoginResponse + { + Token = token, + RefreshToken = refreshToken, + Expiration = expiration + })); + } + + [HttpPost("refresh-token")] + public async Task>> RefreshToken([FromBody] RefreshTokenRequest request) + { + var user = _userManager.Users.FirstOrDefault(u => u.RefreshToken == request.RefreshToken && u.RefreshTokenExpiryUtc > DateTime.UtcNow); + if (user == null) + return Unauthorized(ApiResponse.Fail(new ErrorResponse { StatusCode = 401, Message = "Неверный или просроченный refresh token" })); + + return await GenerateTokenResponse(user); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Controllers/OpenIdController.cs b/PlaylistShared.Api/Controllers/OpenIdController.cs new file mode 100644 index 0000000..cfd7667 --- /dev/null +++ b/PlaylistShared.Api/Controllers/OpenIdController.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PlaylistShared.Api.Entities; +using PlaylistShared.Api.Services; +using System.Security.Claims; + +namespace PlaylistShared.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class OpenIdController : ControllerBase +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly JwtService _jwtService; + private readonly IConfiguration _configuration; + + public OpenIdController( + SignInManager signInManager, + UserManager userManager, + JwtService jwtService, + IConfiguration configuration) + { + _signInManager = signInManager; + _userManager = userManager; + _jwtService = jwtService; + _configuration = configuration; + } + + [HttpGet("login")] + public IActionResult Login(string? returnUrl = null) + { + var redirectUrl = Url.Action(nameof(Callback), "OpenId", new { returnUrl }); + var properties = _signInManager.ConfigureExternalAuthenticationProperties("Keycloak", redirectUrl); + return Challenge(properties, "Keycloak"); + } + + [HttpGet("callback")] + public async Task Callback(string? returnUrl = null, string? remoteError = null) + { + if (remoteError != null) + return BadRequest($"Ошибка внешнего входа: {remoteError}"); + + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info == null) + return BadRequest("Не удалось получить информацию от провайдера"); + + var email = info.Principal.FindFirst(ClaimTypes.Email)?.Value; + var userName = info.Principal.FindFirst(ClaimTypes.Name)?.Value ?? email; + + var user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); + if (user == null) + { + user = await _userManager.FindByEmailAsync(email); + if (user == null) + { + user = new ApplicationUser + { + UserName = userName, + Email = email + }; + var createResult = await _userManager.CreateAsync(user); + if (!createResult.Succeeded) + return BadRequest(createResult.Errors); + } + + var loginResult = await _userManager.AddLoginAsync(user, info); + if (!loginResult.Succeeded) + return BadRequest(loginResult.Errors); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + var (token, refreshToken, _) = await _jwtService.GenerateTokenAsync(user); + return Redirect($"{_configuration["Client:BaseUrl"]}/auth-callback?token={token}&refreshToken={refreshToken}"); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Controllers/PlaylistController.cs b/PlaylistShared.Api/Controllers/PlaylistController.cs new file mode 100644 index 0000000..beeab3a --- /dev/null +++ b/PlaylistShared.Api/Controllers/PlaylistController.cs @@ -0,0 +1,174 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PlaylistShared.Api.Entities; +using PlaylistShared.Api.Extensions; +using PlaylistShared.Api.Services; +using PlaylistShared.Shared.DTO; +using PlaylistShared.Shared.Enums; +using PlaylistShared.Shared.Models; +using YandexMusic; + +namespace PlaylistShared.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class PlaylistController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly SharedPlaylistService _sharedService; + private readonly YandexMusicService _yandexService; + private readonly TrackAdditionLogService _trackLogService; + + public PlaylistController( + UserManager userManager, + SharedPlaylistService sharedService, + YandexMusicService yandexService, + TrackAdditionLogService trackLogService) + { + _userManager = userManager; + _sharedService = sharedService; + _yandexService = yandexService; + _trackLogService = trackLogService; + } + + [HttpPost("add-tracks")] + public async Task>> AddTracks([FromBody] AddTrackRequest request) + { + var currentUserId = User.GetUserId(); + var playlist = await _sharedService.GetEntityByTokenAsync(request.SharedPlaylistToken); + if (playlist == null) + return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); + + if (!await _sharedService.CanAddTrackAsync(playlist, currentUserId)) + return StatusCode(403, ApiResponse.Fail(new ErrorResponse { StatusCode = 403, Message = "Недостаточно прав для добавления треков" })); + + var creator = await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString()); + if (creator == null) + return StatusCode(500, ApiResponse.Fail(new ErrorResponse { StatusCode = 500, Message = "Владелец плейлиста не найден" })); + + var updatedPlaylist = await _yandexService.AddTracksAsync(creator, playlist.YandexPlaylistOwnerUid, playlist.YandexPlaylistKind, request.TrackIds); + if (updatedPlaylist == null) + return StatusCode(500, ApiResponse.Fail(new ErrorResponse { StatusCode = 500, Message = "Ошибка при добавлении треков в Яндекс.Музыку" })); + + // Логируем добавления для права AddedByUserOnly + foreach (var trackId in request.TrackIds) + await _trackLogService.LogAdditionAsync(playlist.Id, trackId, currentUserId); + + return Ok(ApiResponse.Ok(new { message = "Треки успешно добавлены" })); + } + + [HttpPost("remove-tracks")] + public async Task>> RemoveTracks([FromBody] AddTrackRequest request) + { + var currentUserId = User.GetUserId(); + var playlist = await _sharedService.GetEntityByTokenAsync(request.SharedPlaylistToken); + if (playlist == null) + return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); + + // Проверяем права на удаление каждого трека + foreach (var trackId in request.TrackIds) + { + if (!await _sharedService.CanRemoveTrackAsync(playlist, currentUserId, trackId)) + return StatusCode(403, ApiResponse.Fail(new ErrorResponse { StatusCode = 403, Message = $"Недостаточно прав для удаления трека {trackId}" })); + } + + var creator = await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString()); + if (creator == null) + return StatusCode(500, ApiResponse.Fail(new ErrorResponse { StatusCode = 500, Message = "Владелец плейлиста не найден" })); + + var updatedPlaylist = await _yandexService.RemoveTracksAsync(creator, playlist.YandexPlaylistOwnerUid, playlist.YandexPlaylistKind, request.TrackIds); + if (updatedPlaylist == null) + return StatusCode(500, ApiResponse.Fail(new ErrorResponse { StatusCode = 500, Message = "Ошибка при удалении треков из Яндекс.Музыки" })); + + // Удаляем логи добавления для этих треков + foreach (var trackId in request.TrackIds) + await _trackLogService.RemoveLogsForTrackAsync(playlist.Id, trackId); + + return Ok(ApiResponse.Ok(new { message = "Треки успешно удалены" })); + } + + [HttpGet("info/{ownerUid}/{kind}")] + public async Task>> GetPlaylistInfo(string ownerUid, string kind) + { + var currentUserId = User.GetUserId(); + // Найти шеринг-плейлист по данным Яндекс + var shared = await _sharedService.GetEntityByTokenAsync(null); // не можем по токену, надо по параметрам + // Для простоты сделаем отдельный метод поиска по kind/ownerUid + var playlistEntity = await _sharedService.GetByYandexIdsAsync(ownerUid, kind); + if (playlistEntity == null) + return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); + + if (!await _sharedService.CanViewAsync(playlistEntity, currentUserId)) + return Unauthorized(); + + var creator = await _userManager.FindByIdAsync(playlistEntity.CreatorUserId.ToString()); + var yandexPlaylist = await _yandexService.GetPlaylistAsync(creator, ownerUid, kind); + return Ok(ApiResponse.Ok(yandexPlaylist)); + } + + [HttpGet("my")] + public async Task>>> GetMyPlaylists() + { + var userId = User.GetUserId(); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) return Unauthorized(); + + var decryptedToken = _yandexService.DecryptToken(user.YandexAccessToken); + if (string.IsNullOrEmpty(decryptedToken)) + return BadRequest(ApiResponse.Fail(new ErrorResponse { StatusCode = 400, Message = "Токен Яндекс.Музыки не установлен или недействителен" })); + + var yandexClient = new YandexMusicClient(); + var authSuccess = await yandexClient.Authorize(decryptedToken); + if (!authSuccess) + return BadRequest(ApiResponse.Fail(new ErrorResponse { StatusCode = 400, Message = "Не удалось авторизоваться в Яндекс.Музыке. Проверьте токен." })); + + var favorites = await yandexClient.GetFavoritesAsync(); + var ownPlaylists = favorites.Where(p => p.Owner.Uid == yandexClient.Account.Uid).ToList(); + + var sharedPlaylists = await _sharedService.GetAllByUserAsync(userId); + + var result = ownPlaylists.Select(p => new YandexPlaylistInfo + { + Kind = p.Kind, + OwnerUid = p.Owner.Uid, + Title = p.Title, + CoverUrl = p.Cover?.GetUrl() ?? "", + TrackCount = p.TrackCount, + IsShared = sharedPlaylists.Any(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid), + ShareToken = sharedPlaylists.FirstOrDefault(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid)?.ShareToken, + }).ToList(); + + return Ok(ApiResponse>.Ok(result)); + } + + [HttpPost("share")] + public async Task>> SharePlaylist([FromBody] SharePlaylistRequest request) + { + var userId = User.GetUserId(); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) return Unauthorized(); + + // Проверяем, что плейлист действительно принадлежит пользователю + var yandexClient = new YandexMusicClient(); + await yandexClient.Authorize(_yandexService.DecryptToken(user.YandexAccessToken)); + var playlist = await yandexClient.GetPlaylistAsync(request.OwnerUid, request.Kind); + if (playlist == null || playlist.Owner.Uid != yandexClient.Account.Uid) + return BadRequest(ApiResponse.Fail(new ErrorResponse { StatusCode = 400, Message = "Плейлист не принадлежит вам" })); + + var dto = new SharePlaylistDto + { + YandexPlaylistKind = request.Kind, + YandexPlaylistOwnerUid = request.OwnerUid, + Title = playlist.Title, + Description = playlist.Description, + ViewPermission = ViewPermission.Everyone, + AddPermission = EditPermission.AuthorizedOnly, + RemovePermission = EditPermission.AddedByUserOnly + }; + + var result = await _sharedService.CreateAsync(userId, dto); + return Ok(ApiResponse.Ok(result)); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Controllers/SharedPlaylistController.cs b/PlaylistShared.Api/Controllers/SharedPlaylistController.cs new file mode 100644 index 0000000..ba56930 --- /dev/null +++ b/PlaylistShared.Api/Controllers/SharedPlaylistController.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PlaylistShared.Api.Extensions; +using PlaylistShared.Api.Services; +using PlaylistShared.Shared.DTO; +using PlaylistShared.Shared.Models; + +namespace PlaylistShared.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class SharedPlaylistController : ControllerBase +{ + private readonly SharedPlaylistService _sharedService; + private readonly YandexMusicService _yandexService; + + public SharedPlaylistController(SharedPlaylistService sharedService, YandexMusicService yandexService) + { + _sharedService = sharedService; + _yandexService = yandexService; + } + + [HttpPost] + [Authorize] + public async Task>> Create([FromBody] SharePlaylistDto dto) + { + var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var guid)) + return Unauthorized(); + + var result = await _sharedService.CreateAsync(guid, dto); + return Ok(ApiResponse.Ok(result)); + } + + [HttpGet("{token}")] + public async Task>> GetByToken(string token) + { + var playlist = await _sharedService.GetByTokenAsync(token); + if (playlist == null) + return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); + + var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + var userIdGuid = !string.IsNullOrEmpty(currentUserId) ? Guid.Parse(currentUserId) : (Guid?)null; + + // Проверка прав просмотра (требует доступа к сущности) + var entity = await _sharedService.GetEntityByTokenAsync(token); + if (entity == null || !await _sharedService.CanViewAsync(entity, userIdGuid)) + return Unauthorized(ApiResponse.Fail(new ErrorResponse { StatusCode = 401, Message = "Недостаточно прав" })); + + return Ok(ApiResponse.Ok(playlist)); + } + + [HttpPut("{token}/permissions")] + [Authorize] + public async Task>> UpdatePermissions(string token, [FromBody] UpdatePermissionsDto dto) + { + var userId = User.GetUserId(); + var playlist = await _sharedService.GetEntityByTokenAsync(token); + if (playlist == null) + return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); + + if (playlist.CreatorUserId != userId) + return Forbid(); + + var updated = await _sharedService.UpdatePermissionsAsync(playlist.Id, dto); + if (updated == null) + return BadRequest(ApiResponse.Fail(new ErrorResponse { StatusCode = 400, Message = "Ошибка обновления прав" })); + + return Ok(ApiResponse.Ok(updated)); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Controllers/YandexTokenController.cs b/PlaylistShared.Api/Controllers/YandexTokenController.cs new file mode 100644 index 0000000..25b3079 --- /dev/null +++ b/PlaylistShared.Api/Controllers/YandexTokenController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using PlaylistShared.Api.Entities; +using PlaylistShared.Api.Extensions; +using PlaylistShared.Api.Services; +using PlaylistShared.Shared.DTO; + +namespace PlaylistShared.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class YandexTokenController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly YandexMusicService _yandexService; + + public YandexTokenController(UserManager userManager, YandexMusicService yandexService) + { + _userManager = userManager; + _yandexService = yandexService; + } + + [HttpPost("set")] + public async Task>> SetToken([FromBody] SetYandexTokenRequest request) + { + var userId = User.GetUserId(); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) return Unauthorized(); + + user.YandexAccessToken = _yandexService.EncryptToken(request.Token); + // Не храним refresh-токен, так как пользователь вводит только access-токен + user.YandexTokenExpiryUtc = DateTime.UtcNow.AddMonths(1); // условно, т.к. срок жизни токена неизвестен + await _userManager.UpdateAsync(user); + + return Ok(ApiResponse.Ok(new { message = "Токен сохранён" })); + } + + [HttpGet("status")] + public async Task>> GetStatus() + { + var userId = User.GetUserId(); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) return Unauthorized(); + + var hasToken = !string.IsNullOrEmpty(user.YandexAccessToken); + var isValid = hasToken && user.YandexTokenExpiryUtc > DateTime.UtcNow; + + return Ok(ApiResponse.Ok(new YandexTokenStatus + { + HasToken = hasToken, + IsValid = isValid, + ExpiryUtc = user.YandexTokenExpiryUtc + })); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Data/ApplicationDbContext.cs b/PlaylistShared.Api/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..ac45e2e --- /dev/null +++ b/PlaylistShared.Api/Data/ApplicationDbContext.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using PlaylistShared.Api.Entities; + +namespace PlaylistShared.Api.Data; + +public class ApplicationDbContext : IdentityDbContext, Guid> +{ + public ApplicationDbContext(DbContextOptions options) : base(options) { } + + public DbSet SharedPlaylists => Set(); + public DbSet TrackAdditionLogs => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.ShareToken).IsUnique(); + entity.HasOne(e => e.Creator) + .WithMany(u => u.OwnedPlaylists) + .HasForeignKey(e => e.CreatorUserId) + .OnDelete(DeleteBehavior.Restrict); + entity.Property(e => e.YandexPlaylistKind).IsRequired().HasMaxLength(50); + entity.Property(e => e.YandexPlaylistOwnerUid).IsRequired().HasMaxLength(50); + entity.Property(e => e.Title).IsRequired().HasMaxLength(255); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.SharedPlaylistId, e.TrackId }); + entity.HasOne(e => e.SharedPlaylist) + .WithMany(sp => sp.TrackAdditionLogs) + .HasForeignKey(e => e.SharedPlaylistId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.AddedByUser) + .WithMany() + .HasForeignKey(e => e.AddedByUserId) + .OnDelete(DeleteBehavior.Restrict); + }); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Data/Migrations/20260412171234_InitialCreate.Designer.cs b/PlaylistShared.Api/Data/Migrations/20260412171234_InitialCreate.Designer.cs new file mode 100644 index 0000000..039f98b --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/20260412171234_InitialCreate.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("20260412171234_InitialCreate")] + partial class InitialCreate + { + /// + 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/20260412171234_InitialCreate.cs b/PlaylistShared.Api/Data/Migrations/20260412171234_InitialCreate.cs new file mode 100644 index 0000000..5e1ccd2 --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/20260412171234_InitialCreate.cs @@ -0,0 +1,314 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PlaylistShared.Api.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + YandexId = table.Column(type: "nvarchar(max)", nullable: true), + YandexAccessToken = table.Column(type: "nvarchar(max)", nullable: true), + YandexRefreshToken = table.Column(type: "nvarchar(max)", nullable: true), + YandexTokenExpiryUtc = table.Column(type: "datetime2", nullable: false), + RefreshToken = table.Column(type: "nvarchar(max)", nullable: true), + RefreshTokenExpiryUtc = table.Column(type: "datetime2", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "uniqueidentifier", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "uniqueidentifier", nullable: false), + RoleId = table.Column(type: "uniqueidentifier", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "uniqueidentifier", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SharedPlaylists", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CreatorUserId = table.Column(type: "uniqueidentifier", nullable: false), + YandexPlaylistKind = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + YandexPlaylistOwnerUid = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Title = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: true), + CoverUrl = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: false), + ShareToken = table.Column(type: "nvarchar(450)", nullable: false), + ViewPermission = table.Column(type: "int", nullable: false), + AddPermission = table.Column(type: "int", nullable: false), + RemovePermission = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SharedPlaylists", x => x.Id); + table.ForeignKey( + name: "FK_SharedPlaylists_AspNetUsers_CreatorUserId", + column: x => x.CreatorUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TrackAdditionLogs", + 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), + AddedByUserId = table.Column(type: "uniqueidentifier", nullable: false), + AddedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrackAdditionLogs", x => x.Id); + table.ForeignKey( + name: "FK_TrackAdditionLogs_AspNetUsers_AddedByUserId", + column: x => x.AddedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TrackAdditionLogs_SharedPlaylists_SharedPlaylistId", + column: x => x.SharedPlaylistId, + principalTable: "SharedPlaylists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SharedPlaylists_CreatorUserId", + table: "SharedPlaylists", + column: "CreatorUserId"); + + migrationBuilder.CreateIndex( + name: "IX_SharedPlaylists_ShareToken", + table: "SharedPlaylists", + column: "ShareToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TrackAdditionLogs_AddedByUserId", + table: "TrackAdditionLogs", + column: "AddedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_TrackAdditionLogs_SharedPlaylistId_TrackId", + table: "TrackAdditionLogs", + columns: new[] { "SharedPlaylistId", "TrackId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "TrackAdditionLogs"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "SharedPlaylists"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..e2599e0 --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,423 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PlaylistShared.Api.Data; + +#nullable disable + +namespace PlaylistShared.Api.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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/Dockerfile b/PlaylistShared.Api/Dockerfile new file mode 100644 index 0000000..9604a5f --- /dev/null +++ b/PlaylistShared.Api/Dockerfile @@ -0,0 +1,31 @@ +# См. статью по ссылке https://aka.ms/customizecontainer, чтобы узнать как настроить контейнер отладки и как Visual Studio использует этот Dockerfile для создания образов для ускорения отладки. + +# Этот этап используется при запуске из VS в быстром режиме (по умолчанию для конфигурации отладки) +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + + +# Этот этап используется для сборки проекта службы +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["nuget.config", "."] +COPY ["PlaylistShared.Api/PlaylistShared.Api.csproj", "PlaylistShared.Api/"] +RUN dotnet restore "./PlaylistShared.Api/PlaylistShared.Api.csproj" +COPY . . +WORKDIR "/src/PlaylistShared.Api" +RUN dotnet build "./PlaylistShared.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# Этот этап используется для публикации проекта службы, который будет скопирован на последний этап +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./PlaylistShared.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Этот этап используется в рабочей среде или при запуске из VS в обычном режиме (по умолчанию, когда конфигурация отладки не используется) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "PlaylistShared.Api.dll"] \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/ApplicationUser.cs b/PlaylistShared.Api/Entities/ApplicationUser.cs new file mode 100644 index 0000000..9ef4a81 --- /dev/null +++ b/PlaylistShared.Api/Entities/ApplicationUser.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Identity; + +namespace PlaylistShared.Api.Entities; + +/// Пользователь приложения (расширяет IdentityUser). +public class ApplicationUser : IdentityUser +{ + /// Идентификатор пользователя в Яндексе (если привязан). + public string? YandexId { get; set; } + + /// Access токен Яндекс.Музыки (зашифрованный). + public string? YandexAccessToken { get; set; } + + /// Refresh токен Яндекс.Музыки (зашифрованный). + public string? YandexRefreshToken { get; set; } + + /// Время истечения access токена Яндекса. + public DateTime YandexTokenExpiryUtc { get; set; } + + /// Refresh токен для JWT (хранится в БД). + public string? RefreshToken { get; set; } + + /// Время истечения refresh токена JWT. + public DateTime RefreshTokenExpiryUtc { get; set; } + + /// Плейлисты, созданные пользователем. + 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/SharedPlaylistEntity.cs new file mode 100644 index 0000000..b7c70c7 --- /dev/null +++ b/PlaylistShared.Api/Entities/SharedPlaylistEntity.cs @@ -0,0 +1,26 @@ +using PlaylistShared.Shared.Enums; + +namespace PlaylistShared.Api.Entities; + +/// Сущность шеринг-плейлиста (таблица в БД). +public class SharedPlaylistEntity +{ + public Guid Id { get; set; } + public Guid CreatorUserId { get; set; } + public string YandexPlaylistKind { get; set; } = null!; + public string YandexPlaylistOwnerUid { get; set; } = null!; + public string Title { get; set; } = null!; + public string? Description { get; set; } + public string? CoverUrl { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public bool IsDeleted { get; set; } + public string ShareToken { get; set; } = null!; + public ViewPermission ViewPermission { get; set; } + public EditPermission AddPermission { get; set; } + public EditPermission RemovePermission { get; set; } + + // Навигационные свойства + public ApplicationUser Creator { get; set; } = null!; + public ICollection TrackAdditionLogs { get; set; } = new List(); +} \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/TrackAdditionLogEntity.cs b/PlaylistShared.Api/Entities/TrackAdditionLogEntity.cs new file mode 100644 index 0000000..71c8ba1 --- /dev/null +++ b/PlaylistShared.Api/Entities/TrackAdditionLogEntity.cs @@ -0,0 +1,15 @@ +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/Extensions/ClaimsPrincipalExtensions.cs b/PlaylistShared.Api/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..66aa0fa --- /dev/null +++ b/PlaylistShared.Api/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; + +namespace PlaylistShared.Api.Extensions; + +public static class ClaimsPrincipalExtensions +{ + public static Guid GetUserId(this ClaimsPrincipal user) + { + var id = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(id)) + throw new UnauthorizedAccessException("User ID not found"); + return Guid.Parse(id); + } + + public static Guid? GetUserIdOrNull(this ClaimsPrincipal user) + { + var id = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return string.IsNullOrEmpty(id) ? null : Guid.Parse(id); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Extensions/YCoverExtensions.cs b/PlaylistShared.Api/Extensions/YCoverExtensions.cs new file mode 100644 index 0000000..c4f350f --- /dev/null +++ b/PlaylistShared.Api/Extensions/YCoverExtensions.cs @@ -0,0 +1,19 @@ +using YandexMusic.API.Models.Common.Cover; + +namespace PlaylistShared.Api.Extensions; + +public static class YCoverExtensions +{ + public static string GetUrl(this YCover cover, string size = "200x200") + { + switch (cover) + { + case YCoverImage img when !string.IsNullOrEmpty(img.Uri): + return $"https://{img.Uri.Replace("%%", size)}"; + case YCoverPic pic when !string.IsNullOrEmpty(pic.Uri): + return $"https://{pic.Uri.Replace("%%", size)}"; + default: + return string.Empty; + } + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Mapping/AppMappingProfile.cs b/PlaylistShared.Api/Mapping/AppMappingProfile.cs new file mode 100644 index 0000000..7b712c6 --- /dev/null +++ b/PlaylistShared.Api/Mapping/AppMappingProfile.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using PlaylistShared.Api.Entities; +using PlaylistShared.Shared.Models; + +namespace PlaylistShared.Api.Mapping; + +public class AppMappingProfile : Profile +{ + public AppMappingProfile() + { + CreateMap() + .ForMember(dest => dest.Creator, opt => opt.MapFrom(src => src.Creator)); + CreateMap(); + CreateMap(); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/PlaylistShared.Api.csproj b/PlaylistShared.Api/PlaylistShared.Api.csproj new file mode 100644 index 0000000..7a5084b --- /dev/null +++ b/PlaylistShared.Api/PlaylistShared.Api.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + a29c84f3-dccf-4a45-b139-f8effd676cd0 + Linux + ..\docker-compose.dcproj + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/PlaylistShared.Api/Program.cs b/PlaylistShared.Api/Program.cs new file mode 100644 index 0000000..11f5909 --- /dev/null +++ b/PlaylistShared.Api/Program.cs @@ -0,0 +1,133 @@ + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using PlaylistShared.Api.Data; +using PlaylistShared.Api.Entities; +using PlaylistShared.Api.Mapping; +using PlaylistShared.Api.Services; +using System.IdentityModel.Tokens.Jwt; +using System.Text; + +namespace PlaylistShared.Api; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear(); + + // DbContext + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));// Identity + builder.Services.AddIdentity>(options => + { + options.User.RequireUniqueEmail = true; + options.Password.RequireDigit = false; + options.Password.RequiredLength = 6; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + // JWT + var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; + }) + .AddOpenIdConnect("Keycloak", options => + { + options.Authority = builder.Configuration["Keycloak:Authority"]; + options.ClientId = builder.Configuration["Keycloak:ClientId"]; + options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"]; + options.ResponseType = "code"; + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + options.CallbackPath = "/api/auth/keycloak-callback"; + options.SignInScheme = IdentityConstants.ExternalScheme; + }); + + builder.Services.AddAuthorization(); + builder.Services.AddAutoMapper(t => t.AddProfile()); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddDataProtection(); + + builder.Services.AddHttpClient(); + + builder.Services.AddCors(options => + { + options.AddPolicy("Development", policy => + { + policy.WithOrigins("http://localhost:5053", "https://localhost:7225", "http://localhost:5181", "https://api.playlistshare.frigat.duckdns.org", "https://playlistshare.frigat.duckdns.org") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); + + options.AddPolicy("Production", policy => + { + policy.WithOrigins("https://api.playlistshare.frigat.duckdns.org", "https://playlistshare.frigat.duckdns.org") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); + }); + + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + + builder.Services.AddOpenApi(); + + var app = builder.Build(); + + app.MapOpenApi(); + + app.UseSwagger(); + app.UseSwaggerUI(); + + if (app.Environment.IsDevelopment()) + { + app.UseCors("Development"); + } + else + { + + app.UseHttpsRedirection(); + app.UseCors("Production"); + } + + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapControllers(); + + app.Run(); + } +} diff --git a/PlaylistShared.Api/Properties/launchSettings.json b/PlaylistShared.Api/Properties/launchSettings.json new file mode 100644 index 0000000..0f6e4a0 --- /dev/null +++ b/PlaylistShared.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5053" + }, + "https": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7270;http://localhost:5053" + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/PlaylistShared.Api/Services/JwtService.cs b/PlaylistShared.Api/Services/JwtService.cs new file mode 100644 index 0000000..76e243f --- /dev/null +++ b/PlaylistShared.Api/Services/JwtService.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using PlaylistShared.Api.Entities; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace PlaylistShared.Api.Services; + +public class JwtService +{ + private readonly IConfiguration _configuration; + private readonly UserManager _userManager; + + public JwtService(IConfiguration configuration, UserManager userManager) + { + _configuration = configuration; + _userManager = userManager; + } + + public async Task<(string Token, string RefreshToken, DateTime Expiration)> GenerateTokenAsync(ApplicationUser user) + { + var tokenHandler = new JwtSecurityTokenHandler(); + + var key = Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]!); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.UserName!), + new Claim(ClaimTypes.Email, user.Email!), + }), + Expires = DateTime.UtcNow.AddHours(1), + Issuer = _configuration["Jwt:Issuer"], + Audience = _configuration["Jwt:Audience"], + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenString = tokenHandler.WriteToken(token); + + var refreshToken = Guid.NewGuid().ToString(); + user.RefreshToken = refreshToken; + user.RefreshTokenExpiryUtc = DateTime.UtcNow.AddDays(7); + await _userManager.UpdateAsync(user); + + return (tokenString, refreshToken, tokenDescriptor.Expires.Value); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Services/SharedPlaylistService.cs b/PlaylistShared.Api/Services/SharedPlaylistService.cs new file mode 100644 index 0000000..64663bd --- /dev/null +++ b/PlaylistShared.Api/Services/SharedPlaylistService.cs @@ -0,0 +1,136 @@ +using AutoMapper; +using Microsoft.EntityFrameworkCore; +using PlaylistShared.Api.Data; +using PlaylistShared.Api.Entities; +using PlaylistShared.Shared.DTO; +using PlaylistShared.Shared.Enums; +using PlaylistShared.Shared.Models; + +namespace PlaylistShared.Api.Services; + +public class SharedPlaylistService +{ + private readonly ApplicationDbContext _db; + private readonly IMapper _mapper; + private readonly TrackAdditionLogService _trackLogService; + + public SharedPlaylistService(ApplicationDbContext db, IMapper mapper, TrackAdditionLogService trackLogService) + { + _db = db; + _mapper = mapper; + _trackLogService = trackLogService; + } + + public async Task CreateAsync(Guid creatorUserId, SharePlaylistDto dto) + { + var entity = new SharedPlaylistEntity + { + Id = Guid.NewGuid(), + CreatorUserId = creatorUserId, + YandexPlaylistKind = dto.YandexPlaylistKind, + YandexPlaylistOwnerUid = dto.YandexPlaylistOwnerUid, + Title = dto.Title, + Description = dto.Description, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + ShareToken = GenerateToken(), + ViewPermission = dto.ViewPermission, + AddPermission = dto.AddPermission, + RemovePermission = dto.RemovePermission + }; + _db.SharedPlaylists.Add(entity); + await _db.SaveChangesAsync(); + return _mapper.Map(entity); + } + + public async Task GetByTokenAsync(string token) + { + var entity = await _db.SharedPlaylists + .Include(sp => sp.Creator) + .FirstOrDefaultAsync(sp => sp.ShareToken == token && !sp.IsDeleted); + return entity == null ? null : _mapper.Map(entity); + } + + public async Task GetEntityByTokenAsync(string token) + { + return await _db.SharedPlaylists + .Include(sp => sp.Creator) + .FirstOrDefaultAsync(sp => sp.ShareToken == token && !sp.IsDeleted); + } + + public async Task UpdatePermissionsAsync(Guid playlistId, UpdatePermissionsDto dto) + { + var entity = await _db.SharedPlaylists.FindAsync(playlistId); + if (entity == null) return null; + entity.ViewPermission = dto.ViewPermission; + entity.AddPermission = dto.AddPermission; + entity.RemovePermission = dto.RemovePermission; + entity.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + return _mapper.Map(entity); + } + + public async Task DeleteAsync(Guid playlistId) + { + var entity = await _db.SharedPlaylists.FindAsync(playlistId); + if (entity == null) return false; + entity.IsDeleted = true; + entity.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + return true; + } + + public async Task CanViewAsync(SharedPlaylistEntity 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) + { + 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) + { + if (currentUserId == playlist.CreatorUserId) return true; + return playlist.RemovePermission switch + { + EditPermission.Everyone => true, + EditPermission.AuthorizedOnly => currentUserId.HasValue, + EditPermission.AddedByUserOnly when currentUserId.HasValue => + await _trackLogService.IsTrackAddedByUserAsync(playlist.Id, trackId, currentUserId.Value), + _ => false + }; + } + + public async Task IsCreatorAsync(Guid playlistId, Guid userId) + { + var playlist = await _db.SharedPlaylists.FindAsync(playlistId); + return playlist != null && playlist.CreatorUserId == userId; + } + + private string GenerateToken() + { + return Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + .Replace("/", "_") + .Replace("+", "-") + .TrimEnd('='); + } + + public async Task GetByYandexIdsAsync(string ownerUid, string kind) + { + return await _db.SharedPlaylists + .FirstOrDefaultAsync(sp => sp.YandexPlaylistOwnerUid == ownerUid && sp.YandexPlaylistKind == kind && !sp.IsDeleted); + } + + public async Task> GetAllByUserAsync(Guid userId) + { + return await _db.SharedPlaylists + .Where(sp => sp.CreatorUserId == userId && !sp.IsDeleted) + .ToListAsync(); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Services/TrackAdditionLogService.cs b/PlaylistShared.Api/Services/TrackAdditionLogService.cs new file mode 100644 index 0000000..35172b1 --- /dev/null +++ b/PlaylistShared.Api/Services/TrackAdditionLogService.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using PlaylistShared.Api.Data; +using PlaylistShared.Api.Entities; + +namespace PlaylistShared.Api.Services; + +public class TrackAdditionLogService +{ + private readonly ApplicationDbContext _db; + + public TrackAdditionLogService(ApplicationDbContext db) + { + _db = db; + } + + public async Task LogAdditionAsync(Guid sharedPlaylistId, string trackId, Guid addedByUserId) + { + var log = new TrackAdditionLogEntity + { + Id = Guid.NewGuid(), + SharedPlaylistId = sharedPlaylistId, + TrackId = trackId, + AddedByUserId = addedByUserId, + AddedAtUtc = DateTime.UtcNow + }; + _db.TrackAdditionLogs.Add(log); + await _db.SaveChangesAsync(); + } + + public async Task IsTrackAddedByUserAsync(Guid sharedPlaylistId, string trackId, Guid userId) + { + return await _db.TrackAdditionLogs + .AnyAsync(l => l.SharedPlaylistId == sharedPlaylistId && l.TrackId == trackId && l.AddedByUserId == userId); + } + + public async Task RemoveLogsForTrackAsync(Guid sharedPlaylistId, string trackId) + { + var logs = await _db.TrackAdditionLogs + .Where(l => l.SharedPlaylistId == sharedPlaylistId && l.TrackId == trackId) + .ToListAsync(); + _db.TrackAdditionLogs.RemoveRange(logs); + await _db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Services/YandexMusicService.cs b/PlaylistShared.Api/Services/YandexMusicService.cs new file mode 100644 index 0000000..2d95079 --- /dev/null +++ b/PlaylistShared.Api/Services/YandexMusicService.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.DataProtection; +using PlaylistShared.Api.Entities; +using YandexMusic; +using YandexMusic.API.Extensions.API; +using YandexMusic.API.Models.Playlist; + +namespace PlaylistShared.Api.Services; + +public class YandexMusicService +{ + private readonly IDataProtector _dataProtector; + + public YandexMusicService(IDataProtectionProvider provider) + { + _dataProtector = provider.CreateProtector("YandexTokens"); + } + + private async Task CreateClientAsync(ApplicationUser user) + { + if (string.IsNullOrEmpty(user.YandexAccessToken)) + return null; + + string decryptedToken; + try + { + decryptedToken = _dataProtector.Unprotect(user.YandexAccessToken); + } + catch + { + return null; + } + + var client = new YandexMusicClient(); + var success = await client.Authorize(decryptedToken); + return success ? client : null; + } + + public async Task GetPlaylistAsync(ApplicationUser user, string ownerUid, string kind) + { + var client = await CreateClientAsync(user); + if (client == null) return null; + return await client.GetPlaylistAsync(ownerUid, kind); + } + + public async Task CreatePlaylistAsync(ApplicationUser user, string title) + { + var client = await CreateClientAsync(user); + if (client == null) return null; + return await client.CreatePlaylistAsync(title); + } + + public async Task AddTracksAsync(ApplicationUser user, string ownerUid, string kind, IEnumerable trackIds) + { + var client = await CreateClientAsync(user); + if (client == null) return null; + // Получаем треки по ID + var tracks = await client.GetTracksAsync(trackIds); + if (tracks == null || !tracks.Any()) return null; + var playlist = await client.GetPlaylistAsync(ownerUid, kind); + if (playlist == null) return null; + return await playlist.InsertTracksAsync(tracks.ToArray()); + } + + public async Task RemoveTracksAsync(ApplicationUser user, string ownerUid, string kind, IEnumerable trackIds) + { + var client = await CreateClientAsync(user); + if (client == null) return null; + var tracks = await client.GetTracksAsync(trackIds); + if (tracks == null || !tracks.Any()) return null; + var playlist = await client.GetPlaylistAsync(ownerUid, kind); + if (playlist == null) return null; + return await playlist.RemoveTracksAsync(tracks.ToArray()); + } + + public string EncryptToken(string token) => _dataProtector.Protect(token); + + public string DecryptToken(string encryptedToken) + { + try + { + return _dataProtector.Unprotect(encryptedToken); + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/appsettings.Development.json b/PlaylistShared.Api/appsettings.Development.json new file mode 100644 index 0000000..2de20cd --- /dev/null +++ b/PlaylistShared.Api/appsettings.Development.json @@ -0,0 +1,21 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=FRIGAT-PC;Database=PlaylistShared;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + }, + "Jwt": { + "Key": "your-32-character-secret-key-for-jwt-minimum-length", + "Issuer": "PlaylistShared.Api", + "Audience": "PlaylistShared.Client" + }, + "Yandex": { + "ClientId": "0916685f8a3641ca8fc382dbccf77236", + "ClientSecret": "f7398893cd814f8b84b85aeb2a0a6698" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/PlaylistShared.Api/appsettings.json b/PlaylistShared.Api/appsettings.json new file mode 100644 index 0000000..8649f55 --- /dev/null +++ b/PlaylistShared.Api/appsettings.json @@ -0,0 +1,29 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=FRIGAT-PC;Database=PlaylistShared;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + }, + "Jwt": { + "Key": "your-32-character-secret-key-for-jwt-minimum-length", + "Issuer": "PlaylistShared.Api", + "Audience": "PlaylistShared.Client" + }, + "Yandex": { + "ClientId": "your-yandex-oauth-client-id", + "ClientSecret": "your-yandex-oauth-client-secret" + }, + "Client": { + "BaseUrl": "https://localhost:5002" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Keycloak": { + "Authority": "https://your-keycloak-domain/auth/realms/your-realm", + "ClientId": "playlist-shared-client", + "ClientSecret": "your-secret" + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/PlaylistShared.PWA2123/App.razor b/PlaylistShared.PWA2123/App.razor new file mode 100644 index 0000000..34eb91e --- /dev/null +++ b/PlaylistShared.PWA2123/App.razor @@ -0,0 +1,19 @@ + + + + + + @if (context.User.Identity?.IsAuthenticated != true) + { + + } + else + { +

You are not authorized to access this resource.

+ } +
+
+ +
+
+
diff --git a/PlaylistShared.PWA2123/Layout/LoginDisplay.razor b/PlaylistShared.PWA2123/Layout/LoginDisplay.razor new file mode 100644 index 0000000..21b0d19 --- /dev/null +++ b/PlaylistShared.PWA2123/Layout/LoginDisplay.razor @@ -0,0 +1,19 @@ +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@inject NavigationManager Navigation + + + + Hello, @context.User.Identity?.Name! + Log out + + + Log in + + + +@code{ + public void BeginLogOut() + { + Navigation.NavigateToLogout("authentication/logout"); + } +} diff --git a/PlaylistShared.PWA2123/Layout/MainLayout.razor b/PlaylistShared.PWA2123/Layout/MainLayout.razor new file mode 100644 index 0000000..3cd4ef5 --- /dev/null +++ b/PlaylistShared.PWA2123/Layout/MainLayout.razor @@ -0,0 +1,100 @@ +@inherits LayoutComponentBase + + + + + + + + + + Application + + + + + About + + + + + + + + + @Body + + + +@code { + private bool _drawerOpen = true; + private bool _isDarkMode = true; + private MudTheme? _theme; + + protected override void OnInitialized() + { + base.OnInitialized(); + + _theme = new() + { + PaletteLight = _lightPalette, + PaletteDark = _darkPalette, + LayoutProperties = new LayoutProperties() + }; + } + + private void DrawerToggle() + { + _drawerOpen = !_drawerOpen; + } + + private void DarkModeToggle() + { + _isDarkMode = !_isDarkMode; + } + + private readonly PaletteLight _lightPalette = new() + { + Black = "#110e2d", + AppbarText = "#424242", + AppbarBackground = "rgba(255,255,255,0.8)", + DrawerBackground = "#ffffff", + GrayLight = "#e8e8e8", + GrayLighter = "#f9f9f9", + }; + + private readonly PaletteDark _darkPalette = new() + { + Primary = "#7e6fff", + Surface = "#1e1e2d", + Background = "#1a1a27", + BackgroundGray = "#151521", + AppbarText = "#92929f", + AppbarBackground = "rgba(26,26,39,0.8)", + DrawerBackground = "#1a1a27", + ActionDefault = "#74718e", + ActionDisabled = "#9999994d", + ActionDisabledBackground = "#605f6d4d", + TextPrimary = "#b2b0bf", + TextSecondary = "#92929f", + TextDisabled = "#ffffff33", + DrawerIcon = "#92929f", + DrawerText = "#92929f", + GrayLight = "#2a2833", + GrayLighter = "#1e1e2d", + Info = "#4a86ff", + Success = "#3dcb6c", + Warning = "#ffb545", + Error = "#ff3f5f", + LinesDefault = "#33323e", + TableLines = "#33323e", + Divider = "#292838", + OverlayLight = "#1e1e2d80", + }; + + public string DarkLightModeButtonIcon => _isDarkMode switch + { + true => Icons.Material.Rounded.AutoMode, + false => Icons.Material.Outlined.DarkMode, + }; +} diff --git a/PlaylistShared.PWA2123/Layout/MainLayout.razor.css b/PlaylistShared.PWA2123/Layout/MainLayout.razor.css new file mode 100644 index 0000000..ecf25e5 --- /dev/null +++ b/PlaylistShared.PWA2123/Layout/MainLayout.razor.css @@ -0,0 +1,77 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} diff --git a/PlaylistShared.PWA2123/Layout/NavMenu.razor b/PlaylistShared.PWA2123/Layout/NavMenu.razor new file mode 100644 index 0000000..fba6968 --- /dev/null +++ b/PlaylistShared.PWA2123/Layout/NavMenu.razor @@ -0,0 +1,9 @@ + + Home + + + Создать плейлист + Мои ссылки + + + \ No newline at end of file diff --git a/PlaylistShared.PWA2123/Layout/RedirectToLogin.razor b/PlaylistShared.PWA2123/Layout/RedirectToLogin.razor new file mode 100644 index 0000000..a1cf400 --- /dev/null +++ b/PlaylistShared.PWA2123/Layout/RedirectToLogin.razor @@ -0,0 +1,9 @@ +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@inject NavigationManager Navigation + +@code { + protected override void OnInitialized() + { + Navigation.NavigateToLogin("authentication/login"); + } +} diff --git a/PlaylistShared.PWA2123/Pages/AuthCallback.razor b/PlaylistShared.PWA2123/Pages/AuthCallback.razor new file mode 100644 index 0000000..a0493a3 --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/AuthCallback.razor @@ -0,0 +1,24 @@ +@page "/auth-callback" +@using PlaylistShared.PWA.Services +@inject NavigationManager Navigation +@inject AuthStateProvider AuthProvider +@inject ISnackbar Snackbar + +@code { + [Parameter] public string? Token { get; set; } + [Parameter] public string? RefreshToken { get; set; } + + protected override async Task OnInitializedAsync() + { + if (!string.IsNullOrEmpty(Token) && !string.IsNullOrEmpty(RefreshToken)) + { + await AuthProvider.MarkUserAsAuthenticated(Token, RefreshToken); + Navigation.NavigateTo("/"); + } + else + { + Snackbar.Add("Ошибка аутентификации через Яндекс", Severity.Error); + Navigation.NavigateTo("/login"); + } + } +} \ No newline at end of file diff --git a/PlaylistShared.PWA2123/Pages/Authentication.razor b/PlaylistShared.PWA2123/Pages/Authentication.razor new file mode 100644 index 0000000..6c74356 --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/Authentication.razor @@ -0,0 +1,7 @@ +@page "/authentication/{action}" +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication + + +@code{ + [Parameter] public string? Action { get; set; } +} diff --git a/PlaylistShared.PWA2123/Pages/Counter.razor b/PlaylistShared.PWA2123/Pages/Counter.razor new file mode 100644 index 0000000..b7be219 --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/Counter.razor @@ -0,0 +1,18 @@ +@page "/counter" + +Counter + +Counter + +Current count: @currentCount + +Click me + +@code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } +} diff --git a/PlaylistShared.PWA2123/Pages/Home.razor b/PlaylistShared.PWA2123/Pages/Home.razor new file mode 100644 index 0000000..7721bf2 --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/Home.razor @@ -0,0 +1,18 @@ +@page "/" + +Home + +Hello, world! +Welcome to your new app, powered by MudBlazor and the .NET 10 Template! + + + Before authentication will function correctly, you must configure your provider details in Program.cs. + + + + + You can find documentation and examples on our website here: + + www.mudblazor.com + + diff --git a/PlaylistShared.PWA2123/Pages/Login.razor b/PlaylistShared.PWA2123/Pages/Login.razor new file mode 100644 index 0000000..a7f8e97 --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/Login.razor @@ -0,0 +1,52 @@ +@page "/login" +@using PlaylistShared.PWA.Services +@inject NavigationManager Navigation +@inject AuthStateProvider AuthProvider +@inject ApiClient ApiClient +@inject ISnackbar Snackbar + + + + + Вход в PlaylistShared + + + Войти через Яндекс + + + или + + + + Войти по паролю + + + Нет аккаунта? Зарегистрироваться + + + + + +@code { + private string _username = ""; + private string _password = ""; + + private void LoginWithYandex() + { + Navigation.NavigateTo("https://localhost:5001/api/externalauth/login-yandex", true); + } + + private async Task LoginWithPassword() + { + var result = await ApiClient.LoginAsync(_username, _password); + if (result != null) + { + await AuthProvider.MarkUserAsAuthenticated(result.Token, result.RefreshToken); + Navigation.NavigateTo("/"); + } + else + { + Snackbar.Add("Неверный логин или пароль", Severity.Error); + } + } +} \ No newline at end of file diff --git a/PlaylistShared.PWA2123/Pages/LoginDisplay.razor b/PlaylistShared.PWA2123/Pages/LoginDisplay.razor new file mode 100644 index 0000000..608f18d --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/LoginDisplay.razor @@ -0,0 +1,11 @@ +@inject NavigationManager Navigation + + + + Hello, @context.User.Identity?.Name! + Log out + + + Log in + + \ No newline at end of file diff --git a/PlaylistShared.PWA2123/Pages/Logout.razor b/PlaylistShared.PWA2123/Pages/Logout.razor new file mode 100644 index 0000000..da74e30 --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/Logout.razor @@ -0,0 +1,12 @@ +@page "/logout" +@using PlaylistShared.PWA.Services +@inject AuthStateProvider AuthProvider +@inject NavigationManager Navigation + +@code { + protected override async Task OnInitializedAsync() + { + await AuthProvider.MarkUserAsLoggedOut(); + Navigation.NavigateTo("/"); + } +} \ No newline at end of file diff --git a/PlaylistShared.PWA2123/Pages/NotFound.razor b/PlaylistShared.PWA2123/Pages/NotFound.razor new file mode 100644 index 0000000..56c8cc4 --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/NotFound.razor @@ -0,0 +1,9 @@ +@page "/not-found" +@layout MainLayout + +Not Found + +404 - Page Not Found +Sorry, the content you are looking for does not exist. + +Go to Home diff --git a/PlaylistShared.PWA2123/Pages/Weather.razor b/PlaylistShared.PWA2123/Pages/Weather.razor new file mode 100644 index 0000000..242bcf3 --- /dev/null +++ b/PlaylistShared.PWA2123/Pages/Weather.razor @@ -0,0 +1,52 @@ +@page "/weather" +@inject HttpClient Http + +Weather + +Weather forecast +This component demonstrates fetching data from the server. + +@if (forecasts == null) +{ + +} +else +{ + + + Date + Temp. (C) + Temp. (F) + Summary + + + @context.Date + @context.TemperatureC + @context.TemperatureF + @context.Summary + + + + + +} + +@code { + private WeatherForecast[]? forecasts; + + protected override async Task OnInitializedAsync() + { + forecasts = await Http.GetFromJsonAsync("sample-data/weather.json"); + } + + public class WeatherForecast + { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public string? Summary { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + } +} diff --git a/PlaylistShared.PWA2123/PlaylistShared.PWA.csproj b/PlaylistShared.PWA2123/PlaylistShared.PWA.csproj new file mode 100644 index 0000000..3f89db1 --- /dev/null +++ b/PlaylistShared.PWA2123/PlaylistShared.PWA.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + true + service-worker-assets.js + + + + + + + + + + + + + + + + + diff --git a/PlaylistShared.PWA2123/Program.cs b/PlaylistShared.PWA2123/Program.cs new file mode 100644 index 0000000..60c51ca --- /dev/null +++ b/PlaylistShared.PWA2123/Program.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using MudBlazor.Services; +using PlaylistShared.PWA; +using PlaylistShared.PWA.Services; + +internal class Program +{ + private static async global::System.Threading.Tasks.Task Main(string[] args) + { + var builder = WebAssemblyHostBuilder.CreateDefault(args); + builder.RootComponents.Add("#app"); + builder.RootComponents.Add("head::after"); + + builder.Services.AddMudServices(); + builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddAuthorizationCore(); + builder.Services.AddCascadingAuthenticationState(); + + /* + builder.Services.AddOidcAuthentication(options => + { + // Configure your authentication provider options here. + // For more information, see https://aka.ms/blazor-standalone-auth + builder.Configuration.Bind("Local", options.ProviderOptions); + }); + */ + + await builder.Build().RunAsync(); + } +} \ No newline at end of file diff --git a/PlaylistShared.PWA2123/Properties/launchSettings.json b/PlaylistShared.PWA2123/Properties/launchSettings.json new file mode 100644 index 0000000..4adf325 --- /dev/null +++ b/PlaylistShared.PWA2123/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7244;http://localhost:5143", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/PlaylistShared.PWA2123/Services/ApiClient.cs b/PlaylistShared.PWA2123/Services/ApiClient.cs new file mode 100644 index 0000000..c0afbbb --- /dev/null +++ b/PlaylistShared.PWA2123/Services/ApiClient.cs @@ -0,0 +1,77 @@ +using PlaylistShared.Shared.DTO; +using PlaylistShared.Shared.Models; +using System.Net.Http.Json; + +namespace PlaylistShared.PWA.Services; + +public class ApiClient +{ + private readonly HttpClient _http; + private readonly TokenStorage _tokenStorage; + + public ApiClient(HttpClient http, TokenStorage tokenStorage) + { + _http = http; + _tokenStorage = tokenStorage; + } + + public async Task RefreshTokenAsync(string? refreshToken) + { + var response = await _http.PostAsJsonAsync("/api/account/refresh-token", new RefreshTokenRequest { RefreshToken = refreshToken }); + if (!response.IsSuccessStatusCode) return null; + var apiResponse = await response.Content.ReadFromJsonAsync>(); + return apiResponse?.Data; + } + + public async Task LoginAsync(string username, string password) + { + var response = await _http.PostAsJsonAsync("/api/account/login", new LoginRequest { Username = username, Password = password }); + if (!response.IsSuccessStatusCode) return null; + var apiResponse = await response.Content.ReadFromJsonAsync>(); + return apiResponse?.Data; + } + + public async Task RegisterAsync(string username, string email, string password) + { + var response = await _http.PostAsJsonAsync("/api/account/register", new RegisterRequest { Username = username, Email = email, Password = password }); + if (!response.IsSuccessStatusCode) return null; + var apiResponse = await response.Content.ReadFromJsonAsync>(); + return apiResponse?.Data; + } + + public async Task CreateSharedPlaylistAsync(SharePlaylistDto dto) + { + var response = await _http.PostAsJsonAsync("/api/sharedplaylist", dto); + if (!response.IsSuccessStatusCode) return null; + var apiResponse = await response.Content.ReadFromJsonAsync>(); + return apiResponse?.Data; + } + + public async Task GetSharedPlaylistAsync(string token) + { + var response = await _http.GetAsync($"/api/sharedplaylist/{token}"); + if (!response.IsSuccessStatusCode) return null; + var apiResponse = await response.Content.ReadFromJsonAsync>(); + return apiResponse?.Data; + } + + public async Task AddTracksAsync(string sharedPlaylistToken, List trackIds) + { + var response = await _http.PostAsJsonAsync("/api/playlist/add-tracks", new AddTrackRequest + { + SharedPlaylistToken = sharedPlaylistToken, + TrackIds = trackIds + }); + return response.IsSuccessStatusCode; + } + + public async Task RemoveTracksAsync(string sharedPlaylistToken, List trackIds) + { + var response = await _http.PostAsJsonAsync("/api/playlist/remove-tracks", new AddTrackRequest + { + SharedPlaylistToken = sharedPlaylistToken, + TrackIds = trackIds + }); + return response.IsSuccessStatusCode; + } +} \ No newline at end of file diff --git a/PlaylistShared.PWA2123/Services/AuthStateProvider.cs b/PlaylistShared.PWA2123/Services/AuthStateProvider.cs new file mode 100644 index 0000000..538d115 --- /dev/null +++ b/PlaylistShared.PWA2123/Services/AuthStateProvider.cs @@ -0,0 +1,99 @@ +using System.Net.Http.Headers; +using System.Security.Claims; + +namespace PlaylistShared.PWA.Services; + +public class AuthStateProvider : AuthenticationStateProvider, IDisposable +{ + private readonly TokenStorage _tokenStorage; + private readonly ApiClient _apiClient; + private readonly HttpClient _http; + private Timer? _refreshTimer; + private ClaimsPrincipal _currentUser = new(new ClaimsIdentity()); + + public AuthStateProvider(TokenStorage tokenStorage, ApiClient apiClient, HttpClient http) + { + _tokenStorage = tokenStorage; + _apiClient = apiClient; + _http = http; + } + + public override async Task GetAuthenticationStateAsync() + { + var (token, refreshToken) = await _tokenStorage.GetTokensAsync(); + if (string.IsNullOrEmpty(token)) + return new AuthenticationState(_currentUser); + + var principal = ParseToken(token); + if (principal == null) + return new AuthenticationState(_currentUser); + + _currentUser = principal; + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + ScheduleTokenRefresh(token, refreshToken); + return new AuthenticationState(principal); + } + + public async Task MarkUserAsAuthenticated(string token, string refreshToken) + { + await _tokenStorage.SetTokensAsync(token, refreshToken); + var principal = ParseToken(token); + _currentUser = principal ?? new ClaimsPrincipal(new ClaimsIdentity()); + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + public async Task MarkUserAsLoggedOut() + { + await _tokenStorage.ClearTokensAsync(); + _currentUser = new ClaimsPrincipal(new ClaimsIdentity()); + _http.DefaultRequestHeaders.Authorization = null; + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + private ClaimsPrincipal? ParseToken(string token) + { + try + { + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + var identity = new ClaimsIdentity(jwt.Claims, "jwt"); + return new ClaimsPrincipal(identity); + } + catch + { + return null; + } + } + + private void ScheduleTokenRefresh(string token, string? refreshToken) + { + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + var expiresAt = jwt.ValidTo; + var timeToExpiry = expiresAt - DateTime.UtcNow; + var refreshTime = timeToExpiry - TimeSpan.FromMinutes(5); + + if (refreshTime > TimeSpan.Zero && !string.IsNullOrEmpty(refreshToken)) + { + _refreshTimer?.Dispose(); + _refreshTimer = new Timer(async _ => + { + try + { + var newToken = await _apiClient.RefreshTokenAsync(refreshToken); + if (newToken != null) + await MarkUserAsAuthenticated(newToken.Token, newToken.RefreshToken); + else + await MarkUserAsLoggedOut(); + } + catch + { + await MarkUserAsLoggedOut(); + } + }, null, (int)refreshTime.TotalMilliseconds, Timeout.Infinite); + } + } + + public void Dispose() => _refreshTimer?.Dispose(); +} \ No newline at end of file diff --git a/PlaylistShared.PWA2123/Services/TokenStorage.cs b/PlaylistShared.PWA2123/Services/TokenStorage.cs new file mode 100644 index 0000000..bcc7277 --- /dev/null +++ b/PlaylistShared.PWA2123/Services/TokenStorage.cs @@ -0,0 +1,31 @@ +using Microsoft.JSInterop; + +namespace PlaylistShared.PWA.Services; + +public class TokenStorage +{ + private readonly IJSRuntime _js; + private const string TokenKey = "jwt_token"; + private const string RefreshTokenKey = "refresh_token"; + + public TokenStorage(IJSRuntime js) => _js = js; + + public async Task SetTokensAsync(string token, string refreshToken) + { + await _js.InvokeVoidAsync("localStorage.setItem", TokenKey, token); + await _js.InvokeVoidAsync("localStorage.setItem", RefreshTokenKey, refreshToken); + } + + public async Task<(string? token, string? refreshToken)> GetTokensAsync() + { + var token = await _js.InvokeAsync("localStorage.getItem", TokenKey); + var refreshToken = await _js.InvokeAsync("localStorage.getItem", RefreshTokenKey); + return (token, refreshToken); + } + + public async Task ClearTokensAsync() + { + await _js.InvokeVoidAsync("localStorage.removeItem", TokenKey); + await _js.InvokeVoidAsync("localStorage.removeItem", RefreshTokenKey); + } +} \ No newline at end of file diff --git a/PlaylistShared.PWA2123/_Imports.razor b/PlaylistShared.PWA2123/_Imports.razor new file mode 100644 index 0000000..4278aa7 --- /dev/null +++ b/PlaylistShared.PWA2123/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using PlaylistShared.PWA +@using PlaylistShared.PWA.Layout +@using PlaylistShared.PWA.Services +@using MudBlazor diff --git a/PlaylistShared.PWA2123/wwwroot/appsettings.Development.json b/PlaylistShared.PWA2123/wwwroot/appsettings.Development.json new file mode 100644 index 0000000..fdae94a --- /dev/null +++ b/PlaylistShared.PWA2123/wwwroot/appsettings.Development.json @@ -0,0 +1,6 @@ +{ + "Local": { + "Authority": "https://login.microsoftonline.com/", + "ClientId": "33333333-3333-3333-33333333333333333" + } +} diff --git a/PlaylistShared.PWA2123/wwwroot/appsettings.json b/PlaylistShared.PWA2123/wwwroot/appsettings.json new file mode 100644 index 0000000..fdae94a --- /dev/null +++ b/PlaylistShared.PWA2123/wwwroot/appsettings.json @@ -0,0 +1,6 @@ +{ + "Local": { + "Authority": "https://login.microsoftonline.com/", + "ClientId": "33333333-3333-3333-33333333333333333" + } +} diff --git a/PlaylistShared.PWA2123/wwwroot/css/app.css b/PlaylistShared.PWA2123/wwwroot/css/app.css new file mode 100644 index 0000000..e24afa9 --- /dev/null +++ b/PlaylistShared.PWA2123/wwwroot/css/app.css @@ -0,0 +1,110 @@ +html, body { + font-family: 'Roboto', Helvetica, Arial, sans-serif; +} + +#blazor-error-ui { + color-scheme: light; + background: rgba(30, 30, 45, 0.95); + color: #f5f5f7; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(6px); + box-sizing: border-box; + display: none; + left: 50%; + right: auto; + bottom: 1rem; + width: min(52rem, calc(100vw - 2rem)); + transform: translateX(-50%); + padding: 0.85rem 4rem 0.85rem 1rem; + position: fixed; + z-index: 2000; +} + + #blazor-error-ui .reload { + color: #594AE2; + font-weight: 600; + margin-left: 0.5rem; + text-decoration: none; + } + + #blazor-error-ui .reload:hover { + text-decoration: underline; + } + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 1rem; + top: 0.55rem; + width: 1.75rem; + height: 1.75rem; + line-height: 1.65rem; + text-align: center; + border-radius: 999px; + color: #d7d7df; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + } + + #blazor-error-ui .dismiss:hover { + background: rgba(255, 255, 255, 0.12); + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: absolute; + display: block; + width: 8rem; + height: 8rem; + inset: 20vh 0 auto 0; + margin: 0 auto 0 auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #594AE2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} diff --git a/PlaylistShared.PWA2123/wwwroot/favicon.png b/PlaylistShared.PWA2123/wwwroot/favicon.png new file mode 100644 index 0000000..8a4358c Binary files /dev/null and b/PlaylistShared.PWA2123/wwwroot/favicon.png differ diff --git a/PlaylistShared.PWA2123/wwwroot/icon-192.png b/PlaylistShared.PWA2123/wwwroot/icon-192.png new file mode 100644 index 0000000..f275437 Binary files /dev/null and b/PlaylistShared.PWA2123/wwwroot/icon-192.png differ diff --git a/PlaylistShared.PWA2123/wwwroot/icon-512.png b/PlaylistShared.PWA2123/wwwroot/icon-512.png new file mode 100644 index 0000000..2326ede Binary files /dev/null and b/PlaylistShared.PWA2123/wwwroot/icon-512.png differ diff --git a/PlaylistShared.PWA2123/wwwroot/index.html b/PlaylistShared.PWA2123/wwwroot/index.html new file mode 100644 index 0000000..ab8ad1a --- /dev/null +++ b/PlaylistShared.PWA2123/wwwroot/index.html @@ -0,0 +1,41 @@ + + + + + + + PlaylistShared.PWA + + + + + + + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + diff --git a/PlaylistShared.PWA2123/wwwroot/manifest.webmanifest b/PlaylistShared.PWA2123/wwwroot/manifest.webmanifest new file mode 100644 index 0000000..5496339 --- /dev/null +++ b/PlaylistShared.PWA2123/wwwroot/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "PlaylistShared.PWA", + "short_name": "PlaylistShared.PWA", + "id": "./", + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#03173d", + "prefer_related_applications": false, + "icons": [ + { + "src": "icon-512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "icon-192.png", + "type": "image/png", + "sizes": "192x192" + } + ] +} diff --git a/PlaylistShared.PWA2123/wwwroot/sample-data/weather.json b/PlaylistShared.PWA2123/wwwroot/sample-data/weather.json new file mode 100644 index 0000000..b745973 --- /dev/null +++ b/PlaylistShared.PWA2123/wwwroot/sample-data/weather.json @@ -0,0 +1,27 @@ +[ + { + "date": "2022-01-06", + "temperatureC": 1, + "summary": "Freezing" + }, + { + "date": "2022-01-07", + "temperatureC": 14, + "summary": "Bracing" + }, + { + "date": "2022-01-08", + "temperatureC": -13, + "summary": "Freezing" + }, + { + "date": "2022-01-09", + "temperatureC": -16, + "summary": "Balmy" + }, + { + "date": "2022-01-10", + "temperatureC": -2, + "summary": "Chilly" + } +] diff --git a/PlaylistShared.PWA2123/wwwroot/service-worker.js b/PlaylistShared.PWA2123/wwwroot/service-worker.js new file mode 100644 index 0000000..fe614da --- /dev/null +++ b/PlaylistShared.PWA2123/wwwroot/service-worker.js @@ -0,0 +1,4 @@ +// In development, always fetch from the network and do not enable offline support. +// This is because caching would make development more difficult (changes would not +// be reflected on the first load after each change). +self.addEventListener('fetch', () => { }); diff --git a/PlaylistShared.PWA2123/wwwroot/service-worker.published.js b/PlaylistShared.PWA2123/wwwroot/service-worker.published.js new file mode 100644 index 0000000..51a0e5c --- /dev/null +++ b/PlaylistShared.PWA2123/wwwroot/service-worker.published.js @@ -0,0 +1,55 @@ +// Caution! Be sure you understand the caveats before publishing an application with +// offline support. See https://aka.ms/blazor-offline-considerations + +self.importScripts('./service-worker-assets.js'); +self.addEventListener('install', event => event.waitUntil(onInstall(event))); +self.addEventListener('activate', event => event.waitUntil(onActivate(event))); +self.addEventListener('fetch', event => event.respondWith(onFetch(event))); + +const cacheNamePrefix = 'offline-cache-'; +const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; +const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ]; +const offlineAssetsExclude = [ /^service-worker\.js$/ ]; + +// Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. +const base = "/"; +const baseUrl = new URL(base, self.origin); +const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href); + +async function onInstall(event) { + console.info('Service worker: Install'); + + // Fetch and cache all matching items from the assets manifest + const assetsRequests = self.assetsManifest.assets + .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) + .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) + .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); + await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); +} + +async function onActivate(event) { + console.info('Service worker: Activate'); + + // Delete unused caches + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys + .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) + .map(key => caches.delete(key))); +} + +async function onFetch(event) { + let cachedResponse = null; + if (event.request.method === 'GET') { + // For all navigation requests, try to serve index.html from cache, + // unless that request is for an offline resource. + // If you need some URLs to be server-rendered, edit the following check to exclude those URLs + const shouldServeIndexHtml = event.request.mode === 'navigate' + && !manifestUrlList.some(url => url === event.request.url); + + const request = shouldServeIndexHtml ? 'index.html' : event.request; + const cache = await caches.open(cacheName); + cachedResponse = await cache.match(request); + } + + return cachedResponse || fetch(event.request); +} diff --git a/PlaylistShared.Pwa/.dockerignore b/PlaylistShared.Pwa/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/PlaylistShared.Pwa/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/PlaylistShared.Pwa/App.razor b/PlaylistShared.Pwa/App.razor new file mode 100644 index 0000000..34eb91e --- /dev/null +++ b/PlaylistShared.Pwa/App.razor @@ -0,0 +1,19 @@ + + + + + + @if (context.User.Identity?.IsAuthenticated != true) + { + + } + else + { +

You are not authorized to access this resource.

+ } +
+
+ +
+
+
diff --git a/PlaylistShared.Pwa/Dockerfile b/PlaylistShared.Pwa/Dockerfile new file mode 100644 index 0000000..84c9c5b --- /dev/null +++ b/PlaylistShared.Pwa/Dockerfile @@ -0,0 +1,41 @@ +# ---- Stage 1: Build ---- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# Устанавливаем Python (необходим для WebAssembly компиляции) +RUN apt-get update && apt-get install -y python3 && ln -s /usr/bin/python3 /usr/bin/python + +# Устанавливаем workload для WebAssembly +RUN dotnet workload install wasm-tools + +# Копируем csproj всех проектов для восстановления зависимостей +COPY ["PlaylistShared.Pwa/PlaylistShared.Pwa.csproj", "PlaylistShared.Pwa/"] +COPY ["PlaylistShared.Shared/PlaylistShared.Shared.csproj", "PlaylistShared.Shared/"] + +# Восстанавливаем зависимости +RUN dotnet restore "PlaylistShared.Pwa/PlaylistShared.Pwa.csproj" + +# Копируем весь исходный код +COPY . . + +# Переходим в папку проекта и публикуем +WORKDIR "/src/PlaylistShared.Pwa" +RUN dotnet publish "./PlaylistShared.Pwa.csproj" -c $BUILD_CONFIGURATION -o /app/publish + +RUN ls -la /app/publish/wwwroot + +# ---- Stage 2: Nginx ---- +FROM nginx:alpine AS final + +# Копируем кастомную конфигурацию Nginx +COPY PlaylistShared.Pwa/nginx.conf /etc/nginx/nginx.conf + +# Удаляем дефолтную статику Nginx +RUN rm -rf /usr/share/nginx/html/* + +# Копируем собранные файлы Blazor (wwwroot) в папку Nginx +COPY --from=build /app/publish/wwwroot /usr/share/nginx/html + +# Открываем порт 80 +EXPOSE 80 \ No newline at end of file diff --git a/PlaylistShared.Pwa/Layout/LoginDisplay.razor b/PlaylistShared.Pwa/Layout/LoginDisplay.razor new file mode 100644 index 0000000..c4eff22 --- /dev/null +++ b/PlaylistShared.Pwa/Layout/LoginDisplay.razor @@ -0,0 +1,20 @@ +@inject NavigationManager Navigation + + + + Здравствуйте, @context.User.Identity?.Name! + Выйти + + + Вход + | + Регистрация + + + +@code { + public void BeginLogOut() + { + Navigation.NavigateTo("/logout"); + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Layout/MainLayout.razor b/PlaylistShared.Pwa/Layout/MainLayout.razor new file mode 100644 index 0000000..3cd4ef5 --- /dev/null +++ b/PlaylistShared.Pwa/Layout/MainLayout.razor @@ -0,0 +1,100 @@ +@inherits LayoutComponentBase + + + + + + + + + + Application + + + + + About + + + + + + + + + @Body + + + +@code { + private bool _drawerOpen = true; + private bool _isDarkMode = true; + private MudTheme? _theme; + + protected override void OnInitialized() + { + base.OnInitialized(); + + _theme = new() + { + PaletteLight = _lightPalette, + PaletteDark = _darkPalette, + LayoutProperties = new LayoutProperties() + }; + } + + private void DrawerToggle() + { + _drawerOpen = !_drawerOpen; + } + + private void DarkModeToggle() + { + _isDarkMode = !_isDarkMode; + } + + private readonly PaletteLight _lightPalette = new() + { + Black = "#110e2d", + AppbarText = "#424242", + AppbarBackground = "rgba(255,255,255,0.8)", + DrawerBackground = "#ffffff", + GrayLight = "#e8e8e8", + GrayLighter = "#f9f9f9", + }; + + private readonly PaletteDark _darkPalette = new() + { + Primary = "#7e6fff", + Surface = "#1e1e2d", + Background = "#1a1a27", + BackgroundGray = "#151521", + AppbarText = "#92929f", + AppbarBackground = "rgba(26,26,39,0.8)", + DrawerBackground = "#1a1a27", + ActionDefault = "#74718e", + ActionDisabled = "#9999994d", + ActionDisabledBackground = "#605f6d4d", + TextPrimary = "#b2b0bf", + TextSecondary = "#92929f", + TextDisabled = "#ffffff33", + DrawerIcon = "#92929f", + DrawerText = "#92929f", + GrayLight = "#2a2833", + GrayLighter = "#1e1e2d", + Info = "#4a86ff", + Success = "#3dcb6c", + Warning = "#ffb545", + Error = "#ff3f5f", + LinesDefault = "#33323e", + TableLines = "#33323e", + Divider = "#292838", + OverlayLight = "#1e1e2d80", + }; + + public string DarkLightModeButtonIcon => _isDarkMode switch + { + true => Icons.Material.Rounded.AutoMode, + false => Icons.Material.Outlined.DarkMode, + }; +} diff --git a/PlaylistShared.Pwa/Layout/MainLayout.razor.css b/PlaylistShared.Pwa/Layout/MainLayout.razor.css new file mode 100644 index 0000000..ecf25e5 --- /dev/null +++ b/PlaylistShared.Pwa/Layout/MainLayout.razor.css @@ -0,0 +1,77 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} diff --git a/PlaylistShared.Pwa/Layout/NavMenu.razor b/PlaylistShared.Pwa/Layout/NavMenu.razor new file mode 100644 index 0000000..87cc4aa --- /dev/null +++ b/PlaylistShared.Pwa/Layout/NavMenu.razor @@ -0,0 +1,11 @@ + + Главная + + + Мои плейлисты + Профиль + Создать плейлист + Мои ссылки + + + \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/AuthCallback.razor b/PlaylistShared.Pwa/Pages/AuthCallback.razor new file mode 100644 index 0000000..61f4e6c --- /dev/null +++ b/PlaylistShared.Pwa/Pages/AuthCallback.razor @@ -0,0 +1,24 @@ +@page "/auth-callback" +@using PlaylistShared.Pwa.Services +@inject NavigationManager Navigation +@inject AuthStateProvider AuthProvider +@inject ISnackbar Snackbar + +@code { + [Parameter] public string? Token { get; set; } + [Parameter] public string? RefreshToken { get; set; } + + protected override async Task OnInitializedAsync() + { + if (!string.IsNullOrEmpty(Token) && !string.IsNullOrEmpty(RefreshToken)) + { + await AuthProvider.MarkUserAsAuthenticated(Token, RefreshToken); + Navigation.NavigateTo("/"); + } + else + { + Snackbar.Add("Ошибка аутентификации через Яндекс", Severity.Error); + Navigation.NavigateTo("/login"); + } + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/Home.razor b/PlaylistShared.Pwa/Pages/Home.razor new file mode 100644 index 0000000..7721bf2 --- /dev/null +++ b/PlaylistShared.Pwa/Pages/Home.razor @@ -0,0 +1,18 @@ +@page "/" + +Home + +Hello, world! +Welcome to your new app, powered by MudBlazor and the .NET 10 Template! + + + Before authentication will function correctly, you must configure your provider details in Program.cs. + + + + + You can find documentation and examples on our website here: + + www.mudblazor.com + + diff --git a/PlaylistShared.Pwa/Pages/Login.razor b/PlaylistShared.Pwa/Pages/Login.razor new file mode 100644 index 0000000..e3a061f --- /dev/null +++ b/PlaylistShared.Pwa/Pages/Login.razor @@ -0,0 +1,75 @@ +@page "/login" +@using PlaylistShared.Shared.DTO +@using PlaylistShared.Pwa.Services +@inject HttpClient Http +@inject AuthStateProvider AuthProvider +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + + + + + Вход в PlaylistShared + + + Войдите через учётную запись Keycloak или используйте локальный аккаунт. + + + + + Войти через Keycloak + + + или + + + + + + + Войти (локально) + + + + Нет аккаунта? Зарегистрироваться + + + + + +@code { + private LoginModel _loginModel = new(); + + private void LoginWithKeycloak() + { + Navigation.NavigateTo("/api/openid/login", true); + } + + private async Task LocalLogin() + { + var response = await Http.PostAsJsonAsync("/api/account/login", _loginModel); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(); + if (result?.Success == true && result.Data != null) + { + await AuthProvider.MarkUserAsAuthenticated(result.Data.Token, result.Data.RefreshToken); + Navigation.NavigateTo("/"); + } + else + { + Snackbar.Add(result?.Error?.Message ?? "Ошибка входа", Severity.Error); + } + } + else + { + Snackbar.Add("Неверное имя пользователя или пароль", Severity.Error); + } + } + + public class LoginModel + { + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/Logout.razor b/PlaylistShared.Pwa/Pages/Logout.razor new file mode 100644 index 0000000..610abd8 --- /dev/null +++ b/PlaylistShared.Pwa/Pages/Logout.razor @@ -0,0 +1,12 @@ +@page "/logout" +@using PlaylistShared.Pwa.Services +@inject AuthStateProvider AuthProvider +@inject NavigationManager Navigation + +@code { + protected override async Task OnInitializedAsync() + { + await AuthProvider.MarkUserAsLoggedOut(); + Navigation.NavigateTo("/"); + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/MyPlaylists.razor b/PlaylistShared.Pwa/Pages/MyPlaylists.razor new file mode 100644 index 0000000..a00d411 --- /dev/null +++ b/PlaylistShared.Pwa/Pages/MyPlaylists.razor @@ -0,0 +1,125 @@ +@page "/my-playlists" +@attribute [Authorize] +@using PlaylistShared.Shared.DTO +@inject HttpClient Http +@inject ISnackbar Snackbar +@inject NavigationManager Navigation + + + + + + Мои плейлисты + + + + + + + + + @if (_loading) + { + + } + else if (_playlists == null || !_playlists.Any()) + { + Плейлисты не найдены. Убедитесь, что вы сохранили корректный токен Яндекс.Музыки. + } + else + { + + + Название + Треков + Статус + + + + @context.Title + @context.TrackCount + + + @if (context.IsShared) + { + Расшарен + } + else + { + Не расшарен + } + + + @if (!context.IsShared) + { + Поделиться + } + else + { + Управлять + } + + + + } + + + + +@code { + private List _playlists; + private bool _loading = true; + private bool _showOnlyShared = false; + + private List FilteredPlaylists => _showOnlyShared ? _playlists?.Where(p => p.IsShared).ToList() : _playlists; + + protected override async Task OnInitializedAsync() + { + await LoadPlaylists(); + } + + private async Task LoadPlaylists() + { + _loading = true; + try + { + var response = await Http.GetFromJsonAsync>>("/api/playlist/my"); + if (response?.Success == true) + _playlists = response.Data; + else + Snackbar.Add("Ошибка загрузки плейлистов", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private async Task SharePlaylist(YandexPlaylistInfo playlist) + { + var request = new SharePlaylistRequest { Kind = playlist.Kind, OwnerUid = playlist.OwnerUid }; + var response = await Http.PostAsJsonAsync("/api/playlist/share", request); + if (response.IsSuccessStatusCode) + { + Snackbar.Add("Плейлист расшарен", Severity.Success); + await LoadPlaylists(); + } + else + { + Snackbar.Add("Ошибка расшаривания", Severity.Error); + } + } + + private void GoToShared(YandexPlaylistInfo playlist) + { + if (!string.IsNullOrEmpty(playlist.ShareToken)) + Navigation.NavigateTo($"/shared/{playlist.ShareToken}"); + else + Snackbar.Add("Ошибка: токен расшаривания не найден", Severity.Error); + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/NotFound.razor b/PlaylistShared.Pwa/Pages/NotFound.razor new file mode 100644 index 0000000..56c8cc4 --- /dev/null +++ b/PlaylistShared.Pwa/Pages/NotFound.razor @@ -0,0 +1,9 @@ +@page "/not-found" +@layout MainLayout + +Not Found + +404 - Page Not Found +Sorry, the content you are looking for does not exist. + +Go to Home diff --git a/PlaylistShared.Pwa/Pages/Profile.razor b/PlaylistShared.Pwa/Pages/Profile.razor new file mode 100644 index 0000000..2c51f08 --- /dev/null +++ b/PlaylistShared.Pwa/Pages/Profile.razor @@ -0,0 +1,72 @@ +@page "/profile" +@using Microsoft.AspNetCore.Authorization +@using PlaylistShared.Shared.DTO +@attribute [Authorize] +@inject HttpClient Http +@inject ISnackbar Snackbar + + + + + + Личный кабинет + + + + Здесь вы можете указать токен доступа к Яндекс.Музыке. + + Сохранить токен + Статус: @_statusText + + + + +@code { + private string _token = ""; + private string _statusText = "Загрузка..."; + + protected override async Task OnInitializedAsync() + { + await LoadStatus(); + } + + private async Task LoadStatus() + { + try + { + var response = await Http.GetFromJsonAsync>("/api/yandextoken/status"); + if (response?.Success == true) + { + _statusText = response.Data.HasToken + ? $"Токен установлен{(response.Data.IsValid ? "" : " (просрочен)")}" + : "Токен не установлен"; + } + } + catch { _statusText = "Не удалось загрузить статус"; } + } + + private async Task SaveToken() + { + if (string.IsNullOrWhiteSpace(_token)) + { + Snackbar.Add("Введите токен", Severity.Warning); + return; + } + + var request = new SetYandexTokenRequest { Token = _token }; + var response = await Http.PostAsJsonAsync("/api/yandextoken/set", request); + if (response.IsSuccessStatusCode) + { + Snackbar.Add("Токен сохранён", Severity.Success); + await LoadStatus(); + _token = ""; + } + else + { + Snackbar.Add("Ошибка сохранения токена", Severity.Error); + } + } + + public class YandexTokenStatus { public bool HasToken { get; set; } public bool IsValid { get; set; } } + public class SetYandexTokenRequest { public string Token { get; set; } } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/Register.razor b/PlaylistShared.Pwa/Pages/Register.razor new file mode 100644 index 0000000..6abff26 --- /dev/null +++ b/PlaylistShared.Pwa/Pages/Register.razor @@ -0,0 +1,67 @@ +@page "/register" +@inject HttpClient Http +@inject AuthStateProvider AuthProvider +@inject NavigationManager Navigation +@inject ISnackbar Snackbar + + + + + Регистрация + + + + + + + + Зарегистрироваться + + + + Уже есть аккаунт? Войти + + + + + +@code { + private RegisterModel _model = new(); + + private async Task OnRegister() + { + if (_model.Password != _model.ConfirmPassword) + { + Snackbar.Add("Пароли не совпадают", Severity.Error); + return; + } + + var response = await Http.PostAsJsonAsync("/api/account/register", _model); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(); + if (result?.Success == true) + { + await AuthProvider.MarkUserAsAuthenticated(result.Data.Token, result.Data.RefreshToken); + Navigation.NavigateTo("/"); + } + else + { + Snackbar.Add(result?.Error?.Message ?? "Ошибка регистрации", Severity.Error); + } + } + else + { + var error = await response.Content.ReadFromJsonAsync>(); + Snackbar.Add(error?.Error?.Message ?? "Ошибка регистрации", Severity.Error); + } + } + + public class RegisterModel + { + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } + public string ConfirmPassword { get; set; } + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor new file mode 100644 index 0000000..1c2acd8 --- /dev/null +++ b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor @@ -0,0 +1,163 @@ +@page "/shared/{token}" +@attribute [Authorize] +@using PlaylistShared.Shared.DTO +@using PlaylistShared.Shared.Enums +@using PlaylistShared.Pwa.Services +@using PlaylistShared.Shared.Models +@inject HttpClient Http +@inject ISnackbar Snackbar +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthProvider + + + @if (_loading) + { + + } + else if (_playlist == null) + { + Плейлист не найден или у вас нет доступа + } + else + { + + + + @_playlist.Title + Владелец: @_playlist.Creator?.UserName + + + + @if (_isCreator) + { + + Настройки доступа + + + + Все + Только авторизованные + + + + + Все + Только авторизованные + Только добавивший + + + + + Все + Только авторизованные + Только добавивший + + + + + @if (_savingPermissions) + { + + } + else + { + + Сохранить + } + + + } + + + Функционал управления треками в разработке + + + } + + +@code { + [Parameter] public string Token { get; set; } + + private SharedPlaylistDto? _playlist; + private bool _loading = true; + private bool _isCreator; + private UpdatePermissionsDto _editPermissions = new(); + private bool _savingPermissions; + private string? _currentUserId; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthProvider.GetAuthenticationStateAsync(); + _currentUserId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + await LoadPlaylist(); + } + + private async Task LoadPlaylist() + { + try + { + var response = await Http.GetFromJsonAsync>($"/api/sharedplaylist/{Token}"); + if (response?.Success == true) + { + _playlist = response.Data; + _isCreator = _playlist.CreatorUserId.ToString() == _currentUserId; + if (_isCreator) + { + _editPermissions = new UpdatePermissionsDto + { + ViewPermission = _playlist.ViewPermission, + AddPermission = _playlist.AddPermission, + RemovePermission = _playlist.RemovePermission + }; + } + } + else + { + Snackbar.Add(response?.Error?.Message ?? "Не удалось загрузить плейлист", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private async Task SavePermissions() + { + _savingPermissions = true; + try + { + var response = await Http.PutAsJsonAsync($"/api/sharedplaylist/{Token}/permissions", _editPermissions); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync>(); + if (result?.Success == true) + { + _playlist = result.Data; + Snackbar.Add("Права доступа обновлены", Severity.Success); + } + else + { + Snackbar.Add(result?.Error?.Message ?? "Ошибка обновления", Severity.Error); + } + } + else + { + Snackbar.Add("Ошибка сохранения прав", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error); + } + finally + { + _savingPermissions = false; + } + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/PlaylistShared.Pwa.csproj b/PlaylistShared.Pwa/PlaylistShared.Pwa.csproj new file mode 100644 index 0000000..ae4521f --- /dev/null +++ b/PlaylistShared.Pwa/PlaylistShared.Pwa.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + true + service-worker-assets.js + + + + + + + + + + + + + + + + + + + diff --git a/PlaylistShared.Pwa/Program.cs b/PlaylistShared.Pwa/Program.cs new file mode 100644 index 0000000..89f928b --- /dev/null +++ b/PlaylistShared.Pwa/Program.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using MudBlazor.Services; +using PlaylistShared.Pwa; +using PlaylistShared.Pwa.Services; + +internal class Program +{ + private static async global::System.Threading.Tasks.Task Main(string[] args) + { + var builder = WebAssemblyHostBuilder.CreateDefault(args); + builder.RootComponents.Add("#app"); + builder.RootComponents.Add("head::after"); + + builder.Services.AddMudServices(); + builder.Services.AddScoped(sp => + { + var apiUrl = builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment.BaseAddress; + return new HttpClient { BaseAddress = new Uri(apiUrl) }; + }); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => sp.GetRequiredService()); + builder.Services.AddScoped(); + + /* + builder.Services.AddOidcAuthentication(options => + { + // Configure your authentication provider options here. + // For more information, see https://aka.ms/blazor-standalone-auth + builder.Configuration.Bind("Local", options.ProviderOptions); + }); + */ + builder.Services.AddAuthorizationCore(); + builder.Services.AddCascadingAuthenticationState(); + + await builder.Build().RunAsync(); + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Properties/launchSettings.json b/PlaylistShared.Pwa/Properties/launchSettings.json new file mode 100644 index 0000000..6d80cdc --- /dev/null +++ b/PlaylistShared.Pwa/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7225;http://localhost:5181", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/PlaylistShared.Pwa/Services/ApiClient.cs b/PlaylistShared.Pwa/Services/ApiClient.cs new file mode 100644 index 0000000..3e2fa37 --- /dev/null +++ b/PlaylistShared.Pwa/Services/ApiClient.cs @@ -0,0 +1,19 @@ +using PlaylistShared.Shared.DTO; +using System.Net.Http.Json; + +namespace PlaylistShared.Pwa.Services; + +public class ApiClient +{ + private readonly HttpClient _http; + + public ApiClient(HttpClient http) => _http = http; + + public async Task RefreshTokenAsync(string? refreshToken) + { + var response = await _http.PostAsJsonAsync("/api/account/refresh-token", new RefreshTokenRequest { RefreshToken = refreshToken }); + if (!response.IsSuccessStatusCode) return null; + var apiResponse = await response.Content.ReadFromJsonAsync>(); + return apiResponse?.Data; + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Services/AuthStateProvider.cs b/PlaylistShared.Pwa/Services/AuthStateProvider.cs new file mode 100644 index 0000000..c531880 --- /dev/null +++ b/PlaylistShared.Pwa/Services/AuthStateProvider.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Components.Authorization; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using System.Security.Claims; + +namespace PlaylistShared.Pwa.Services; + +public class AuthStateProvider : AuthenticationStateProvider, IDisposable +{ + private readonly TokenStorage _tokenStorage; + private readonly ApiClient _apiClient; + private readonly HttpClient _http; + private Timer? _refreshTimer; + private ClaimsPrincipal _currentUser = new(new ClaimsIdentity()); + + public AuthStateProvider(TokenStorage tokenStorage, ApiClient apiClient, HttpClient http) + { + _tokenStorage = tokenStorage; + _apiClient = apiClient; + _http = http; + } + + public override async Task GetAuthenticationStateAsync() + { + var (token, refreshToken) = await _tokenStorage.GetTokensAsync(); + if (string.IsNullOrEmpty(token)) + return new AuthenticationState(_currentUser); + + var principal = ParseToken(token); + if (principal == null) + return new AuthenticationState(_currentUser); + + _currentUser = principal; + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + ScheduleTokenRefresh(token, refreshToken); + return new AuthenticationState(principal); + } + + public async Task MarkUserAsAuthenticated(string token, string refreshToken) + { + await _tokenStorage.SetTokensAsync(token, refreshToken); + var principal = ParseToken(token); + _currentUser = principal ?? new ClaimsPrincipal(new ClaimsIdentity()); + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + public async Task MarkUserAsLoggedOut() + { + await _tokenStorage.ClearTokensAsync(); + _currentUser = new ClaimsPrincipal(new ClaimsIdentity()); + _http.DefaultRequestHeaders.Authorization = null; + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + private ClaimsPrincipal? ParseToken(string token) + { + try + { + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + var identity = new ClaimsIdentity(jwt.Claims, "jwt"); + return new ClaimsPrincipal(identity); + } + catch (Exception ex) + { + return null; + } + } + + private void ScheduleTokenRefresh(string token, string? refreshToken) + { + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + var expiresAt = jwt.ValidTo; + var timeToExpiry = expiresAt - DateTime.UtcNow; + var refreshTime = timeToExpiry - TimeSpan.FromMinutes(5); + + if (refreshTime > TimeSpan.Zero && !string.IsNullOrEmpty(refreshToken)) + { + _refreshTimer?.Dispose(); + _refreshTimer = new Timer(async _ => + { + try + { + var newToken = await _apiClient.RefreshTokenAsync(refreshToken); + if (newToken != null) + await MarkUserAsAuthenticated(newToken.Token, newToken.RefreshToken); + else + await MarkUserAsLoggedOut(); + } + catch + { + await MarkUserAsLoggedOut(); + } + }, null, (int)refreshTime.TotalMilliseconds, Timeout.Infinite); + } + } + + public void Dispose() => _refreshTimer?.Dispose(); +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Services/TokenStorage.cs b/PlaylistShared.Pwa/Services/TokenStorage.cs new file mode 100644 index 0000000..e7de939 --- /dev/null +++ b/PlaylistShared.Pwa/Services/TokenStorage.cs @@ -0,0 +1,31 @@ +using Microsoft.JSInterop; + +namespace PlaylistShared.Pwa.Services; + +public class TokenStorage +{ + private readonly IJSRuntime _js; + private const string TokenKey = "jwt_token"; + private const string RefreshTokenKey = "refresh_token"; + + public TokenStorage(IJSRuntime js) => _js = js; + + public async Task SetTokensAsync(string token, string refreshToken) + { + await _js.InvokeVoidAsync("localStorage.setItem", TokenKey, token); + await _js.InvokeVoidAsync("localStorage.setItem", RefreshTokenKey, refreshToken); + } + + public async Task<(string? token, string? refreshToken)> GetTokensAsync() + { + var token = await _js.InvokeAsync("localStorage.getItem", TokenKey); + var refreshToken = await _js.InvokeAsync("localStorage.getItem", RefreshTokenKey); + return (token, refreshToken); + } + + public async Task ClearTokensAsync() + { + await _js.InvokeVoidAsync("localStorage.removeItem", TokenKey); + await _js.InvokeVoidAsync("localStorage.removeItem", RefreshTokenKey); + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/_Imports.razor b/PlaylistShared.Pwa/_Imports.razor new file mode 100644 index 0000000..1303155 --- /dev/null +++ b/PlaylistShared.Pwa/_Imports.razor @@ -0,0 +1,15 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using PlaylistShared.Pwa +@using PlaylistShared.Pwa.Layout +@using PlaylistShared.Pwa.Services +@using MudBlazor +@using Microsoft.AspNetCore.Authorization +@using PlaylistShared.Shared.DTO \ No newline at end of file diff --git a/PlaylistShared.Pwa/nginx.conf b/PlaylistShared.Pwa/nginx.conf new file mode 100644 index 0000000..44e1099 --- /dev/null +++ b/PlaylistShared.Pwa/nginx.conf @@ -0,0 +1,55 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Сжатие + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/wasm application/json; + + server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Для Service Worker – запрещаем кэширование, чтобы он всегда был свежим + location = /service-worker.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + try_files $uri =404; + } + + # Для файла манифеста Service Worker assets – тоже не кэшируем + location = /service-worker-assets.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + try_files $uri =404; + } + + # Основной SPA fallback: все неизвестные пути отдаём через index.html + location / { + try_files $uri $uri/ /index.html?$args; + } + + # Кэширование статических ресурсов (css, js, изображения, шрифты) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Для .wasm файлов – правильный MIME‑тип и кэширование + location ~* \.wasm$ { + default_type application/wasm; + expires 1y; + add_header Cache-Control "public, immutable"; + } + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/wwwroot/appsettings.Development.json b/PlaylistShared.Pwa/wwwroot/appsettings.Development.json new file mode 100644 index 0000000..f0f7ccd --- /dev/null +++ b/PlaylistShared.Pwa/wwwroot/appsettings.Development.json @@ -0,0 +1,3 @@ +{ + "ApiBaseUrl": "http://localhost:5053" +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/wwwroot/appsettings.json b/PlaylistShared.Pwa/wwwroot/appsettings.json new file mode 100644 index 0000000..62a9192 --- /dev/null +++ b/PlaylistShared.Pwa/wwwroot/appsettings.json @@ -0,0 +1,3 @@ +{ + "ApiBaseUrl": "" +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/wwwroot/css/app.css b/PlaylistShared.Pwa/wwwroot/css/app.css new file mode 100644 index 0000000..e24afa9 --- /dev/null +++ b/PlaylistShared.Pwa/wwwroot/css/app.css @@ -0,0 +1,110 @@ +html, body { + font-family: 'Roboto', Helvetica, Arial, sans-serif; +} + +#blazor-error-ui { + color-scheme: light; + background: rgba(30, 30, 45, 0.95); + color: #f5f5f7; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(6px); + box-sizing: border-box; + display: none; + left: 50%; + right: auto; + bottom: 1rem; + width: min(52rem, calc(100vw - 2rem)); + transform: translateX(-50%); + padding: 0.85rem 4rem 0.85rem 1rem; + position: fixed; + z-index: 2000; +} + + #blazor-error-ui .reload { + color: #594AE2; + font-weight: 600; + margin-left: 0.5rem; + text-decoration: none; + } + + #blazor-error-ui .reload:hover { + text-decoration: underline; + } + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 1rem; + top: 0.55rem; + width: 1.75rem; + height: 1.75rem; + line-height: 1.65rem; + text-align: center; + border-radius: 999px; + color: #d7d7df; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + } + + #blazor-error-ui .dismiss:hover { + background: rgba(255, 255, 255, 0.12); + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: absolute; + display: block; + width: 8rem; + height: 8rem; + inset: 20vh 0 auto 0; + margin: 0 auto 0 auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #594AE2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} diff --git a/PlaylistShared.Pwa/wwwroot/favicon.png b/PlaylistShared.Pwa/wwwroot/favicon.png new file mode 100644 index 0000000..8a4358c Binary files /dev/null and b/PlaylistShared.Pwa/wwwroot/favicon.png differ diff --git a/PlaylistShared.Pwa/wwwroot/icon-192.png b/PlaylistShared.Pwa/wwwroot/icon-192.png new file mode 100644 index 0000000..f275437 Binary files /dev/null and b/PlaylistShared.Pwa/wwwroot/icon-192.png differ diff --git a/PlaylistShared.Pwa/wwwroot/icon-512.png b/PlaylistShared.Pwa/wwwroot/icon-512.png new file mode 100644 index 0000000..2326ede Binary files /dev/null and b/PlaylistShared.Pwa/wwwroot/icon-512.png differ diff --git a/PlaylistShared.Pwa/wwwroot/index.html b/PlaylistShared.Pwa/wwwroot/index.html new file mode 100644 index 0000000..6b6e106 --- /dev/null +++ b/PlaylistShared.Pwa/wwwroot/index.html @@ -0,0 +1,41 @@ + + + + + + + PlaylistShared.Pwa + + + + + + + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + diff --git a/PlaylistShared.Pwa/wwwroot/manifest.webmanifest b/PlaylistShared.Pwa/wwwroot/manifest.webmanifest new file mode 100644 index 0000000..d509b45 --- /dev/null +++ b/PlaylistShared.Pwa/wwwroot/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "PlaylistShared.Pwa", + "short_name": "PlaylistShared.Pwa", + "id": "./", + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#03173d", + "prefer_related_applications": false, + "icons": [ + { + "src": "icon-512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "icon-192.png", + "type": "image/png", + "sizes": "192x192" + } + ] +} diff --git a/PlaylistShared.Pwa/wwwroot/service-worker.js b/PlaylistShared.Pwa/wwwroot/service-worker.js new file mode 100644 index 0000000..fe614da --- /dev/null +++ b/PlaylistShared.Pwa/wwwroot/service-worker.js @@ -0,0 +1,4 @@ +// In development, always fetch from the network and do not enable offline support. +// This is because caching would make development more difficult (changes would not +// be reflected on the first load after each change). +self.addEventListener('fetch', () => { }); diff --git a/PlaylistShared.Pwa/wwwroot/service-worker.published.js b/PlaylistShared.Pwa/wwwroot/service-worker.published.js new file mode 100644 index 0000000..51a0e5c --- /dev/null +++ b/PlaylistShared.Pwa/wwwroot/service-worker.published.js @@ -0,0 +1,55 @@ +// Caution! Be sure you understand the caveats before publishing an application with +// offline support. See https://aka.ms/blazor-offline-considerations + +self.importScripts('./service-worker-assets.js'); +self.addEventListener('install', event => event.waitUntil(onInstall(event))); +self.addEventListener('activate', event => event.waitUntil(onActivate(event))); +self.addEventListener('fetch', event => event.respondWith(onFetch(event))); + +const cacheNamePrefix = 'offline-cache-'; +const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; +const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ]; +const offlineAssetsExclude = [ /^service-worker\.js$/ ]; + +// Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. +const base = "/"; +const baseUrl = new URL(base, self.origin); +const manifestUrlList = self.assetsManifest.assets.map(asset => new URL(asset.url, baseUrl).href); + +async function onInstall(event) { + console.info('Service worker: Install'); + + // Fetch and cache all matching items from the assets manifest + const assetsRequests = self.assetsManifest.assets + .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) + .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) + .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); + await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); +} + +async function onActivate(event) { + console.info('Service worker: Activate'); + + // Delete unused caches + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys + .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) + .map(key => caches.delete(key))); +} + +async function onFetch(event) { + let cachedResponse = null; + if (event.request.method === 'GET') { + // For all navigation requests, try to serve index.html from cache, + // unless that request is for an offline resource. + // If you need some URLs to be server-rendered, edit the following check to exclude those URLs + const shouldServeIndexHtml = event.request.mode === 'navigate' + && !manifestUrlList.some(url => url === event.request.url); + + const request = shouldServeIndexHtml ? 'index.html' : event.request; + const cache = await caches.open(cacheName); + cachedResponse = await cache.match(request); + } + + return cachedResponse || fetch(event.request); +} diff --git a/PlaylistShared.Shared/DTO/AddTrackRequest.cs b/PlaylistShared.Shared/DTO/AddTrackRequest.cs new file mode 100644 index 0000000..065465c --- /dev/null +++ b/PlaylistShared.Shared/DTO/AddTrackRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +public class AddTrackRequest +{ + [JsonPropertyName("sharedPlaylistToken")] + public string SharedPlaylistToken { get; set; } = null!; + + [JsonPropertyName("trackIds")] + public List TrackIds { get; set; } = new(); +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/ApiResponse.cs b/PlaylistShared.Shared/DTO/ApiResponse.cs new file mode 100644 index 0000000..d08db33 --- /dev/null +++ b/PlaylistShared.Shared/DTO/ApiResponse.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Универсальный контейнер ответа API. +/// Тип данных ответа. +public class ApiResponse +{ + /// Успешен ли запрос. + [JsonPropertyName("success")] + public bool Success { get; set; } + + /// Данные ответа (при успехе). + [JsonPropertyName("data")] + public T? Data { get; set; } + + /// Сообщение (опционально). + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// Ошибка (при неудаче). + [JsonPropertyName("error")] + public ErrorResponse? Error { get; set; } + + /// Создаёт успешный ответ. + public static ApiResponse Ok(T data, string? message = null) => + new() { Success = true, Data = data, Message = message }; + + /// Создаёт ответ с ошибкой. + public static ApiResponse Fail(ErrorResponse error) => + new() { Success = false, Error = error }; +} diff --git a/PlaylistShared.Shared/DTO/ErrorResponse.cs b/PlaylistShared.Shared/DTO/ErrorResponse.cs new file mode 100644 index 0000000..a7f6e11 --- /dev/null +++ b/PlaylistShared.Shared/DTO/ErrorResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Стандартный ответ сервера при ошибке. +public class ErrorResponse +{ + /// HTTP статус-код. + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } + + /// Сообщение об ошибке. + [JsonPropertyName("message")] + public string Message { get; set; } = null!; + + /// Дополнительные детали (опционально). + [JsonPropertyName("details")] + public string? Details { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/ExternalLoginCallbackRequest.cs b/PlaylistShared.Shared/DTO/ExternalLoginCallbackRequest.cs new file mode 100644 index 0000000..a1587ad --- /dev/null +++ b/PlaylistShared.Shared/DTO/ExternalLoginCallbackRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +public class ExternalLoginCallbackRequest +{ + [JsonPropertyName("code")] + public string Code { get; set; } = null!; + + [JsonPropertyName("state")] + public string State { get; set; } = null!; +} diff --git a/PlaylistShared.Shared/DTO/LoginRequest.cs b/PlaylistShared.Shared/DTO/LoginRequest.cs new file mode 100644 index 0000000..85e3b01 --- /dev/null +++ b/PlaylistShared.Shared/DTO/LoginRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Запрос на вход по паролю. +public class LoginRequest +{ + /// Имя пользователя (логин). + [JsonPropertyName("username")] + public string Username { get; set; } = null!; + + /// Пароль. + [JsonPropertyName("password")] + public string Password { get; set; } = null!; + + /// Запомнить пользователя (продлить сессию). + [JsonPropertyName("rememberMe")] + public bool RememberMe { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/LoginResponse.cs b/PlaylistShared.Shared/DTO/LoginResponse.cs new file mode 100644 index 0000000..49ff3fc --- /dev/null +++ b/PlaylistShared.Shared/DTO/LoginResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Ответ после успешного входа. +public class LoginResponse +{ + /// JWT токен доступа. + [JsonPropertyName("token")] + public string Token { get; set; } = null!; + + /// Refresh токен для обновления сессии. + [JsonPropertyName("refreshToken")] + public string RefreshToken { get; set; } = null!; + + /// Время истечения токена (UTC). + [JsonPropertyName("expiration")] + public DateTime Expiration { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/RefreshTokenRequest.cs b/PlaylistShared.Shared/DTO/RefreshTokenRequest.cs new file mode 100644 index 0000000..55aa5f2 --- /dev/null +++ b/PlaylistShared.Shared/DTO/RefreshTokenRequest.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Запрос на обновление JWT токена. +public class RefreshTokenRequest +{ + /// Refresh токен, полученный при входе. + [JsonPropertyName("refreshToken")] + public string RefreshToken { get; set; } = null!; +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/RegisterRequest.cs b/PlaylistShared.Shared/DTO/RegisterRequest.cs new file mode 100644 index 0000000..fb4e099 --- /dev/null +++ b/PlaylistShared.Shared/DTO/RegisterRequest.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Запрос на регистрацию нового пользователя. +public class RegisterRequest +{ + /// Имя пользователя (логин). + [JsonPropertyName("username")] + public string Username { get; set; } = null!; + + /// Email пользователя. + [JsonPropertyName("email")] + public string Email { get; set; } = null!; + + /// Пароль. + [JsonPropertyName("password")] + public string Password { get; set; } = null!; +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/SetYandexTokenRequest.cs b/PlaylistShared.Shared/DTO/SetYandexTokenRequest.cs new file mode 100644 index 0000000..7bcc741 --- /dev/null +++ b/PlaylistShared.Shared/DTO/SetYandexTokenRequest.cs @@ -0,0 +1,6 @@ +namespace PlaylistShared.Shared.DTO; + +public class SetYandexTokenRequest +{ + public string Token { get; set; } +} diff --git a/PlaylistShared.Shared/DTO/SharePlaylistDto.cs b/PlaylistShared.Shared/DTO/SharePlaylistDto.cs new file mode 100644 index 0000000..f4041d8 --- /dev/null +++ b/PlaylistShared.Shared/DTO/SharePlaylistDto.cs @@ -0,0 +1,48 @@ +using PlaylistShared.Shared.Enums; +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Запрос на создание нового шеринг-плейлиста. +public class SharePlaylistDto +{ + /// Идентификатор плейлиста в Яндекс.Музыке (kind). + [JsonPropertyName("yandexPlaylistKind")] + public string YandexPlaylistKind { get; set; } = null!; + + /// Идентификатор владельца плейлиста в Яндекс.Музыке (uid). + [JsonPropertyName("yandexPlaylistOwnerUid")] + public string YandexPlaylistOwnerUid { get; set; } = null!; + + /// Название плейлиста. + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + /// Описание плейлиста. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Ссылка на обложку. + [JsonPropertyName("coverUrl")] + public string? CoverUrl { get; set; } + + /// Дата создания плейлиста. + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + /// Токен для расшаривания плейлиста. + [JsonPropertyName("shareToken")] + public string ShareToken { get; set; } + + /// Права на просмотр. + [JsonPropertyName("viewPermission")] + public ViewPermission ViewPermission { get; set; } + + /// Права на добавление треков. + [JsonPropertyName("addPermission")] + public EditPermission AddPermission { get; set; } + + /// Права на удаление треков. + [JsonPropertyName("removePermission")] + public EditPermission RemovePermission { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/TrackOperationDto.cs b/PlaylistShared.Shared/DTO/TrackOperationDto.cs new file mode 100644 index 0000000..5bee3c6 --- /dev/null +++ b/PlaylistShared.Shared/DTO/TrackOperationDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Запрос на добавление или удаление треков. +public class TrackOperationDto +{ + /// Токен шеринг-плейлиста (для проверки прав). + [JsonPropertyName("sharedPlaylistToken")] + public string SharedPlaylistToken { get; set; } = null!; + + /// Список идентификаторов треков в Яндекс.Музыке. + [JsonPropertyName("trackIds")] + public List TrackIds { get; set; } = new(); +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/UpdatePermissionsDto.cs b/PlaylistShared.Shared/DTO/UpdatePermissionsDto.cs new file mode 100644 index 0000000..15477a1 --- /dev/null +++ b/PlaylistShared.Shared/DTO/UpdatePermissionsDto.cs @@ -0,0 +1,20 @@ +using PlaylistShared.Shared.Enums; +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Запрос на обновление прав доступа шеринг-плейлиста. +public class UpdatePermissionsDto +{ + /// Новые права на просмотр. + [JsonPropertyName("viewPermission")] + public ViewPermission ViewPermission { get; set; } + + /// Новые права на добавление треков. + [JsonPropertyName("addPermission")] + public EditPermission AddPermission { get; set; } + + /// Новые права на удаление треков. + [JsonPropertyName("removePermission")] + public EditPermission RemovePermission { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/YandexPlaylistInfo.cs b/PlaylistShared.Shared/DTO/YandexPlaylistInfo.cs new file mode 100644 index 0000000..c766cb7 --- /dev/null +++ b/PlaylistShared.Shared/DTO/YandexPlaylistInfo.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.DTO; + +/// Информация о плейлисте из Яндекс.Музыки (для импорта). +public class YandexPlaylistInfo +{ + /// Идентификатор плейлиста (kind). + [JsonPropertyName("kind")] + public string Kind { get; set; } = null!; + + /// Идентификатор владельца плейлиста (uid). + [JsonPropertyName("ownerUid")] + public string OwnerUid { get; set; } = null!; + + /// Название плейлиста. + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + /// Описание плейлиста. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// URL обложки. + [JsonPropertyName("coverUrl")] + public string? CoverUrl { get; set; } + + /// Кол-во треков. + [JsonPropertyName("trackCount")] + public int TrackCount { get; set; } + + /// Расшаренный + [JsonPropertyName("isShared")] + public bool IsShared { get; set; } + + /// Расшаренная ссылка + [JsonPropertyName("shareToken")] + public string? ShareToken { get; set; } +} + +public class SharePlaylistRequest +{ + public string Kind { get; set; } + public string OwnerUid { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/DTO/YandexTokenStatus.cs b/PlaylistShared.Shared/DTO/YandexTokenStatus.cs new file mode 100644 index 0000000..7eb6a1f --- /dev/null +++ b/PlaylistShared.Shared/DTO/YandexTokenStatus.cs @@ -0,0 +1,8 @@ +namespace PlaylistShared.Shared.DTO; + +public class YandexTokenStatus +{ + public bool HasToken { get; set; } + public bool IsValid { get; set; } + public DateTime ExpiryUtc { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/Enums/EditPermission.cs b/PlaylistShared.Shared/Enums/EditPermission.cs new file mode 100644 index 0000000..92bd9bf --- /dev/null +++ b/PlaylistShared.Shared/Enums/EditPermission.cs @@ -0,0 +1,17 @@ +namespace PlaylistShared.Shared.Enums; + +/// Кто может выполнять действие (добавление/удаление). +public enum EditPermission +{ + /// Все, включая неавторизованных (но для выполнения действия нужна авторизация, так как API требует токен). + Everyone, + + /// Только авторизованные пользователи. + AuthorizedOnly, + + /// Никто, кроме создателя. + Nobody, + + /// Только тот пользователь, который добавил трек (актуально для удаления). + AddedByUserOnly, +} \ No newline at end of file diff --git a/PlaylistShared.Shared/Enums/ViewPermission.cs b/PlaylistShared.Shared/Enums/ViewPermission.cs new file mode 100644 index 0000000..f3213c8 --- /dev/null +++ b/PlaylistShared.Shared/Enums/ViewPermission.cs @@ -0,0 +1,11 @@ +namespace PlaylistShared.Shared.Enums; + +/// Кто может просматривать плейлист. +public enum ViewPermission +{ + /// Все, включая неавторизованных. + Everyone, + + /// Только авторизованные пользователи. + AuthorizedOnly, +} diff --git a/PlaylistShared.Shared/Models/ApplicationUserDto.cs b/PlaylistShared.Shared/Models/ApplicationUserDto.cs new file mode 100644 index 0000000..db1160c --- /dev/null +++ b/PlaylistShared.Shared/Models/ApplicationUserDto.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.Models; + +/// DTO пользователя (без конфиденциальных данных). +public class ApplicationUserDto +{ + /// Идентификатор пользователя в системе. + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// Имя пользователя (логин). + [JsonPropertyName("userName")] + public string UserName { get; set; } = null!; + + /// Email пользователя. + [JsonPropertyName("email")] + public string? Email { get; set; } + + /// Идентификатор пользователя в Яндексе (если привязан). + [JsonPropertyName("yandexId")] + public string? YandexId { get; set; } + + /// Отображаемое имя (можно использовать UserName). + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/Models/SharedPlaylistDto.cs b/PlaylistShared.Shared/Models/SharedPlaylistDto.cs new file mode 100644 index 0000000..d270c28 --- /dev/null +++ b/PlaylistShared.Shared/Models/SharedPlaylistDto.cs @@ -0,0 +1,68 @@ +using PlaylistShared.Shared.Enums; +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.Models; + +/// DTO шеринг-плейлиста (без навигационных свойств). +public class SharedPlaylistDto +{ + /// Уникальный идентификатор записи. + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// Идентификатор пользователя-создателя (владельца). + [JsonPropertyName("creatorUserId")] + public Guid CreatorUserId { get; set; } + + /// Идентификатор плейлиста в Яндекс.Музыке (kind). + [JsonPropertyName("yandexPlaylistKind")] + public string YandexPlaylistKind { get; set; } = null!; + + /// Идентификатор владельца плейлиста в Яндекс.Музыке (uid). + [JsonPropertyName("yandexPlaylistOwnerUid")] + public string YandexPlaylistOwnerUid { get; set; } = null!; + + /// Название плейлиста. + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + /// Описание плейлиста. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// URL обложки плейлиста. + [JsonPropertyName("coverUrl")] + public string? CoverUrl { get; set; } + + /// Дата создания записи. + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + /// Дата последнего обновления. + [JsonPropertyName("updatedAt")] + public DateTime UpdatedAt { get; set; } + + /// Признак мягкого удаления. + [JsonPropertyName("isDeleted")] + public bool IsDeleted { get; set; } + + /// Уникальный токен для публичной ссылки. + [JsonPropertyName("shareToken")] + public string ShareToken { get; set; } = null!; + + /// Права на просмотр. + [JsonPropertyName("viewPermission")] + public ViewPermission ViewPermission { get; set; } + + /// Права на добавление треков. + [JsonPropertyName("addPermission")] + public EditPermission AddPermission { get; set; } + + /// Права на удаление треков. + [JsonPropertyName("removePermission")] + public EditPermission RemovePermission { get; set; } + + /// Информация о создателе (опционально, подгружается отдельно). + [JsonPropertyName("creator")] + public ApplicationUserDto? Creator { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/Models/TrackAdditionLogDto.cs b/PlaylistShared.Shared/Models/TrackAdditionLogDto.cs new file mode 100644 index 0000000..98b366c --- /dev/null +++ b/PlaylistShared.Shared/Models/TrackAdditionLogDto.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace PlaylistShared.Shared.Models; + +/// DTO лога добавления трека (без навигации). +public class TrackAdditionLogDto +{ + /// Уникальный идентификатор записи. + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// Идентификатор шеринг-плейлиста. + [JsonPropertyName("sharedPlaylistId")] + public Guid SharedPlaylistId { get; set; } + + /// Идентификатор трека в Яндекс.Музыке. + [JsonPropertyName("trackId")] + public string TrackId { get; set; } = null!; + + /// Идентификатор пользователя, добавившего трек. + [JsonPropertyName("addedByUserId")] + public Guid AddedByUserId { get; set; } + + /// Дата и время добавления (UTC). + [JsonPropertyName("addedAtUtc")] + public DateTime AddedAtUtc { get; set; } +} \ No newline at end of file diff --git a/PlaylistShared.Shared/PlaylistShared.Shared.csproj b/PlaylistShared.Shared/PlaylistShared.Shared.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/PlaylistShared.Shared/PlaylistShared.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/PlaylistShared.slnx b/PlaylistShared.slnx new file mode 100644 index 0000000..ca443c1 --- /dev/null +++ b/PlaylistShared.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docker-compose.dcproj b/docker-compose.dcproj new file mode 100644 index 0000000..c58b109 --- /dev/null +++ b/docker-compose.dcproj @@ -0,0 +1,15 @@ + + + + 2.1 + Linux + 81dded9d-158b-e303-5f62-77a2896d2a5a + + + + docker-compose.yml + + + + + \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..e5a8bb4 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,14 @@ +services: + playlistshared.api: + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_HTTP_PORTS=8080 + - ASPNETCORE_HTTPS_PORTS=8081 + ports: + - "8080" + - "8081" + volumes: + - ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro + - ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro + - ${APPDATA}/ASP.NET/Https:/home/app/.aspnet/https:ro + - ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da476aa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +name: playlistshared + +networks: + playlistshared_network: + driver: bridge + proxy_network: + external: true + +services: + playlistshared.api: + image: ${DOCKER_REGISTRY-}playlistshared.api + build: + context: . + dockerfile: PlaylistShared.Api/Dockerfile + container_name: playlistshared_api + ports: + - "7001:80" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DOTNET_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:80 + networks: + - playlistshared_network + - proxy_network + + playlistshared.pwa: + image: ${DOCKER_REGISTRY-}playlistshared.pwa + build: + dockerfile: PlaylistShared.Pwa/Dockerfile + container_name: playlistshared_pwa + ports: + - "7101:80" + depends_on: + - playlistshared.api + networks: + - playlistshared_network + - proxy_network diff --git a/launchSettings.json b/launchSettings.json new file mode 100644 index 0000000..3bb15e7 --- /dev/null +++ b/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Docker Compose": { + "commandName": "DockerCompose", + "commandVersion": "1.0", + "serviceActions": { + "playlistshared.api": "StartDebugging" + } + } + } +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..91ba526 --- /dev/null +++ b/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/run-docker-compose.bat b/run-docker-compose.bat new file mode 100644 index 0000000..e846b84 --- /dev/null +++ b/run-docker-compose.bat @@ -0,0 +1,2 @@ +docker-compose up -d --force-recreate +pause \ No newline at end of file