В этой статье я продолжаю цикл публикаций, в котором я хочу рассказать о своём опыте написания веб-расширения для браузеров. У меня уже был опыт создания веб-расширения, которое установили около 100 000 пользователей Chrome, которое работало автономно, но в данном цикле статей я решил углубиться в процесс разработки веб-расширения тесно интегрировав его с серверной частью.
Как уже было описано ранее для серверной части используется Meteor.js. Для имплементации RESTful API используется пакет github.com/kahmali/meteor-restivus. Он уже имеет в себе некоторую реализованную часть для покрытия пользовательских механизмов связанных с авторизацией.
Например, достаточно указать authRequired: true, как в примере ниже, чтобы API point работал только для авторизированных пользователей.
Таким образом были добавлены три API point для регистрации, для получения данных профиля и его обновления, для сброса пароля.
В самом веб-расширении при вызове методов, требующих авторизацию используется примерно следующий код:
Здесь хорошо виден пример запроса с авторизацией. В заголовках передается X-Auth-Token и X-User-Id, которые были получены в результате процесса регистрации или авторизации. Эти данные хранятся в локальном хранилище веб-расширения и всегда доступны в content.js скрипте.
Загрузка файлов в веб-расширении сделана через чтение файла на стороне браузера и отправкой через XHR:
Здесь важно отметить строку 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, а далее инициализируется скачивание файла:
Для взаимодействия скрипта, веб-расширения и серверной части необходимо иметь промежуточное звено, которое позволит быстро получать данные из загруженного файла, сохранять данные после выполнения скрипта и т. д.
Для этой цели была написана внутренняя библиотека, которая инициализируется перед началом работы любого скрипта путем добавления себя в код страницы. Здесь необходимо добавить информацию о политике защиты источников, для загрузки ресурсов, а именно о content-security-policy.
Многие сайты используют заголовки с CSP для защиты от выполнения произвольного кода javascript на страницах своих веб-сервисов, таким образом защищаясь от XSS на стороне веб-браузера.
Так как пользователь самостоятельно устанавливает веб-расширение, оно способно изменять заголовки и содержимое загружаемого ресурса. Из-за бага в Mozilla Firefox, это является проблемой для некоторых сайтов. То есть в веб-расширении для Firefox не получится модифицировать заголовки или добавить meta тэг для отмены политики CSP для сайтов, на которых они используются. Данный баг не закрывают уже несколько лет, хотя в стандартах четко прописано положения для веб-расширений, которое гласит, что политика в отношении загружаемых ресурсов со стороны сервера приложения не может быть главенствующей по отношению к устанавливаемым самим пользователем веб-расширениям.
Ограничение политики CSP можно реализовать при помощи kango фреймворка следующим способом:
При этом необходимо не забыть в манифесте веб-расширения добавить строки, разрешающие работу с объектом webRequest в блокирующем режиме:
После решения проблемы с ограничениями накладываемыми со стороны CSP пользователь может применять написанные им скрипты на любой странице в сети интернет.
Вызов функций из внутренней библиотеке доступен через глобальный объект Gc.
На текущей момент реализованы функции:
В следующей статье я расскажу о “задачах по расписанию”.
Подводные камни при реализации взаимодействия веб-расширения и серверной части
Как уже было описано ранее для серверной части используется 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.
В следующей статье я расскажу о “задачах по расписанию”.