У ВКонтакте нет API объектного хранилища, зато есть API документов, и ограничений на их объём практически нет. Отсюда идея: использовать документы соцсети как бесплатный безлимитный бэкенд для облачного диска. Выглядит как хак, и отчасти им и является.
Проект называется BridgeCloud. Идея тянулась ещё с конца 2023 года, и с тех пор проект прошёл три архитектуры: прототип на PHP с отдельным Node-процессом, версию 1.0 на Express и версию 2.0 на React и MongoDB. В этой части разберу прототип и расскажу, как выросла версия 1.0 и что в ней появлялось от релиза к релизу. Во второй части речь пойдёт о переписывании на версию 2.0.
Прототип на PHP
Самая первая версия проекта — прототип, и его задача была проверить саму идею: можно ли хранить файлы в документах VK и работать с ними как с обычным диском. Он состоял из двух частей: PHP-сайт на Apache и отдельный Node-процесс, который общался с VK. Напрямую PHP и Node друг друга не вызывали, они обменивались файлами на диске.
Как это работало:
- Вход. Пользователь входил через виджет VK. Node проверял, что он уже писал боту сообщества, создавал для него токен и записывал данные в JSON-файл. PHP читал этот файл и открывал сессию, а на каждой странице сверял токен с файлом.
- Загрузка. Файл перетаскивался в Dropzone, а
upload.phpклал его в папку пользователя на диске. Chokidar в Node замечал новый файл и отправлял его документом в личный диалог пользователя с ботом. - Список файлов. Node записывал список документов в JSON, а страница раз в секунду читала этот файл и обновляла список, если в нём что-то менялось:
// drive.php
setInterval(() => {
fetch(`app/userdata/unloads/<?= $_SESSION['userData']['id'] ?>.json`)
.then(res => res.json())
.then(data => {
if (JSON.stringify(data) !== JSON.stringify(previousData)) {
loadData();
previousData = data;
}
});
}, 1000);
- Удаление. Тоже через файл: страница помечала документ в JSON как удалённый, а Node, следивший за этим файлом, удалял сообщение в VK.
Для прототипа такая схема работала: файлы действительно уходили в VK и возвращались списком на странице. Поэтому, когда я переезжал на Express, я её сохранил.
Версия 1.0: переезд на Express
В начале января 2024 года я перенёс прототип на Express — так появилась версия 1.0. Теперь один процесс отдавал страницы через EJS, работал с MySQL и общался с VK. Запускать и разрабатывать стало проще: не нужно держать вместе Apache, PHP и Node.
Дальше проект рос итерациями. К маю 2024 года в нём уже было:
- регистрация и вход, в том числе через VK;
- двухфакторная аутентификация с кодом в личных сообщениях;
- подтверждение аккаунта через бота сообщества и страница настроек;
- создание файлов и папок прямо в интерфейсе;
- удаление файлов.
Позже появилась админ-панель, а интерфейс доводился: тёмная тема, адаптивная вёрстка.
На чём всё держалось: сервер на Node.js и Express, данные в MySQL, страницы собирает EJS. Файлы принимает multer, в VK они уходят через vk-io, а порядок отправки держит очередь из пакета async. Вся серверная часть жила в одном файле index.js.
Пароли
Пароли хранились в виде MD5-хешей. Реализацию алгоритма на чистом JavaScript я взял готовую и подключил прямо в index.js: хеш считался при регистрации и сверялся при входе.
const MD5 = function (d) { var r = M(V(Y(X(d), 8 * d.length))); return r.toLowerCase() };
function M(d) { for (var _, m = "0123456789ABCDEF", f = "", r = 0; r < d.length; r++)_ = d.charCodeAt(r), f += m.charAt(_ >>> 4 & 15) + m.charAt(15 & _); return f }
function X(d) { for (var _ = Array(d.length >> 2), m = 0; m < _.length; m++)_[m] = 0; for (m = 0; m < 8 * d.length; m += 8)_[m >> 5] |= (255 & d.charCodeAt(m / 8)) << m % 32; return _ }
function V(d) { for (var _ = "", m = 0; m < 32 * d.length; m += 8)_ += String.fromCharCode(d[m >> 5] >>> m % 32 & 255); return _ }
function Y(d, _) { d[_ >> 5] |= 128 << _ % 32, d[14 + (_ + 64 >>> 9 << 4)] = _; for (var m = 1732584193, f = -271733879, r = -1732584194, i = 271733878, n = 0; n < d.length; n += 16) { ... } }
// ... (полная низкоуровневая побитовая реализация MD5)
Загрузка файлов
Самая интересная часть проекта — загрузка. Файл не уходит в VK напрямую: сначала он попадает на диск сервера, оттуда его забирает очередь и отправляет в VK документом. ID сообщения сохраняется в MySQL, а временный файл удаляется.
Первая проблема, с которой я столкнулся при настройке Multer: файлы с русскими названиями сохранялись на диск нечитаемыми символами. Решилось перекодированием имени из Latin1 в UTF-8:
var storage = multer.diskStorage({
destination: function (req, file, cb) {
var dir = path.join(
"userdata",
"uploads",
String(req.session.authData.user_id),
)
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: function (req, file, cb) {
// Декодирование оригинального имени из кодировки Latin1 в UTF-8
file.originalname = Buffer.from(file.originalname, "latin1").toString(
"utf8",
)
cb(null, file.originalname)
},
})
Дальше файл попадал в очередь. Она отправляла файлы по одному, поэтому загрузки шли строго друг за другом.
Подтверждение аккаунта через бота и 2FA
Веб-интерфейс был связан с чат-ботом группы ВКонтакте: через него подтверждался профиль и приходили коды двухфакторной аутентификации.
Регистрация
Пользователь заполняет форму на сайте, и в БД создаётся запись с verified = null. Пока аккаунт не подтверждён, расширенные настройки и облачный диск закрыты.
Подтверждение через личные сообщения группы
Чтобы подтвердить аккаунт, нужно написать боту сообщества любое сообщение. Бот перехватывает его через vk.updates.on("message"):
vk.updates.on("message", async (msg) => {
const results = await new Promise((resolve, reject) => {
db.query(
"SELECT * FROM users WHERE vk_id = ?",
[msg.senderId],
function (error, results) {
if (error) reject(error)
else resolve(results)
},
)
})
// Если пользователь найден в БД и еще не верифицирован
if (results.length > 0 && results[0].verified === null) {
// 1. Активируем статус верификации
db.query("UPDATE users SET verified = 1 WHERE vk_id = ?", [msg.senderId])
try {
// 2. Создаем дефолтные настройки безопасности
db.query(
`INSERT INTO user_settings (user_id, 2fa, remote_control, auth_notify) VALUES (${results[0].id}, 0, 0, 1)`,
)
msg.send(
"Здравствуйте! Вы успешно подтвердили аккаунт. \n\nТеперь вам доступны все функции нашего сервиса! 🎉",
)
} catch (error) {
console.error("Ошибка при подтверждении аккаунта:", error)
msg.send(
"Ошибка при подтверждении аккаунта. Пожалуйста, попробуйте еще раз.",
)
}
}
// Обработка системной команды "СБРОС"
if (msg.text === "СБРОС") {
try {
fs.unlink(`./userdata/sessions/${results[0].id}.json`)
} catch {}
}
})
Команда СБРОС заодно позволяла выйти со всех устройств.
Двухфакторная аутентификация
Если в настройках включён флаг 2fa = 1, то при входе на сайт сервер генерирует временный код и отправляет его пользователю в личные сообщения через бота:
if (settings[0]["2fa"] === 1) {
const code = generatePassword(6, false).toUpperCase()
vk.api.messages.send({
user_id: data.vk_id,
message: `Ваш код 2FA: ${code}\n\nПожалуйста, введите его в приложении.`,
random_id: Math.random(),
})
req.session.authData.code = code
}
Как хранится и отображается список файлов
Сами файлы лежат в VK, а в MySQL хранится только то, по чему их можно найти: владелец, ID сообщения с документом и путь вместе с папкой. В версии 1.0 все файлы лежат в одном общем диалоге, а не в личке каждого пользователя, как в прототипе.
Чтобы показать диск, сервер берёт строки пользователя из таблицы, по каждой запрашивает у VK сообщение с документом и собирает из ответов общий список. В документе уже есть ссылка на файл, размер и расширение:
async function getFiles(user_id) {
const results = await new Promise((resolve, reject) => {
db.query("SELECT * FROM files WHERE user_id = ?", [user_id], function (error, results) {
if (error) reject(error)
else resolve(results)
})
})
const docs = []
for (let result of results) {
const { items } = await vk.api.messages.getByConversationMessageId({
peer_id: target_id,
conversation_message_ids: result.file_id,
})
// Нас интересуют сообщения от сообщества (from_id < 0)
const botAttachments = items.filter((item) => item.from_id < 0)
botAttachments.forEach((attachment, index) => {
const doc = attachment.attachments[index].doc
doc["msgId"] = attachment.conversation_message_id
doc["path"] = result.path
docs.push(doc)
})
}
return docs
}
Готовый список сервер записывал в JSON-файл, как и в прототипе, а клиент получал данные из него. Удаление работало в обратную сторону: сервер удалял сообщение в VK и строку в таблице.
Интерфейс на EJS
Интерфейс — обычные страницы на шаблонах EJS, а интерактивность сделана работой с DOM на клиенте.
- Диск. Шаблон
drive.ejsсобирался на сервере: в разметку подставлялись имя пользователя и аватар. - Навигация. Переходы по папкам строились на GET-параметрах адреса, вроде
/my-drive?dir=path/to/folder. - Стили. В
public/main.cssбольше тысячи строк, включая адаптивную вёрстку через медиа-запросы. Тёмная тема включалась классом на тегеbody:
document.body.classList.toggle("dark-theme")
Итог
К осени 2024 года версия 1.0 была рабочим сервисом с регистрацией, двухфакторной аутентификацией, ботом, админ-панелью и загрузкой файлов в VK. Идея подтвердилась: документы соцсети действительно можно использовать как хранилище.
Но по мере роста проекта становилось заметно, что часть решений, которые я принёс из прототипа, начинает мешать. О том, что именно, и о том, как я переписал проект на React и MongoDB, расскажу во второй части.