
Коротко для тех, кто не читал первую часть
В прошлый раз я рассказывал, как в 15 лет с нуля написал российскую соцсеть для учеников, учителей и студентов «Агора» — тогда было 99 пользователей, около сотни SQL-миграций и куча энтузиазма. Статья неожиданно собрала 12 тысяч просмотров и почти 40 комментариев. Часть из них были просто добрыми словами, но был и один разбор — от PyXiion — на несколько экранов, по существу, без скидок на возраст. Отвечать на него сходу я тогда не стал, но за прошедшее время большая часть замечаний либо уже была исправлена, либо стала понятнее, почему сделано именно так.С тех пор проект вырос — не столько по пользователям (~160 человек, аудитория та же — учащиеся, педагоги, студенты), сколько по тому, что под капотом. Расскажу и про рост, и по порядку разберу, что из той критики было верным, а что — нет.
«У вас нет бэкенда, только Supabase» — отчасти верно, но важна деталь
Самое частое возражение: раз всё держится на Supabase, то какая разница, что «без Node.js» — просто фронтенд приклеен к чужому бэкенду, и если Supabase уйдёт из России, весь проект превратится в тыкву.
Возражение справедливо ровно наполовину. Supabase — это не сервис, к которому я подключаюсь по подписке, а open-source стек (Postgres + GoTrue + PostgREST + Realtime), который я разворачиваю и держу сам, на своём сервере, через Docker. Компания Supabase может завтра исчезнуть целиком — на моей инсталляции это не отразится никак, потому что она у меня, а не у них. Зависимость реальная — от конкретных open-source компонентов и от того, что я один слежу за апдейтами и уязвимостями в них, — но это не то же самое, что зависимость от чужого облака. Удобно видеть все данные, но я расту, как и все мои проекты, поэтому постепенно отхожу от SaaS и разворачиваю всё сам на Docker. В Kubernetes пока нет необходимости.
«NSFW-фильтр на клиенте» — критика была верной, перенёс на сервер
Раньше проверка изображений на нежелательный контент (NSFWJS + TensorFlow.js) шла в браузере перед загрузкой — идея была не гонять такие фото через сервер вообще. В комментариях справедливо указали: это не защита, а иллюзия — ничто не мешает обратиться к API напрямую, в обход браузера и любых клиентских проверок, и залить что угодно.
Согласен полностью, поэтому проверку перенёс на сторону сервера — на upload-прокси, через который в любом случае проходит любой файл перед тем, как попасть в хранилище. Клиентской версии больше нет.
EXIF и E2E — это не противоречие, просто было неточно описано
Ещё один вопрос был логичным: если сообщения зашифрованы E2E, как сервер вообще может чистить EXIF-метаданные из фото — получается, он их видит?
Не видит. Экранирование EXIF, как и шифрование, происходит в браузере до отправки — фото перерисовывается через canvas прямо на устройстве, метаданные обнуляются, и уже потом результат уходит на сервер. Формулировка в прошлой статье была нечёткой и создавала впечатление, что это две разные, конфликтующие операции — на деле это один и тот же клиентский шаг.
Бан проверялся на фронтенде — да, было, и это давно закрыто
Про миграцию ban_rls_server_side (проверку бана раньше действительно делал фронт, просто прятал кнопки у заблокированных) в комментариях справедливо иронизировали — выглядит странно рядом с рассказом про шифрование. Это правда была ошибка по неопытности, и на момент публикации статьи она уже была исправлена этой самой миграцией — проверка сейчас на уровне RLS-политик в базе, обойти её со стороны клиента нельзя.
Бизнес-логика в Postgres вместо отдельного бэкенда — сознательный компромисс
Критику, что вся логика в RPC-функциях и RLS-политиках плохо тестируется, версионируется и не выдержит нагрузки — принимаю, но не считаю, что это ошибка при текущем масштабе. При ~160 пользователях отдельный слой бэкенда добавил бы сложности больше, чем пользы. Если аудитория вырастет на порядки — да, придётся пересматривать архитектуру, и я это понимаю. Пока это осознанный выбор под конкретный размер проекта, а не то, о чём не подумал.
Доверие к серверу при обмене ключами — открытый вопрос, ещё не решён
Самое обоснованное из замечаний: сервер раздаёт публичные ключи для E2E, и без сверки ключей вне сервиса (как safety numbers в Signal) он теоретически может подменить чужой публичный ключ своим и провести MITM. Это правда так, и я это не закрыл — честной проверки ключей вне канала, которым управляет сервер, пока нет. Пишу это прямо, а не потому что кто-то ждёт красивого ответа: это реальное ограничение текущей модели угроз, и решать его — не косметика, а отдельная фича, до которой пока не дошли руки и мои знания 8 класса. Да и вообще, я очень рад, что думаю о шифровании пользователей, а так кидал бы plain text (обычный текст без шифрования) и получился бы "идеальный" продукт. Каждый день захожу в приложение и на сайт, проверяя при этом буквально ВСЁ, это моя работа, но и выгорал я не мало за 5 месяцев беспрерывного контроля. Вот именно поэтому, я сейчас активно собираю команду единомышленников, об этом чуть попозже...
Доступ к чужим сообщениям и пользователям через API
Отдельно проверяли доступ к данным напрямую через Supabase API, в обход интерфейса — и находили, что часть таблиц отдавала на чтение больше, чем должна была (INSERT/UPDATE, к счастью, уязвимости были закрыты). Это подтолкнуло к отдельному аудиту RLS-политик по всем таблицам — один реальный пробел (в уведомлениях, где INSERT был доступен вообще всем, включая анонимных пользователей) нашёл и закрыл, остальные политики пересмотрел и ужесточил. Учитывая, что сообщения и так зашифрованы E2E, чтение самой таблицы сообщений давало в лучшем случае метаданные (кто кому писал и когда), а не содержимое — но это не оправдание, лишний доступ есть лишний доступ, и его быть не должно.
Про 149-ФЗ и шифрование
В комментариях упоминали статью 10.1 149-ФЗ — обязанность организаторов (операторов) распространения информации по требованию предоставлять ключи для расшифровки. Формально она существует, но на практике относится к сервисам с оборотом и аудиторией совсем другого порядка, зарегистрированным как ОРИ (интернет-сервисы, позволяющие пользователям обмениваться сообщениями, мессенджеры грубо говоря). При полутора сотнях пользователей это пока не тот масштаб, но тема реальная, и по мере роста я собираюсь её отслеживать, а не игнорировать.
Инфраструктура: заодно слез с Cloudflare
Ещё одно частое замечание было в духе «данные только в РФ, но воркеры на Cloudflare» — тоже справедливо. Так и получилось: почти все воркеры (обработка платежей, пуши, напоминания о списаниях, мониторинг статуса) переехали с Cloudflare на обычный Bun-процесс под PM2 на моём же сервере.
Причина скучная: домен переехал с .com-зоны на agorasocial.ru, а Cloudflare для российских доменов работает через одно место, ситуация не очень сейчас. Проще было забрать логику к себе:
// было: cloudflare worker, привязанный к DNS-роутам Cloudflare export default { async fetch(request, env) { // ... } } // стало: обычный http-сервер (https) на Bun, живёт под PM2 рядом с остальным бэкендом import { serve } from "bun"; serve({ port: 3004, fetch(req) { // та же логика, нет привязки к чужому DNS }, });
Из живого на Cloudflare остался буквально один воркер — тот, что отдаёт версию приложения для автообновления. Мигрировать его не критично, руки пока не дошли.
Сервер, кстати, скромный — 3.8 ГБ (~4 ГБ) оперативки и своп в качестве подушки безопасности. При 160 пользователях это уже ощущается: пришлось выключить лишние сервисы в Docker-compose (аналитику Supabase, например) просто чтобы всё помещалось в память. У школьника финансы будут поменьше, чем у того же самого Яндекса или мелкого стартап-проекта.
Шифрование переехало из localStorage в отдельный сервис
В первой статье я хвастался, что личные сообщения шифруются на клиенте (ECDH P-256 + AES-GCM), а сервер видит только шифротекст. Это осталось, но раньше приватный ключ жил в localStorage — что удобно, но означает: снёс браузер — потерял историю переписки, да и не практично в плане безопасности...
Теперь ключ хранится на сервере, зашифрованный отдельным мастер-ключом, и отдаётся клиенту только после авторизации:
async function getPrivateKey(token: string) { const res = await fetch("https://keys.example.com/keys", { headers: { Authorization: `Bearer ${token}` }, }); const { privateKey } = await res.json(); return privateKey; // расшифровывается уже в браузере }

Сервер физически не может прочитать сообщения — он выдаёт зашифрованный ключ, а расшифровка происходит только на устройстве пользователя. Зато теперь E2E работает с любого устройства без переноса ключей руками (раньше пользователю надо было запоминать пароль для шифрования/дешифрования сообщений.
Вход стал проще: VK ID и биометрия
Раньше был только email OTP. Теперь добавились:
вход через VK ID (обёрнутый в тот же аккаунт-сервис, что и остальная авторизация);
вход по биометрии на телефоне (Face ID / отпечаток) — просто более удобный способ подтвердить уже существующую сессию, отдельной "биометрической" учётки не заводится.
Ничего необычного, но количество сообщений в духе "забыл, куда падает код с почты" ощутимо упало, да и то, такие сообщения редко приходили к нам на почту))
AI-модерация — помощник, а не "судья"
Добавил "под-сервис" на базе Claude Haiku, который смотрит на новые посты в очереди модерации (если есть жалобы от пользователей) и подсвечивает подозрительные — грубость, спам, потенциально запрещённый контент. Модератор видит бейдж с рекомендацией и решает сам:
async function suggestModeration(postText: string) { const verdict = await askModel(postText); // "ok" | "review" | "risky" return verdict; // финальное решение всегда за живым модератором }
Специально не давал ему право удалять посты самостоятельно — при 160 пользователях ложноположительные срабатывания обошлись бы дороже, чем ручная проверка лишней минуты. К тому же данные поступают в анонимизированном формате, и проверяется только текст в общей ленте (по коду видно, можно увидеть параметр postText) — личные сообщения зашифрованы end-to-end, и проверить их даже при желании невозможно.
Что я снёс
Пиксель-батл и Stories я убрал целиком. Оба фичи выглядели красиво в демо, но живой аудиторией почти не использовались — а поддерживать realtime-канвас ради двух активных пользователей не было смысла и пользы.
Импорт постов из Telegram-каналов тоже вырезал — целиком, вместе с интерфейсом и воркером. Фичей почти не пользовались, а поддерживать отдельный n8n-воркфлоу и вкладку в админке ради этого не было смысла и пользы.
Мобилка и десктоп (есть! ура)
Android-приложение дошло до Play Store — живёт там с начала июля. Написано на React Native через Expo (не Capacitor, как можно было бы предположить — от него отказался ещё раньше, native-модули и сборка через голый Gradle оказались надёжнее). Windows-версия на Electron тоже есть, устанавливается через обычный .exe с S3 Хранилища.
Из забавного: апгрейд Electron с 34 до 43 версии одним махом закрыл три десятка предупреждений от Dependabot (бот в Github репозитории, который отслеживает уязвимости в проекте). Иногда самое полезное архитектурное решение — это просто обновить зависимость, логично.
Тредовые ответы и переход по цитате (фича, добавил на днях)
Добавил тредовые ответы (дерево сообщений) на комментарии и переход к цитируемому сообщению в личных и групповых чатах — простая штука, но раньше её не было, и это было одно из первых, о чём спрашивали сами пользователи внутри Агоры.

Цифры на сегодня
152 SQL-миграции (было ~100)
~29 000 строк TypeScript
~160 пользователей, всё ещё без единого рубля на рекламу
Один Cloudflare Worker вместо десятка
Что дальше
Если честно, самая большая невыполненная задача сейчас — не новые фичи, а расплата за скорость: CSS разросся до 20 с лишним тысяч строк (даже с применением технологии модульного CSS. Например: ChatArea.module.css), и один и тот же паттерн модального окна независимо продублирован в паре десятков файлов вместо общего компонента. Работать это не мешает, но каждая новая модалка добавляет ещё один дубликат — рано или поздно придётся сесть и привести к одному компоненту. Таковы реалии с использованием Claude как ускоритель разработки, моя ошибка, что не уследил, но сейчас привожу всё в норму.
Ещё есть пара SQL-миграций, которые лежат в репозитории, но так и не применены на проде — надо либо накатить, либо решить, что они больше не нужны, и убрать.
Рутина, в основном уборка за собой. Но именно такие вещи в итоге экономят больше времени, чем любая новая "фича".
За это время я стал больше внимания уделять самому языку и алгоритмам, а не только тому, чтобы фича просто заработала — и заметил, что оптимизация начала нравиться сама по себе, отдельно от результата. Из этого вырос и более долгосрочный план: не оставаться при одном проекте в одиночку, а собрать команду и делать продукты для людей — что-то, что закрывает конкретную, реальную боль, а не просто демонстрирует технологию. Прошёл через многое и предстоит ещё пройти. Боль, обиду, радость, восторг, какие эмоции только не испытаешь от такого душевного, но и одновременно сложного проекта, требующего большой опыт и внимательность.
Как и в прошлый раз — пишу это не потому что кто-то попросил, а потому что интересно зафиксировать, что изменилось. Если у кого-то есть вопросы по конкретным техническим решениям — обратная связь в комментариях, буду рад рассмотреть новые идеи/разобрать текущий стек.
Ссылка на мой проект, социальную сеть для учеников, учителей и студентов — agorasocial.ru.

