Агора: что случилось за три месяца после прошлой статьи. Разбор комментариев

от автора

родной интерфейс, Figma)

родной интерфейс, Figma)

Коротко для тех, кто не читал первую часть

В прошлый раз я рассказывал, как в 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-роутам Cloudflareexport 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; // расшифровывается уже в браузере}
фото с ТГК Агоры (1 июля)

фото с ТГК Агоры (1 июля)

Сервер физически не может прочитать сообщения — он выдаёт зашифрованный ключ, а расшифровка происходит только на устройстве пользователя. Зато теперь 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.

Так выглядит Агора сейчас :) 19 июня 2026 год 1:11

Так выглядит Агора сейчас 🙂 19 июня 2026 год 1:11

ссылка на оригинал статьи https://habr.com/ru/articles/1060534/