В этой статье я продолжаю цикл публикаций, в котором я хочу рассказать о своём опыте написания веб-расширения для браузеров. У меня уже был опыт создания веб-расширения, которое установили около 100 000 пользователей Chrome, которое работало автономно, но в данном цикле статей я решил углубиться в процесс разработки веб-расширения тесно интегрировав его с серверной частью.

imageimageimageimageimage

Подводные камни при реализации взаимодействия веб-расширения и серверной части


Как уже было описано ранее для серверной части используется Meteor.js. Для имплементации RESTful API используется пакет github.com/kahmali/meteor-restivus. Он уже имеет в себе некоторую реализованную часть для покрытия пользовательских механизмов связанных с авторизацией.

Например, достаточно указать authRequired: true, как в примере ниже, чтобы API point работал только для авторизированных пользователей.

Api.addRoute('clientScript/:id_script',
  {authRequired: true},
  {get: {
      action: function() {
        //method for GET on htts://example.com/api/v1/clientScript/:id_script
      }
 });

Таким образом были добавлены три API point для регистрации, для получения данных профиля и его обновления, для сброса пароля.

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

var details = {
	url: API_URL + '/api/v1/clientDataRowDownload/' + dataRowId + '/download',
	method: 'GET',
	contentType: 'json',
	headers: {'X-Auth-Token': kango.storage.getItem("authToken"), 'X-User-Id': kango.storage.getItem("userId")}
	};
kango.xhr.send(details, function(data) {
//code for response handler
})

Здесь хорошо виден пример запроса с авторизацией. В заголовках передается X-Auth-Token и X-User-Id, которые были получены в результате процесса регистрации или авторизации. Эти данные хранятся в локальном хранилище веб-расширения и всегда доступны в content.js скрипте.

Загрузка файлов в веб-расширении сделана через чтение файла на стороне браузера и отправкой через XHR:

$("form#uploadFile").on("submit", function(event, template) {
        event.preventDefault();
	var reader = new FileReader();
	reader.onload = function(evt) {
		var details = {
			url: API_URL + '/api/v1/clientFileAdd/' + kango.storage.getItem("userId"),
			method: 'POST',
			contentType: 'json',
			params: {"content": encodeURIComponent(evt.target.result.replace(/^data:[^;]*;base64,/, "")),
				 "name": encodeURIComponent(event.currentTarget.fileInput.files[0].name),
				 "size": event.currentTarget.fileInput.files[0].size,
				 "type": event.currentTarget.fileInput.files[0].type,
				 "lastModified": event.currentTarget.fileInput.files[0].lastModified
			        },
			headers: {'X-Auth-Token': kango.storage.getItem("authToken"), 'X-User-Id': kango.storage.getItem("userId")}
	  			};
		kango.xhr.send(details, function(data) {
			if (data.status == 200 && data.response != null) {
				if(data.response.status == "success") {
					//ok
				} else {
                                        //error
				}
		        } else {
				if(data.status == 401) {
					//notAuth
				} else {
                                        //error
				}
			}
		});
	};
	if (event.currentTarget.fileInput.files.length != 0) {
	      reader.readAsDataURL(event.currentTarget.fileInput.files[0]);
	}
	return false;
  });

Здесь важно отметить строку event.target.result.replace(/^data:[^;]*;base64,/, ""). Файл на стороне браузера закодирован в base64, но для совместимости на стороне сервера при использовании этой кодировки в строке Buffer.from(new String(this.bodyParams.content), «base64») мы должны отрезать префикс кодировки и читать только “тело” файла. Также необходимо отметить оборачивание в encodeURIComponent, так как тот же + часто встречается в base64 и именах файлов.

При редактировании скриптов нужно учитывать кодировку символов в теле скрипта при передаче содержания. В некоторых случаях кодирование base64 не давало правильных результатов при декодировании на стороне сервера при использовании encodeURIComponent. Поэтому предварительно используется принудительное кодирование в utf8 при помощи utf8.encode(str); где mths.be/utf8js v3.0.0 от @mathias

Скачивание файлов реализовано с использованием хорошо зарекомендованной библиотекой FileSaver. Данные полученные через XHR просто передаются на вход конструктора File, а далее инициализируется скачивание файла:

var file = new File([data.response.data.join("\n")], "data_rows" + date.getFullYear() + "_" + (date.getMonth() + 1) + "_" +  date.getDate() + ".csv", {type: "application/vnd.ms-excel"});
saveAs(file);

Внутренняя библиотека для пользовательских скриптов


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

Для этой цели была написана внутренняя библиотека, которая инициализируется перед началом работы любого скрипта путем добавления себя в код страницы. Здесь необходимо добавить информацию о политике защиты источников, для загрузки ресурсов, а именно о content-security-policy.

Многие сайты используют заголовки с CSP для защиты от выполнения произвольного кода javascript на страницах своих веб-сервисов, таким образом защищаясь от XSS на стороне веб-браузера.

Так как пользователь самостоятельно устанавливает веб-расширение, оно способно изменять заголовки и содержимое загружаемого ресурса. Из-за бага в Mozilla Firefox, это является проблемой для некоторых сайтов. То есть в веб-расширении для Firefox не получится модифицировать заголовки или добавить meta тэг для отмены политики CSP для сайтов, на которых они используются. Данный баг не закрывают уже несколько лет, хотя в стандартах четко прописано положения для веб-расширений, которое гласит, что политика в отношении загружаемых ресурсов со стороны сервера приложения не может быть главенствующей по отношению к устанавливаемым самим пользователем веб-расширениям.

Ограничение политики CSP можно реализовать при помощи kango фреймворка следующим способом:

var browserObject;
if(kango.browser.getName() == 'chrome') {
  browserObject = chrome;
} else {
  browserObject = browser;
}

var filter = {
  urls: ["*://*/*"],
  types: ["main_frame", "sub_frame"]
};

var onHeadersReceived = function(details) {
  var newHeaders = [];
  for (var i = 0; i < details.responseHeaders.length; i++) {
    if ('content-security-policy' !== details.responseHeaders[i].name.toLowerCase() &&
				'x-xss-protection' !== details.responseHeaders[i].name.toLowerCase()
			 ) {
      newHeaders.push(details.responseHeaders[i]);
    }
  }

  return {
    responseHeaders: newHeaders
  };
};

browserObject.webRequest.onHeadersReceived.addListener(onHeadersReceived, filter, ["blocking", "responseHeaders"]);

При этом необходимо не забыть в манифесте веб-расширения добавить строки, разрешающие работу с объектом webRequest в блокирующем режиме:

"permissions": {
...
        "webRequest": true,
        "webRequestBlocking": true,
...
}

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

Вызов функций из внутренней библиотеке доступен через глобальный объект Gc.
На текущей момент реализованы функции:

  • GC.saveRow(name, content, [rewrite = 0, async = false]); , где name — имя строк для записи в коллекцию, content — сама строка данных для записи, rewrite — флаг перезаписи всей коллекции, используется в вызове Gc.saveRow(name, ‘clear’, 1); который удаляет все записи в коллекции строк, async — флаг для работы в асинхронном режиме.
  • GC.getRows(name, number, [count = 1, async = false]);, где name — имя строк в коллекции, number — порядковый номер строки для получения данных, count — количество получаемых данных начиная с number, async — флаг для работы в асинхронном режиме.
  • GC.console(string);, где string — строка для вывода в консоль GC на странице, где выполняется скрипт. Например, для демонстрации прогресса выполнения задачи.
  • GC.clearConsole();, функция очищает консоль GC на странице, где выполняется скрипт.
  • GC.stopScript();, функция для остановки выполнения скрипта.
  • GC.loadFile(name, [parseAs = text]);, где name — имя файла с расширением, содержимое которого необходимо получить, parseAs — формат препроцессора данных, на текущий момент поддерживается json и text.

В следующей статье я расскажу о “задачах по расписанию”.

Комментарии (0)