Для тех из нас, кто привык к файлам cookie в традиционном ASP.NET, переход на ASP.NET Core может заставить нас почесать затылок. В старой системе мы могли напрямую добавлять и удалять файлы cookie как из объектов запроса, так и из объекта ответа (к лучшему или к худшему). Это могло привести к тому, что мы записывали и перезаписывали один и тот же файл cookie несколько раз во время запроса, поскольку на него влияли разные части кода. DotNetCore изменил правила игры, и это хорошо, поверьте мне. Сегодня мы собираемся изучить технику управления файлами cookie в веб-приложениях DotNetCore.
Весь код для этого поста можно найти на мой GitHub.
Понимание прошлого
Ради аргумента я хотел представить то, что может быть «общим» кодом в традиционном ASP.NET MVC для загрузки файла cookie. Проблема, конечно, в том, что если где-то в нашем коде установлено значение cookie, и мы позже ищем его снова, мы хотели убедиться, что всегда получаем самую последнюю копию, а не обязательно то, что пришло по запросу. Приведенный ниже код проверяет, есть ли в ответе что-то, что соответствует первому.
public static System.Web.HttpCookie GetCookie(this System.Web.HttpContextBase context, string keyName) { System.Web.HttpCookieCollection cookies = new System.Web.HttpCookieCollection(); System.Web.HttpCookie cookie = null; // check for response value first... if (context.Response.Cookies.AllKeys.Any(key => string.Equals(key, keyName, StringComparison.OrdinalIgnoreCase))) cookie = context.Response.Cookies.Get(keyName); else if (context.Request.Cookies.AllKeys.Any(key => string.Equals(key, keyName, StringComparison.OrdinalIgnoreCase))) cookie = context.Request.Cookies.Get(keyName); return cookie; }
Итак, учитывая, как мы могли получить доступ к куки для потребления, как мы могли испортить его изменение? Я уверен, что есть множество способов, но вот пример того, что я мог бы сделать:
public static void SetCookie(this System.Web.HttpContextBase context, string keyName, string value, DateTime? expiry = null) { if (context.Response.HeadersWritten) return; // a null value is equivalent to deletion if (value == null) { context.Request.Cookies.Remove(keyName); context.Response.Cookies.Add(new System.Web.HttpCookie(keyName, "") { Expires = DateTime.Today.AddYears(-1) }); return; } System.Web.HttpCookie newCookie = new System.Web.HttpCookie(keyName, value); if (expiry.HasValue) newCookie.Expires = expiry.Value; context.Response.Cookies.Add(newCookie); }
В приведенном выше коде мы пытаемся гарантировать, что удаление файла cookie также предотвратит попытку использования в том же запросе, если он не найдет его. Мы также предотвращаем запись файла cookie, если заголовки уже были отправлены (поскольку это вызовет исключение). Одна вещь, которую этот код *не делает*, — это предотвращение дубликатов, и я сделал эту ошибку намеренно.
Как только это запишется в браузер, последний в ответе выиграет, поэтому он все еще будет «работать», как задумано, но опять же, у нас есть ошибка. Если вам интересно, вы не хотите волей-неволей context.Response.Cookies.Add
, но должны проверить, существует ли он уже, и, если да, вместо этого вызвать context.Response.SetCookie
.
Хотя написать диспетчер файлов cookie и убедиться, что весь код файлов cookie проходит через него, было нетрудно, как новички, так и опытные разработчики считали, что «это просто работает». Все это говорит о том, что если вы изучите свой обходной путь и привыкнете к нему, DotNetCore отбросит вас.
Различия в DotNetCore
Теперь, когда мы немного рассмотрели то, как вы могли бы ожидать выполнения действий в традиционном ASP.NET MVC, важно подчеркнуть различия в DotNetCore.
Во-первых, коллекция HttpContext.Request.Cookies
в DotNetCore не может быть изменена. Вы, надеюсь, заметили в предыдущих примерах, что когда мы удаляли файл cookie в традиционной версии, мы также удаляли копию запроса, чтобы гарантировать, что мы не используем недопустимый файл cookie позже. Точно так же HttpContext.Response.Cookies
не позволит вам удалить добавленный к нему элемент. Конечно, вы можете попросить «удалить» файл cookie, но это просто изменит срок действия, поэтому браузер удалит его. Как только это внутри, это внутри.
Когда я переписывал большое приложение в DotNetCore и «копировал» код из старой системы, я столкнулся с этими различиями очень рано, и это привело к изучению управления файлами cookie в ASP.NET Core.
Эти различия — хорошая вещь, потому что они заставляют вас немного больше думать о том, что вы делаете, а не просто предполагать, что все это работает. Учитывая пример кода для традиционного ASP.NET MVC, вы можете получить несколько копий файла cookie в ответе, если не будете осторожны. Если это произойдет, и вы попытаетесь прочитать значение позже в том же запросе, вы можете не получить обратно то, на что надеялись. Плохо.
Представляем службу файлов cookie
Таким образом, учитывая наши разногласия и тот факт, что DotNetCore действительно изо всех сил пытается заставить вас использовать внедрение зависимостей, как вы могли бы подойти к управлению файлами cookie? Мое личное мнение заключается в том, что все ваше управление файлами cookie должно проходить через службу, а затем промежуточное программное обеспечение будет отвечать за запись конечного состояния обратно в ответ. Давайте продолжим и начнем:
public class CachedCookie { public string Name { get; set; } public string Value { get; set; } public CookieOptions Options { get; set; } public bool IsDeleted { get; set; } } public interface ICookieService { void Delete(string cookieName); T Get<T>(string cookieName, bool isBase64 = false) where T : class; T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false) where T : class; void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false) where T : class; void WriteToResponse(HttpContext context); } public class CookieService : ICookieService { private readonly HttpContext _httpContext; private Dictionary<string, CachedCookie> _pendingCookies = null; public CookieService(IHttpContextAccessor httpContextAccessor) { _httpContext = httpContextAccessor.HttpContext; _pendingCookies = new Dictionary<string, CachedCookie>(); } public void Delete(string cookieName) { } public T Get<T>(string cookieName, bool isBase64 = false) where T : class { throw new NotImplementedException(); } public T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false) where T : class { throw new NotImplementedException(); } public void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false) where T : class { } public void WriteToResponse(HttpContext context) { } }
В приведенном выше блоке кода я добавил класс CachedCookie
,
заглушил интерфейс для нашего CookieService
и настроил скелет для нашего сервиса.
Одна вещь, которую мы должны понять на раннем этапе, заключается в том, что этот сервис основан на дженериках по какой-то причине. Я хочу иметь возможность записывать практически любое значение в свои файлы cookie. В этом случае я решил, что универсальный должен быть ограничен классом (который string
будет соответствовать требованиям, но все основные типы значений не будут работать). Чтобы это волшебство сработало, я собираюсь JSON сериализовать свое значение в строку.
Чтобы понять, как все части сочетаются друг с другом, я решил, что мы будем делать это шаг за шагом.
Наш конструктор внедряет IHttpContextAccessor
, что позволяет нам получить доступ к текущему HttpContext
для запроса. Это похоже на старый ASP.NET, где мы использовали HttpContext.Current
. Однако, чтобы это работало, нам нужно зарегистрировать его как инъекционный, поэтому перейдите к вашему Startup.cs
и добавьте эти строки в свой метод ConfigureServices
:
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddScoped<ICookieService, CookieService>();
Еще одна вещь, которую вы заметите в нашем конструкторе, это то, что мы настраиваем пустой словарь для экземпляров нашего CachedCookie
. Именно здесь мы собираемся отслеживать состояние наших файлов cookie на время запроса, прежде чем промежуточное программное обеспечение выдаст их в ответ.
ПО промежуточного слоя
Следующее, о чем нам нужно позаботиться, — это создать наше промежуточное ПО и добавить его в наш конвейер. Добавим CookieServiceMiddleware.cs
и заполним:
internal class CookieServiceMiddleware { private readonly RequestDelegate _next; public CookieServiceMiddleware(RequestDelegate next) { _next = next; } public async Task Invoke(HttpContext context, ICookieService cookieService) { // write cookies to response right before it starts writing out from MVC/api responses... context.Response.OnStarting(() => { // cookie service should not write out cookies on 500, possibly others as well if (!context.Response.StatusCode.IsInRange(500, 599)) { cookieService.WriteToResponse(context); } return Task.CompletedTask; }); await _next(context); } }
Внедрение службы с заданной областью в промежуточное ПО невозможно на уровне конструктора. Вы заметите, что я ввожу его в метод Invoke, и это, наверное, немного похоже на волшебство. Где-то глубоко в недрах DotNetCore он достаточно умен, чтобы знать, как туда вводить.
Еще одна вещь, которую следует отметить, это то, что я определяю, когда начинается ответ, а затем проверяю, не находится ли код состояния в определенном диапазоне. Если он выходит за пределы этого диапазона, мы записываем наши файлы cookie в ответ через службу. Я добавил метод расширения IsInRange
, поэтому, без лишних слов, вот базовый IntExtensions.cs
, который я добавил в проект:
public static class IntExtensions { public static bool IsInRange(this int checkVal, int value1, int value2) { // First check to see if the passed in values are in order. If so, then check to see if checkVal is between them if (value1 <= value2) return checkVal >= value1 && checkVal <= value2; // Otherwise invert them and check the checkVal to see if it is between them return checkVal >= value2 && checkVal <= value1; } }
Регистрация промежуточного программного обеспечения
Хорошо. Последнее, что нам нужно добавить в наш промежуточный код и подключиться. Соглашение с промежуточным программным обеспечением заключается в создании статического класса и метода расширения, который будет обрабатывать регистрацию промежуточного программного обеспечения. Добавим CookieServiceMiddlewareExtensions
:
public static class CookieServiceMiddlewareExtensions { public static IApplicationBuilder UseCookieService(this IApplicationBuilder builder) { return builder.UseMiddleware<CookieServiceMiddleware>(); } }
И давайте зайдем в Startup.cs в наш метод Configure и добавим app.UseCookieService();
где-нибудь вверх по цепочке. Хитрость здесь в том, что вы хотите, чтобы он отображался перед вашим вызовом app.UseMvc
, а также перед чем-либо еще, что может повлиять на ваш ответ, но не слишком высоко, чтобы преждевременно записать файлы cookie в ответ. В данном случае я решил добавить его после вызова app.UseCookiePolicy
. Ваш собственный пробег может отличаться, если у вас много другого промежуточного программного обеспечения.
Давайте ненадолго вернемся назад. Если бы мое промежуточное ПО было немного сложнее и имело бы более одной службы, требующей регистрации, я мог бы также создать метод расширения для вызова из моего метода ConfigureServices. Если бы я создавал промежуточное ПО для распространения, я бы наверняка сделал это, даже если бы существовал только один сервис. Я не хочу заставлять кого-то знать все, чтобы настроить мое промежуточное ПО для DI, они должны иметь возможность просто попросить добавить его и двигаться дальше.
Этот метод расширения может иметь сигнатуру вроде этой public static IServiceCollection ConfigureCookieService(this IServiceCollection services, IConfiguration configuration)
. (IConfiguration необязателен здесь... Мне он нужен для некоторых вещей, но явно в этом случае он нам не понадобится).
Воплотить это
Замечательно, теперь у нас есть зарегистрированный сервис и промежуточное ПО, но оно еще ничего не делает. Давайте продолжим и начнем конкретизировать его по одному методу за раз. Поскольку первое, что мы на самом деле попытаемся сделать, это загрузить файл cookie для использования, возможно, нам следует начать с него. Зайдите в свой CookieService.cs
и добавьте следующий код в метод public T Get<T>
:
Получить‹Т›
public T Get<T>(string cookieName, bool isBase64 = false) where T : class { return ExceptionHandler.SwallowOnException(() => { // check local cache first... if (_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie)) { // don't retrieve a "deleted" cookie if (cookie.IsDeleted) return default(T); return isBase64 ? Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookie.Value.FromBase64String()) : Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookie.Value); } if (_httpContext.Request.Cookies.TryGetValue(cookieName, out string cookieValue)) return isBase64 ? Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookieValue.FromBase64String()) : Newtonsoft.Json.JsonConvert.DeserializeObject<T>(cookieValue); return default(T); }); }
Давайте посмотрим на это, прежде чем говорить о ExceptionHandler
. Наш метод Get‹T› сначала запрашивает наш pendingCookies
словарь, есть ли в нем что-то, совпадающее с ключом. Если да, то он спрашивает, пометили ли мы его IsDeleted
. Если он у нас есть, и он не удален, мы продолжаем десериализовать его до запрошенного типа объекта, и, возможно, нам нужно будет сначала дополнительно декодировать его из base64.
Если у нас нет его локальной копии в нашем кеше, мы идем дальше и смотрим, есть ли он в HttpContext.Request.Cookies
, и, как и в случае с нашим локальным кешем, дополнительно декодируем из base64, прежде чем окончательно его десериализовать.
Теперь, с какой стати мне закодировать его в base64? Дело не столько в том, что я хочу «защитить» свой файл cookie от посторонних глаз как таковой, но если у меня есть довольно сложный объект, который я записываю в файл cookie, я хочу немного его разбить. Строковое представление объекта в формате JSON может стать довольно громоздким.
Говоря о кодировке base64… это еще пара методов расширения, которые я добавил в файл StringExtensions.cs
. Ну вот:
public static class StringExtensions { public static string FromBase64String(this string value, bool throwException = true) { try { byte[] decodedBytes = System.Convert.FromBase64String(value); string decoded = System.Text.Encoding.UTF8.GetString(decodedBytes); return decoded; } catch (Exception ex) { if (throwException) throw new Exception(ex.Message, ex); else return value; } } public static string ToBase64String(this string value) { byte[] bytes = System.Text.ASCIIEncoding.UTF8.GetBytes(value); string encoded = System.Convert.ToBase64String(bytes); return encoded; } }
Хорошо, а что это за ExceptionHandler.SwallowOnException
колдовство? Я мог бы использовать блок try {} catch {}
, но это вариант использования, когда я на 100% согласен с отказом, который просто исчезает, потому что файл cookie просто не должен существовать. Теперь... если вы покопаетесь в коде этого обработчика, вы увидите, что он по-прежнему выполняет блок try/catch, я просто немного абстрагирую его. Позвольте мне доказать это вам.
Обработчик исключений
public static class ExceptionHandler { public static T SwallowOnException<T>(Func<T> func) { try { return func(); } catch { return default(T); } } }
Установить‹Т›
Эй, мы довольно крутые и можем загрузить файл cookie, но это не очень полезно, если мы не можем его создать, верно? Давайте заставим эту часть работать.
public void Set<T>(string cookieName, T data, DateTimeOffset? expiry = null, bool base64Encode = false) where T : class { // info about cookieoptions CookieOptions options = new CookieOptions() { Secure = _httpContext.Request.IsHttps }; if (expiry.HasValue) options.Expires = expiry.Value; if (!_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie)) cookie = Add(cookieName); // always set options and value; cookie.Options = options; cookie.Value = base64Encode ? Newtonsoft.Json.JsonConvert.SerializeObject(data).ToBase64String() : Newtonsoft.Json.JsonConvert.SerializeObject(data); }
При создании файла cookie нам нужно настроить несколько битов информации. Я буду здесь довольно голым, но я настоятельно рекомендую почитать о CookieOptions. Если не установить срок действия, по умолчанию это будет сеансовый файл cookie. Они не работают должным образом, если вы используете Google Chrome с всегда открытым режимом (или как там, черт возьми, они это называют).
В нашем коде здесь мы увидим, есть ли у нас экземпляр pendingCookie, и если нет, мы добавим его. Я перейду к этому методу через минуту. После того, как у нас будет экземпляр файла cookie, мы добавим параметры и запишем значение, необязательно закодированное в base64. Давайте теперь посмотрим на метод Add
.
protected CachedCookie Add(string cookieName) { var cookie = new CachedCookie { Name = cookieName }; _pendingCookies.Add(cookieName, cookie); return cookie; }
Довольно базовые вещи. Мы просто верим, что можем добавить его, и делаем это. Этот метод не открыт, поэтому я надеюсь, что мне не нужно сначала проверять словарь. Если вам это не нравится, не стесняйтесь изменить это.
Удаление файла cookie
В какой-то момент мы захотим удалить куки, верно? Мы хотим убедиться, что последующие запросы к тому же файлу cookie знают, что он удален, как мы видели в вызове Get‹T›. Чтобы это работало правильно, нам нужен наш локальный кеш, чтобы отслеживать это.
void ICookieService.Delete(string cookieName) { Delete(cookieName); } protected CachedCookie Delete(string cookieName) { if (_pendingCookies.TryGetValue(cookieName, out CachedCookie cookie)) cookie.IsDeleted = true; else { cookie = new CachedCookie { Name = cookieName, IsDeleted = true }; _pendingCookies.Add(cookieName, cookie); } return cookie; }
В приведенном выше коде у нас есть метод Delete интерфейса и метод Delete класса с одинаковой сигнатурой. Я мог бы назвать их по-другому, но мне очень не хотелось. Однако, чтобы компилятор не ныл по этому поводу, мы должны сделать метод интерфейса явным вызовом интерфейса. Мы просто передаем этот вызов в наш метод экземпляра класса.
Оказавшись внутри нашего метода удаления экземпляра класса, мы видим, есть ли у нас ожидающий экземпляр, и если да, то помечаем его как удаленный. Если нет, то добавляем его в кеш и помечаем как удаленный.
GetOrSet‹T›
Иногда вы хотите, чтобы файл cookie существовал, несмотря ни на что, но если он уже существует, вы хотите получить его значение. Вариант использования для этого, если вы хотите загрузить файл cookie, если он есть, или установить значения по умолчанию в противном случае. На одном сайте, над которым я работал, есть «планировщик поездок», который подходит для этого варианта использования. Я хочу знать их детали, если они у них есть, в противном случае я собираюсь установить некоторые значения по умолчанию, чтобы остальная часть сеанса основывалась на той же информации. Это довольно просто настроить:
public T GetOrSet<T>(string cookieName, Func<T> setFunc, DateTimeOffset? expiry = null, bool isBase64 = false) where T : class { T cookie = Get<T>(cookieName, isBase64); if (cookie != null) return cookie; T data = setFunc(); Set(cookieName, data, expiry, isBase64); return data; }
Если файл cookie существует, мы его получаем. Если это не так, мы устанавливаем его. Очень просто.
Написание этого
Весь приведенный выше код действительно не имеет значения, если мы никогда не запишем его обратно в ответ, верно? Помните, в нашем промежуточном программном обеспечении во время блока context.Response.OnStarting
мы сообщали сервису WriteToResponse
? Давайте сделаем это на самом деле что-то сделать сейчас:
public void WriteToResponse(HttpContext context) { foreach (var cookie in _pendingCookies.Values) { if (cookie.IsDeleted) context.Response.Cookies.Delete(cookie.Name); else context.Response.Cookies.Append(cookie.Name, cookie.Value, cookie.Options); } }
Мы повторяем каждый из наших ожидающих файлов cookie и будем либо Delete
, либо Append
их в зависимости от нашего кэшированного значения. Теперь у нас есть только одна копия каждого записываемого файла cookie вместо нашего классического фиаско ASP.NET, которое мы представили в начале этого поста.
Собираем это вместе
В коде на GitHub есть довольно убогая, надуманная демонстрация в HomeController. Далее следует несколько модульных тестов. Прежде чем опубликовать код, я хотел немного рассказать о том, как работает мой BaseTest.cs
. Я мог бы (и, откровенно говоря, должен был, но, поскольку я скопировал этот нерабочий код, у которого были другие проблемы, я не стал) использовать сервисную коллекцию DotNetCore. Вместо этого BaseTest зависит от UnityContainer. Для меня это был очень простой способ настроить механизм зависимостей. Издевайтесь над этим сколько хотите.
То, что последует, будет просто жирной дамбой класса CookieServiceTests
. Метод Initialize
устанавливает вещи, которые будут использоваться каждым тестом, а затем каждый отдельный тест устанавливает свои собственные сценарии. Должно стать очевидным, как пользоваться сервисом, и, надеюсь, это даст вам некоторые идеи о том, как использовать его в ваших собственных проектах.
[TestClass] public class CookieServiceTests : BaseTest { IHttpContextAccessor _httpContextAccessor; HttpContext _httpContext; CookieService _target; [TestInitialize] public void Initialize() { _httpContextAccessor = Substitute.For<IHttpContextAccessor>(); _httpContext = new DefaultHttpContext(); _httpContextAccessor.HttpContext.Returns(_httpContext); Container.RegisterInstance(_httpContextAccessor); _target = Container.Resolve<CookieService>(); } [TestMethod] public void CookieService_SetCookie_Success() { CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" }; _target.Set("fakecookie", cookie); CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie"); Assert.IsNotNull(cachedCookie); Assert.AreEqual(cookie.TestProperty, cachedCookie.TestProperty); Assert.AreEqual(cookie.TestPropertyString, cachedCookie.TestPropertyString); } [TestMethod] public void CookieService_SetCookie_StringOnly_Success() { string value = "I'm a cookie value"; _target.Set("fakecookie", value); string result = _target.Get<string>("fakecookie"); Assert.IsFalse(string.IsNullOrWhiteSpace(result)); Assert.AreEqual(value, result); } [TestMethod] public void CookieService_SetCookie_Base64_Success() { CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" }; _target.Set("fakecookie", cookie, base64Encode: true); CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie", true); Assert.IsNotNull(cachedCookie); Assert.AreEqual(cookie.TestProperty, cachedCookie.TestProperty); Assert.AreEqual(cookie.TestPropertyString, cachedCookie.TestPropertyString); } [TestMethod] public void CookieService_GetOrSetCookie_SetsCookie_Success() { Func<CookieFake> createCookie = () => { return new CookieFake { TestProperty = 25, TestPropertyString = "blah" }; }; var cookie = _target.GetOrSet<CookieFake>("fakecookie", createCookie); Assert.IsNotNull(cookie); Assert.AreEqual(cookie.TestProperty, 25); } [TestMethod] public void CookieService_GetOrSetCookie_GetsCookie_Success() { CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" }; _target.Set("fakecookie", cookie); Func<CookieFake> createCookie = () => { return new CookieFake { TestProperty = 55, TestPropertyString = "blah2" }; }; var retrievedCookie = _target.GetOrSet<CookieFake>("fakecookie", createCookie); Assert.IsNotNull(retrievedCookie); Assert.AreEqual(retrievedCookie.TestProperty, cookie.TestProperty); Assert.AreEqual(retrievedCookie.TestPropertyString, cookie.TestPropertyString); } [TestMethod] public void CookieService_GetCookie_Fail() { CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie"); Assert.IsNull(cachedCookie); } [TestMethod] public void CookieService_GetCookie_Base64_Fail() { CookieFake cookie = new CookieFake { TestProperty = 25, TestPropertyString = "blah" }; _target.Set("fakecookie", cookie); CookieFake cachedCookie = _target.Get<CookieFake>("fakecookie", true); Assert.IsNull(cachedCookie); } } public class CookieFake { public int TestProperty { get; set; } public string TestPropertyString { get; set; } }
Вывод
Управление файлами cookie в веб-приложениях DotNetCore — несложная вещь, но ее легко сделать неэффективной. Мы рассмотрели способ сделать наш ответ максимально чистым, внедрив CookieService и промежуточное ПО.
Весь код из сегодняшнего поста можно найти на мой GitHub. Я рекомендую вам ознакомиться с полным проектом, посмотреть на мой хромой надуманный пример в веб-приложении, а затем использовать его самостоятельно.
Кредиты
Фото автора Джон Дэнси на Unsplash
Первоначально опубликовано на https://www.seeleycoder.com 13 декабря 2018 г.