From 37c997dbe09d1037fc4dad6c480dcb3d211523b1 Mon Sep 17 00:00:00 2001 From: FrigaT Date: Mon, 13 Apr 2026 14:16:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D1=8C=D1=82?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 30 ++ .../Controllers/AccountController.cs | 75 +++ .../Controllers/OpenIdController.cs | 76 ++++ .../Controllers/PlaylistController.cs | 174 +++++++ .../Controllers/SharedPlaylistController.cs | 71 +++ .../Controllers/YandexTokenController.cs | 57 +++ .../Data/ApplicationDbContext.cs | 46 ++ .../20260412171234_InitialCreate.Designer.cs | 426 ++++++++++++++++++ .../20260412171234_InitialCreate.cs | 314 +++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 423 +++++++++++++++++ PlaylistShared.Api/Dockerfile | 31 ++ .../Entities/ApplicationUser.cs | 28 ++ .../Entities/SharedPlaylistEntity.cs | 26 ++ .../Entities/TrackAdditionLogEntity.cs | 15 + .../Extensions/ClaimsPrincipalExtensions.cs | 20 + .../Extensions/YCoverExtensions.cs | 19 + .../Mapping/AppMappingProfile.cs | 16 + PlaylistShared.Api/PlaylistShared.Api.csproj | 35 ++ PlaylistShared.Api/Program.cs | 133 ++++++ .../Properties/launchSettings.json | 31 ++ PlaylistShared.Api/Services/JwtService.cs | 49 ++ .../Services/SharedPlaylistService.cs | 136 ++++++ .../Services/TrackAdditionLogService.cs | 44 ++ .../Services/YandexMusicService.cs | 88 ++++ .../appsettings.Development.json | 21 + PlaylistShared.Api/appsettings.json | 29 ++ PlaylistShared.PWA2123/App.razor | 19 + .../Layout/LoginDisplay.razor | 19 + .../Layout/MainLayout.razor | 100 ++++ .../Layout/MainLayout.razor.css | 77 ++++ PlaylistShared.PWA2123/Layout/NavMenu.razor | 9 + .../Layout/RedirectToLogin.razor | 9 + .../Pages/AuthCallback.razor | 24 + .../Pages/Authentication.razor | 7 + PlaylistShared.PWA2123/Pages/Counter.razor | 18 + PlaylistShared.PWA2123/Pages/Home.razor | 18 + PlaylistShared.PWA2123/Pages/Login.razor | 52 +++ .../Pages/LoginDisplay.razor | 11 + PlaylistShared.PWA2123/Pages/Logout.razor | 12 + PlaylistShared.PWA2123/Pages/NotFound.razor | 9 + PlaylistShared.PWA2123/Pages/Weather.razor | 52 +++ .../PlaylistShared.PWA.csproj | 25 + PlaylistShared.PWA2123/Program.cs | 36 ++ .../Properties/launchSettings.json | 15 + PlaylistShared.PWA2123/Services/ApiClient.cs | 77 ++++ .../Services/AuthStateProvider.cs | 99 ++++ .../Services/TokenStorage.cs | 31 ++ PlaylistShared.PWA2123/_Imports.razor | 13 + .../wwwroot/appsettings.Development.json | 6 + .../wwwroot/appsettings.json | 6 + PlaylistShared.PWA2123/wwwroot/css/app.css | 110 +++++ PlaylistShared.PWA2123/wwwroot/favicon.png | Bin 0 -> 2588 bytes PlaylistShared.PWA2123/wwwroot/icon-192.png | Bin 0 -> 8799 bytes PlaylistShared.PWA2123/wwwroot/icon-512.png | Bin 0 -> 23883 bytes PlaylistShared.PWA2123/wwwroot/index.html | 41 ++ .../wwwroot/manifest.webmanifest | 22 + .../wwwroot/sample-data/weather.json | 27 ++ .../wwwroot/service-worker.js | 4 + .../wwwroot/service-worker.published.js | 55 +++ PlaylistShared.Pwa/.dockerignore | 30 ++ PlaylistShared.Pwa/App.razor | 19 + PlaylistShared.Pwa/Dockerfile | 41 ++ PlaylistShared.Pwa/Layout/LoginDisplay.razor | 20 + PlaylistShared.Pwa/Layout/MainLayout.razor | 100 ++++ .../Layout/MainLayout.razor.css | 77 ++++ PlaylistShared.Pwa/Layout/NavMenu.razor | 11 + PlaylistShared.Pwa/Pages/AuthCallback.razor | 24 + PlaylistShared.Pwa/Pages/Home.razor | 18 + PlaylistShared.Pwa/Pages/Login.razor | 75 +++ PlaylistShared.Pwa/Pages/Logout.razor | 12 + PlaylistShared.Pwa/Pages/MyPlaylists.razor | 125 +++++ PlaylistShared.Pwa/Pages/NotFound.razor | 9 + PlaylistShared.Pwa/Pages/Profile.razor | 72 +++ PlaylistShared.Pwa/Pages/Register.razor | 67 +++ .../Pages/SharedPlaylistView.razor | 163 +++++++ PlaylistShared.Pwa/PlaylistShared.Pwa.csproj | 27 ++ PlaylistShared.Pwa/Program.cs | 41 ++ .../Properties/launchSettings.json | 15 + PlaylistShared.Pwa/Services/ApiClient.cs | 19 + .../Services/AuthStateProvider.cs | 101 +++++ PlaylistShared.Pwa/Services/TokenStorage.cs | 31 ++ PlaylistShared.Pwa/_Imports.razor | 15 + PlaylistShared.Pwa/nginx.conf | 55 +++ .../wwwroot/appsettings.Development.json | 3 + PlaylistShared.Pwa/wwwroot/appsettings.json | 3 + PlaylistShared.Pwa/wwwroot/css/app.css | 110 +++++ PlaylistShared.Pwa/wwwroot/favicon.png | Bin 0 -> 2588 bytes PlaylistShared.Pwa/wwwroot/icon-192.png | Bin 0 -> 8799 bytes PlaylistShared.Pwa/wwwroot/icon-512.png | Bin 0 -> 23883 bytes PlaylistShared.Pwa/wwwroot/index.html | 41 ++ .../wwwroot/manifest.webmanifest | 22 + PlaylistShared.Pwa/wwwroot/service-worker.js | 4 + .../wwwroot/service-worker.published.js | 55 +++ PlaylistShared.Shared/DTO/AddTrackRequest.cs | 12 + PlaylistShared.Shared/DTO/ApiResponse.cs | 32 ++ PlaylistShared.Shared/DTO/ErrorResponse.cs | 19 + .../DTO/ExternalLoginCallbackRequest.cs | 12 + PlaylistShared.Shared/DTO/LoginRequest.cs | 19 + PlaylistShared.Shared/DTO/LoginResponse.cs | 19 + .../DTO/RefreshTokenRequest.cs | 11 + PlaylistShared.Shared/DTO/RegisterRequest.cs | 19 + .../DTO/SetYandexTokenRequest.cs | 6 + PlaylistShared.Shared/DTO/SharePlaylistDto.cs | 48 ++ .../DTO/TrackOperationDto.cs | 15 + .../DTO/UpdatePermissionsDto.cs | 20 + .../DTO/YandexPlaylistInfo.cs | 45 ++ .../DTO/YandexTokenStatus.cs | 8 + PlaylistShared.Shared/Enums/EditPermission.cs | 17 + PlaylistShared.Shared/Enums/ViewPermission.cs | 11 + .../Models/ApplicationUserDto.cs | 27 ++ .../Models/SharedPlaylistDto.cs | 68 +++ .../Models/TrackAdditionLogDto.cs | 27 ++ .../PlaylistShared.Shared.csproj | 9 + PlaylistShared.slnx | 8 + docker-compose.dcproj | 15 + docker-compose.override.yml | 14 + docker-compose.yml | 37 ++ launchSettings.json | 11 + nuget.config | 12 + run-docker-compose.bat | 2 + 120 files changed, 5364 insertions(+) create mode 100644 .dockerignore create mode 100644 PlaylistShared.Api/Controllers/AccountController.cs create mode 100644 PlaylistShared.Api/Controllers/OpenIdController.cs create mode 100644 PlaylistShared.Api/Controllers/PlaylistController.cs create mode 100644 PlaylistShared.Api/Controllers/SharedPlaylistController.cs create mode 100644 PlaylistShared.Api/Controllers/YandexTokenController.cs create mode 100644 PlaylistShared.Api/Data/ApplicationDbContext.cs create mode 100644 PlaylistShared.Api/Data/Migrations/20260412171234_InitialCreate.Designer.cs create mode 100644 PlaylistShared.Api/Data/Migrations/20260412171234_InitialCreate.cs create mode 100644 PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 PlaylistShared.Api/Dockerfile create mode 100644 PlaylistShared.Api/Entities/ApplicationUser.cs create mode 100644 PlaylistShared.Api/Entities/SharedPlaylistEntity.cs create mode 100644 PlaylistShared.Api/Entities/TrackAdditionLogEntity.cs create mode 100644 PlaylistShared.Api/Extensions/ClaimsPrincipalExtensions.cs create mode 100644 PlaylistShared.Api/Extensions/YCoverExtensions.cs create mode 100644 PlaylistShared.Api/Mapping/AppMappingProfile.cs create mode 100644 PlaylistShared.Api/PlaylistShared.Api.csproj create mode 100644 PlaylistShared.Api/Program.cs create mode 100644 PlaylistShared.Api/Properties/launchSettings.json create mode 100644 PlaylistShared.Api/Services/JwtService.cs create mode 100644 PlaylistShared.Api/Services/SharedPlaylistService.cs create mode 100644 PlaylistShared.Api/Services/TrackAdditionLogService.cs create mode 100644 PlaylistShared.Api/Services/YandexMusicService.cs create mode 100644 PlaylistShared.Api/appsettings.Development.json create mode 100644 PlaylistShared.Api/appsettings.json create mode 100644 PlaylistShared.PWA2123/App.razor create mode 100644 PlaylistShared.PWA2123/Layout/LoginDisplay.razor create mode 100644 PlaylistShared.PWA2123/Layout/MainLayout.razor create mode 100644 PlaylistShared.PWA2123/Layout/MainLayout.razor.css create mode 100644 PlaylistShared.PWA2123/Layout/NavMenu.razor create mode 100644 PlaylistShared.PWA2123/Layout/RedirectToLogin.razor create mode 100644 PlaylistShared.PWA2123/Pages/AuthCallback.razor create mode 100644 PlaylistShared.PWA2123/Pages/Authentication.razor create mode 100644 PlaylistShared.PWA2123/Pages/Counter.razor create mode 100644 PlaylistShared.PWA2123/Pages/Home.razor create mode 100644 PlaylistShared.PWA2123/Pages/Login.razor create mode 100644 PlaylistShared.PWA2123/Pages/LoginDisplay.razor create mode 100644 PlaylistShared.PWA2123/Pages/Logout.razor create mode 100644 PlaylistShared.PWA2123/Pages/NotFound.razor create mode 100644 PlaylistShared.PWA2123/Pages/Weather.razor create mode 100644 PlaylistShared.PWA2123/PlaylistShared.PWA.csproj create mode 100644 PlaylistShared.PWA2123/Program.cs create mode 100644 PlaylistShared.PWA2123/Properties/launchSettings.json create mode 100644 PlaylistShared.PWA2123/Services/ApiClient.cs create mode 100644 PlaylistShared.PWA2123/Services/AuthStateProvider.cs create mode 100644 PlaylistShared.PWA2123/Services/TokenStorage.cs create mode 100644 PlaylistShared.PWA2123/_Imports.razor create mode 100644 PlaylistShared.PWA2123/wwwroot/appsettings.Development.json create mode 100644 PlaylistShared.PWA2123/wwwroot/appsettings.json create mode 100644 PlaylistShared.PWA2123/wwwroot/css/app.css create mode 100644 PlaylistShared.PWA2123/wwwroot/favicon.png create mode 100644 PlaylistShared.PWA2123/wwwroot/icon-192.png create mode 100644 PlaylistShared.PWA2123/wwwroot/icon-512.png create mode 100644 PlaylistShared.PWA2123/wwwroot/index.html create mode 100644 PlaylistShared.PWA2123/wwwroot/manifest.webmanifest create mode 100644 PlaylistShared.PWA2123/wwwroot/sample-data/weather.json create mode 100644 PlaylistShared.PWA2123/wwwroot/service-worker.js create mode 100644 PlaylistShared.PWA2123/wwwroot/service-worker.published.js create mode 100644 PlaylistShared.Pwa/.dockerignore create mode 100644 PlaylistShared.Pwa/App.razor create mode 100644 PlaylistShared.Pwa/Dockerfile create mode 100644 PlaylistShared.Pwa/Layout/LoginDisplay.razor create mode 100644 PlaylistShared.Pwa/Layout/MainLayout.razor create mode 100644 PlaylistShared.Pwa/Layout/MainLayout.razor.css create mode 100644 PlaylistShared.Pwa/Layout/NavMenu.razor create mode 100644 PlaylistShared.Pwa/Pages/AuthCallback.razor create mode 100644 PlaylistShared.Pwa/Pages/Home.razor create mode 100644 PlaylistShared.Pwa/Pages/Login.razor create mode 100644 PlaylistShared.Pwa/Pages/Logout.razor create mode 100644 PlaylistShared.Pwa/Pages/MyPlaylists.razor create mode 100644 PlaylistShared.Pwa/Pages/NotFound.razor create mode 100644 PlaylistShared.Pwa/Pages/Profile.razor create mode 100644 PlaylistShared.Pwa/Pages/Register.razor create mode 100644 PlaylistShared.Pwa/Pages/SharedPlaylistView.razor create mode 100644 PlaylistShared.Pwa/PlaylistShared.Pwa.csproj create mode 100644 PlaylistShared.Pwa/Program.cs create mode 100644 PlaylistShared.Pwa/Properties/launchSettings.json create mode 100644 PlaylistShared.Pwa/Services/ApiClient.cs create mode 100644 PlaylistShared.Pwa/Services/AuthStateProvider.cs create mode 100644 PlaylistShared.Pwa/Services/TokenStorage.cs create mode 100644 PlaylistShared.Pwa/_Imports.razor create mode 100644 PlaylistShared.Pwa/nginx.conf create mode 100644 PlaylistShared.Pwa/wwwroot/appsettings.Development.json create mode 100644 PlaylistShared.Pwa/wwwroot/appsettings.json create mode 100644 PlaylistShared.Pwa/wwwroot/css/app.css create mode 100644 PlaylistShared.Pwa/wwwroot/favicon.png create mode 100644 PlaylistShared.Pwa/wwwroot/icon-192.png create mode 100644 PlaylistShared.Pwa/wwwroot/icon-512.png create mode 100644 PlaylistShared.Pwa/wwwroot/index.html create mode 100644 PlaylistShared.Pwa/wwwroot/manifest.webmanifest create mode 100644 PlaylistShared.Pwa/wwwroot/service-worker.js create mode 100644 PlaylistShared.Pwa/wwwroot/service-worker.published.js create mode 100644 PlaylistShared.Shared/DTO/AddTrackRequest.cs create mode 100644 PlaylistShared.Shared/DTO/ApiResponse.cs create mode 100644 PlaylistShared.Shared/DTO/ErrorResponse.cs create mode 100644 PlaylistShared.Shared/DTO/ExternalLoginCallbackRequest.cs create mode 100644 PlaylistShared.Shared/DTO/LoginRequest.cs create mode 100644 PlaylistShared.Shared/DTO/LoginResponse.cs create mode 100644 PlaylistShared.Shared/DTO/RefreshTokenRequest.cs create mode 100644 PlaylistShared.Shared/DTO/RegisterRequest.cs create mode 100644 PlaylistShared.Shared/DTO/SetYandexTokenRequest.cs create mode 100644 PlaylistShared.Shared/DTO/SharePlaylistDto.cs create mode 100644 PlaylistShared.Shared/DTO/TrackOperationDto.cs create mode 100644 PlaylistShared.Shared/DTO/UpdatePermissionsDto.cs create mode 100644 PlaylistShared.Shared/DTO/YandexPlaylistInfo.cs create mode 100644 PlaylistShared.Shared/DTO/YandexTokenStatus.cs create mode 100644 PlaylistShared.Shared/Enums/EditPermission.cs create mode 100644 PlaylistShared.Shared/Enums/ViewPermission.cs create mode 100644 PlaylistShared.Shared/Models/ApplicationUserDto.cs create mode 100644 PlaylistShared.Shared/Models/SharedPlaylistDto.cs create mode 100644 PlaylistShared.Shared/Models/TrackAdditionLogDto.cs create mode 100644 PlaylistShared.Shared/PlaylistShared.Shared.csproj create mode 100644 PlaylistShared.slnx create mode 100644 docker-compose.dcproj create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.yml create mode 100644 launchSettings.json create mode 100644 nuget.config create mode 100644 run-docker-compose.bat 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 0000000000000000000000000000000000000000..8a4358cf0d7c5dfe0e7ac5838351ba6b055995cc GIT binary patch literal 2588 zcmd^Ai#wEC7k|yT4l2iWN{pOJil)INmq=sWYK;51Wz4uVxlJ<@hA?9!O$Zsc&m(|zP@w5f8g8C-tSuLx7XTh@3q#u_bDR5N*1vm z0RVukjWx~@qNvp&35UM868^8N0@d2_AOJ*d0RT<{0DOWV&J+L~Lj%AJ832rO06>|Z z-|Aoj0I+Q00cT4{AvO$*qk~CN;Egsg;{?b*4jvBynN*NX14F&R0B4X!0^M*R0Du85 zptmjPVFiZyfWe;NAv@6D3FNUrZ#&S_8YEeQ`*lFN4|wPR=yL#smi@o&(ZN5So#pp8 z66F5l)Ym9ci2s0l+58Ab9tLAVL3jM$xS!){rl5>`3pM%RYA(Ks-xx^eYyW;0;vw*> zm#;h|ey{M&9AW}p{$bhg75%dY(G?*eJn?U6GZ<bK^xMR&2Cv9rRB zNsxI>kRV03c4q>BwEXH20rK)yAW(v3bI?*^2BsorDEparbqN6A8>5^_EJxo66rB+k zKnwOqvDkEfRAdMp0IW~-xZ2g7L$4bf+{O^4;nx>ECBxgC{d1p|Mc2rRd;Mmo#2$uM z*xTC=TJ;XTHhZ>VrOrdHZS-zpmPy;vK+yI)uWM27UezDG{EpG~SjitGk*h%LKhaRD7dvryug~Hgacee_yojGa6SRh}wqf+2_u3Do7mTf1oX0l=^QAizd zG`LHaN_VJ9D7z4Uq^T_Ckyd{u0yQHP;FYeQpP4NY;2v}GiejdS#cVqK?xTh{w1b`d zdHtIE1?V34preD0Cfcc`k*9B{79U^aoYix0jh@{gG?9zoOhxQ8|G;fbGwZ^4$@%Ph zmpk`BSmf|CIHk5{ubi&M*iveur>1P6pBXzJcQIVXDzmq1RxqHOn4U@X4Mf*{*aKVR z-^}pll}yh)hv_08Yn(KGh#BQ`H@6aQjB>KvMwauLd&XWyu4$f^Zf~HYPcNMksqcC8 z3(H={Hh+r*>}``ws_3LgQE!atO<~s;GjvoTTOYO7o9>GD>!@1j%H>)&F7HjO;?xH` zkiQT(S}(9#nbYP6zWmGpa}afdC*o|xT9Ud`_XYJ%v@%Q;Z+oWqSqffrb5A>A*q+E$>vdo-15ekbUb<5hm zh#O39I2z;1Rm`sp_~nwnez|dsk&MEQ1zA)lz9xQ4O+0g8u282kgk3i3jWm8HL>P4< z^!N3@Q*Jd3wrxd56Ba!%9~Sm2k~j)u2{9%_H+R_%TLD02-s#x8pSf81og@ZKT`R&0q9gTI^p| z_vkWC;)<%I0+V3Go*CwMA6Iop+W(1j;!VPV^fbIYW`^_tx{x=sEJ-XsUzWcSmEjK& z0E5P0b-();S9rWw%)*#I_{1YDH`IsPw?1YLxA8U1Pva-5*&gPLeNDLAV@Yr z0t7jJS65ecb@i`T^SY|v`|9iI>6x0D?qmZ!4I&5~1ONaKX=$n&JxJ7l4G-txZM5c& z`ygNrnnt<+Kqw~wfQ$nGt{+s$eE=W;4gmZ_007d3006ajNspoI!v`FD9Sv2${eMq+ zU+s$r4ZgRgr5^x5K>l9?0*Xs$9+bHLTDoeuKS4CulB@bY6>r|#`K|&1mQ*K54 zdG*!y7BayFBJW%59NNY6yQxOWkp2Z`o>&oDE_^fL!f1Vvv}bhqf|js0_-xYWV4Z&d zhM+VrXh1nQm57aNdqa#-Fha5py(MlYP<`3!_ouKbR)w!CC*Av*>+f2>LEcxfezD)( zUzx*Mm|KgJSWbdl67URPh@*rp&^Iy!28zw3kaH%hfM;=(Ga=Elkg%-$1~b>cbLB%W zAMM;XVyfB{hljbkM3DNdAxSSw=T=}X!-80v<>irFvx8xRLw)uRPM8DUhDOvCVOknp zGj$pozOg%#)E~^;;Uzy7|8BPWF?WnmVOFI$phOV$F3;#?6H7O!KnIr?-7uF>?l5H} zHjlv1Hf)Zot%z~!tc_UV{+tVe{VdN@r|K{Jd03UFx{fS<9kFjv8R89|UwIz|H`K8# zd(^!23O;Q4+-Ig;mN%n1IQ0`lW%ys@3sK9SutWNCS!fV)KS)UNBDy_GVNiV#YAbwN zbo90SwITccK9iI@R8V>CDkI?u8fq=*NRW1&B~j*^$ZAS7M~~j8Rb;;T@=b}K)Y@?{aTkW*<9ly->QyqZZFuFa zzdoUM2`4V-PbXmp7TW6dScKeY`QWVrJhU|A5sc)ix0$*ls_}fVC|a}qMn0ht+2;I} zyuxL*El=a_h&RRM_@D26A*6LCK1vI_J839G>G{}ZkFOl%^posKwY*=^g?GVVM9m~e zNn6`In8mpFj#7XVXm4g zlr&q-AP&ZzbdB}*uc{&R{L%ob=0u)^SAjfA&{c|2Ho*F34_6$M{>E9P<$QS1_$RYT zdeJ1*x#x|ihuq&Fy|UdifAjSxw-G`A#NjlAII#nW|4E!9wR?bue!sCVn#;O(Q+lEZ z6O`(DZ6j%-XQU!JfX|yrXya+57N>-ss$mFF;;6?65(F+nWR~y@d>Ac(EI>v9b-a1v z4P&yEenfrejZq+yB$K3)#4F6eyG2k$JE7Q63}~J-)-)QC+r!YfC~0qLjIiU75LVZ& z6`BsMp&0gEw-W1Ihm=K&Arrk9y%XI8#EtuK#=a*l11mUGp^58Tu36!Rc)1+O=zx%9{eqZ%MPzR}o0--4pJgqI8 zpYDWn@>t%AqUu$JZeTCBpF&wu;&@{=2^qG}ey^hl-R@tFu#BR5yEpj2^rYCqP^llU zNcdHq(|{f{t`k{m*fazTObdN<$a=%4Yiu_V=*j*3Q&A#7%^qogpQmhBFm073;oV&&wM)BofQbEUs6kOUoaeF!14)eQskrpr zwF3WJ>>9+H=txeLSwx?TvYf=nIyLrd*3QsuFqR9IHzg*&@ERH?oW!`xgg#9os<5&Z z(WRmUpd)wFWZalEoTCkbv79+EemG2~HAS>2r!gas{9gU@a=aoVz}MuVw_|@;w56eH z1o0Z09XNux7pYyxJ>zU)F{{x8q$<|!cTO3Pd60>R$bQw}+Yr>Im`xM*@28=*xg;F+ z9l%ID>r@)NXm>uPd`g6vH`IP>QCH#*TaXH-%{voOLQ=wbNS=}a!a&jDXvva=w+({H zBDDY}yBof3;&rAYw^(0ldk&&B4VnzKZNlyMwyIMbNw?dRNjr%4$lB7XnNsjh$ZT77 zeMnA%ju1#s)fyX=erHPfv>*54@eeIEJ^U`H@NT>5#^)4lDL+v~>=|$=xCHkD)1Owx zF_Np88fQIFq~a|^VJv5#eC+kmNuETT2)Uyf5JA9k3Eoz^g6CWe2|Y!1X3h1ioIn3` zJ}AL5;cq=Z!^4?@;|U^e{KIdgBmSe_u5Q~RP!Pl87F1*d4k!P1nGbu9*~{{t__giJ zMZ?06C7&+23_P$QuI_Y_uNPVQK>3Zn1aPEzKGwpD!NSO)_46de8veO}71XRdHCR)j z9EDFp*xqSpO`FA+r?4IYK9Kv!u2MoL^C)N0|+FXW^dsJ(WEQ5}3s`#|LN?gL>7zN~Qwn}Bv! zhM6PsVVXh00hf?(ex5opwMbF#kl!^*_^mYL3dmLDXp;+k9@zk#|#Ta3vCZsamf*+X=2yeIW%NV9qEmY1z@qB z`8ZeBnO{RkQOsKs^Iw|$eI@CPT zRdb}vY-s-FN+c}~0%(i-(Kdwb#1a-Dsz^CdMtGFDJ69BcdHT5GW5ru_UMcqP6XDhy zl|DHs*{vzFd)d1CiCNyCiVE99OL*lxJM3hMfT z%qq9dGC8vdzmab4WL=;vV~ET(nI-MdOi?6wr^}Em40)j0T1Sh&wwguMp{|ET4_mQ> zQDJ)rlmjn$$qek_gv0onoSB-**1$JagjcGIe;+Mk??7J~u&GKA40U6zkdGH5SnCPf zeF-rc++L*hpsx3UkL-VJh+TfMe~opa+O(LtT`nex-Za$Ay8>zKkZurnad``o2*mC3<-*$3b@6{$uPZVUFy(FokVq zafOuB(Jg}a%$VxJlkbUp$mdy$5tF1&b?RW&8x59GKU`zEnP*fMxNE74*P+A~sh(R}Tk^h@LH=t|TAGU8#OHlt3~RDU!xzsEWsuxp7G86Eucj*J#4L2%Du z&ixB~qM<(VmQ?8y(ky$RhlPd?9q*DS%?pjP-IPWaWJ8MXRp&>dX!^t!ZoFlibttHp zPCRxkuS*iyQ*?DO+RWR5VYH3RQC-mLiIr0W_J61HT0wO<{OC|enAP!}iU3=E?Jr!| zIeFmYa%Nj)5v{5y9PeeK@$53HEnM2)_sqTM&@>?MSZ(h`GKE&%gE$QWKor0h%$XQ?vl=M}{#-$(zlni|d*6NSp>gxL9U z=n~y-KXW+4HXq@B^7Dp87BPRDhHah)de;*5Qqpg zRCiurmjsOl*nAb?PN7A|qOx1$a#n&%_d$(c0p@JAYOZ#S6@|CZ^AMmO^mk!V^bZt| z00cchqBYi|;1TkNx-z6_8~Ua@iA6v_HqG6lef#IB$ZpajPv0cF$EyP=C$wlRYbOz7 zRrZ6fr=S)p^N=rb2S87jo;har&b^hm>may&$wDh3SP-9xJz? z3XIA&vi0)+>y5vP<0zDcrH1z0Es-bgA^XXt8TzmDu*(2}?X4>(niZ#BKh)Zs8j)LR z0wD48aVHjT16)2&`>V5Z;zLSD#(7u>Bxa%gh1xPS5FBSJw@AA%YqUhLj9Vlyvu_+t zRAXK5J9Cn~Cr@dHWT}AE=7Z0YK@%L@dApm1`zklbJ7*+6hkVFVv#Uf?z0%p|Anj1% zw*cepdhWdC4!nMk@q33|;UQOk!BUpHsi1ktY#fCsp&AU^+jZ^nJmqmac6DT~$0+&a zp;hPyy|WQ+iNCBP^^&PNRa;|WUdoN~pCBW5RgzQ59x74z@hK6>X6pIuKJFCe=s(wK z*^e&6j@31rcHodKjaH5-s>v8%1#ONOQ(H|*0g`T|0GsG#g3r}Tz#4L2HU@S02+&LG z8lN!UG{Hz}gBHaRH<|nq^8{b&pZ>O{2oVI%w1*|KuP&JeddxChotB$J)NGm%=M2bN zC@;lp>c>mg*Nb^H6e>X`@IYfFKR;-uc=spHav@si95=J@p@&z2(tVR0e}QWM4Me_RG) zjSK;b-?C0$jycqcRnN>l>?SM`4L=0s^?j4yz~mrsqTeddN&b9?>y(?A2et59f`Fvc z#NIUCERE96@)FkftaNN&ZimSCUj%Zp;EF`!vC)v{oLm{?>KOP^pjr)q0s~Kgn_xi= zWv0JPH+GU7-uU0uA9nKN#dwWHrXy(f&k$t;>@i^F0|>#*F+!*8CQ*JrkA}IDg%d=;)Y9sO$ zyvTwT=uCj_>{vn(+)}qZME=Cj2+IK>I6-GJSmV);_?rYms{U-6L;GLB&sZBtTtBN7 zvqxtZ4st8zd?Ue814i_Dc_Lp)(7WHWzHsnXu?6xx5`la8+vQIAJ-Cdi)11q0O$9 z8stt^F)({+Grh=}%mDd?SPFJAxGE1a7 z(&{~4Fw9XV(;0DB6A|5)@I0uG+!^N?+qavnCkFgxPw&CYygh8E$Iz`mgSTVJdhgbB zk!?{k`%%PT{q~Wp?KCEN{BV8-b>NDYhP1bml0Hf9TXvlHm8O8KHV^xi zt7yeJS_WG1v%E5j&71Q}dT4K!eXTn48CfUD-y?2=>5kHLBch+MP)x1~yx7B=`VmChoj>U+5FGc8n(=Bk6(N=%9#kmd)fAur^^eMNCyuR?i(q`GiEn4w+eX*n1L)j&H!noP~yXHL6sR0Xpr(6 z%g}Vf$ufiJ=aKV*esfMK)t#IT$>@0At^*a|H}sm+8;8sbBV3{_#In5F{lkD;`EtHC zP6T*c35x@zy#!26&Ca40`qn?R@BqI`RuBDIT^KYFH7Ijx5l6D{CxMP)GTQ=XNIEE_ zXnQCNUTUG{m{pVAwCE*YPXx883b7m+MlV!Y`^Im_h2PXK3nvLeYX9-9-31O-~P{>^LzNe%ye#XoV7|U!x9xA^$kf4)0sYbqL(QsDW@S*6mRmUxYU32(RSF z&S~XXhgdK+i@mXOz)j*;o)KN7gpE^x;LOtwqQkGmVeIws8ARKc;tbkz8;g%^Ut}^+ zF^!7#KLTs0-(%r?cSM+%9Z$7__&HTU+p~%5|yJB>KOPYoGf~^H!`s}1W6bV zpa1J3@^BcLqo1YbuH$9aPBLMP^jUYH^G(@JL=CX>Y*8w+Kf`+mQ0P~iTY-oMZdY6D zLE6v%4*txWzP9Fqr1UihPNB^<1*GdNuIAe;faw8B0%^S!^;vEDdMF>C3nlVzrpU;f zkT0Im`Snfx%r;@zZw=7u)kDF?hB4E`y7UfQtE+JfI|%~J4Zo^!@fmNKO; zW3i5kUF)lsY~0+{!~-_92#z&7(owapR??AE$0bP&t5Jr^Ck)T1okJtg1-XY`^h))~s77YK!`ut!)5n4{48J!2T{~X2+VeEna&bv| z{>87}2#@C03!-lDj5U3*F^sptNXiTc1AfyCsJW^*OZH0DMUcUy?(Z8Mrgt)MF2o&jFhpO!l z1{d=Vztx>;XbKp#ju+gcT(mpF8>+Spqmui&zZsKuSO0E_9D0mW7_?D6T$BiqX7I)G z_i*$PoN>}7I?+btsg23Vlt&0OJUu?5UP_97+bL6m0hb3y>e3H~dy8$VcA@TXz4=?A zOo<-}f`d$`nPcMCm?hefS@ek620(%J>);00Y+@JO3xXgslcn#UU3#=85LK5MLD zmdmr0y|b;J1+O1R8_O73<&Mj!+0H1Sx{_?WUf(RhO?BYvzPoKDK5S(mYtD^PR#Bo5 zX7`AjJRsSoYLR&+L$pF5X*e36#kK-V*M4JkWBiO?hQ6C>Gq~wfPTTE;GMM^@?;7p7 zoL|a6@qqd6proOw#`*vbmNgH?nk@ydS%Z?~wx(IdS!jitLzLSv$dgSm2R)GFME6qC zGCX?1X$3+Q&RMW?QFl^JG3gs}^4v2%K6JWliInv?A(aHmdbecU{f9z8j(lV*>XA92 zjXbZmjj~_Ft_1~}OM;wID)&8dHle=`MoIA*c+lokw9+>F8{*iy*3QWfu+M3!FN z#l~)c+oKCF{axDkfU&Ws*Vfd@?SoJ4N=kA(>Ur&6kJ)`@_NvwJevy zUU|?Nu4Vr5PMg3BQof*)8@kbRC9Y%!r*uZH$rl;t7Q?JT6m*ip1r8ZT0}?NJN6F(s z3jh92F6UD&--f{b{buVZN_3i}pBlq;_Abm;BNu%zL4RU6 zRY{SKs@9BX;*M%MMC$LfE}O6Hy3Qn{(uX?n@5n2>5Bp}caO({K$Wy%*%NyRutLm)% zRvMx$*4t0>hg&+Plu#WVx_sp&)${bcqwDdYuay5RdcLUQ_jF$n_dWHEywMsXc`Z77 z>sqyZVUXCaiz|u!Pf8j91hOz&QEMWtd); zt<5>t7GYNUEnO_v&X*#(!?q?L^A;3l*r&wctU3pLMCC|OwS@LEa!8%j&0WWD4&yk| zRv80{L6Nci9!!pt97}phm@-}2$#g1o@0jl)9U|}0YX>SbqL$=kY;B!ND4U&KOpBVl zs{NL$xq|+7fE&r2 z4{YLCPlBSXRe|nFW|#k|r025YQE>JqmPAnW!mEG7+(?B|_A3M?w3^_OQ_%LlP9q8q zF(7Yb-nPVHBB47&uw5iR;qw||xj|5%gi^B?V6iqQz(^!{2z3D_))3Ht2FQHiklwLG zza8p2338x7DP$_$xZ3>8b<&jEjn9+VW_Ykgk<>=n!QcX~226X!{PqL1^8t%2{=G=l zt8j4PSR#*>L3#0cvH!;c@2J^?`F!3D0yI#qS zCM}2p4K|)OZC5;6?M$6l0w(xn<vl$R+xT z#2IHchRbiI1Er6Ak>hFp_&pmgP+{1#;}O9p5_RDFEyEbCq_(e8i$VE`R}Tq+59%+O z70v#$?AdT7mH^5n`X0MOyT_yW8^Agx_8N{|J1Q9&_I({G7{5vBpFOj^pnoEfts zn(A**VZ!<>DvNa#PY`kI|6{84f2Uy=W$v-xaJ;fzX@*HX6jE^bt6BIv+W9+4JNP<1 z2!IG&L|gzaA|Ud}1TH2mCN3@Vhz|~zhQrsF-}L@J01q!mSLcxb8PE_<_Fwe}_y1+^ jclB`c^S67b$oT(AME?(ogtVyo0|xMrrBZEFM#TItPnz-Y literal 0 HcmV?d00001 diff --git a/PlaylistShared.PWA2123/wwwroot/icon-512.png b/PlaylistShared.PWA2123/wwwroot/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..2326ede971cbe512169beb919954173c071108c2 GIT binary patch literal 23883 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 + + + + + + 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 0000000000000000000000000000000000000000..8a4358cf0d7c5dfe0e7ac5838351ba6b055995cc GIT binary patch literal 2588 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 literal 0 HcmV?d00001 diff --git a/PlaylistShared.Pwa/wwwroot/icon-512.png b/PlaylistShared.Pwa/wwwroot/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..2326ede971cbe512169beb919954173c071108c2 GIT binary patch literal 23883 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 + + + + + + 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