В первой части я рассказал, как 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.

  1. Client (React SPA): React, сборщик Vite с SWC. Состояние в Redux Toolkit, маршрутизация через React Router, перетаскивание файлов на @dnd-kit. Весь интерфейс собирается в статические файлы (HTML/JS/CSS) и может лежать на любом CDN (например, на Vercel).
  2. Server (REST API): Node.js + Express. Страницы больше не рендерятся: сервер принимает и отдаёт JSON, принимает бинарные файлы через Multer и отправляет их в VK API.
Архитектура версии 2.0: React-клиент обменивается JSON с Express API, API работает с VK API и MongoDB Client (React SPA) Vite + SWC · React Redux Toolkit · React Router dnd-kit JSON Server (REST API) Express · /api/... JWT-middleware Multer → VK API VK API сами файлы MongoDB метаданные

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 изменить нельзя, поэтому «сохранить» значит загрузить новую версию, а старую удалить:

  1. Записать новый текст во временный файл на диске.
  2. Отправить его в VK и получить новый conversation_message_id.
  3. Удалить старое сообщение в VK, чтобы не копить версии.
  4. Обновить ссылку и 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 может быть хранилищем, осталось прежним и работает, но подстраховать его пока нечем.