Поместите сессию на слой Node, и мы с удовольствием это сделаем 😋

Node.js Express

Введение

Каждый раз, когда запускается новый проект, будете ли вы, как и я, беспокоиться о том, использовать ли на этот раз сейссон или токен для серверной части? Если это токен, есть ли проблема с продлением? Это повторяющаяся совместная отладка, полная входов и выходов, эй 😔...

Текущий процесс:

  1. Серверная часть отправляет электронное письмо для подачи заявки на SSO и RBAC и предоставляет системный идентификатор.
  2. Внешний интерфейс заполняет информацию о проекте и автоматически завершает доступ SSO и RBAC.
  3. Создание ключа RSA, загрузка и разработка шаблона серверной бизнес-системы, совместная отладка инструмента совместной отладки RSA

Вы заметили, что фронтенд и бэкэнд упрощены до такой степени, что продукт может сделать это сам😋.

Боль действительно болезненная

В компании очень зрелая система SSO и RBAC, что очень удобно, но с увеличением проектов меня это не радует, каждый раз запуская новый проект, приходится повторять работу:

1. Функция сеансаПосле того, как пользователь входит в систему единого входа, возвращается только один токен.Бэкенд должен получить информацию о пользователе и разрешения на основе токена и установить сеанс в своей собственной системе, что приводит к серии разработок и совместной отладке интерфейсного и внутренние функции входа и выхода.

2. Проверка разрешенияRBAC предоставляет полные функции. Разрешения интерфейса необходимо ввести в контроллер и действие, а серверная часть должна проверить разрешения на основе информации RBAC. Его нельзя повторно использовать между одной и несколькими бизнес-системами. Этот код функции появится в каждой бизнес-системе.

Есть ли решениеПусть будет, пусть случится или найди другой способ, цитируя фразу из «Собора и базара»:

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

Группа внешнего интерфейса тесно сотрудничает с несколькими группами по развитию бизнеса и, естественно, должна брать на себя такие обязанности как за себя, так и за других.

Наша идея состоит в том, чтобы построить средний уровень Node, использовать JWT для входа в систему без сохранения состояния, все внешние интерфейсы перенаправляются в бизнес-систему с пользовательской информацией через Node, а перед переадресацией выполняется проверка разрешений, что является идеальным решением. так просто?

Реализовать идеи

сторона браузера куда мне идти

Первым шагом является отправка браузером данных интерфейса в Node, затем как определить, с какой системой браузер взаимодействует с Node, и как определить, какую среду (тестовую, производственную) перенаправить в систему до того, как Node перенаправит? Многодоменная обработка? Что делать, если бизнес-система не хочет использовать переадресацию?

Ответственность со стороны браузера ясна, то есть сообщать стороне Node, куда я иду, в какой среде, под какой системой я иду, и какие данные прикрепляются.

Входная информация

Большинство наших проектов имеют только тестовую и производственную среды, поэтому мы резервируем тестовые и онлайн-адреса и можем добавить несколько доменных имен, ввести их в систему, нажать «Сохранить», и файл конфигурации будет сгенерирован во внешнем каталоге git для последующие запросы.

В компоненте генерируется интерфейсный адрес всех запрошенных интерфейсов, первая половина — это идентификатор серверной службы, а вторая половина — адрес интерфейса.

Примечание. Наши внешние страницы создаются с использованием внутренней конфигурации системы, и идеи могут быть использованы для справки.

запрос на отправку

Это типичный прокси-режим, в котором все запросы передаются через прокси.requestВ методе этот модуль отвечает за различение пересылающих и непересылающих интерфейсов, сопоставление с внутренним адресом в соответствии с fetchUrl и внешними переменными среды и отправку его на сервер Node с системным идентификатором.

Первая категория — это интерфейс, который напрямую взаимодействует с Node, например, логин, данные пользователя и т. д.
Второй тип заключается в переносе информации о пользователе и данных интерфейса на сторону Node и их пересылке бизнес-стороне.

Эти два типа интерфейсов приведут системный моменту, специфичный для формата интерфейса, который необходимо перенаправить.
Формат интерфейса следующий:

параметр иллюстрировать Типы
agentPath адрес переадресации String
url переменная среды
method парадигма запроса интерфейса String
time отметка времени String
random случайное число String
data сериализованные данные интерфейса String
sign подпись данных md5 String

Например:

agentPathЭто адрес интерфейса, который Node отправит на конец службы в конце.systemNameNodeсистемный идентификатор, используемый для сопоставления закрытого ключа,dataЭто данные, используемые конкретным бизнес-интерфейсом.signПросто сделайте простой md5 для проверки других полей на стороне узла.

Остальное остается Node подумать над философским вопросом, кто я такой, ха-ха, это часть нашего разговора.

Реализация сеанса узла кто я

Если интерфейс отправлен, Node получает адрес переадресации и данные интерфейса.
Ха-ха, конечно нет. Мы должны получить доступ к SSO, а затем использовать информацию о пользователе, предоставленную SSO, для установки сеанса уровня узла. Этот раздел в основном представляет собой узел и JWT. Давайте начнем наш первый шаг.

перекрестный домен

Мы используем AntPro для внешнего интерфейса, а инструмент запросаumi-request, Следует отметить, что если вы отправляете запрос на получение и случайно передаете значение в тело, не забудьте удалить его, иначе не будет сообщено об ошибке и запрос не будет отправлен🙃.

const jsonMethod = ['POST','PUT','DELETE'];
if (jsonMethod.includes(newOptions.method)){
    //...
} else {
    // 序列化参数
    if (newOptions.body) url += `?${stringify(newOptions.body)}
    // 删除body
    delete newOptions.body
}

Настройки на стороне узла позволяют различать простые и сложные запросы между доменами и доменами (запросы с предварительным обнаружением).Междоменное введение, так как мы используем настраиваемый атрибут «токен» в заголовке HTTP, это сложный запрос, который будет отправлять дополнительный запрос предварительного обнаружения.

app.all('*', function (req, res, next) {
  res.header("Access-Control-Allow-Origin",     req.headers.origin);
  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, token');
  res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
  res.header("Access-Control-Allow-Credentials", "true");
  res.header("Content-Type", "application/json;charset=utf-8");
  if (req.method == 'OPTIONS') {
    res.send(200); /*让options请求快速返回*/
  }
  else {
    next();
  }
});

две просьбы


Вспомогательные настройки для express-jwt

Мы используемjsonwebtokenа такжеexpress-jwtЭти два инструмента,jsonwebtokenОчень взрослый, нечего сказать,express-jwtЛегко позволитьexpressа такжеjwtВ сочетании с.

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

Укажите источник токена
getToken указывает источник токена. Файлы cookie использовались и раньше, что позволяет не выполнять некоторую работу внешнего интерфейса по доступу к токенам. Например, чтобы выйти из системы, только сторона Node должна удалить файл cookie, но босс сказал, что, учитывая расширение других клиентов в будущем, таких как небольшие программы, мобильный терминал, мы зарезервировали query.token и headers.token.

интерфейс без входа в систему
Используйте, если не указываете интерфейс, который не проходит проверку токена.Если вы используете регулярку, поместите ее в последнюю цифру массива.Иначе, если регулярка не пройдет, она сразу вернет false 🙂 (указывая на то, что она посадили 🙋).

//签名加盐
const secret = 'salt' + process.env.ACTIVE;

//使用中间件验证token合法性
app.use(expressJwt({
  secret: secret,
  getToken: function fromHeaderOrQuerystring(req) {
    if (req.query && req.query.token) { // 使用query.token
      return req.query.token;
    } else if (req.headers.token) {  // 使用req.headers.token
      return req.headers.token
    }
    return null;
  }
}).unless({
  path: ['/login','/agent/rsatool', /^\/proxy\/.*/,]  //除了这些地址,其他的URL都需要验证
}));

перехват ошибок
Такие какtokenЕсли проверка не удалась, вернитесь напрямую{ status: 41002 }, я предпочитаю использовать код статуса http, но предложение не было одобрено 🙄, оно не было удаленоres.statusКод, а затем стремитесь к следующему 🤤.

//拦截器
app.use(function (err, req, res, next) {
  //当token验证失败时会抛出如下错误
  if (err.name === 'UnauthorizedError') {
    //这个需要根据自己的业务逻辑来处理( 具体的err值 请看下面)
    res.status(200).send({ code: -1, msg: '未登录', status: 41002 });
  }
});

Разобрать
Для всех интерфейсов, прошедших проверку токена, мы помещаем разобранную информацию о пользователе в req.tokenDecode, что удобно для использования интерфейсом.

// 解析token
app.use((req, res, next) => {
  // 获取token
  let token = req.headers.token;

  if (token) {
    let decoded = jwt.decode(token, secret);
    req.tokenDecode = decoded
  }
  next()
});

Процесс входа

Это просто вспомогательные, не генерируетtokenНичего хорошего 😜, тогда мы рассмотрим часть логина, как сгенерировать токен на основе информации SSO.

Войти

  1. браузер отправитьПолучить информацию о пользователепросить
  2. Анализ узловtoken, пустой илиtokenНеверный возврат без входа в систему
  3. Браузер переходит на страницу входа SSO
  4. Войти успешно перейти кФронтальное доменное имя+SSOtoken
  5. браузер отправитьSSOtokenЗапрос на вход в узел
  6. узел будетSSOtokenОтправить в систему единого входа для получения информации о пользователе
  7. Node использует информацию о пользователе для созданияtoken, и воляtokenи информация о пользователе отправляется на внешний интерфейс.

не путайSSOtokenа такжеtoken,SSOtokenиспользуется для отправки в систему единого входа для получения информации о пользователе; а такжеtokenгенерируется из пользовательской информации.

Проще говоря: пользователь должен быть пользователем системы единого входа, чтобы генерироватьSSOtoken, а затем получите информацию о пользователе для созданияtoken, без подробностей 🤔.

сгенерировать токен
использоватьjwt.signгенерироватьtoken, Содержит имя пользователя, информацию о разрешениях.

app.use('/login', async function (req, res) {

  // 系统标识
  let systemName = req.query.systemNameNode;

  // 无系统标识 直接退出
  if(!systemName) {
    res.send({ code: -1, msg: '无系统标识' });
    return
  }
  // 获取用户信息
  let userInfo = await fetch(baseConfig.SSO + "/api/sso/verifyToken", {
    method: "post",
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: encode({ token: req.query.token, system: systemName })
  }).then((res) => res.json())

  if (userInfo.code !== -1) {

    let { masterName, masterFullName } = userInfo.data
    // 根据用户信息获取菜单
    const menu = await getRBAC({ masterName, system:systemName})

    // 403
    if(menu.code === -1 ) {
      res.status(403).send(menu);
    } else {
      // 接口权限
      const { powers } = menu.data;
      //生成token
      const token = jwt.sign({
        systemName,
        masterName,
        masterFullName,
        powers,
      }, secret, {
        expiresIn: 3600 * 2 //到期时间
      });
      res.send({ token: token, ...userInfo.data });
    }
  } else {
    res.send(userInfo)
  }

});

Exit — это просто интерфейс, который будет существовать в браузере.tokenудалять и не рассматривать досрочный выход из системы иtokenПродлить.

Зная, кто пользователь, следующим шагом будет пересылка.Уровень узла должен решить, следует ли пересылать его бизнес-стороне в соответствии с разрешениями, так как же общаться с бизнес-стороной? Есть ли какие-либо проблемы с безопасностью? 😋 Давайте посмотрим на ретвитнутую часть.

Часть реализации пересылки

Конечная цель-решить две проблемы дублирования труда.Первая решена,а вторая намного проще.Его нужно только судить по разрешениям пользователя перед пересылкой.Узел взаимодействует с бизнес-сторонойRSAсделал простой знак,RSAПриложение слишком широкое см.Принцип алгоритма Ruan Yifeng RSA (1)
.

Суждение власти

Требуется запись в RBACController,Actionинформация, соответствующая адресу интерфейса,
Если на рисунке ниже соответствующий адрес интерфейсаweb/delinter.

Информация о разрешениях, полученная от RBAC, выглядит следующим образом, и эта часть информации существуетtoken, непосредственно перед пересылкой, из  req.tokenDecodeРезультат возможен при проверке.

Для удобства чтения вставьте формат интерфейса.

// rbac接口权限判断
	const { url } = req.body
	// 环境变量、域名、接口地址
	let [ env, host, apiUrl ] = url.split('||');
	// host 去掉域名http和环境变量信息
	host = hostWipeEnv(host);
	// 获取控制器和行为
	let [ , Controller, Action ] = apiUrl.split('/')
	// key转小写,rabc中数据key为小写
	Controller = Controller.toLowerCase();
	Action = Action.toLowerCase();
	// 不带域名 带斜杠与不带斜杠的权限
	const noHostPowers = _.get(powers, `/${Controller}.${Action}`) || _.get(powers, `${Controller}.${Action}`);
	// 带域名
	const key = `${host}:/${Controller}`;
	const hasHostPowers = _.get(powers[key], `${Action}`)

	// 权限判断
	if (!noHostPowers && !hasHostPowers ){
		res.send({ code: -1, msg: '接口无权限' });
		return
	}

формат интерфейса

Должны быть установлены правила для общения.Бизнес-система должна знать информацию о пользователе и данные интерфейса.Другие только для увеличения сложности взлома.

добавить подпись

RSA-подпись
согласно сsystemNameNodeПолучите файл с закрытым ключом и подпишите его с помощью закрытого ключа.

1. Создать объект параметра
Содержит имя пользователя, систему, данные, сгенерированное время и случайные свойства.

Код:

'use strict';

// 生成签名
const Encrypt = require('./encrypt');

/**
 * 处理接口请求的参数,添加全局参数
 * username: 当前登录的用户名
 * system: 当前使用的系统名
 * time: 当前时间戳
 * random: 随机数
 * data: 实际请求的参数
 */
module.exports = function (data, signStr, masterName, systemName) {

  const newParam = {
    username: masterName,
    system: systemName,
    time: Date.now(),
    random: Math.random(),
    data,
  };

  // 生成签名
  const sign = new Encrypt(newParam, signStr).value;
  newParam.sign = sign;
  return newParam;
};

2. Сериализация объекта параметра
Используйте функцию sortAndQuery для сериализации параметров, сгенерированных на первом шаге. Формат сериализации выглядит следующим образом.

data={"page":"1","limit":"10"}&random=0.10105494318877817&system=op-log&time=1578997818828&username=qinshaowei

Код

'use strict';

/**
 * 把Object的属性名从小到大排序,如果属性的值是对象则转成JSON字符串,并把所有属性转成query字符串
 * obj: 要转换的对象
 */
module.exports = function (obj) {

  const keys = Object.keys(obj).sort();
  const query = keys.map(key => {
    let value = obj[key];
    if (typeof value === 'object') {
      value = JSON.stringify(value);
    }
    return `${key}=${value}`;
  }).join('&');
  return query;
};

3. Создайте подпись, используя сериализованную строку
Используйте алгоритм RAS для создания подписи с использованием алгоритма RAS и возврата объекта записи.

'use strict';
/**
 * 用密钥对文本做签名
 */

// 转换对象为query字符串
const sortAndQuery = require('./sortAndQuery');

const NodeRSA = require('node-rsa');

// 生成RSA密钥对象
let private_key;

class Encrypt {
  constructor(params, signStr) {
    // 秘钥赋值
    private_key = new NodeRSA(signStr)
    if (params) {
      // 如果new对象时就有传入参数,则调用签名方法,返回签名结果,因为class构造函数只能返回对象,所以...
      const signStr = this.sign(params);
      return {
        toString: () => signStr,
        value: signStr,
      };
    }
  }

  // 对参数对象做签名,返回生成的签名
  sign(params, signStr) {
    // 先把对象转成query字符串,再做签名
    const queryStr = sortAndQuery(params);
    const sign = private_key.sign(queryStr, 'base64', 'utf8');
    return sign;
  }

}

module.exports = Encrypt;

Проверка подписи

Версия узла

// 序列化方法
const sortAndQuery = Param => {
  // 排序
  const keys = Object.keys(obj).sort();
  // 对象转字符串
	const query = keys.map(key => {
    let value = obj[key];
    if (typeof value === 'object') {
      value = JSON.stringify(value);
    }
    return `${key}=${value}`;
  }).join('&');	
	return query;  
};


// 初始化
const init = req => {
  const { sign, ...params } = req.body;
  // 公钥字符串
	const public_key_data = '-----BEGIN PUBLIC KEY-----\n' +
  'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCD4EalJIz4YMGrj6oARl30Rji7\n' +
  'cH9mzW2p2sNIUmNb48TeR7WN17z1lPxPuFkODmMyRuU7i+zZNbx2OZ/lAx8gGNQN\n' +
  'BdeUWBUAJX0KfLc3y6bY/k81PE1V7SOV3Ordd8aJwQO/bQnNcDUvC6WP3feTnbu7\n' +
  'Mja0HHTpXYcVn9uO+wIDAQAB\n' +
  '-----END PUBLIC KEY-----';
  const public_key = new NodeRSA(public_key_data);
	const dataStr = sortAndQuery(params);
  // 返回签名验证是否成功,true or false
	return public_key.verify(dataStr, sign, 'utf8', 'base64');
}

Совместная отладка PHP

Во время совместной отладки с бизнес-системой PHP происходит сбой проверки подписи, и информация о подписи, сгенерированная с использованием одного и того же закрытого ключа и данных, несовместима.

Причина в том,node-rsaи phpopensslПравила подписи по умолчанию отличаются, PHP указываетSHA256Можно, как показано на рисунке:


GO совместная отладка

При совместной отладке с системой go сгенерированная подпись точно такая же, как напечатана в консоли, собственная подпись Go может пройти, а та, что выдана Node, может пройти по-другому.

РаспечататьNodeRSAПри генерации подписи обнаруживается, что если второй входной параметр не передан, выходом по умолчанию является тип буфера, а подпись, сгенерированная go, имеет видbetyФормат, который должен быть сгенерирован Nodebase64превратиться вbetyФормат.

Действительно ли точка идеальна?

Ну наконец-то я избавился от двух упомянутых выше проблем, счастлив 🙂, подождите, неужели он идеален?
Еще одна цитата из "Собор и базар"

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

Мы находили и решали проблемы по-своему, только чтобы знать, что были созданы новые проблемы.

На совместном RSA бизнес-конце надеется всегда предоставить информацию подписи, генерируемую фиксированное значение, используется для координации, и если государственно-закрытый ключ является договор, необходимо заменить открытый ключ ключевым ключом.

Совместное расследование действительно заняло у меня очень много времени, и весь процесс был в сомнениях друг в друге и неуверенности в себе 😂, тогда повторитеУстранение нового дублирования работы.

Устранение нового дублирования работы

Вновь добавленного дублирования работы может быть не так много, но в будущем будет подключено больше проектов, и время, затрачиваемое на совместную настройку, скорее всего, возрастет в геометрической прогрессии.

решение

  1. Автоматически генерировать открытый и закрытый ключи и синхронизировать закрытый ключ с приложением Node.
  2. загружаемый
  3. Предоставляет интерфейс отладки.

сгенерировать ключ

Это относительно просто и может быть сгенерировано непосредственно с помощью RSA.

const key = new NodeRSA({ b: 512 }); // 生成512位的密钥
const publicDer = key.exportKey('pkcs1-public-pem'); // 公钥
const privateDer = key.exportKey('pkcs1-private-pem'); // 私钥

нажать на git

Все проекты используютgitlabХостинг, стандартная эксплуатация и техническое обслуживаниеgithook+dockerАвтоматическое развертывание, использование API gitlab для обновления кода, автоматическое развертывание, отлично 😘.


// 公钥私钥路径
const publicPath = `base/RSA/${proName}/pkcs1-public.pem`;
const privatePath = `base/RSA/${proName}/pkcs1-private.pem`;

// 获取文件返回详情 用于判断更新文件的更新方式
const publicInfo = await getGitLabInfo(publicPath);
const privateInfo = await getGitLabInfo(privatePath);

const getGitLabInfo = async path => {
    const result = await getFlieContent(2660, path, 'develop').then(res => res).catch(err => false);
    return result;
  };

// 生成公钥
await setFlieContent({
      projectID: 2660,
      filePath: publicPath,
      branch: 'develop',
      content: publicDer,
      method: getMethod(publicInfo),
      message: `${userName} 生成公私钥`,
    });

// 生成私钥
await setFlieContent({
  projectID: 2660,
  filePath: privatePath,
  branch: 'develop',
  content: privateDer,
  method: getMethod(privateInfo),
  message: `${userName} 生成公私钥`,
});

gitlab API
Коллеги инкапсулируют метод setFlieContent и вызывают его напрямую.

/**
 * 修改文件内容
 * @param { 项目ID } projectID
 * @param { 文件路径 } filePath
 * @param { 分支名 } branch
 * @param { 文件内容 } content
 * @param { commit信息 } message
 * @param { 用户email } email
 * @param { 用户名 } username
 * @param { 请求类型 } method
 */
function setFlieContent({
  projectID,  filePath,  branch,  content,  message,  email,  username,  method,
}) {
  const pathFile = encodeURIComponent(filePath);
  return new Promise((resolve, reject) => {
    request({
      url: `https://git.xin.com/api/v4/projects/${projectID}/repository/files/${pathFile}`,
      method,
      headers: {
        'content-type': 'application/json',
        'PRIVATE-TOKEN': PersonalAccessTokens,
      },
      body: JSON.stringify({
        branch,
        content,
        commit_message: message || '接口提交',
        author_email: email || 'machine_xin_com@xin.com',
        author_name: username || 'machine_xin_com',
      }),
    }, (error, response, body) => {
      if (!error && (response.statusCode === 200 || response.statusCode === 201)) {
        const data = JSON.parse(body);
        resolve(data);
      } else {
        reject(error);
      }
    });
  });
}

Есть сомнения? Для чего нужен метод get? Почему оценивается код возврата 200 или 201?

Я также узнал позже, что меня тронул проект верхнего уровня, потому что gitlab строго следует интерфейсу спецификации HTTP,methodизPUTа такжеPOSTОбновлены, новый файл, успешный код статуса 200, 201 соответственно.

Скачать ключ

чтениемgitlabсодержимое файла вbuffer,передачаexpressизsendПросто скачайте файл.


const { getFlieContent } = require('../git/fileLIst');

const getRSA = async (req, res) => {

  const { proName, keyType } = req.query;
  const keyTypes = ['public', 'private'];
  // 类型判断
  if (!keyTypes.includes(keyType)) {
    res.send({code: -1,msg: '类型错误'})
    return
  }

  try {
    // 获取秘钥内容
    const keyString = await getFlieContent(2660, `base/RSA/${proName}/pkcs1-${keyType}.pem`, 'develop');
    // 转为buffer
    const buff = Buffer.from(JSON.stringify(keyString, '\t', 2));
    // 设置文件类型
    res.set({'Content-Disposition': `attachment; filename=pkcs1-${keyType}.pem`});
    // 发送文件
    res.end(buff);
        
  } catch (error) {
    res.send({code: -1,msg: error})
  }

};

module.exports = getRSA;

Подвеска инструмента

Многие инструменты просты, но предоставляют удобный способ предварительного просмотра.

веселье и рост

Просто в процессе совместной отладки РАН я набрал много очков знаний, типа встроенного Макаapache,VScodeВы можете напрямую запускать код go или php, различать простые и сложные запросы по доменам и т. д. 😇.

Я думаю, что это интересный процесс решения повторяющейся низкоуровневой работы с помощью технологий., Что более интересно, так это то, что вы можете получить ценность и рост в процессе; моя недавняя работа заставила меня осознать свои собственные недостатки,Многому научился и обнаружил, что не узнал больше, начал изучать шаблоны проектирования, сложность программного обеспечения, принципы проектирования, слушал выступления Джона Оустерхаута и Эриха Гаммы и начал изучать архитектуру исходного кода.

Сегодня последний день занятий по лунному календарю в 2019 г. В этом году я могу использовать Google для написания Node, React, vue, разработки инструментов для генерации страниц и выполнения интересующей меня работы.Я могу осознать свою несостоятельность и ценности на работе и все больше и больше понимать свое направление, что делает меня довольным моей нынешней работой., я думаю, что смогу нарисовать счастливый конец 2019 года.

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