Миграция ASP.NET MVC Identity на ASP.NET Core Identity

Миграция ASP.NET MVC Identity на ASP.NET Core Identity

При попытке мигрировать один мой проект на .NET core я столкнулся с тем, что достаточно мало толковых статей про то, как перенести пользователей и роли на .NET Core 3.0.

Из того полезного, что я нашел было только несколько обзорных статей и советов:

В этой статье я разберу как за 10 шагов мигрировать свое ASP.NET MVC приложение на ASP.NET Core.

Подготовка тестового проекта

В качестве проекта который я буду мигрировать, я возьму свой старый MVC проект. Давайте для того, чтобы немного усложнить задачу, добавим к пользователю еще одно entity – hobby и сделаем связь many-to-many.

public class Hobby : BaseEntity
    {
        public string Name { get; set; }

        public ICollection<ApplicationUser> Users{ get; set; }
    }

Entity User'a я модифицирую следующим образом:

public class ApplicationUser : IdentityUser
    {
        public ICollection<Hobby> Hobbies { get; set; }
        public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager)
        {
            // Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType
            var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);
            // Add custom user claims here
            return userIdentity;
        }
    }

Для того, чтобы в базе были хоть какие-то данные по юзеру и его хобби, добавим Seed данных:

protected override void Seed(ApplicationDbContext context)
        {
            var userManager = new ApplicationUserManager(new UserStore<ApplicationUser>(context));
            var roleManager = new ApplicationRoleManager(new RoleStore<IdentityRole>(context));

            const string adminRoleName = "Administrator";

            var roles = new List<IdentityRole>
            {
                new IdentityRole
                {
                    Name = adminRoleName
                },
                new IdentityRole
                {
                    Name = "User"
                }
            };

            foreach (var identityRole in roles)
            {
                var existingRole = roleManager.FindByName(identityRole.Name);

                if (existingRole == null)
                {
                    context.Roles.Add(identityRole);
                }
            }

            var hobbies = new List<Hobby>
            {
                new Hobby
                {
                    Name = "Танцы"
                },
                new Hobby
                {
                    Name = "Рисование"
                }
            };

            context.Hobbies.AddRange(hobbies);

            var user = new ApplicationUser
            {
                UserName = "test@test.com",
                Email = "test@test.com",
                Hobbies = hobbies
            };         
            userManager.Create(user, "123456");

            base.Seed(context);
        }

Так как в моем проекте уже была миграция, то я сначала применю ее для новой базы выполнив команду update-database в package manager console.

Теперь добавим новую миграцию и выполняем ее:

add-migration AddHobbies
update-database

Открыв вашу базу данных в SQL Management studio вы должны увидеть следующее:

database

У вас должна появиться таблица с хобби и таблица для связки many to many хобби и юзера.

Давайте попробуем теперь залогиниться с использованием нашего логина и пароля:

login

Как видим, мы успешно залогинились:

login ok

Миграция ASP.NET MVC приложения на ASP.NET Core

Шаг 1: Создание Web проекта

Создаем новый solution с ASP.NET Core веб проектом

create asp.net core

и выбираем имя проекта и solution'а

solution name

В качестве шаблона я выбираю Web Application (MVC) 

template

Готово, веб проект создан. 

Шаг 2: Создание Data проекта

Приступим к созданию Data проекта, где будет хранится наш контекст и Entities.

Создаем class library проект:

class library

 Называем наш проект "data"

class library

Шаг 3: Генерация DB контекста

Теперь нам нужно используя Package Manger Console  сгенерировать контекст который нами будет использоваться в качестве "черновика". 

Для этого мы воспользуемся командой c PMC. Но перед этим нам нужно установить необходимые nuget пакеты:

nuget

Install-Package Microsoft.EntityFrameworkCore.Tools
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.AspNetCore.Identity.EntityFrameworkCore

После установки этих пакетов, выполним генерацию Db контекста и наших моделей с помощью команды:

Scaffold-DbContext "Data Source=localhost;Initial Catalog=migration-test;Integrated Security=True;MultipleActiveResultSets=True" Microsoft.EntityFrameworkCore.SqlServer -OutputDir MigrationEntities

где Data Source это connectionString к нашей локальной базе, которая использовалась в MVC проекте.

В результате у нас должен был сгенерироваться контекст и entities.

database context

Шаг 4: Nuget пакеты для веб проекта

Добавим в конфигурацию connectionstrings для нашей базы

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Initial Catalog=migration-test;Integrated Security=True;MultipleActiveResultSets=True"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

И установим недостающие nuget пакеты:

Install-Package Microsoft.AspNetCore
Install-Package Microsoft.AspNetCore.Identity
Install-Package Microsoft.EntityFrameworkCore
Install-Package Microsoft.AspNetCore.Identity.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Design

Шаг 5: Перенос Entities и DB контекста в дата проекте

Начинаем переносить Entity в новый проект. Просто скопируем папку Entities с нашего старого проекта,  в наш новый .NET Core дата проект

Удаляем GenerateUserIdentityAsync метод с  ApplicationUser класса и пока закомментируем Hobbies Entity

public class ApplicationUser : IdentityUser
    {
        //public ICollection<Hobby> Hobbies { get; set; }
    }

Переносим наш Db контекст с старого проекта и модифицируем его, чтобы он выглядел таким образом:

public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.Entity<ApplicationUser>(entity =>
            {
                entity.Property(e => e.UserName)
                    .IsRequired()
                    .HasMaxLength(256);
            });
        }
    }

Для того, чтобы старые пользователи корректно логинились при новой схеме. Нужно добавить обработчик старого хэша и мигратор на новый тип хэша.

Создадим класс:

public class OldMvcPasswordHasher : PasswordHasher<ApplicationUser>
    {
        public override PasswordVerificationResult VerifyHashedPassword(ApplicationUser user, string hashedPassword, string providedPassword)
        {
            // if it's the new algorithm version, delegate the call to parent class
            if (user.HashVersion == PasswordHashVersion.Core)
                return base.VerifyHashedPassword(user, hashedPassword, providedPassword);

            byte[] buffer4;
            if (hashedPassword == null)
            {
                return PasswordVerificationResult.Failed;
            }
            if (providedPassword == null)
            {
                throw new ArgumentNullException("providedPassword");
            }
            byte[] src = Convert.FromBase64String(hashedPassword);
            if ((src.Length != 0x31) || (src[0] != 0))
            {
                return PasswordVerificationResult.Failed;
            }
            byte[] dst = new byte[0x10];
            Buffer.BlockCopy(src, 1, dst, 0, 0x10);
            byte[] buffer3 = new byte[0x20];
            Buffer.BlockCopy(src, 0x11, buffer3, 0, 0x20);
            using (Rfc2898DeriveBytes bytes = new Rfc2898DeriveBytes(providedPassword, dst, 0x3e8))
            {
                buffer4 = bytes.GetBytes(0x20);
            }
            if (AreHashesEqual(buffer3, buffer4))
            {
                user.HashVersion = PasswordHashVersion.Core;
                return PasswordVerificationResult.SuccessRehashNeeded;
            }
            return PasswordVerificationResult.Failed;
        }

        private bool AreHashesEqual(byte[] firstHash, byte[] secondHash)
        {
            int minHashLength = firstHash.Length <= secondHash.Length ? firstHash.Length : secondHash.Length;
            var xor = firstHash.Length ^ secondHash.Length;
            for (int i = 0; i < minHashLength; i++)
                xor |= firstHash[i] ^ secondHash[i];
            return 0 == xor;
        }
    }

И Enum 

public enum PasswordHashVersion
    {
        OldMvc,
        Core
    }

Теперь добавим в ApplicationUser новое проперти:

public class ApplicationUser : IdentityUser
    {
        public PasswordHashVersion HashVersion { get; set; }
        //public ICollection<Hobby> Hobbies { get; set; }
    }

Шаг 6: Конфигурация web проекта

Конфигурируем наш Startup.cs:

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.Configure<CookiePolicyOptions>(options =>
            {
                options.CheckConsentNeeded = context => true;
                options.MinimumSameSitePolicy = SameSiteMode.None;
            });

            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(
                    Configuration.GetConnectionString("DefaultConnection")));
            services.AddIdentity<ApplicationUser, IdentityRole>(options =>
            {
                options.Password.RequireDigit = true;
                options.Password.RequiredLength = 6;
                options.Password.RequireNonAlphanumeric = false;
                options.Password.RequireUppercase = false;
                options.Password.RequireLowercase = false;
                options.User.AllowedUserNameCharacters =
                    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
                options.User.RequireUniqueEmail = true;
            }).AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddScoped<SignInManager<ApplicationUser>, SignInManager<ApplicationUser>>();
            services.Replace(new ServiceDescriptor(
                serviceType: typeof(IPasswordHasher<ApplicationUser>),
                implementationType: typeof(OldMvcPasswordHasher),
                ServiceLifetime.Scoped));
            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie();
            services.AddMvc(options => options.EnableEndpointRouting = false).SetCompatibilityVersion(CompatibilityVersion.Latest);
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseAuthentication();
            app.UseAuthorization();
            app.UseRouting();
           
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
        }
    }

Program.cs

public class Program
    {

        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseContentRoot(Directory.GetCurrentDirectory())
                .UseIISIntegration()
                .UseStartup<Startup>();
    }

Шаг 7: Миграция базы данных

Следующим шагом у нас будет миграция нашей базы данных, для этого нам нужно выполнить несколько sql скриптов:

Добавляем нужные таблицы:

/*
	Generate new tables AspNetRoleClaims,AspNetUserTokens
*/

GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[AspNetRoleClaims](
	[Id] [int] IDENTITY(1,1) NOT NULL,
	[ClaimType] [nvarchar](max) NULL,
	[ClaimValue] [nvarchar](max) NULL,
	[RoleId] [nvarchar](450) NOT NULL,
 CONSTRAINT [PK_AspNetRoleClaims] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
/****** Object:  Table [dbo].[AspNetUserTokens]    Script Date: 17/5/2018 7:25:29 AM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[AspNetUserTokens](
	[UserId] [nvarchar](450) NOT NULL,
	[LoginProvider] [nvarchar](450) NOT NULL,
	[Name] [nvarchar](450) NOT NULL,
	[Value] [nvarchar](max) NULL,
 CONSTRAINT [PK_AspNetUserTokens] PRIMARY KEY CLUSTERED 
(
	[UserId] ASC,
	[LoginProvider] ASC,
	[Name] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

Добавляем таблицу миграций .NET Core и первую миграцию:


GO
/* Object:  Table [dbo].[__EFMigrationsHistory]   */
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[__EFMigrationsHistory](
	[MigrationId] [nvarchar](150) NOT NULL,
	[ProductVersion] [nvarchar](32) NOT NULL,
 CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY CLUSTERED 
(
	[MigrationId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
INSERT [dbo].[__EFMigrationsHistory] ([MigrationId], [ProductVersion]) VALUES (N'00000000000000_CreateIdentitySchema', N'2.0.2-rtm-10011')
GO

Меняем структуру таблиц пользователей, и ролей:

/*
	AspNetUsers table changes
*/ 

/*
	AspNetUsers table changes
*/
GO
DROP INDEX [UserNameIndex] ON [AspNetUsers];
GO
EXEC sp_rename N'AspNetUsers.LockoutEndDateUtc', N'LockoutEnd', N'COLUMN';
GO
ALTER TABLE [AspNetUsers] ADD [ConcurrencyStamp] nvarchar(max) NULL;
GO
ALTER TABLE [AspNetUsers] ADD [NormalizedEmail] nvarchar(256) NULL;
GO
ALTER TABLE [AspNetUsers] ADD [NormalizedUserName] nvarchar(256) NULL;
GO

drop index [EmailIndex] on [AspNetUsers]
CREATE INDEX [EmailIndex] ON [AspNetUsers] ([NormalizedEmail]);
GO
drop index [UserNameIndex] on [AspNetUsers]
CREATE UNIQUE INDEX [UserNameIndex] ON [AspNetUsers] ([NormalizedUserName]) WHERE [NormalizedUserName] IS NOT NULL;
GO


/*
	AspNetRoles table changes
*/
GO
DROP INDEX [RoleNameIndex] ON [AspNetRoles];
GO
ALTER TABLE [AspNetRoles] ADD [ConcurrencyStamp] nvarchar(max) NULL;
GO
ALTER TABLE [AspNetRoles] ADD [NormalizedName] nvarchar(256) NULL;
GO

drop index [RoleNameIndex] on [AspNetRoles]
CREATE UNIQUE INDEX [RoleNameIndex] ON [AspNetRoles] ([NormalizedName]) WHERE [NormalizedName] IS NOT NULL;
GO

И последним скриптом обновляем пользователей:

UPDATE AspNetUsers SET NormalizedEmail = UPPER(Email), NormalizedUserName = UPPER(username)

ALTER TABLE [AspNetUsers]
ADD HashVersion int NOT NULL DEFAULT(0)

Шаг 8: Вход пользователей

Добавим контроллер для входа пользователей:

using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using data.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace web.Controllers
{
    public class AccountController : Microsoft.AspNetCore.Mvc.Controller
    {
        private readonly SignInManager<ApplicationUser> _signInManager;

        public AccountController(SignInManager<ApplicationUser> signInManager)
        {
            _signInManager = signInManager;
        }
        [HttpGet]
        public IActionResult Login(string returnUrl = null)
        {
            return View(new LoginModel { ReturnUrl = returnUrl });
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Login(LoginModel model)
        {
            if (ModelState.IsValid)
            {
                var result =
                    await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, false);
                if (result.Succeeded)
                {
                    if (model.ReturnUrl != null)
                    {
                        return LocalRedirect(model.ReturnUrl);
                    }

                    return RedirectToAction("Index", "Home");
                }

                ModelState.AddModelError("", "Неправильный логин и (или) пароль");
            }
            return View(model);
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Logout()
        {
            await _signInManager.SignOutAsync();
            return RedirectToAction("Index", "Home");
        }
    }

    public class LoginModel
    {

        [Required]
        [Display(Name = "Email")]
        public string Email { get; set; }

        [Required]
        [DataType(DataType.Password)]
        [Display(Name = "Пароль")]
        public string Password { get; set; }

        [Display(Name = "Запомнить меня?")]
        public bool RememberMe { get; set; }

        public string ReturnUrl { get; set; }
    }
}

Создадим View  для логина:

@model web.Controllers.LoginModel
<partial name="_ValidationScriptsPartial"/>
<h2>Вход в приложение</h2>
<form method="post" asp-controller="Account" asp-action="Login"
      asp-route-returnUrl="@Model.ReturnUrl">
    <div asp-validation-summary="ModelOnly"></div>
    <div>
        <label asp-for="Email"></label><br />
        <input asp-for="Email" />
        <span asp-validation-for="Email"></span>
    </div>
    <div>
        <label asp-for="Password"></label><br />
        <input asp-for="Password" />
        <span asp-validation-for="Password"></span>
    </div>
    <div>
        <label asp-for="RememberMe"></label><br />
        <input asp-for="RememberMe" />
    </div>
    <div>
        <input type="submit" value="Войти" />
    </div>
</form>

Модифицируем Layout, добавим в него индикатор того, что юзер успешно залогинился в систему:

  @if(User.Identity.IsAuthenticated)
    {
        <p>@User.Identity.Name</p>
        <form method="post" asp-controller="Account" asp-action="Logout">
            <input type="submit" value="Выход" />
        </form>
    }
    else
    {
        <a asp-controller="Account" asp-action="Login">Вход</a>
        <a asp-controller="Account" asp-action="Register">Регистрация</a>
    } 

Запускаем проект и проверяем работает ли вход.

core login

Вводим данные которые мы использовали для входа пользователя в Old MVC приложении

login ok

Отлично, как видим все заработало.

Шаг 9: Миграция Many-to-Many 

Для того, чтобы мигрировать many-to-many таблицы, необходимо сделать несколько манипуляций, EntityFrameworkCore уже не работает по той же схеме как и EF с .NET framework. Теперь для этого нужно создавать отдельную Entity. Обратимся к папке MigrationEntities, которую мы сгенерировали на шаге 3. Возмем оттуда файл ApplicationUserHobbies.cs и модифицируем его следующим образом:

public  class ApplicationUserHobbies
    {
        public string ApplicationUserId { get; set; }
        public Guid HobbyId { get; set; }

        public virtual ApplicationUser ApplicationUser { get; set; }
        public virtual Hobby Hobby { get; set; }
    }

А entity хобби так:

public class Hobby : BaseEntity
    {
        public string Name { get; set; }
        public virtual ICollection<ApplicationUserHobbies> ApplicationUserHobbies { get; set; }
    }

ApplicationUser:

public class ApplicationUser : IdentityUser
    {
        public PasswordHashVersion HashVersion { get; set; }
        public ICollection<ApplicationUserHobbies> ApplicationUserHobbies { get; set; }
    }

Теперь добавим таблицы и модифицируем OnModelCreating с помощью контекста. который нам автоматически сгенерировал EF на шаге 3. 

Наш ApplicationDbContext должен выглядеть следующим образом:

public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        public virtual DbSet<Hobby> Hobbies { get; set; }
        public virtual DbSet<ApplicationUserHobbies> ApplicationUserHobbies { get; set; }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.Entity<ApplicationUser>(entity =>
            {
                entity.Property(e => e.UserName)
                    .IsRequired()
                    .HasMaxLength(256);
            });

            modelBuilder.Entity<ApplicationUserHobbies>(entity =>
            {
                entity.HasKey(e => new { e.ApplicationUserId, e.HobbyId })
                    .HasName("PK_dbo.ApplicationUserHobbies");

                entity.HasIndex(e => e.ApplicationUserId)
                    .HasName("IX_ApplicationUser_Id");

                entity.HasIndex(e => e.HobbyId)
                    .HasName("IX_Hobby_Id");

                entity.Property(e => e.ApplicationUserId)
                    .HasColumnName("ApplicationUser_Id")
                    .HasMaxLength(128);

                entity.Property(e => e.HobbyId).HasColumnName("Hobby_Id");

                entity.HasOne(d => d.ApplicationUser)
                    .WithMany(p => p.ApplicationUserHobbies)
                    .HasForeignKey(d => d.ApplicationUserId)
                    .HasConstraintName("FK_dbo.ApplicationUserHobbies_dbo.AspNetUsers_ApplicationUser_Id");

                entity.HasOne(d => d.Hobby)
                    .WithMany(p => p.ApplicationUserHobbies)
                    .HasForeignKey(d => d.HobbyId)
                    .HasConstraintName("FK_dbo.ApplicationUserHobbies_dbo.Hobbies_Hobby_Id");
            });
            modelBuilder.Entity<Hobbies>(entity =>
            {
                entity.Property(e => e.Id).ValueGeneratedNever();

                entity.Property(e => e.AddedDate).HasColumnType("datetime");
            });
        }
    }

Шаг 10: Работа с Many-to-Many

Для того, чтобы проверить работает ли контекст после наших манипуляций, давайте создадим контроллер, в котором выведем хобби пользователя:

[Authorize]
    public class HobbiesController : Controller
    {
        private readonly ApplicationDbContext _context;

        public HobbiesController(ApplicationDbContext context)
        {
            _context = context;
        }

        public IActionResult Index()
        {
            var hobbies =  _context.ApplicationUserHobbies
                .Include(userHobbies => userHobbies.Hobby)
                .Include(userHobbies => userHobbies.ApplicationUser)
                .Where(userHobbies => userHobbies.ApplicationUser.UserName == User.Identity.Name)
                .Select(userHobbies => new HobbyDto
                {
                    Id = userHobbies.Hobby.Id,
                    Name =  userHobbies.Hobby.Name
                }).ToList();
            return View(hobbies);
        }
    }

    public class HobbyDto
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
    }

View

@model System.Collections.Generic.List<web.Controllers.HobbyDto>

<h1>Hobbies:</h1>
@foreach (var item in Model)
{
    <p>@item.Name</p>
}

Запускаем проект и переходим на страницу /hobbies

hobbies

Как видим, все работает, путем нехитрых манипуляций мы мигрировали пользователей и роли ASP.NET MVC на ASP.NET Core 3.0.

Исходники проекта можно найти тут.

 

0 69 25.12.2019 09:43

Комментарии:

Пожалуйста авторизируйтесь, чтобы получить возможность оставлять комментарии