authentication refresh fixed, movie creation page added

This commit is contained in:
2024-07-30 16:19:51 +02:00
Unverified
parent f9323b3d8c
commit 5b871714fa
63 changed files with 1568 additions and 200 deletions

View File

@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace WatchIt.Common.Model.Accounts;
public abstract class AccountProfilePicture
{
#region PROPERTIES
[JsonPropertyName("image")]
public required byte[] Image { get; set; }
[JsonPropertyName("mime_type")]
public required string MimeType { get; set; }
#endregion
#region CONSTRUCTORS
[JsonConstructor]
public AccountProfilePicture() {}
#endregion
}

View File

@@ -0,0 +1,35 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace WatchIt.Common.Model.Accounts;
public class AccountProfilePictureResponse : AccountProfilePicture
{
#region PROPERTIES
[JsonPropertyName("id")]
public required Guid Id { get; set; }
[JsonPropertyName("upload_date")]
public required DateTime UploadDate { get; set; }
#endregion
#region CONSTRUCTORS
[JsonConstructor]
public AccountProfilePictureResponse() {}
[SetsRequiredMembers]
public AccountProfilePictureResponse(Database.Model.Account.AccountProfilePicture accountProfilePicture)
{
Id = accountProfilePicture.Id;
Image = accountProfilePicture.Image;
MimeType = accountProfilePicture.MimeType;
UploadDate = accountProfilePicture.UploadDate;
}
#endregion
}

View File

@@ -26,6 +26,9 @@ public class RegisterResponse
#region CONSTRUCTORS
[JsonConstructor]
public RegisterResponse() {}
[SetsRequiredMembers]
public RegisterResponse(Account account)
{

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace WatchIt.Common.Model.Media;
public abstract class Media
{
[JsonPropertyName("title")]
public required string Title { get; set; }
[JsonPropertyName("original_title")]
public string? OriginalTitle { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("release_date")]
public DateOnly? ReleaseDate { get; set; }
[JsonPropertyName("length")]
public short? Length { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace WatchIt.Common.Model.Media;
public class MediaPosterImage
{
[JsonPropertyName("image")]
public required byte[] Image { get; set; }
[JsonPropertyName("mime_type")]
public required string MimeType { get; set; }
}

View File

@@ -2,23 +2,8 @@
namespace WatchIt.Common.Model.Movies;
public class Movie
public class Movie : Media.Media
{
[JsonPropertyName("title")]
public required string Title { get; set; }
[JsonPropertyName("original_title")]
public string? OriginalTitle { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("release_date")]
public DateOnly? ReleaseDate { get; set; }
[JsonPropertyName("length")]
public short? Length { get; set; }
[JsonPropertyName("budget")]
public decimal? Budget { get; set; }
}

View File

@@ -1,9 +1,29 @@
using WatchIt.Database.Model.Media;
using System.Diagnostics.CodeAnalysis;
using WatchIt.Database.Model.Media;
namespace WatchIt.Common.Model.Movies;
public class MovieRequest : Movie
{
#region CONSTRUCTORS
[SetsRequiredMembers]
public MovieRequest(MovieResponse initData)
{
Title = initData.Title;
OriginalTitle = initData.OriginalTitle;
Description = initData.Description;
ReleaseDate = initData.ReleaseDate;
Length = initData.Length;
Budget = initData.Budget;
}
public MovieRequest() {}
#endregion
#region PUBLIC METHODS
public Database.Model.Media.Media CreateMedia() => new Database.Model.Media.Media

View File

@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using WatchIt.Database.Model.Media;
namespace WatchIt.Common.Model.Movies;
@@ -7,6 +8,7 @@ public class MovieResponse : Movie
{
#region PROPERTIES
[JsonPropertyName("id")]
public long Id { get; set; }
#endregion
@@ -15,6 +17,9 @@ public class MovieResponse : Movie
#region CONSTRUCTORS
[JsonConstructor]
public MovieResponse() {}
[SetsRequiredMembers]
public MovieResponse(MediaMovie mediaMovie)
{

View File

@@ -30,4 +30,15 @@ public class AccountsController(IAccountsControllerService accountsControllerSer
[ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)]
public async Task<ActionResult> AuthenticateRefresh() => await accountsControllerService.AuthenticateRefresh();
[HttpDelete("logout")]
[Authorize(AuthenticationSchemes = "refresh")]
[ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)]
public async Task<ActionResult> Logout() => await accountsControllerService.Logout();
[HttpGet("{id}/profile-picture")]
[AllowAnonymous]
[ProducesResponseType(typeof(AccountProfilePictureResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> GetAccountProfilePicture([FromRoute(Name = "id")]long id) => await accountsControllerService.GetAccountProfilePicture(id);
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using System.Net;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using WatchIt.Common.Model.Genres;
@@ -23,7 +25,7 @@ public class MoviesController(IMoviesControllerService moviesControllerService)
public async Task<ActionResult> Get([FromRoute]long id) => await moviesControllerService.Get(id);
[HttpPost]
[Authorize]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ProducesResponseType(typeof(MovieResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
@@ -31,15 +33,16 @@ public class MoviesController(IMoviesControllerService moviesControllerService)
public async Task<ActionResult> Post([FromBody]MovieRequest body) => await moviesControllerService.Post(body);
[HttpPut("{id}")]
[Authorize]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Put([FromRoute]long id, [FromBody]MovieRequest body) => await moviesControllerService.Put(id, body);
[HttpDelete("{id}")]
[Authorize]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Delete([FromRoute] long id) => await moviesControllerService.Delete(id);

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>

View File

@@ -11,6 +11,7 @@ using WatchIt.WebAPI.Services.Controllers.Common;
using WatchIt.WebAPI.Services.Utility.Tokens;
using WatchIt.WebAPI.Services.Utility.Tokens.Exceptions;
using WatchIt.WebAPI.Services.Utility.User;
using AccountProfilePicture = WatchIt.Common.Model.Accounts.AccountProfilePicture;
namespace WatchIt.WebAPI.Services.Controllers.Accounts;
@@ -73,16 +74,10 @@ public class AccountsControllerService(
return RequestResult.Unauthorized();
}
AuthenticateResponse response;
string refreshToken;
try
{
Task<string> refreshTokenTask = tokensService.ExtendRefreshTokenAsync(token.Account, token.Id);
Task<string> accessTokenTask = tokensService.CreateAccessTokenAsync(token.Account);
response = new AuthenticateResponse
{
AccessToken = await accessTokenTask,
RefreshToken = await refreshTokenTask,
};
refreshToken = await tokensService.ExtendRefreshTokenAsync(token.Account, token.Id);
}
catch (TokenNotFoundException)
{
@@ -90,11 +85,48 @@ public class AccountsControllerService(
}
catch (TokenNotExtendableException)
{
return RequestResult.Forbidden();
refreshToken = userService.GetRawToken().Replace("Bearer ", string.Empty);
}
string accessToken = await tokensService.CreateAccessTokenAsync(token.Account);
logger.LogInformation($"Account with ID {token.AccountId} was authenticated by token refreshing");
return RequestResult.Ok(response);
return RequestResult.Ok(new AuthenticateResponse
{
AccessToken = accessToken,
RefreshToken = refreshToken,
});
}
public async Task<RequestResult> Logout()
{
Guid jti = userService.GetJti();
AccountRefreshToken? token = await database.AccountRefreshTokens.FirstOrDefaultAsync(x => x.Id == jti);
if (token is not null)
{
database.AccountRefreshTokens.Attach(token);
database.AccountRefreshTokens.Remove(token);
await database.SaveChangesAsync();
}
return RequestResult.NoContent();
}
public async Task<RequestResult> GetAccountProfilePicture(long id)
{
Account? account = await database.Accounts.FirstOrDefaultAsync(x => x.Id == id);
if (account is null)
{
return RequestResult.BadRequest()
.AddValidationError("id", "Account with this id does not exists");
}
if (account.ProfilePicture is null)
{
return RequestResult.NotFound();
}
AccountProfilePictureResponse picture = new AccountProfilePictureResponse(account.ProfilePicture);
return RequestResult.Ok(picture);
}
#endregion

View File

@@ -8,4 +8,6 @@ public interface IAccountsControllerService
Task<RequestResult> Register(RegisterRequest data);
Task<RequestResult> Authenticate(AuthenticateRequest data);
Task<RequestResult> AuthenticateRefresh();
Task<RequestResult> Logout();
Task<RequestResult> GetAccountProfilePicture(long id);
}

View File

@@ -66,7 +66,7 @@ public class MoviesControllerService(DatabaseContext database, IUserService user
data.UpdateMedia(item.Media);
await database.SaveChangesAsync();
return RequestResult.Ok();
return RequestResult.NoContent();
}
public async Task<RequestResult> Delete(long id)
@@ -105,7 +105,7 @@ public class MoviesControllerService(DatabaseContext database, IUserService user
database.Media.Remove(item.Media);
await database.SaveChangesAsync();
return RequestResult.Ok();
return RequestResult.NoContent();
}
#endregion

View File

@@ -85,7 +85,9 @@ public class TokensService(DatabaseContext database, IConfigurationService confi
return TokenToString(tokenDescriptor);
}
protected SecurityTokenDescriptor CreateBaseSecurityTokenDescriptor(Account account, Guid id, DateTime expirationTime) => new SecurityTokenDescriptor
protected SecurityTokenDescriptor CreateBaseSecurityTokenDescriptor(Account account, Guid id, DateTime expirationTime)
{
return new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new List<Claim>
{
@@ -100,6 +102,7 @@ public class TokensService(DatabaseContext database, IConfigurationService confi
Issuer = configurationService.Data.Authentication.Issuer,
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configurationService.Data.Authentication.Key)), SecurityAlgorithms.HmacSha512)
};
}
protected string TokenToString(SecurityTokenDescriptor tokenDescriptor)
{

View File

@@ -18,6 +18,15 @@ public class UserService(DatabaseContext database, IHttpContextAccessor accessor
return accessor.HttpContext.User;
}
public string? GetRawToken()
{
if (accessor.HttpContext is null)
{
throw new NullReferenceException();
}
return accessor.HttpContext.Request.Headers.Authorization;
}
public UserValidator GetValidator()
{
ClaimsPrincipal rawUser = GetRawUser();

View File

@@ -0,0 +1,14 @@
using FluentValidation;
using WatchIt.Common.Model.Movies;
namespace WatchIt.WebAPI.Validators.Movies;
public class MovieRequestValidator : AbstractValidator<MovieRequest>
{
public MovieRequestValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(250);
RuleFor(x => x.OriginalTitle).MaximumLength(250);
RuleFor(x => x.Description).MaximumLength(1000);
}
}

View File

@@ -0,0 +1,85 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Components.Authorization;
using WatchIt.Website.Services.Utility.Tokens;
using WatchIt.Website.Services.WebAPI.Accounts;
namespace WatchIt.Website.Services.Utility.Authentication;
public class AuthenticationService : IAuthenticationService
{
#region SERVICES
private readonly AuthenticationStateProvider _authenticationStateProvider;
private readonly HttpClient _httpClient;
private readonly ITokensService _tokensService;
private readonly IAccountsWebAPIService _accountsWebAPIService;
#endregion
#region CONSTRUCTORS
public AuthenticationService(AuthenticationStateProvider authenticationStateProvider, HttpClient httpClient, ITokensService tokensService, IAccountsWebAPIService accountsWebAPIService)
{
_authenticationStateProvider = authenticationStateProvider;
_httpClient = httpClient;
_tokensService = tokensService;
_accountsWebAPIService = accountsWebAPIService;
}
#endregion
#region PUBLIC METHODS
public async Task<User?> GetUserAsync()
{
AuthenticationState state = await _authenticationStateProvider.GetAuthenticationStateAsync();
if (!GetAuthenticationStatusAsync(state))
{
return null;
}
return new User
{
Id = int.Parse(state.User.FindFirst(x => x.Type == JwtRegisteredClaimNames.Sub)!.Value),
Username = state.User.FindFirst(x => x.Type == JwtRegisteredClaimNames.UniqueName)!.Value,
Email = state.User.FindFirst(x => x.Type == JwtRegisteredClaimNames.Email)!.Value,
IsAdmin = bool.Parse(state.User.FindFirst(x => x.Type == "admin")!.Value),
};
}
public async Task<bool> GetAuthenticationStatusAsync()
{
AuthenticationState state = await _authenticationStateProvider.GetAuthenticationStateAsync();
return GetAuthenticationStatusAsync(state);
}
public async Task LogoutAsync()
{
string? refreshToken = await _tokensService.GetRefreshToken();
if (refreshToken is not null)
{
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("refresh", refreshToken.Replace("\"", ""));
await _accountsWebAPIService.Logout();
_httpClient.DefaultRequestHeaders.Authorization = null;
}
}
#endregion
#region PRIVATE METHODS
private bool GetAuthenticationStatusAsync(AuthenticationState state)
{
return state.User.HasClaim(x => x.Type == JwtRegisteredClaimNames.Iss && x.Value == "WatchIt");
}
#endregion
}

View File

@@ -0,0 +1,8 @@
namespace WatchIt.Website.Services.Utility.Authentication;
public interface IAuthenticationService
{
Task<User?> GetUserAsync();
Task<bool> GetAuthenticationStatusAsync();
Task LogoutAsync();
}

View File

@@ -0,0 +1,151 @@
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using WatchIt.Common.Model.Accounts;
using WatchIt.Website.Services.WebAPI.Accounts;
namespace WatchIt.Website.Services.Utility.Tokens;
public class JWTAuthenticationStateProvider : AuthenticationStateProvider
{
#region SERVICES
private readonly HttpClient _httpClient;
private readonly ILogger<JWTAuthenticationStateProvider> _logger;
private readonly ITokensService _tokensService;
private readonly IAccountsWebAPIService _accountsService;
#endregion
#region CONSTRUCTORS
public JWTAuthenticationStateProvider(HttpClient httpClient, ILogger<JWTAuthenticationStateProvider> logger, ITokensService tokensService, IAccountsWebAPIService accountsService)
{
_httpClient = httpClient;
_logger = logger;
_tokensService = tokensService;
_accountsService = accountsService;
}
#endregion
#region PUBLIC METHODS
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
AuthenticationState state = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
Task<string?> accessTokenTask = _tokensService.GetAccessToken();
Task<string?> refreshTokenTask = _tokensService.GetRefreshToken();
await Task.WhenAll(accessTokenTask, refreshTokenTask);
string? accessToken = await accessTokenTask;
string? refreshToken = await refreshTokenTask;
bool refreshed = false;
if (string.IsNullOrWhiteSpace(accessToken))
{
if (string.IsNullOrWhiteSpace(refreshToken))
{
return state;
}
string? accessTokenNew = await Refresh(refreshToken);
if (string.IsNullOrWhiteSpace(accessToken))
{
return state;
}
accessToken = accessTokenNew;
refreshed = true;
}
IEnumerable<Claim> claims = GetClaimsFromToken(accessToken);
Claim? expClaim = claims.FirstOrDefault(c => c.Type == "exp");
if (expClaim is not null && ConvertFromUnixTimestamp(int.Parse(expClaim.Value)) > DateTime.UtcNow)
{
if (refreshed)
{
return state;
}
}
else
{
if (string.IsNullOrWhiteSpace(refreshToken))
{
return state;
}
string? accessTokenNew = await Refresh(refreshToken);
if (accessTokenNew is null)
{
return state;
}
accessToken = accessTokenNew;
}
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Replace("\"", ""));
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims)));
}
#endregion
#region PRIVATE METHODS
private async Task<string?> Refresh(string refreshToken)
{
AuthenticateResponse response = null;
await _accountsService.AuthenticateRefresh((data) => response = data);
await _tokensService.SaveAuthenticationData(response);
return response.AccessToken;
}
private static IEnumerable<Claim> GetClaimsFromToken(string token)
{
string payload = token.Split('.')[1];
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
byte[] jsonBytes = Convert.FromBase64String(payload);
Dictionary<string, object>? keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
if (keyValuePairs is null)
{
throw new Exception("Incorrect token");
}
return keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()));
}
public static DateTime ConvertFromUnixTimestamp(int timestamp)
{
DateTime origin = new DateTime(1970, 1, 1, 0, 0, 0, 0);
return origin.AddSeconds(timestamp);
}
#endregion
}

View File

@@ -0,0 +1,13 @@
namespace WatchIt.Website.Services.Utility.Authentication;
public class User
{
#region PROPERTIES
public required long Id { get; init; }
public required string Username { get; init; }
public required string Email { get; init; }
public required bool IsAdmin { get; init; }
#endregion
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\WatchIt.Website.Services.WebAPI\WatchIt.Website.Services.WebAPI.Accounts\WatchIt.Website.Services.WebAPI.Accounts.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services.Utility.Tokens\WatchIt.Website.Services.Utility.Tokens.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,4 +6,6 @@ public class Accounts
public string Register { get; set; }
public string Authenticate { get; set; }
public string AuthenticateRefresh { get; set; }
public string Logout { get; set; }
public string GetProfilePicture { get; set; }
}

View File

@@ -4,5 +4,6 @@ public class ConfigurationData
{
public Logging Logging { get; set; }
public string AllowedHosts { get; set; }
public StorageKeys StorageKeys { get; set; }
public Endpoints Endpoints { get; set; }
}

View File

@@ -8,7 +8,4 @@ public class Movies
public string Post { get; set; }
public string Put { get; set; }
public string Delete { get; set; }
public string GetGenres { get; set; }
public string PostGenre { get; set; }
public string DeleteGenre { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace WatchIt.Website.Services.Utility.Configuration.Model;
public class StorageKeys
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
}

View File

@@ -0,0 +1,15 @@
using WatchIt.Common.Model.Accounts;
namespace WatchIt.Website.Services.Utility.Tokens;
public interface ITokensService
{
Task<string?> GetAccessToken();
Task<string?> GetRefreshToken();
Task SaveAuthenticationData(AuthenticateResponse authenticateResponse);
Task SaveAccessToken(string accessToken);
Task SaveRefreshToken(string refreshToken);
Task RemoveAuthenticationData();
Task RemoveAccessToken();
Task RemoveRefreshToken();
}

View File

@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using WatchIt.Common.Model.Accounts;
using WatchIt.Website.Services.Utility.Configuration;
namespace WatchIt.Website.Services.Utility.Tokens;
public class TokensService : ITokensService
{
#region SERVICES
private readonly ProtectedLocalStorage _localStorageService;
private readonly IConfigurationService _configurationService;
#endregion
#region CONSTRUCTORS
public TokensService(ProtectedLocalStorage localStorageService, IConfigurationService configurationService)
{
_localStorageService = localStorageService;
_configurationService = configurationService;
}
#endregion
#region PUBLIC METHODS
public async Task<string?> GetAccessToken() => await GetValueAsync<string>(GetAccessTokenStorageKey());
public async Task<string?> GetRefreshToken() => await GetValueAsync<string>(GetRefreshTokenStorageKey());
public async Task SaveAuthenticationData(AuthenticateResponse authenticateResponse) => await Task.WhenAll(SaveAccessToken(authenticateResponse.AccessToken), SaveRefreshToken(authenticateResponse.RefreshToken));
public async Task SaveAccessToken(string accessToken) => await _localStorageService.SetAsync(GetAccessTokenStorageKey(), accessToken);
public async Task SaveRefreshToken(string refreshToken) => await _localStorageService.SetAsync(GetRefreshTokenStorageKey(), refreshToken);
public async Task RemoveAuthenticationData() => await Task.WhenAll(RemoveAccessToken(), RemoveRefreshToken());
public async Task RemoveAccessToken() => await _localStorageService.DeleteAsync(GetAccessTokenStorageKey());
public async Task RemoveRefreshToken() => await _localStorageService.DeleteAsync(GetRefreshTokenStorageKey());
#endregion
#region PRIVATE METHODS
private string GetAccessTokenStorageKey() => _configurationService.Data.StorageKeys.AccessToken;
private string GetRefreshTokenStorageKey() => _configurationService.Data.StorageKeys.RefreshToken;
private async Task<T?> GetValueAsync<T>(string key)
{
ProtectedBrowserStorageResult<T> result = await _localStorageService.GetAsync<T>(key);
return result.Success ? result.Value : default;
}
#endregion
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>WatchIt.Website.Services.Utility.Token</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\WatchIt.Common\WatchIt.Common.Model\WatchIt.Common.Model.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services.Utility.Configuration\WatchIt.Website.Services.Utility.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,15 +2,16 @@
using WatchIt.Common.Services.HttpClient;
using WatchIt.Website.Services.Utility.Configuration;
using WatchIt.Website.Services.Utility.Configuration.Model;
using WatchIt.Website.Services.Utility.Tokens;
using WatchIt.Website.Services.WebAPI.Common;
namespace WatchIt.Website.Services.WebAPI.Accounts;
public class AccountsWebAPIService(IHttpClientService httpClientService, IConfigurationService configurationService) : BaseWebAPIService(configurationService), IAccountsWebAPIService
public class AccountsWebAPIService(IHttpClientService httpClientService, IConfigurationService configurationService, ITokensService tokensService) : BaseWebAPIService(configurationService), IAccountsWebAPIService
{
#region PUBLIC METHODS
public async Task Register(RegisterRequest data, Action<RegisterResponse> createdAction, Action<IDictionary<string, string[]>> badRequestAction)
public async Task Register(RegisterRequest data, Action<RegisterResponse>? createdAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null)
{
string url = GetUrl(EndpointsConfiguration.Accounts.Register);
HttpRequest request = new HttpRequest(HttpMethodType.Post, url)
@@ -24,7 +25,7 @@ public class AccountsWebAPIService(IHttpClientService httpClientService, IConfig
.ExecuteAction();
}
public async Task Authenticate(AuthenticateRequest data, Action<AuthenticateResponse> successAction, Action<IDictionary<string, string[]>> badRequestAction, Action unauthorizedAction)
public async Task Authenticate(AuthenticateRequest data, Action<AuthenticateResponse>? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? unauthorizedAction = null)
{
string url = GetUrl(EndpointsConfiguration.Accounts.Authenticate);
HttpRequest request = new HttpRequest(HttpMethodType.Post, url)
@@ -39,10 +40,13 @@ public class AccountsWebAPIService(IHttpClientService httpClientService, IConfig
.ExecuteAction();
}
public async Task AuthenticateRefresh(Action<AuthenticateResponse> successAction, Action unauthorizedAction, Action forbiddenAction)
public async Task AuthenticateRefresh(Action<AuthenticateResponse>? successAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null)
{
string url = GetUrl(EndpointsConfiguration.Accounts.AuthenticateRefresh);
string? token = await tokensService.GetRefreshToken();
HttpRequest request = new HttpRequest(HttpMethodType.Post, url);
request.Headers.Add("Authorization", $"Bearer {token}");
HttpResponse response = await httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
@@ -51,6 +55,31 @@ public class AccountsWebAPIService(IHttpClientService httpClientService, IConfig
.ExecuteAction();
}
public async Task Logout(Action? successAction = null)
{
string url = GetUrl(EndpointsConfiguration.Accounts.Logout);
string? token = await tokensService.GetRefreshToken();
HttpRequest request = new HttpRequest(HttpMethodType.Delete, url);
request.Headers.Add("Authorization", $"Bearer {token}");
HttpResponse response = await httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.ExecuteAction();
}
public async Task GetAccountProfilePicture(long id, Action<AccountProfilePictureResponse>? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? notFoundAction = null)
{
string url = GetUrl(EndpointsConfiguration.Accounts.GetProfilePicture, id);
HttpRequest request = new HttpRequest(HttpMethodType.Get, url);
HttpResponse response = await httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor400BadRequest(badRequestAction)
.RegisterActionFor404NotFound(notFoundAction)
.ExecuteAction();
}
#endregion

View File

@@ -4,7 +4,9 @@ namespace WatchIt.Website.Services.WebAPI.Accounts;
public interface IAccountsWebAPIService
{
Task Register(RegisterRequest data, Action<RegisterResponse> createdAction, Action<IDictionary<string, string[]>> badRequestAction);
Task Authenticate(AuthenticateRequest data, Action<AuthenticateResponse> successAction, Action<IDictionary<string, string[]>> badRequestAction, Action unauthorizedAction);
Task AuthenticateRefresh(Action<AuthenticateResponse> successAction, Action unauthorizedAction, Action forbiddenAction);
Task Register(RegisterRequest data, Action<RegisterResponse>? createdAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null);
Task Authenticate(AuthenticateRequest data, Action<AuthenticateResponse>? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? unauthorizedAction = null);
Task AuthenticateRefresh(Action<AuthenticateResponse>? successAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null);
Task Logout(Action? successAction = null);
Task GetAccountProfilePicture(long id, Action<AccountProfilePictureResponse>? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? notFoundAction = null);
}

View File

@@ -0,0 +1,7 @@
namespace WatchIt.Website.Services.WebAPI.Common;
public enum AuthorizationType
{
Access,
Refresh
}

View File

@@ -1,13 +1,33 @@
using WatchIt.Website.Services.Utility.Configuration;
using WatchIt.Website.Services.Utility.Configuration.Model;
using WatchIt.Website.Services.Utility.Tokens;
namespace WatchIt.Website.Services.WebAPI.Common;
public abstract class BaseWebAPIService(IConfigurationService configurationService)
public abstract class BaseWebAPIService
{
#region SERVICES
protected readonly IConfigurationService _configurationService;
#endregion
#region FIELDS
protected Endpoints EndpointsConfiguration => configurationService.Data.Endpoints;
protected Endpoints EndpointsConfiguration => _configurationService.Data.Endpoints;
#endregion
#region CONSTRUCTORS
protected BaseWebAPIService(IConfigurationService configurationService)
{
_configurationService = configurationService;
}
#endregion

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\WatchIt.WebAPI\WatchIt.WebAPI.Services\WatchIt.WebAPI.Services.Utility\WatchIt.WebAPI.Services.Utility.Configuration\WatchIt.WebAPI.Services.Utility.Configuration.csproj" />
<ProjectReference Include="..\..\WatchIt.Website.Services.Utility\WatchIt.Website.Services.Utility.Configuration\WatchIt.Website.Services.Utility.Configuration.csproj" />
<ProjectReference Include="..\..\WatchIt.Website.Services.Utility\WatchIt.Website.Services.Utility.Tokens\WatchIt.Website.Services.Utility.Tokens.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,7 +5,7 @@ namespace WatchIt.Website.Services.WebAPI.Media;
public interface IMediaWebAPIService
{
Task GetGenres(long mediaId, Action<IEnumerable<GenreResponse>> successAction, Action notFoundAction);
Task PostGenre(long mediaId, long genreId, Action successAction, Action unauthorizedAction, Action forbiddenAction, Action notFoundAction);
Task GetPhotoRandomBackground(Action<MediaPhotoResponse> successAction, Action notFoundAction);
Task GetGenres(long mediaId, Action<IEnumerable<GenreResponse>>? successAction = null, Action? notFoundAction = null);
Task PostGenre(long mediaId, long genreId, Action? successAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null, Action? notFoundAction = null);
Task GetPhotoRandomBackground(Action<MediaPhotoResponse>? successAction = null, Action? notFoundAction = null);
}

View File

@@ -0,0 +1,12 @@
using WatchIt.Common.Model.Movies;
namespace WatchIt.Website.Services.WebAPI.Movies;
public interface IMoviesWebAPIService
{
Task GetAll(MovieQueryParameters? query = null, Action<IEnumerable<MovieResponse>>? successAction = null);
Task Post(MovieRequest data, Action<MovieResponse>? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null);
Task Get(long id, Action<MovieResponse>? successAction = null, Action? notFoundAction = null);
Task Put(long id, MovieRequest data, Action? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null);
Task Delete(long id, Action? successAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null);
}

View File

@@ -0,0 +1,109 @@
using Microsoft.AspNetCore.Components;
using WatchIt.Common.Model.Movies;
using WatchIt.Common.Services.HttpClient;
using WatchIt.Website.Services.Utility.Configuration;
using WatchIt.Website.Services.WebAPI.Common;
namespace WatchIt.Website.Services.WebAPI.Movies;
public class MoviesWebAPIService : BaseWebAPIService, IMoviesWebAPIService
{
#region SERVICES
private IHttpClientService _httpClientService;
private IConfigurationService _configurationService;
#endregion
#region CONSTRUCTORS
public MoviesWebAPIService(IHttpClientService httpClientService, IConfigurationService configurationService) : base(configurationService)
{
_httpClientService = httpClientService;
_configurationService = configurationService;
}
#endregion
#region PUBLIC METHODS
public async Task GetAll(MovieQueryParameters? query = null, Action<IEnumerable<MovieResponse>>? successAction = null)
{
string url = GetUrl(EndpointsConfiguration.Movies.GetAll);
HttpRequest request = new HttpRequest(HttpMethodType.Get, url);
request.Query = query;
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.ExecuteAction();
}
public async Task Post(MovieRequest data, Action<MovieResponse>? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null)
{
string url = GetUrl(EndpointsConfiguration.Movies.Post);
HttpRequest request = new HttpRequest(HttpMethodType.Post, url);
request.Body = data;
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor400BadRequest(badRequestAction)
.RegisterActionFor401Unauthorized(unauthorizedAction)
.RegisterActionFor403Forbidden(forbiddenAction)
.ExecuteAction();
}
public async Task Get(long id, Action<MovieResponse>? successAction = null, Action? notFoundAction = null)
{
string url = GetUrl(EndpointsConfiguration.Movies.Get, id);
HttpRequest request = new HttpRequest(HttpMethodType.Get, url);
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor404NotFound(notFoundAction)
.ExecuteAction();
}
public async Task Put(long id, MovieRequest data, Action? successAction = null, Action<IDictionary<string, string[]>>? badRequestAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null)
{
string url = GetUrl(EndpointsConfiguration.Movies.Put, id);
HttpRequest request = new HttpRequest(HttpMethodType.Put, url);
request.Body = data;
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor400BadRequest(badRequestAction)
.RegisterActionFor401Unauthorized(unauthorizedAction)
.RegisterActionFor403Forbidden(forbiddenAction)
.ExecuteAction();
}
public async Task Delete(long id, Action? successAction = null, Action? unauthorizedAction = null, Action? forbiddenAction = null)
{
string url = GetUrl(EndpointsConfiguration.Movies.Delete, id);
HttpRequest request = new HttpRequest(HttpMethodType.Delete, url);
HttpResponse response = await _httpClientService.SendRequestAsync(request);
response.RegisterActionFor2XXSuccess(successAction)
.RegisterActionFor401Unauthorized(unauthorizedAction)
.RegisterActionFor403Forbidden(forbiddenAction)
.ExecuteAction();
}
#endregion
#region PRIVATE METHODS
protected override string GetServiceBase() => EndpointsConfiguration.Movies.Base;
#endregion
}

View File

@@ -6,4 +6,18 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components">
<HintPath>..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.2\Microsoft.AspNetCore.Components.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\WatchIt.Common\WatchIt.Common.Model\WatchIt.Common.Model.csproj" />
<ProjectReference Include="..\..\..\..\WatchIt.Common\WatchIt.Common.Services\WatchIt.Common.Services.HttpClient\WatchIt.Common.Services.HttpClient.csproj" />
<ProjectReference Include="..\..\..\..\WatchIt.WebAPI\WatchIt.WebAPI.Services\WatchIt.WebAPI.Services.Utility\WatchIt.WebAPI.Services.Utility.Configuration\WatchIt.WebAPI.Services.Utility.Configuration.csproj" />
<ProjectReference Include="..\..\WatchIt.Website.Services.Utility\WatchIt.Website.Services.Utility.Configuration\WatchIt.Website.Services.Utility.Configuration.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services.WebAPI.Common\WatchIt.Website.Services.WebAPI.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,14 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<link rel="stylesheet" href="bootstrap/bootstrap.min.css"/>
<link rel="stylesheet" href="app.css?version=0.2"/>
<link rel="stylesheet" href="WatchIt.Website.styles.css?version=0.2"/>
<link rel="icon" type="image/png" href="favicon.png"/>
<!-- CSS -->
<link rel="stylesheet" href="app.css?version=0.7"/>
<link rel="stylesheet" href="WatchIt.Website.styles.css?version=0.7"/>
<!-- BOOTSTRAP -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- FONTS -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Belanosima:wght@400;600;700&family=PT+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<HeadOutlet @rendermode="InteractiveServer"/>
</head>

View File

@@ -0,0 +1,67 @@
<div class="row">
<div class="col-auto rounded-3 panel panel-regular m-1">
<img class="rounded-2 m-2 mt-3 shadow" src="@(_posterBase64 is not null ? $"data:{_posterMediaType};base64,{_posterBase64}" : "assets/poster.png")" alt="poster" width="300" height="500"/>
<br/>
<InputFile id="posterInput" class="m-2 form-control" OnChange="LoadPoster" disabled=@(!Id.HasValue) autocomplete="off"/>
@if (_posterChanged)
{
<button id="posterButton" type="button" class="btn btn-secondary m-2 mb-3 form-control" @onclick="SavePoster" disabled=@(!Id.HasValue) autocomplete="off">Save poster</button>
}
</div>
<div class="col rounded-3 panel panel-regular m-1 p-3">
<EditForm Model="Data" FormName="MediaInfo">
<AntiforgeryToken/>
<div class="form-group row my-1">
<label for="title" class="col-2 col-form-label">Title*</label>
<div class="col-10">
<InputText id="title" class="form-control" @bind-Value="Data!.Title"/>
</div>
</div>
<div class="form-group row my-1">
<label for="originalTitle" class="col-2 col-form-label">Original title</label>
<div class="col-10">
<InputText id="originalTitle" class="form-control" @bind-Value="Data!.OriginalTitle"/>
</div>
</div>
<div class="form-group row my-1">
<label for="description" class="col-2 col-form-label">Description</label>
<div class="col-10">
<InputTextArea id="description" class="form-control" rows="14" @bind-Value="Data!.Description"/>
</div>
</div>
<div class="form-group row my-1">
<label for="releaseDate" class="col-2 col-form-label">Release date</label>
<div class="col-10">
<InputDate TValue="DateOnly?" id="releaseDate" class="form-control" @bind-Value="Data!.ReleaseDate"/>
</div>
</div>
<div class="form-group row my-1">
<label for="length" class="col-2 col-form-label">Length</label>
<div class="col-10">
<InputNumber TValue="short?" id="length" class="form-control" @bind-Value="Data!.Length"/>
</div>
</div>
<div class="row my-1 mt-3">
<div class="col d-flex align-items-center">
@if (SaveDataErrors is not null && SaveDataErrors.Any())
{
<p class="text-danger m-0">@(SaveDataErrors.ElementAt(0))</p>
}
else if (!string.IsNullOrWhiteSpace(SaveDataInfo))
{
<p class="text-success m-0">@(SaveDataInfo)</p>
}
</div>
<div class="col-auto">
<button type="button" class="btn btn-secondary" @onclick="SaveDataAction">Save data</button>
</div>
</div>
</EditForm>
</div>
</div>
<style>
#posterInput, #posterButton {
width: 300px;
}
</style>

View File

@@ -0,0 +1,60 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using WatchIt.Common.Model.Media;
namespace WatchIt.Website.Components;
public partial class MediaForm : ComponentBase
{
#region PROPERTIES
[Parameter] public Media Data { get; set; }
[Parameter] public long? Id { get; set; }
[Parameter] public Func<Task> SaveDataAction { get; set; }
[Parameter] public IEnumerable<string>? SaveDataErrors { get; set; }
[Parameter] public string? SaveDataInfo { get; set; }
#endregion
#region FIELDS
private string? _actualPosterBase64 = null;
private string? _actualPosterMediaType = null;
private bool _posterChanged = false;
private string? _posterBase64 = null;
private string? _posterMediaType = null;
#endregion
#region PRIVATE METHODS
private async Task LoadPoster(InputFileChangeEventArgs args)
{
if (args.File.ContentType.StartsWith("image"))
{
Stream stream = args.File.OpenReadStream(5242880);
byte[] array;
using (MemoryStream ms = new MemoryStream())
{
await stream.CopyToAsync(ms);
array = ms.ToArray();
}
_posterMediaType = args.File.ContentType;
_posterBase64 = Convert.ToBase64String(array);
_posterChanged = true;
}
}
private async Task SavePoster()
{
throw new NotImplementedException();
}
#endregion
}

View File

@@ -0,0 +1,3 @@
#posterInput {
width: 300px;
}

View File

@@ -0,0 +1,7 @@
<div class="row">
<div class="col rounded-3 panel panel-regular m-1">
<div>
<h2 class="text-danger">Sorry. You have no permission to view this site.</h2>
</div>
</div>
</div>

View File

@@ -1,36 +1,47 @@
@using System.Diagnostics
@using System.Text
@using WatchIt.Common.Model.Media
@using WatchIt.Website.Services.WebAPI.Media
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@if (loaded)
@if (_loaded)
{
<div class="container-xl">
<div class="row align-items-center m-2 rounded-3 header panel">
<div class="col-sm-4">
<div class="row align-items-center m-1 my-2 rounded-3 header panel panel-header z-3">
<div class="col-2">
<a class="logo" href="/">
WatchIt
</a>
</div>
<div class="col-sm-4">
<div class="col">
<p>Menu</p>
</div>
<div class="col-sm-4">
<div class="col-auto">
<div class="d-flex flex-row-reverse">
@if (signedIn)
@if (_user is null)
{
<p>test</p>
<a class="main-button" href="/auth">Sign in</a>
}
else
{
<a class="main-button" href="/auth">Sign in or up</a>
<div class="dropdown z-3">
<a class="dropdown-toggle align-items-center text-decoration-none d-flex" id="dropdownUser" aria-expanded="false" @onclick="() => _userMenuIsActive = !_userMenuIsActive">
<img class="rounded-circle" alt="avatar" height="30" src="@(_userProfilePicture)"/>
<div class="text-decoration-none mx-2 text-white">@(_user.Username)</div>
</a>
<ul class="dropdown-menu dropdown-menu-right text-small z-3" id="user-menu" aria-labelledby="dropdownUser">
<li>
@if (_user.IsAdmin)
{
<a class="dropdown-item" href="/admin">Administrator panel</a>
}
<div class="dropdown-menu-separator"></div>
<a class="dropdown-item text-danger" @onclick="UserMenuLogOut">Log out</a>
</li>
</ul>
</div>
}
</div>
</div>
</div>
<div class="row body-content">
<div class="col-sm-12">
<div class="row">
<div class="col z-0">
@Body
</div>
</div>
@@ -38,11 +49,16 @@
<style>
body {
background: url('@background') no-repeat center center fixed;
background: url('@_background') no-repeat center center fixed;
}
.logo, .main-button {
background-image: linear-gradient(45deg, @firstGradientColor, @secondGradientColor);
background-image: linear-gradient(45deg, @_firstGradientColor, @_secondGradientColor);
}
#user-menu {
display: @(_userMenuIsActive ? "block" : "none");
position: fixed;
}
</style>
}
@@ -55,6 +71,10 @@
#region SERVICES
[Inject] public ILogger<MainLayout> Logger { get; set; } = default!;
[Inject] public NavigationManager NavigationManager { get; set; } = default!;
[Inject] public ITokensService TokensService { get; set; } = default!;
[Inject] public IAuthenticationService AuthenticationService { get; set; } = default!;
[Inject] public IAccountsWebAPIService AccountsWebAPIService { get; set; } = default!;
[Inject] public IMediaWebAPIService MediaWebAPIService { get; set; } = default!;
#endregion
@@ -63,12 +83,15 @@
#region FIELDS
private bool loaded = false;
private bool _loaded = false;
private string background = "assets/background_temp.jpg";
private string firstGradientColor = "#c6721c";
private string secondGradientColor = "#85200c";
private bool signedIn = false;
private string _background = "assets/background_temp.jpg";
private string _firstGradientColor = "#c6721c";
private string _secondGradientColor = "#85200c";
private User? _user = null;
private string _userProfilePicture = "assets/user_placeholder.png";
private bool _userMenuIsActive = false;
#endregion
@@ -76,7 +99,29 @@
#region METHODS
protected override async Task OnInitializedAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
List<Task> bgTasks = new List<Task>();
bgTasks.Add(GetBackground());
await GetAuthenticatedUser();
if (_user is not null)
{
bgTasks.Add(GetProfilePicture());
}
await Task.WhenAll(bgTasks);
_loaded = true;
StateHasChanged();
}
}
private async Task GetBackground()
{
Action<MediaPhotoResponse> backgroundSuccess = (data) =>
{
@@ -86,13 +131,33 @@
string secondColor = BitConverter.ToString(data.Background.SecondGradientColor)
.Replace("-", string.Empty);
background = $"data:{data.MimeType};base64,{imageBase64}";
firstGradientColor = $"#{firstColor}";
secondGradientColor = $"#{secondColor}";
_background = $"data:{data.MimeType};base64,{imageBase64}";
_firstGradientColor = $"#{firstColor}";
_secondGradientColor = $"#{secondColor}";
};
await MediaWebAPIService.GetPhotoRandomBackground(backgroundSuccess, null);
await MediaWebAPIService.GetPhotoRandomBackground(backgroundSuccess);
}
loaded = true;
private async Task GetAuthenticatedUser()
{
_user = await AuthenticationService.GetUserAsync();
}
private async Task GetProfilePicture()
{
Action<AccountProfilePictureResponse> successAction = (data) =>
{
string imageBase64 = Convert.ToBase64String(data.Image);
_userProfilePicture = $"data:{data.MimeType};base64,{imageBase64}";
};
await AccountsWebAPIService.GetAccountProfilePicture(_user.Id, successAction);
}
private async Task UserMenuLogOut()
{
await AuthenticationService.LogoutAsync();
await TokensService.RemoveAuthenticationData();
NavigationManager.Refresh(true);
}
#endregion

View File

@@ -0,0 +1,56 @@
@page "/admin"
<PageTitle>WatchIt administrator panel</PageTitle>
@if (_loaded)
{
<div class="container-fluid">
@if (_authenticated)
{
<div class="row">
<div class="col rounded-3 panel panel-regular m-1">
<h2>Add new data</h2>
</div>
</div>
<div class="row">
<a class="col rounded-3 panel panel-regular m-1" href="/movies/new">
<p class="text-center text-decorations-none">New movie</p>
</a>
<div class="col rounded-3 panel panel-regular m-1">
<p class="text-center">New TV series</p>
</div>
<div class="col rounded-3 panel panel-regular m-1">
<p class="text-center">New TV series</p>
</div>
</div>
}
else
{
<NoPermissionComponent/>
}
</div>
}
@code {
[Inject] public IAuthenticationService AuthenticationService { get; set; } = default!;
private bool _loaded = false;
private bool _authenticated = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
User? user = await AuthenticationService.GetUserAsync();
if (user is not null && user.IsAdmin)
{
_authenticated = true;
}
_loaded = true;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1 @@

View File

@@ -1,4 +1,11 @@
@page "/auth"
@using System.Text
@using WatchIt.Common.Model.Accounts
@using WatchIt.Common.Model.Media
@using WatchIt.Website.Services.Utility.Authentication
@using WatchIt.Website.Services.Utility.Tokens
@using WatchIt.Website.Services.WebAPI.Accounts
@using WatchIt.Website.Services.WebAPI.Media
@layout EmptyLayout
<PageTitle>WatchIt - @(_authType == AuthType.SignIn ? "Sign in" : "Sign up")</PageTitle>
@@ -6,20 +13,97 @@
@if (_loaded)
{
<div class="h-100 d-flex align-items-center justify-content-center">
<div class="d-inline-flex flex-column justify-content-center panel rounded-3">
<div class="d-inline-flex flex-column justify-content-center panel panel-header rounded-3">
<a class="logo" href="/">
WatchIt
</a>
<div>
@if (_authType == AuthType.SignIn)
{
<form method="post" @onsubmit="Login" @formname="login">
<AntiforgeryToken/>
<div>
<label>
Username or email:
<InputText @bind-Value="_loginModel!.UsernameOrEmail"/>
</label>
</div>
<div>
<label>
Password:
<InputText type="password" @bind-Value="_loginModel!.Password"/>
</label>
</div>
<div>
<label>
<InputCheckbox @bind-Value="_loginModel!.RememberMe"></InputCheckbox>
Remember me
</label>
</div>
<div>
<button type="submit">Sign in</button>
</div>
</form>
}
else
{
<form method="post" @onsubmit="Register" @formname="register">
<AntiforgeryToken/>
<div>
<label>
Username:
<InputText @bind-Value="_registerModel!.Username"/>
</label>
</div>
<div>
<label>
Email:
<InputText @bind-Value="_registerModel!.Email"/>
</label>
</div>
<div>
<label>
Password:
<InputText type="password" @bind-Value="_registerModel!.Password"/>
</label>
</div>
<div>
<label>
Confirm password:
<InputText type="password" @bind-Value="_passwordConfirmation"/>
</label>
</div>
<div>
<button type="submit">Sign up</button>
</div>
</form>
}
</div>
@if (_errors is not null)
{
<div class="text-danger">
@foreach (string error in _errors)
{
@error
<br/>
}
</div>
}
<div>
<label>
<input type="radio" checked="@(() => _authType == AuthType.SignIn)" name="auth" @onchange="@(() => _authType = AuthType.SignIn)" />
Sign in
</label>
<label>
<input type="radio" checked="@(() => _authType == AuthType.SignUp)" name="auth" @onchange="@(() => _authType = AuthType.SignUp)" />
Sign up
</label>
</div>
</div>
</div>
@@ -32,12 +116,26 @@
background-image: linear-gradient(45deg, @_firstGradientColor, @_secondGradientColor);
}
</style>
}
@code {
@code
{
#region SERVICES
[Inject] public ILogger<Auth> Logger { get; set; } = default!;
[Inject] public IAuthenticationService AuthenticationService { get; set; } = default!;
[Inject] public ITokensService TokensService { get; set; } = default!;
[Inject] public IMediaWebAPIService MediaWebAPIService { get; set; } = default!;
[Inject] public IAccountsWebAPIService AccountsWebAPIService { get; set; } = default!;
[Inject] public NavigationManager NavigationManager { get; set; } = default!;
#endregion
#region ENUMS
@@ -53,20 +151,99 @@
#region FIELDS
private bool _loaded = false;
private AuthType _authType = AuthType.SignIn;
private string _background = "assets/background_temp.jpg";
private string _firstGradientColor = "#c6721c";
private string _secondGradientColor = "#85200c";
private AuthenticateRequest _loginModel = new AuthenticateRequest
{
UsernameOrEmail = null,
Password = null
};
private RegisterRequest _registerModel = new RegisterRequest
{
Username = null,
Email = null,
Password = null
};
private string _passwordConfirmation;
private IEnumerable<string> _errors;
#endregion
#region METHODS
protected override Task OnInitializedAsync()
protected override async Task OnInitializedAsync()
{
return base.OnInitializedAsync();
if (await AuthenticationService.GetAuthenticationStatusAsync())
{
NavigationManager.NavigateTo("/");
}
Action<MediaPhotoResponse> backgroundSuccess = (data) =>
{
string imageBase64 = Convert.ToBase64String(data.Image);
string firstColor = BitConverter.ToString(data.Background.FirstGradientColor)
.Replace("-", string.Empty);
string secondColor = BitConverter.ToString(data.Background.SecondGradientColor)
.Replace("-", string.Empty);
_background = $"data:{data.MimeType};base64,{imageBase64}";
_firstGradientColor = $"#{firstColor}";
_secondGradientColor = $"#{secondColor}";
};
await MediaWebAPIService.GetPhotoRandomBackground(backgroundSuccess);
_loaded = true;
}
private async Task Login()
{
await AccountsWebAPIService.Authenticate(_loginModel, LoginSuccess, LoginBadRequest, LoginUnauthorized);
async void LoginSuccess(AuthenticateResponse data)
{
await TokensService.SaveAuthenticationData(data);
NavigationManager.NavigateTo("/");
}
void LoginBadRequest(IDictionary<string, string[]> data)
{
_errors = data.SelectMany(x => x.Value).Select(x => $"• {x}");
}
void LoginUnauthorized()
{
_errors = [ "Incorrect account data" ];
}
}
private async Task Register()
{
if (_registerModel.Password != _passwordConfirmation)
{
_errors = [ "Password fields don't match" ];
return;
}
await AccountsWebAPIService.Register(_registerModel, RegisterSuccess, RegisterBadRequest);
void RegisterSuccess(RegisterResponse data)
{
_authType = AuthType.SignIn;
}
void RegisterBadRequest(IDictionary<string, string[]> data)
{
_errors = data.SelectMany(x => x.Value).Select(x => $"• {x}");
}
}
#endregion

View File

@@ -5,46 +5,50 @@
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>Hello, world!</h1>
Welcome to your new app.
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
<h2>Hello, world!</h2>
<p>Welcome to your new app.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
@page "/movies/new"
@page "/movies/{id:long}/edit"
<PageTitle>WatchIt - New movie</PageTitle>
@if (_loaded)
{
<div class="container-fluid">
@if (_authenticated)
{
<div class="row">
<div class="col rounded-3 panel panel-regular m-1">
<h2>@(Id is null ? "Create new movie" : $"Edit movie \"{_movieInfo.Title}\"")</h2>
</div>
</div>
<MediaForm Data=@(_movieData) SaveDataAction=SaveData Id=@(Id) SaveDataErrors=@(_movieDataErrors) SaveDataInfo=@(_movieDataInfo)/>
}
else
{
<NoPermissionComponent/>
}
</div>
}

View File

@@ -0,0 +1,102 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Components;
using WatchIt.Common.Model.Movies;
using WatchIt.Website.Services.Utility.Authentication;
using WatchIt.Website.Services.WebAPI.Movies;
namespace WatchIt.Website.Pages;
public partial class MovieEditPage : ComponentBase
{
#region SERVICES
[Inject] public NavigationManager NavigationManager { get; set; } = default!;
[Inject] public IAuthenticationService AuthenticationService { get; set; } = default!;
[Inject] public IMoviesWebAPIService MoviesWebAPIService { get; set; } = default!;
#endregion
#region PARAMETERS
[Parameter]
public long? Id { get; set; }
#endregion
#region FIELDS
private bool _loaded = false;
private bool _authenticated = false;
private MovieResponse? _movieInfo = null;
private MovieRequest _movieData = new MovieRequest { Title = string.Empty };
private IEnumerable<string>? _movieDataErrors = null;
private string? _movieDataInfo = null;
#endregion
#region PRIVATE METHODS
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
User? user = await AuthenticationService.GetUserAsync();
if (user is not null && user.IsAdmin)
{
_authenticated = true;
await LoadData();
}
_loaded = true;
StateHasChanged();
}
}
private async Task LoadData()
{
if (Id is not null)
{
await MoviesWebAPIService.Get(Id.Value, GetSuccessAction, NoIdAction);
}
return;
void GetSuccessAction(MovieResponse data)
{
_movieInfo = data;
_movieData = new MovieRequest(_movieInfo);
}
void NoIdAction() => NavigationManager.NavigateTo("/movies/new", true); // await for all
}
private async Task SaveData()
{
_movieDataErrors = null;
_movieDataInfo = null;
if (Id is null)
{
await MoviesWebAPIService.Post(_movieData, PostSuccessAction, BadRequestAction, NoPermissionsAction, NoPermissionsAction);
}
else
{
await MoviesWebAPIService.Put(Id.Value, _movieData, PutSuccessAction, BadRequestAction, NoPermissionsAction, NoPermissionsAction);
}
return;
void PutSuccessAction() => _movieDataInfo = "Data saved";
void PostSuccessAction(MovieResponse data) => NavigationManager.NavigateTo($"/movies/{data.Id}/edit", true);
void BadRequestAction(IDictionary<string, string[]> errors) => _movieDataErrors = errors.SelectMany(x => x.Value);
void NoPermissionsAction() => NavigationManager.Refresh(true);
}
#endregion
}

View File

@@ -0,0 +1 @@

View File

@@ -1,7 +1,13 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.Authorization;
using WatchIt.Common.Services.HttpClient;
using WatchIt.Website.Services.Utility.Authentication;
using WatchIt.Website.Services.Utility.Configuration;
using WatchIt.Website.Services.Utility.Tokens;
using WatchIt.Website.Services.WebAPI.Accounts;
using WatchIt.Website.Services.WebAPI.Media;
using WatchIt.Website.Services.WebAPI.Movies;
namespace WatchIt.Website;
@@ -13,6 +19,7 @@ public static class Program
{
WebApplication app = WebApplication.CreateBuilder(args)
.SetupServices()
.SetupAuthentication()
.SetupApplication()
.Build();
@@ -43,15 +50,26 @@ public static class Program
private static WebApplicationBuilder SetupServices(this WebApplicationBuilder builder)
{
builder.Services.AddHttpClient();
builder.Services.AddSingleton<HttpClient>();
// Utility
builder.Services.AddSingleton<IHttpClientService, HttpClientService>();
builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
builder.Services.AddScoped<ITokensService, TokensService>();
builder.Services.AddScoped<IAuthenticationService, AuthenticationService>();
// WebAPI
builder.Services.AddSingleton<IAccountsWebAPIService, AccountsWebAPIService>();
builder.Services.AddScoped<IAccountsWebAPIService, AccountsWebAPIService>();
builder.Services.AddSingleton<IMediaWebAPIService, MediaWebAPIService>();
builder.Services.AddSingleton<IMoviesWebAPIService, MoviesWebAPIService>();
return builder;
}
private static WebApplicationBuilder SetupAuthentication(this WebApplicationBuilder builder)
{
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, JWTAuthenticationStateProvider>();
return builder;
}

View File

@@ -15,9 +15,13 @@
<ItemGroup>
<ProjectReference Include="..\..\WatchIt.Common\WatchIt.Common.Services\WatchIt.Common.Services.HttpClient\WatchIt.Common.Services.HttpClient.csproj" />
<ProjectReference Include="..\..\WatchIt.WebAPI\WatchIt.WebAPI.Services\WatchIt.WebAPI.Services.Utility\WatchIt.WebAPI.Services.Utility.Tokens\WatchIt.WebAPI.Services.Utility.Tokens.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services\WatchIt.Website.Services.Utility\WatchIt.Website.Services.Utility.Authentication\WatchIt.Website.Services.Utility.Authentication.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services\WatchIt.Website.Services.Utility\WatchIt.Website.Services.Utility.Configuration\WatchIt.Website.Services.Utility.Configuration.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services\WatchIt.Website.Services.Utility\WatchIt.Website.Services.Utility.Tokens\WatchIt.Website.Services.Utility.Tokens.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services\WatchIt.Website.Services.WebAPI\WatchIt.Website.Services.WebAPI.Accounts\WatchIt.Website.Services.WebAPI.Accounts.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services\WatchIt.Website.Services.WebAPI\WatchIt.Website.Services.WebAPI.Media\WatchIt.Website.Services.WebAPI.Media.csproj" />
<ProjectReference Include="..\WatchIt.Website.Services\WatchIt.Website.Services.WebAPI\WatchIt.Website.Services.WebAPI.Movies\WatchIt.Website.Services.WebAPI.Movies.csproj" />
</ItemGroup>
<ItemGroup>
@@ -27,6 +31,8 @@
<_ContentIncludedByDefault Remove="Components\Pages\Error.razor" />
<_ContentIncludedByDefault Remove="Components\Pages\Home.razor" />
<_ContentIncludedByDefault Remove="Components\Pages\Weather.razor" />
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" />
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" />
</ItemGroup>
<ItemGroup>
@@ -34,9 +40,4 @@
<AdditionalFiles Include="Pages\Home.razor" />
</ItemGroup>
<ItemGroup>
<Folder Include="Components\" />
<Folder Include="wwwroot\assets\" />
</ItemGroup>
</Project>

View File

@@ -8,3 +8,10 @@
@using Microsoft.JSInterop
@using WatchIt.Website
@using WatchIt.Website.Layout
@using WatchIt.Website.Components
@using WatchIt.Common.Model.Accounts
@using WatchIt.Common.Model.Media
@using WatchIt.Website.Services.Utility.Tokens
@using WatchIt.Website.Services.Utility.Authentication
@using WatchIt.Website.Services.WebAPI.Accounts
@using WatchIt.Website.Services.WebAPI.Media

View File

@@ -6,13 +6,19 @@
}
},
"AllowedHosts": "*",
"StorageKeys": {
"AccessToken": "access_token",
"RefreshToken": "refresh_token"
},
"Endpoints": {
"Base": "https://localhost:7160",
"Accounts": {
"Base": "/accounts",
"Register": "/register",
"Authenticate": "/authenticate",
"AuthenticateRefresh": "/authenticate-refresh"
"AuthenticateRefresh": "/authenticate-refresh",
"Logout": "/logout",
"GetProfilePicture": "/{0}/profile-picture"
},
"Genres": {
"Base": "/genres",
@@ -28,10 +34,7 @@
"Get": "/{0}",
"Post": "",
"Put": "/{0}",
"Delete": "/{0}",
"GetGenres": "/{0}/genres",
"PostGenre": "{0}/genres/{1}",
"DeleteGenre": "{0}/genres/{1}"
"Delete": "/{0}"
},
"Media": {
"Base": "/media",

View File

@@ -1,33 +1,31 @@
@font-face {
font-family: Belanosima;
src: url(fonts/Belanosima-Regular.ttf) format('truetype');
}
@font-face {
font-family: Belanosima;
src: url(fonts/Belanosima-Bold.ttf) format('truetype');
font-weight: bold;
}
body, html {
background-color: transparent;
height: 100%;
margin: 0;
padding: 0;
color: lightgray;
font-family: "PT Sans";
}
.logo {
font-family: Belanosima;
font-family: "Belanosima";
text-decoration: none;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.panel {
.panel-header {
background-color: rgba(0, 0, 0, 0.8);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.panel-regular {
background-color: rgba(0, 0, 0, 0.6);
}
.panel {
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(25px);
z-index: 1000;
}
.main-button {
@@ -78,3 +76,8 @@ body, html {
.main-button:hover::before {
-webkit-mask:none;
}
.dropdown-menu-left {
right: auto;
left: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -76,6 +76,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WatchIt.Website.Services.We
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WatchIt.Website.Services.WebAPI.Common", "WatchIt.Website\WatchIt.Website.Services\WatchIt.Website.Services.WebAPI\WatchIt.Website.Services.WebAPI.Common\WatchIt.Website.Services.WebAPI.Common.csproj", "{2D62ED42-489E-4888-9479-E5A50A0E7D70}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WatchIt.Website.Services.Utility.Tokens", "WatchIt.Website\WatchIt.Website.Services\WatchIt.Website.Services.Utility\WatchIt.Website.Services.Utility.Tokens\WatchIt.Website.Services.Utility.Tokens.csproj", "{77FDAFDD-E97E-4059-A935-B563B6B0D555}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WatchIt.Website.Services.Utility.Authentication", "WatchIt.Website\WatchIt.Website.Services\WatchIt.Website.Services.Utility\WatchIt.Website.Services.Utility.Authentication\WatchIt.Website.Services.Utility.Authentication.csproj", "{8720AECA-7084-429A-BA15-49B6622C1A32}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -116,6 +120,8 @@ Global
{3156AD7B-D6EC-4EB6-AEE8-4FBAF14C18E4} = {CEC468DB-CC49-47D3-9E3E-1CC9530C3CE7}
{1D64B7B5-650D-4AF3-AC33-A8D1F0999906} = {46E3711F-18BD-4004-AF53-EA4D8643D92F}
{2D62ED42-489E-4888-9479-E5A50A0E7D70} = {46E3711F-18BD-4004-AF53-EA4D8643D92F}
{77FDAFDD-E97E-4059-A935-B563B6B0D555} = {130BC8F5-82CE-4EDF-AECB-21594DD41849}
{8720AECA-7084-429A-BA15-49B6622C1A32} = {130BC8F5-82CE-4EDF-AECB-21594DD41849}
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{23383776-1F27-4B5D-8C7C-57BFF75FA473}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -222,5 +228,13 @@ Global
{2D62ED42-489E-4888-9479-E5A50A0E7D70}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2D62ED42-489E-4888-9479-E5A50A0E7D70}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2D62ED42-489E-4888-9479-E5A50A0E7D70}.Release|Any CPU.Build.0 = Release|Any CPU
{77FDAFDD-E97E-4059-A935-B563B6B0D555}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{77FDAFDD-E97E-4059-A935-B563B6B0D555}.Debug|Any CPU.Build.0 = Debug|Any CPU
{77FDAFDD-E97E-4059-A935-B563B6B0D555}.Release|Any CPU.ActiveCfg = Release|Any CPU
{77FDAFDD-E97E-4059-A935-B563B6B0D555}.Release|Any CPU.Build.0 = Release|Any CPU
{8720AECA-7084-429A-BA15-49B6622C1A32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8720AECA-7084-429A-BA15-49B6622C1A32}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8720AECA-7084-429A-BA15-49B6622C1A32}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8720AECA-7084-429A-BA15-49B6622C1A32}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal