dev #12

Merged
mfnefd merged 5 commits from dev into registration 2024-06-20 04:20:37 +04:00
21 changed files with 880 additions and 315 deletions

View File

@ -43,6 +43,8 @@ namespace BusinessLogic.BusinessLogic
}
// Хешируем пароль
model.PasswordHash = PasswordHasher.Hash(model.Password!);
model.Birthday = model.Birthday.ToUniversalTime();
var user = _userStorage.Insert(model);
if (user is null)
{
@ -103,6 +105,7 @@ namespace BusinessLogic.BusinessLogic
_validate(model);
model.PasswordHash = PasswordHasher.Hash(model.Password!);
model.Birthday = model.Birthday.ToUniversalTime();
var user = _userStorage.Update(model);
if (user is null)
{

View File

@ -9,5 +9,6 @@ namespace Contracts.SearchModels
public class RoleSearchModel
{
public Guid? Id { get; set; }
public string? Name { get; set; }
}
}

View File

@ -32,20 +32,21 @@ namespace DatabaseImplement.Implements
public RoleBindingModel? GetElement(RoleSearchModel model)
{
if (model.Id is null)
if (model.Id is null && string.IsNullOrWhiteSpace(model.Name))
{
return null;
}
var context = new Database();
return context.Roles
.FirstOrDefault(r => r.Id == model.Id)
.FirstOrDefault(r => (model.Id.HasValue && r.Id == model.Id)
|| (!string.IsNullOrWhiteSpace(model.Name) && r.Name.Contains(model.Name)))
?.GetBindingModel();
}
public IEnumerable<RoleBindingModel> GetList(RoleSearchModel? model)
{
var context = new Database();
if (model is null)
if (model is null && string.IsNullOrWhiteSpace(model.Name))
{
return context.Roles.Select(r => r.GetBindingModel());
}
@ -54,7 +55,8 @@ namespace DatabaseImplement.Implements
return [];
}
return context.Roles
.Where(r => r.Id == model.Id)
.Where(r => (model.Id.HasValue && r.Id == model.Id)
|| (!string.IsNullOrWhiteSpace(model.Name) && r.Name.Contains(model.Name)))
.Select(r => r.GetBindingModel());
}

View File

@ -1,4 +1,5 @@
using BusinessLogic.BusinessLogic;
using BusinessLogic.Tools.Mail.MailTemplates;
using Contracts.BindingModels;
using Contracts.BusinessLogicContracts;
using Contracts.Exceptions;
@ -21,11 +22,11 @@ namespace RestAPI.Controllers
}
[HttpPost]
public IResult Login(string email, string password)
public IResult Login([FromBody] UserData data)
{
try
{
var res = _userLogic.Login(email, password);
var res = _userLogic.Login(data.email, data.password);
return Results.Ok(res);
}
catch (ElementNotFoundException ex)
@ -125,4 +126,6 @@ namespace RestAPI.Controllers
}
}
}
public record class UserData(string email, string password);
}

70
WebApp/APIClient.cs Normal file
View File

@ -0,0 +1,70 @@
using Newtonsoft.Json;
using System.Net.Http.Headers;
using System.Text;
namespace WebApp;
public class APIClient
{
private static readonly HttpClient _client = new();
public static void Connect(IConfiguration configuration)
{
_client.BaseAddress = new Uri(configuration["API"]);
_client.DefaultRequestHeaders.Accept.Clear();
_client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
public static T? GetRequest<T>(string requestUrl)
{
var response = _client.GetAsync(requestUrl);
var result = response.Result.Content.ReadAsStringAsync().Result;
if (!response.Result.IsSuccessStatusCode)
{
throw new Exception(response.Result.ReasonPhrase);
}
return JsonConvert.DeserializeObject<T>(result);
}
public static object? PostRequest<T>(string requestUrl, T model)
{
var json = JsonConvert.SerializeObject(model);
var data = new StringContent(json, Encoding.UTF8, "application/json");
var response = _client.PostAsync(requestUrl, data);
var result = response.Result.Content.ReadAsStringAsync().Result;
if (!response.Result.IsSuccessStatusCode)
{
throw new Exception(response.Result.ReasonPhrase);
}
return result;
}
public static object? DeleteRequest(string requestUrl)
{
var response = _client.DeleteAsync(requestUrl);
var result = response.Result.Content.ReadAsStringAsync().Result;
if (!response.Result.IsSuccessStatusCode)
{
throw new Exception(response.Result.ReasonPhrase);
}
return result;
}
public static object? PatchRequest<T>(string requestUrl, T model)
{
var json = JsonConvert.SerializeObject(model);
var data = new StringContent(json, Encoding.UTF8, "application/json");
var response = _client.PatchAsync(requestUrl, data);
var result = response.Result.Content.ReadAsStringAsync().Result;
if (!response.Result.IsSuccessStatusCode)
{
throw new Exception(response.Result.ReasonPhrase);
}
return result;
}
}

10
WebApp/Helpers/Roles.cs Normal file
View File

@ -0,0 +1,10 @@
namespace WebApp.Helpers;
public static class Roles
{
public const string User = "Обычный пользователь";
public const string Admin = "Админ";
// TODO: Добавить нужные роли
public const string Worker = "Сотрудник";
}

57
WebApp/Pages/Login.cshtml Normal file
View File

@ -0,0 +1,57 @@
@page
@model WebApp.Pages.LoginModel
@{
ViewData["Title"] = "Log In";
}
<section class="vh-100">
<style>
.bg-image-vertical {
position: relative;
overflow: hidden;
background-repeat: no-repeat;
background-position: right center;
background-size: auto 100%;
}
@@media (min-width: 1025px) {
.h-custom-2 {
height: 100%;
}
}
</style>
<div class="container-fluid">
<div class="row">
<div class="col-sm-6 text-black">
<div class="d-flex align-items-center h-custom-2 px-5 ms-xl-4 mt-5 pt-5 pt-xl-0 mt-xl-n5">
<form style="width: 23rem;" method="post">
<h3 class="fw-normal mb-3 pb-3" style="letter-spacing: 1px;">Log in</h3>
<div data-mdb-input-init class="form-outline mb-4">
<input type="email" id="email" class="form-control form-control-lg" name="email" />
<label class="form-label" for="email">Email address</label>
</div>
<div data-mdb-input-init class="form-outline mb-4">
<input type="password" id="password" class="form-control form-control-lg" name="password" />
<label class="form-label" for="password">Password</label>
</div>
<div class="pt-1 mb-4">
<button data-mdb-button-init data-mdb-ripple-init class="btn btn-info btn-lg btn-block" type="submit">Login</button>
</div>
<p>Don't have an account? <a asp-page="/SignUp" class="link-info">Register here</a></p>
</form>
</div>
</div>
<div class="col-sm-6 px-0 d-none d-sm-block">
<img src="~/background-login.jpg"
alt="Login image" class="w-100 vh-100" style="object-fit: cover; object-position: left;">
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,28 @@
using Contracts.BindingModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Newtonsoft.Json;
namespace WebApp.Pages
{
public class LoginModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnPostAsync(string email, string password)
{
var response = APIClient.PostRequest("user/login", new { email, password });
if (response is null || response is not string)
{
throw new Exception("Something wrong LOL!");
}
this.SetJWT((string)response);
return RedirectToPage("Index");
}
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Newtonsoft.Json;
namespace WebApp.Pages
{
public static class PageModelExtension
{
public static string? GetUserId(this PageModel pageModel)
{
if (pageModel.User.Identity.IsAuthenticated)
{
var userIdClaim = pageModel.User.Claims.FirstOrDefault(c => c.Type == "userId");
return userIdClaim?.Value;
}
return null;
}
public static void SetJWT(this PageModel pageModel, string jwt)
{
string token = (string)JsonConvert.DeserializeObject(jwt);
// Сохраняем в кукис токен
pageModel.Response.Cookies.Append("21gunsthebest", token);
}
public static void DeleteJWT(this PageModel pageModel)
{
pageModel.Response.Cookies.Delete("21gunsthebest");
}
}
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - WebApp</title>
<title>@ViewData["Title"] - 21 GUNS</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/WebApp.styles.css" asp-append-version="true" />
@ -12,7 +12,7 @@
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container">
<a class="navbar-brand" asp-area="" asp-page="/Index">WebApp</a>
<a class="navbar-brand" asp-area="" asp-page="/Index">21 GUNS</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@ -25,6 +25,18 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
</li>
@if (User.Identity.IsAuthenticated)
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/User/Index">Profile</a>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Login">Login</a>
</li>
}
</ul>
</div>
</div>
@ -38,7 +50,7 @@
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2024 - WebApp - <a asp-area="" asp-page="/Privacy">Privacy</a>
&copy; 2024 - 21 GUNS - <a asp-area="" asp-page="/Privacy">Privacy</a>
</div>
</footer>

View File

@ -0,0 +1,93 @@
@page
@model WebApp.Pages.SignUpModel
@{
ViewData["Title"] = "Sign Up";
}
<!-- Section: Design Block -->
<section class="overflow-hidden">
<style>
.bg-glass {
background-color: hsla(0, 0%, 100%, 0.9) !important;
backdrop-filter: saturate(200%) blur(25px);
}
</style>
<div class="container px-4 py-5 px-md-5 text-center text-lg-start my-5">
<div class="row gx-lg-5 align-items-center mb-5">
<div class="col-lg-6 mb-5 mb-lg-0" style="z-index: 10">
<h1 class="my-5 display-5 fw-bold ls-tight" style="color: #373A40">
Time to buy some... guns!*<br />
<span style="color: #DC5F00">In the store** of death*** and despair****</span>
</h1>
<div class="mb-4 opacity-70 text-body-emphasis" style="color: #686D76">
<p>
We would like to draw your attention to the fact that our company does not sell products to anyone under the age of 18. All of our products are intended for adult audiences only. We also do not ship to countries where our products are prohibited or restricted by law. Please make sure you meet all the necessary requirements before placing your order. We appreciate your understanding and co-operation in this matter.
<div style="font-size: 6px">
*toy guns,
**21 guns,
***a metaphor for death of the happiness of buying our merchandise,
****a metaphor for despair over the consumer's failure to find this shop previously
</div>
</div>
</div>
<div class="col-lg-6 mb-5 mb-lg-0 position-relative">
<div class="card bg-glass">
<div class="card-body px-4 py-5 px-md-5">
<form method="post">
<!-- 2 column grid layout with text inputs for the first and last names -->
<div class="row">
<div class="col-md-6 mb-4">
<div data-mdb-input-init class="form-outline">
<input asp-for="UserModel.FirstName" type="text" id="firstname" class="form-control" />
<label class="form-label" for="firstname">First name</label>
</div>
</div>
<div class="col-md-6 mb-4">
<div data-mdb-input-init class="form-outline">
<input asp-for="UserModel.SecondName" type="text" id="lastname" class="form-control" />
<label class="form-label" for="lastname">Last name</label>
</div>
</div>
</div>
<!-- Email input -->
<div data-mdb-input-init class="form-outline mb-4">
<input asp-for="UserModel.Email" type="email" id="email" class="form-control" />
<label class="form-label" for="email">Email address</label>
</div>
<!-- Password input -->
<div data-mdb-input-init class="form-outline mb-4">
<input asp-for="UserModel.Password" type="password" id="password" class="form-control" />
<label class="form-label" for="password">Password</label>
</div>
<!-- Checkbox -->
<div class="form-check d-flex justify-content-center mb-4">
<input class="form-check-input me-2" type="checkbox" value="" id="confirming" />
<label class="form-check-label" for="confirming">
I confirm that I am 18 years of age and have read the Privacy Policy and Terms of Agreement
</label>
</div>
<!-- Submit button -->
<button type="submit" data-mdb-button-init data-mdb-ripple-init class="btn btn-primary btn-block mb-4">
Sign up
</button>
<div>
<p class="mb-0">
Already have an account? <a class="fw-bold" asp-area="" asp-page="/Login">Login</a>
</p>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Section: Design Block -->

View File

@ -0,0 +1,38 @@
using Contracts.BindingModels;
using Contracts.ViewModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using WebApp.Helpers;
namespace WebApp.Pages
{
public class SignUpModel : PageModel
{
[BindProperty]
public UserBindingModel UserModel { get; set; }
public void OnGet()
{
}
public IActionResult OnPostAsync()
{
var userRole = APIClient.GetRequest<RoleViewModel>($"role/get?name={Roles.User}");
if (userRole is null)
{
throw new Exception("User role is not found");
}
UserModel.Role = new() { Id = userRole.Id };
var response = APIClient.PostRequest("user/registration", UserModel);
if (response is null || response is not string)
{
throw new Exception("Something wrong LOL!");
}
this.SetJWT((string)response);
return RedirectToPage("Index");
}
}
}

View File

@ -0,0 +1,23 @@
@page
@model WebApp.Pages.User.IndexModel
@{
ViewData["Title"] = "User page";
}
<div class="container mt-5">
<div class="row">
<div class="col-md-6 offset-md-3">
<h1 class="text-center">@Model.UserModel.FirstName @Model.UserModel.SecondName</h1>
<p class="text-center">@Model.UserModel.Email</p>
<hr>
<div class="mt-3">
<a class="btn btn-primary" asp-page="/User/Settings">Settings</a>
</div>
<form asp-page-handler="SignOut" method="post">
<div class="mt-3">
<button class="btn btn-danger">Sign out</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
using Contracts.BindingModels;
using Contracts.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using WebApp.Helpers;
namespace WebApp.Pages.User
{
[Authorize(Roles = Roles.User)]
public class IndexModel : PageModel
{
public UserViewModel UserModel { get; set; }
public void OnGet()
{
var id = this.GetUserId();
if (id is null)
{
return;
}
UserModel = APIClient.GetRequest<UserViewModel>($"user/get?id={id}");
}
public IActionResult OnPostSignOut()
{
this.DeleteJWT();
return RedirectToPage("../Index");
}
}
}

View File

@ -0,0 +1,54 @@
@page
@model WebApp.Pages.User.SettingsModel
<div class="card bg-glass">
<div class="card-body px-4 py-5 px-md-5">
<form method="post">
<!-- 2 column grid layout with text inputs for the first and last names -->
<div class="row">
<div class="col-md-6 mb-4">
<div data-mdb-input-init class="form-outline">
<input asp-for="UserModel.FirstName" type="text" id="firstname" class="form-control" />
<label class="form-label" for="firstname">First name</label>
</div>
</div>
<div class="col-md-6 mb-4">
<div data-mdb-input-init class="form-outline">
<input asp-for="UserModel.SecondName" type="text" id="lastname" class="form-control" />
<label class="form-label" for="lastname">Last name</label>
</div>
</div>
</div>
<!-- Email input -->
<div data-mdb-input-init class="form-outline mb-4">
<input asp-for="UserModel.Email" type="email" id="email" class="form-control" />
<label class="form-label" for="email">Email address</label>
</div>
<!-- Password input -->
<div data-mdb-input-init class="form-outline mb-4">
<input asp-for="UserModel.Password" type="password" id="password" class="form-control" />
<label class="form-label" for="password">Password</label>
</div>
<!-- Birthday input -->
<div data-mdb-input-init class="form-outline mb-4">
<input asp-for="UserModel.Birthday" type="date" id="birthday" class="form-control" />
<label class="form-label" for="birthday">Birthday</label>
</div>
<!-- Hidden inputs -->
<input type="hidden" asp-for="UserModel.Id" />
<input type="hidden" asp-for="UserModel.Role.Id" />
<!-- Submit button -->
<button type="submit" data-mdb-button-init data-mdb-ripple-init class="btn btn-primary btn-block mb-4">
Save settings
</button>
</form>
<form asp-page-handler="DeleteProfile" method="post">
<button class="btn btn-danger btn-block mb-4">Delete this account permanently</button>
</form>
</div>
</div>

View File

@ -0,0 +1,65 @@
using Contracts.BindingModels;
using Contracts.Converters;
using Contracts.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using WebApp.Helpers;
namespace WebApp.Pages.User
{
[Authorize(Roles = Roles.User)]
public class SettingsModel : PageModel
{
[BindProperty]
public UserBindingModel UserModel { get; set; }
public void OnGet()
{
var id = this.GetUserId();
if (id is null)
{
return;
}
var userView = APIClient.GetRequest<UserViewModel>($"user/get?id={id}");
if (userView is null)
{
throw new Exception("User is not found.");
}
UserModel = UserConverter.ToBinding(userView);
}
public IActionResult OnPostDeleteProfile()
{
var id = this.GetUserId();
if (id is null)
{
throw new Exception("User not found!");
}
var response = APIClient.DeleteRequest($"user/delete?id={id}");
if (response is null)
{
throw new Exception("Something wrong LOL!");
}
this.DeleteJWT();
return RedirectToPage("../Index");
}
public IActionResult OnPostAsync()
{
var response = APIClient.PatchRequest("user/update", UserModel);
if (response is null)
{
throw new Exception("Something wrong LOL!");
}
return RedirectToPage("Index");
}
}
}

View File

@ -1,8 +1,40 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using WebApp;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["SecretKey"]));
options.TokenValidationParameters = new()
{
ValidateLifetime = true,
IssuerSigningKey = secretKey,
ValidateIssuer = false,
ValidateAudience = false,
};
options.Events = new JwtBearerEvents()
{
OnMessageReceived = context =>
{
// Ïîëó÷àåì èç êóêèñ JWT òîêåí
context.Token = context.Request.Cookies["21gunsthebest"];
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
// Ïîäêëþ÷àåìñÿ ê API
APIClient.Connect(builder.Configuration);
var app = builder.Build();
// Configure the HTTP request pipeline.
@ -18,6 +50,7 @@ app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();

View File

@ -6,4 +6,13 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Contracts\Contracts.csproj" />
</ItemGroup>
</Project>

View File

@ -3,19 +3,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34728.123
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp.csproj", "{494318C5-209C-42B9-B15F-BF0D5A8ECF18}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApp", "WebApp.csproj", "{494318C5-209C-42B9-B15F-BF0D5A8ECF18}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormsApp", "..\WinFormsApp\WinFormsApp.csproj", "{11F917BB-0ABC-41A0-91B9-B3FD9CEC5277}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WinFormsApp", "..\WinFormsApp\WinFormsApp.csproj", "{11F917BB-0ABC-41A0-91B9-B3FD9CEC5277}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestAPI", "..\RestAPI\RestAPI.csproj", "{D3211E26-438E-48B6-9396-2FFC28271DE1}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RestAPI", "..\RestAPI\RestAPI.csproj", "{D3211E26-438E-48B6-9396-2FFC28271DE1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BusinessLogic", "..\BusinessLogic\BusinessLogic.csproj", "{919726B5-89B3-43B3-AA9A-25C1348D86B1}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BusinessLogic", "..\BusinessLogic\BusinessLogic.csproj", "{919726B5-89B3-43B3-AA9A-25C1348D86B1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataModels", "..\DataModels\DataModels.csproj", "{645ED499-8C00-4F04-91FB-A9EF6F1A438E}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataModels", "..\DataModels\DataModels.csproj", "{645ED499-8C00-4F04-91FB-A9EF6F1A438E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contracts", "..\Contracts\Contracts.csproj", "{D7BD8791-F687-460F-8BF7-8F9CD2301EB5}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Contracts", "..\Contracts\Contracts.csproj", "{D7BD8791-F687-460F-8BF7-8F9CD2301EB5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseImplement", "..\DatabaseImplement\DatabaseImplement.csproj", "{527EAA88-4EAF-42D7-93E9-494221351F9C}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabaseImplement", "..\DatabaseImplement\DatabaseImplement.csproj", "{527EAA88-4EAF-42D7-93E9-494221351F9C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@ -5,5 +5,7 @@
"Microsoft.AspNetCore": "Warning"
}
},
"API": "https://localhost:7051/",
"SecretKey": "secretkey_secretkey_secretkey_secretkey",
"AllowedHosts": "*"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB