Все началось с того, что мне дали задачу, реализовать тестовый заказ в веб-приложении, которая встроена через iframe в одном популярном ресурсе.
Задача была решена и работала следующим образом:
- неавторизованный пользователь кликает на магазин (ссылка «_blank»);
- в новом окне отображаются тестовые товары, а в iframe мы перенаправляем пользователя в профиль тестового пользователя и ждем появления данных покупки в localStorage;
- после совершения покупки, данные о ней сохраняем в localStorage (сумма, количество, магазин, время покупки и количество бонусов)
- в iframe при появлении данных тестовой покупки в localStorage, мы отображаем информацию в блоке «история покупок»;
Все работало в большинстве браузеров, и даже в IE11, но только не в Safari, чья политика безопастности (более известный как porno-mode) не разрешала получить доступ к данным localStorage одного и того же домена внутри iframe и снаружи (в новом окне).
Нужно где-то хранить промежуточные данные, привлечь к этой задачи бэкенд разработчиков для создания какого-либо API для хранения данных разрешения не получил, оставалось только найти какое-нибудь онлайн хранилище, с возможностью создание для каждого пользователя своего токена.
Поиски привели меня к сервису keyvalue.xyz, он позволяет создавать ключ, записывать и считывать данные. И так я начал для каждого пользователя, который решил попробовать тестовый заказ, создавать токен и передавать его в url параметрах в новое окно, далее при успешном тестовом заказе, записываем данные в хранилище, а уже в iframe переодически запрашивал данные, пока они не появятся.?
Все работало, но тут пришло сообщение от тестировщика, на этот раз она сообщила, что с включенным adblock’ом не работает тестовый заказ. Так и есть, в консоли adblock писал, о том что заблокирован запрос к ресурсу. Я обратился к разработчикам сервиса, с просьбой сделать зеркало, они не ответили, попытался через nginx (proxy_pass) сделать зеркало, тоже не помогло, скорее всего из-за фильтра cloudflare.?
Было не приятно, нужно было выходить из ситуации.
Решил написать простое ключ=значение хранилище подобное localStorage, с бэкапом, доступом с определенного домена, защитой паролем от записи и удобную библиотеку для работы с ним.
Разработка
Написать на Node.js простой rest api c помощью express не составляет труда, для хранения данных я выбрал MongoDB, потому что нет жесткой структуры и изменить структуру документа можно всего лишь одной строчкой кода в схеме и конечно то, что mongodb может работать с документами большого размера (100-200Гб).
Подробно рассказывать о разработке не имеет смысла, она очень проста и большинство из нас уже пользовались фреймворком express.
Начнем с основных требований к хранилищу:
- Создание токена
- Обновление токена
- Получение значения из хранилища
- Получение всего хранилища
- Запись данных
- Удаление элемента
- Очистка хранилища
- Получения списка резервных копий
- Восстановление хранилища из резервной копии
Схема токена достаточно простая, выглядит следующим образом:
const TokenSchema = new db.mongoose.Schema({
token: { type: String, required: [true, "tokenRequired"] },
connect: { type: String, required: [true, "connectRequired"] },
refreshToken: { type: String, required: [true, "refreshTokenRequired"] },
domains: { type: Array, default: [] },
backup: { type: Boolean, default: false },
password: { type: String },
})
Дополнительные параметры:
token | Используется для доступа к хранилищу |
connect | Свойство для связки хранилища с токеном |
refreshToken | Обновление токена в случаи, если нужно обновить токен или токен где-то засветился, например в git коммите |
domains | Массив доменов, доступ к хранилищу, которым разрешен. Для проверки используется HTTP заголовок Origin |
backup | Если установлено true, то каждые 2 часа будет выполнено резервное копирование всего хранилище, то есть в течении суток всегда доступно несколько резервных копий, к которым можно откатиться |
password | Установка пароля для записи и удаления |
app.post('/create', async (req, res) => {
try {
// Additional storage protection data
const { domains, backup, password } = req.body
// New unique uuid token
const token = uuid.v4()
// A unique identifier for connecting the token to the storage
// as well as using it you can update the token
const connect = uuid.v1()
// Default
const tokenParam = {
token: token,
connect: connect,
refreshToken: connect
}
// The list of domains for accessing the repository
if (domains) {
// If an array is passed, store it as it is
if (Array.isArray(domains)) tokenParam.domains = domains
// If a string is passed, wrap it in an array
if (typeof domains === 'string') tokenParam.domains = [domains]
// If passed boolean true, save the host
if (typeof domains === 'boolean') tokenParam.domains = [req.hostname]
}
// Availability of backup
if (backup) tokenParam.backup = true
// If a password is sent, we save it
if (password) tokenParam.password = md5(password)
// Save to db
await new TokenModel.Token(tokenParam).save()
// Sending the token to the client
res.json({ status: true, data: tokenParam })
} catch (e) {
res.status(500).send({ status: false, description: 'Error: There was an error creating the token' })
}
})
Примеры запросов будет приводить с использованием библиотеки axios, в отличии от curl команды, его код достаточно лаконичен и понятен.
axios.post('https://api.kurtuba.ru/create', {
domains: ['example.com', 'google.com'],
backup: true,
password: 'qwerty'
})
В результате выполнения мы получим ответ с токеном, который можно использовать для записи и чтения данных с хранилища:
{
"status": true,
"data":{
"token": "002cac23-aa8b-4803-a94f-3888020fa0df",
"refreshToken": "5bf365e0-1fc0-11e8-85d2-3f7a9c4f742e",
"domains": ["example.com", "google.com"],
"backup": true,
"password": "d8578edf8458ce06fbc5bb76a58c5ca4"
}
}
Запись данных в хранилище:
axios.post('https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/set', {
name: 'hazratgs',
age: 25,
city: 'Derbent'
skills: ['javascript', 'react+redux', 'nodejs', 'mongodb']
})
Получение элемента из хранилища:
axios.get('https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/get/name')
В результате мы получим:
{
"status": true,
"data": "hazratgs"
}
Остальные примеры вы можете посмотреть на странице проекта в GitHub..
Вы можете клонировать репозиторий и развернуть хранилище у себя, подробная инструкция в репозитории проекта.
Для удобства доступна JavaScript библиотека и Python библиотека.
Само хранилище доступно по адресу api.kurtuba.ru
Пример работы с библиотекой
И так у нас уже есть хранилище и библиотека для работы с ним, давайте установим библиотеку:
npm i kurtuba-client
Импортируем в проект:
import onlineStorage from 'kurtuba-client'
Хочу заметить, так как мы делаем онлайн имплементацию localStorage, в коде проекта по умолчанию мы возвращаем объект, а не класс, для того, чтобы работать с одним источником данных по всему проекту, если вам нужно несколько объектов, вы можете импортировать сам класс KurtubaStorage и создавать на основе его сколько угодно объектов.
Создаем токен:
onlineStorage.create()
Надо сказать, что onlineStorage асинхронный метод, как практически все методы объекта onlineStorage, поэтому лучшим вариантом будет использовать синтаксис async/await.
После создания токена, он записывается в свойство token и далее подставляется при необходимости, например запись данных:
await onlineStorage.set({
name: 'hazratgs',
age: 25,
city: 'Derbent'
skills: ['javascript', 'react+redux', 'nodejs', 'mongodb']
})
чтение данных:
const order = await onlineStorage.get('name') // hazratgs
удаление свойства:
await onlineStorage.remove('name')
Теперь можно смело сказать, что у нас есть онлайн имплементация localStorage и даже больше, так localStorage работает только со строками, а наше хранилище работает умеет хранить строки, числа, объекты и логические типы.
Заключение
В результате мы имеем хранилище данных, с резервным копированием, доступом с определенных доменов и парольной защитой, но хочется сказать, что данное хранилище так же как и localStorage не безопасна и не предназначена для хранения основных данных приложения от которых зависит работоспособность проекта, данных пользователя и многое другое, что может навредить вашему приложению.
?Используйте её только для малозначимых и общедоступных данных, как в примере у меня, для передачи данных тестовой покупки из окна в iframe.
Проблемы могут возникнуть по причине того, что токен, как и весь javascript мы передаем клиенту и ему не составит труда получить данные всего хранилища, это не проблема конкретно нашего хранилища, любые api-key переданные клиенту, становятся публичными и хотя у нас есть некие защиты в лице работы с определенными доменами и паролями, все это скорее всего можно обойти.
Для того, чтобы спрятать токен, конечно можно написать обертку над api у себя на сервере, но это уже такое, легче уже свою базу данных настроить.
Просьба не ругать сильно, это моя первая публикация и вклад в open source.
Очень буду благодарен в помощи устранения уязвимостей, советам, pull request’ам.
Комментарии (13)
NightSilf
17.03.2018 09:54Простите, а Firebase то чем не подошел?
Hazrat Автор
17.03.2018 23:54— платный
— сложный, нельзя просто выполнить нечто подобное onlineStorage.get('name')
— одно хранилище, а для моей задачи необходимо для каждого пользователя свое хранилище, ниже объясню почему
— api key и db link передаются клиенту, если я буду вести список тестовых заказов в одном хранилище, недоброжелатель может просто взять api key и db link и очистить хранилище, в моем случае у каждого своя область видимости
— опыт, всегда хочется сделать что-то полезноеBringoff
18.03.2018 13:47платный
Только под серьезной нагрузкой.
сложный, нельзя просто выполнить нечто подобное onlineStorage.get('name')
пишем cloud function в несколько строк, к которой обращаемся с клиента
api key и db link передаются клиенту, если я буду вести список тестовых заказов в одном хранилище, недоброжелатель может просто взять api key и db link и очистить хранилище, в моем случае у каждого своя область видимости
настраиваем права доступа для чтения и записи в отдельные части базы.
опыт, всегда хочется сделать что-то полезное
ну, тут возразить нечего :) Если хочется, то куда деваться.
В ощем, картинка с троллейбусом здесь как нельзя кстати.
Hazrat Автор
18.03.2018 15:00— посещаемость ресурса около 600 тыс в день, бесплатный вариант не подходит
— зачем мне писать «cloud function»? когда за это же время можно собрать свое хранилище?
— вы видимо не понимаете? запись в хранилище с клиента должна быть доступна! А значит любой может воспользоваться и очистить хранилище
понимаете, мы долго будем приводить аргументы за и против, но суть в том, что вы не поняли задачу, ее решить с firebase нельзя или сложнее чем использовать просто хранилище, или как минимум написать обертку над api firebase на сервере, что равноценно разработки самого хранилища…
xytop
17.03.2018 11:13+1А я не понимаю проблем с localStorage. Есть ссылка на описание проблемы?
А вообще, есть ещё postMessage для общения между фреймом и основным сайтом.Hazrat Автор
17.03.2018 23:59habrahabr.ru/post/349164
Я конечно не согласен с некоторыми пунктами из статьи.
На счет postMessage, мы используем этот метод для общения iframe с родительским окном, но ведь я написал в статье, что ссылка из iframe открывается в новой вкладе, и она уже ни как не может обратиться к той вкладке.
Hazrat Автор
18.03.2018 00:10Если вы имеете ввиду, почему к данным localStorage установленным внутри iframe не возможно получить доступ в новой вкладе, то скорее всего из-за параметра по умолчанию «block cookies and other website data» в iOS Safari
rumkin
18.03.2018 23:59Внесу конструктивную критику (в отличие от товарищей выше). Идея хорошо проработана, хорошо оформлен репозиторий. Есть несколько недочетов, некоторые критические:
- Пароль слабо защищен (это можно считать уязвимостью). Во-первых, сегодня использование md5 не рекомендуется (объяснение почему), переходите на sha3. Во-вторых, пароль нужно солить, для этого используйте bcrypt. В-третьих, пароль (даже захешированный) никогда не возвращается в ответе.
Сейчас хранилище намертво привязано к express и mongodb, этого можно избежать используя адаптеры. Обычно я делаю нечто подобное:
const manager = new Manager({db: new MongoAdapter({mongoose})); express.use(createManagerRouter(manager));
Это позволит использовать различные бекенды (CoachDB, LMDB) и фронтенды (Connect, Koa, Hapi). И сделает код модульным. У меня есть пример похожего REST-приложения, разделенного на независимые компоненты. Очень помогло, когда заказчик попросил доработать функционал для MongoDB.
- Код тестов не стоит оборачивать в стрелочные функции.
- mocha поддерживает промисы.
- Методы get/set не стоит добавлять в URL для этого используются POST, GET, DELETE. Относитесь к URL как к ID ресурса.
POST https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/item {name: 'Username'} GET https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/item/name DELETE https://api.kurtuba.ru/002cac23-aa8b-4803-a94f-3888020fa0df/item/name
- Добавьте бенчмарки для пользователей, которые на них ориентируются.
Hazrat Автор
19.03.2018 13:15Столько полезной информации в одном лишь комментарии, спасибо, обязательно поправлю!
xakepmega
Hazrat Автор
это ваша картинка, которую вы излюбленно добавляете к постам, смысл которых не понятен именно вам? Я привел проблему, с которой столкнулся и решение задачи данным способом. Я согласен, есть разные способы решения задачи, но я выбрал именно этот