У ВКонтакте нет API объектного хранилища, зато есть API документов, и ограничений на их объём практически нет. Отсюда идея: использовать документы соцсети как бесплатный безлимитный бэкенд для облачного диска. Выглядит как хак, и отчасти им и является.

Проект называется BridgeCloud. Идея тянулась ещё с конца 2023 года, и с тех пор проект прошёл три архитектуры: прототип на PHP с отдельным Node-процессом, версию 1.0 на Express и версию 2.0 на React и MongoDB. В этой части разберу прототип и расскажу, как выросла версия 1.0 и что в ней появлялось от релиза к релизу. Во второй части речь пойдёт о переписывании на версию 2.0.

Три версии BridgeCloud: PHP с Node в конце 2023, Express-монолит в 2024, React с MongoDB в ноябре 2024 Прототип: PHP + Node конец 2023 · эта часть Версия 1.0: Express + EJS 2024 год · эта часть Версия 2.0: React + MongoDB ноябрь 2024 · часть 2

Прототип на PHP

Самая первая версия проекта — прототип, и его задача была проверить саму идею: можно ли хранить файлы в документах VK и работать с ними как с обычным диском. Он состоял из двух частей: PHP-сайт на Apache и отдельный Node-процесс, который общался с VK. Напрямую PHP и Node друг друга не вызывали, они обменивались файлами на диске.

Прототип: браузер загружает файл через upload.php в папку на диске, Node с Chokidar отправляет его в VK и записывает список файлов в JSON, страница раз в секунду читает этот JSON Браузер Dropzone, PHP-страница upload.php кладёт файл на диск uploads/<id> папка пользователя Node + Chokidar видит новый файл VK диалог с ботом пишет список unloads/<id>.json список файлов страница раз в секунду читает JSON

Как это работало:

  • Вход. Пользователь входил через виджет 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, а временный файл удаляется.

Путь файла в версии 1.0: клиент, Multer, папка на диске, Chokidar, очередь, VK API, запись в MySQL и удаление файла Клиент POST /send-files Multer разбирает multipart Папка на диске uploads/<id> Chokidar видит новый файл файл попадает в очередь Очередь async по одному файлу VK API messageDocument MySQL и очистка ID в БД, файл удаляется

Первая проблема, с которой я столкнулся при настройке 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

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

Верификация через VK-бота: регистрация на сайте, сообщение боту, подтверждение в базе и приветствие Браузер Сервер (Express) VK-бот (vk-io) 1. POST /reg 2. запись в БД, verified = null 3. сообщение боту в личку VK 4. бот получает сообщение 5. UPDATE users SET verified = 1 6. INSERT INTO user_settings 7. приветствие в личные сообщения

Регистрация

Пользователь заполняет форму на сайте, и в БД создаётся запись с 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, расскажу во второй части.