В первой части я рассказал, как BridgeCloud вырос от прототипа на PHP до Express-монолита (версия 1.0). В этой части расскажу, как я переписал его на версию 2.0 и от каких проблем версии 1.0 это избавило. Чинить монолит я не стал: вместо этого разделил проект на React-клиент и REST API на Node.js и заменил MySQL на MongoDB.
Делал я это не быстро: перенос шёл несколько месяцев параллельно с поддержкой монолита, который всё это время продолжал работать и обновляться.
Дальше по порядку: новая архитектура, база данных, безопасность, загрузка файлов, редактор и интерфейс.
1. От монолита к клиент-серверному API
В версии 1.0 один процесс делал всё сразу: рендерил страницы, обслуживал API, запускал бота и работал с диском. В версии 2.0 я разделил обязанности, и система стала состоять из двух отдельных проектов. Заодно я отказался от JSON-файлов на диске, оставшихся ещё от прототипа: клиент теперь получает данные из ответов API.
- Client (React SPA): React, сборщик Vite с SWC. Состояние в Redux Toolkit, маршрутизация через React Router, перетаскивание файлов на
@dnd-kit. Весь интерфейс собирается в статические файлы (HTML/JS/CSS) и может лежать на любом CDN (например, на Vercel). - Server (REST API): Node.js + Express. Страницы больше не рендерятся: сервер принимает и отдаёт JSON, принимает бинарные файлы через Multer и отправляет их в VK API.
2. MySQL → MongoDB
В MySQL дерево каталогов строить неудобно: для обхода папок нужны рекурсивные SQL-запросы или модель Adjacency List. Поэтому я выбрал MongoDB через Mongoose ODM: вложенные объекты там ложатся на документы без усилий. Заодно ушла проблема единственного глобального соединения с MySQL: драйвер MongoDB сам управляет пулом соединений и переподключается.
Схема пользователя (User.js)
Вместо паролей в MD5 и запутанного двухэтапного сопоставления с VK ID в схеме теперь отдельные поля для ролей и метаданных:
// server/models/User.js
const { Schema, model } = require("mongoose");
const User = new Schema({
login: { type: String, required: true, unique: true },
name: { type: String, required: false, unique: false },
password: { type: String, required: true, unique: false }, // Хеш bcrypt
photo: { type: String, required: false, unique: false },
vk_id: { type: String, required: false, unique: false },
role: { type: String, required: true, unique: false }, // 'user' | 'admin'
created_at: { type: String, required: true, unique: false }
});
module.exports = model('users', User);
Схема файлов и папок (File.js)
Папки и файлы лежат в одной коллекции files, а роль определяется полем type:
// server/models/File.js
const { Schema, model } = require("mongoose");
const File = new Schema({
user: { type: String, required: true, unique: false }, // ID владельца (User._id)
name: { type: String, required: true, unique: false },
type: { type: String, required: true, unique: false }, // 'file' | 'folder'
folder: { type: String, required: true, unique: false }, // ID родительской папки (или 'ROOT')
ext: { type: String, required: false, unique: false }, // Расширение файла
size: { type: Number, required: false, unique: false }, // Размер в байтах
document_id: { type: Number, required: false, unique: false }, // ID документа VK
url: { type: String, required: false, unique: false } // Прямая ссылка на CDN VK
});
module.exports = model('file', File);
Что это даёт:
- Унификация: и папка, и файл — документы одной коллекции.
- Простая навигация: содержимое директории достаётся одним запросом,
File.find({ folder: currentFolderId }). - Лёгкое перемещение: чтобы перенести файл или папку, достаточно обновить поле
folder.
3. Безопасность: bcrypt и JWT
MD5 без соли быстро перебирается, а сессии express-session жили в памяти процесса, с секретом прямо в коде. Во второй версии я заменил это на bcrypt и JWT.
Хеширование паролей
Пароли теперь хешируются библиотекой bcryptjs. Соль генерирует сам bcrypt и кладёт её в хеш, а число раундов у меня 8:
const bcrypt = require('bcryptjs');
const hashPassword = await bcrypt.hash(password, 8);
Авторизация на JWT
Состояние сессии на сервере больше не хранится: авторизация stateless, на JWT (JSON Web Tokens). После входа сервер выдаёт подписанный токен с ID пользователя и сроком жизни 1 час. Подписан он, а не зашифрован: содержимое токена видно любому, но подделать его без секрета нельзя.
const jwt = require('jsonwebtoken');
const token = jwt.sign({ id: user.id }, secretKey, { expiresIn: "1h" });
Проверка токена (Auth Middleware)
Каждый защищённый запрос сопровождается заголовком Authorization: Bearer <token>. Middleware проверяет подпись токена без обращения к базе данных:
// server/middleware/auth.middleware.js
require('dotenv').config();
const jwt = require('jsonwebtoken');
const secretKey = process.env.JWT_SECRET;
module.exports = (req, res, next) => {
if (req.method === 'OPTIONS') {
return next();
}
try {
const token = req.headers.authorization && req.headers.authorization.split(' ')[1];
if (!token) {
return res.status(401).json({ message: "Auth error" });
}
// Декодирование и верификация токена
const decoded = jwt.verify(token, secretKey);
req.user = decoded; // Добавление данных пользователя в объект запроса
next();
} catch (e) {
return res.status(401).json({ message: "Auth error" });
}
};
Секрет теперь берётся из переменной окружения JWT_SECRET, а не лежит в коде, как "your-secret-key" в монолите.
4. Загрузка без Chokidar и очереди
В монолите на каждую загрузку создавался отдельный наблюдатель Chokidar, а отправка в VK шла через одну общую очередь, в которой файлы разных пользователей ждали друг друга. Здесь я убрал и то и другое.
Multer по-прежнему сначала кладёт файлы во временную папку пользователя. Дальше контроллер отправляет их в VK параллельно и после успешной отправки удаляет временный файл:
// server/controller/file.controller.js
async uploadFiles(req, res) {
try {
const user_id = req.user.id;
const folder = req.body.folder; // ID текущей виртуальной директории
const files = req.files; // Массив файлов от Multer
const result = [];
// Параллельная загрузка группы файлов в VK через Promise.all
await Promise.all(files.map(async (fileData) => {
// 1. Загрузка документа на сервер VK
const document = await upload.messageDocument({
source: {
value: fs.createReadStream(fileData.path),
filename: path.basename(fileData.path),
},
peer_id: process.env.VK_ALT_USERID,
});
// 2. Генерация сообщения с вложением
const sendFile = await vk.api.messages.send({
user_id: process.env.VK_ALT_USERID,
attachment: `doc${document.ownerId}_${document.id}`,
random_id: Date.now(),
});
// 3. Получение уникального conversation_message_id
let messageInfo = await vk.api.messages.getById({ message_ids: sendFile });
let conversation_id = messageInfo.items[0].conversation_message_id;
// 4. Удаление временного файла с локального диска сервера
fs.unlinkSync(fileData.path);
// 5. Создание записи метаданных в MongoDB
const file = new File({
user: user_id,
name: fileData.originalname,
type: 'file',
folder: folder,
ext: path.extname(fileData.originalname),
size: fileData.size,
document_id: conversation_id,
url: document.url
});
const savedFile = await file.save();
result.push(savedFile);
}));
res.send({ message: 'Files uploaded successfully', files: result });
} catch (error) {
console.error("Ошибка загрузки файлов:", error);
res.status(500).send({ message: 'Server error' });
}
}
Что получилось
- Наблюдателей больше нет. Chokidar из проекта убран, поэтому нет накапливающихся наблюдателей и дубликатов событий.
- Общей очереди нет. Файлы разных пользователей уходят в VK параллельно, и маленький файл не ждёт чужой гигабайтный.
- Файл лежит на диске недолго. Multer по-прежнему сначала сохраняет его во временную папку, но удаляется он сразу после успешной отправки в VK.
5. Редактор файлов в браузере
Во второй версии появилось редактирование текстовых файлов прямо в браузере: пользователь открывает файл, меняет текст и сохраняет изменения обратно в облако.
Получение файла
Физически файл лежит в VK, поэтому при открытии сервер получает ссылку на документ, скачивает содержимое в память и отдаёт клиенту:
// server/controller/file.controller.js
async getFile(req, res) {
try {
const user_id = req.user.id;
const { id } = req.params; // ID документа в VK (document_id)
const data = await File.findOne({ user: user_id, document_id: id, type: 'file' });
// Запрашиваем метаданные сообщения у VK API
const { items } = await vk.api.messages.getByConversationMessageId({
peer_id: process.env.VK_ALT_USERID,
conversation_message_ids: data.document_id,
});
const { url } = items[0].attachments[0].doc;
// Скачиваем содержимое текстового файла по прямой ссылке
const response = await fetch(url);
const text = await response.text();
res.send({
id: items[0].conversation_message_id,
data: text
});
} catch (error) {
console.error(error);
res.status(500).send({ message: 'Error fetching document' });
}
}
Сохранение изменённого файла
Документ в VK изменить нельзя, поэтому «сохранить» значит загрузить новую версию, а старую удалить:
- Записать новый текст во временный файл на диске.
- Отправить его в VK и получить новый
conversation_message_id. - Удалить старое сообщение в VK, чтобы не копить версии.
- Обновить ссылку и ID документа в MongoDB.
// server/controller/file.controller.js
async saveFile(req, res) {
try {
const user_id = req.user.id;
const { document_id, data } = req.body; // Старый ID документа и новое содержимое
const existingFile = await File.findOne({ user: user_id, document_id });
if (!existingFile) {
return res.status(404).send({ message: 'File not found' });
}
const file_name = existingFile.ext ? existingFile.name : existingFile.name + '.noext';
const dir_path = path.join('userdata', 'downloads');
const file_path = path.join(dir_path, file_name);
if (!fs.existsSync(dir_path)) {
fs.mkdirSync(dir_path, { recursive: true });
}
// Запись нового содержимого во временный буфер
fs.writeFileSync(file_path, data);
// Отправка новой версии файла в VK
const newDocument = await upload.messageDocument({
source: { value: fs.createReadStream(file_path), filename: file_name },
peer_id: process.env.VK_ALT_USERID,
});
const sendFileResponse = await vk.api.messages.send({
user_id: process.env.VK_ALT_USERID,
attachment: `doc${newDocument.ownerId}_${newDocument.id}`,
random_id: Date.now(),
});
// Удаление старой версии файла из VK
if (document_id) {
await vk.api.messages.delete({
peer_id: process.env.VK_ALT_USERID,
cmids: document_id,
delete_for_all: 1,
});
}
let messageInfo = await vk.api.messages.getById({ message_ids: sendFileResponse });
let newConversationId = messageInfo.items[0].conversation_message_id;
// Обновление метаданных файла в СУБД
existingFile.document_id = newConversationId;
existingFile.url = newDocument.url;
existingFile.size = data.length;
await existingFile.save();
fs.unlinkSync(file_path); // Очистка локального диска
res.send({ message: 'File updated successfully', file: existingFile });
} catch (error) {
res.status(500).send({ message: 'Error updating file' });
}
}
6. Drag-and-Drop на dnd-kit
С переходом на React SPA в интерфейсе появилась интерактивность: файлы можно перетаскивать в папки через @dnd-kit/core.
// client/src/pages/Drive/Drive.jsx
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
const Drive = () => {
// Перетаскивание начнётся только после сдвига мыши на 5 пикселей,
// чтобы обычный клик не считался началом drag
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
const handleDragEnd = (event) => {
const { active, over } = event;
// Раньше здесь было over.id: если отпустить элемент вне цели,
// over равен null и обработчик падал с TypeError
if (active.id && over?.id && active.id !== over.id) {
const draggedFile = files.find(file => file._id === active.id);
const targetFolder = folders.find(folder => folder._id === over.id);
// Если перетаскиваемый объект — файл, а цель — папка
if (draggedFile && targetFolder) {
moveFiles(dispatch, {
files: [draggedFile._id],
folder: targetFolder._id
});
}
}
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="content">
<SortableContext items={[...folders.map(f => f._id), ...files.map(f => f._id)]} strategy={rectSortingStrategy}>
<DriveSection title="Папки" items={folders} />
<DriveSection title="Файлы" items={files} />
</SortableContext>
</div>
</DndContext>
);
};
Пока запрос на сервер идёт, Redux Toolkit сразу обновляет интерфейс, и файл исчезает из текущей папки:
// client/src/redux/storageSlice.js
moveFile: (state, action) => {
const { _id, folderId } = action.payload;
const file = state.files.find(file => file._id === _id);
if (file) {
file.folder = folderId; // Файл мгновенно исчезает из текущей папки в UI
}
}
7. Состояние на Redux Toolkit
Раз страницы больше не перезагружаются, понадобилось общее хранилище. Файловая структура и прогресс загрузки живут в storageSlice.js.
// client/src/redux/storageSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
files: [],
folders: [],
currentFolderId: 'ROOT',
currentPath: [{ id: 'ROOT', name: 'Мой диск' }],
progress: [], // Прогресс-бар для загружаемых файлов
isLoading: false,
};
const storageSlice = createSlice({
name: 'storage',
initialState,
reducers: {
addFile: (state, action) => { state.files.push(action.payload); },
removeFile: (state, action) => {
state.files = state.files.filter(file => file._id !== action.payload);
},
addFolder: (state, action) => { state.folders.push(action.payload); },
removeFolder: (state, action) => {
state.folders = state.folders.filter(folder => folder._id !== action.payload);
},
setFiles: (state, action) => { state.files = action.payload; },
setFolders: (state, action) => { state.folders = action.payload; },
setProgress: (state, action) => {
state.progress[action.payload.index] = action.payload;
},
clearProgress: (state) => { state.progress = []; },
setCurrentFolderId: (state, action) => { state.currentFolderId = action.payload; },
setCurrentPath: (state, action) => { state.currentPath = action.payload; },
// ...остальные редьюсеры: renameFile, renameFolder, updateFileData,
// setLoading, setCreating, setError
}
});
8. Что изменилось
Замеров производительности у меня нет, поэтому сравниваю то, что видно по коду и по поведению:
| Что сравниваю | Версия 1.0 | Версия 2.0 |
|---|---|---|
| Переходы по папкам | Полная перезагрузка страницы (EJS, GET-параметр dir). | Клиентский роутинг без перезагрузки. |
| Наблюдатели файлов | Новый Chokidar на каждую загрузку. | Chokidar убран. |
| Загрузка | Одна глобальная очередь, Concurrency = 1. | Promise.all: файлы уходят в VK параллельно. |
| Дисковый буфер | Есть, файл лежит до конца очереди. | Есть, но файл удаляется сразу после отправки. |
| База данных | Одно соединение mysql.createConnection. | Mongoose и драйвер MongoDB, который держит пул соединений. |
| Авторизация | express-session в памяти, MD5 без соли, секрет в коде. | Stateless JWT, bcrypt с солью, секрет в JWT_SECRET. |
| Данные между процессами | JSON-файлы на диске (наследство PHP). | Только БД и ответы API. |
Что осталось на будущее
Версия 2.0 закрыла главные проблемы версии 1.0, но кое-что осталось:
- Все файлы всех пользователей лежат в одном диалоге VK. Во всех контроллерах
peer_idиuser_id— этоprocess.env.VK_ALT_USERID. Если VK заблокирует аккаунт или диалог, пострадают все пользователи сразу. - Зависимость от чужого API. Правила и лимиты VK могут поменяться, а запасного хранилища в проекте нет.
- Временные файлы при ошибке. Если загрузка падает до удаления файла, он остаётся в папке загрузок. Здесь нужен блок
finallyи асинхронное удаление вместоfs.unlinkSync.
Итог
Версия 2.0 закрыла то, что можно было закрыть архитектурой: ушли наблюдатели Chokidar, общая очередь, JSON-файлы на диске и самописный MD5, а интерфейс получил клиентский роутинг. Главное допущение проекта, что API документов VK может быть хранилищем, осталось прежним и работает, но подстраховать его пока нечем.