Serein
Rust로 만든 가볍고 성능 좋은 Discord 전용 네이티브 클라이언트
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Мессенджер для общения внутри МБОУ Кировская Гимназия. Личные диалоги и групповые чаты, сообщения приходят мгновенно, работает в браузере — и на компьютере, и на телефоне (iPhone и Android), откуда его можно поставить на домашний экран как обычное приложение.README 전문 보기 ↗
Мессенджер для общения внутри МБОУ Кировская Гимназия. Личные диалоги и групповые чаты, сообщения приходят мгновенно, работает в браузере — и на компьютере, и на телефоне (iPhone и Android), откуда его можно поставить на домашний экран как обычное приложение.
Переписка
Мелочи, из которых складывается ощущение живого мессенджера
Группы по коду
CFH6QKO и 0, I, 1, L.
Код диктуют вслух и переписывают от руки, и путаница здесь дороже пары
лишних комбинацийНа телефоне
Люди и поиск
Вход и устройства
Управление
Администрирование
Нужен Node.js 20+ и любая база PostgreSQL.
npm install
cp .env.example .env.local # укажите DATABASE_URL и AUTH_SECRET
npm run db:push # создать таблицы
npm run dev # http://localhost:3000
Секрет для кук удобно сгенерировать так:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Хотите сразу посмотреть на заполненный интерфейс, не регистрируя всех вручную:
npm run db:seed # 5 демо-аккаунтов, пароль у всех: gimroom-demo
# заодно печатает код демо-группы для проверки входа по коду
Заведите базу. В Neon создайте проект и
скопируйте строку подключения — ту, где в адресе есть -pooler.
Подойдёт и Vercel Postgres, и Supabase, и свой сервер: код обычный
PostgreSQL, ничего специфичного для Neon в нём нет.
Создайте таблицы. С той же строкой подключения выполните локально:
DATABASE_URL="postgresql://…" npm run db:push
Залейте проект на Vercel (импорт репозитория с GitHub) и пропишите переменные окружения.
Важно про порядок: переменные подхватывает только новая сборка. Если добавить их к уже собранному проекту, приложение продолжит отвечать «Не задан адрес базы данных», пока не будет сделан Redeploy — или пока не придёт следующий коммит в ветку, из которой собирается production.
Переменные:
| Переменная | Зачем |
|---|---|
DATABASE_URL | Строка подключения к базе (обязательно) |
AUTH_SECRET | Случайная строка от 32 символов для подписи сессий (обязательно) |
INVITE_ONLY | true — регистрация только по коду. По умолчанию открытая |
ADMIN_USERNAME | Кому выдать права администратора при регистрации |
REGISTRATION_LIMIT | Сколько регистраций в час с одного адреса (по умолчанию 60) |
Названия гимназии, список классов и длина кодов заданы в src/lib/config.ts —
если что-то из этого изменится, править нужно только там.
Откройте адрес и зарегистрируйтесь первым — этот аккаунт получит права администратора.
Никакой отдельной настройки Vercel не требуется: приложение обходится обычными маршрутами Next.js и не нуждается в WebSocket-сервере (см. ниже).
Обычное приложение на Next.js (App Router) и PostgreSQL. Внешних сервисов нет,
из зависимостей — только Next, React и драйвер pg.
src/lib/ работа с базой, сессии, проверка ввода, лента событий
src/app/api/ маршруты API
src/components/ интерфейс
db/schema.sql схема базы
scripts/ применение схемы и демо-данные
tests/ модульные и сквозные проверки
Решения, которые стоит пояснить.
Мгновенная доставка без WebSocket. На serverless-хостинге вроде Vercel негде
держать постоянный сокет и общую шину между экземплярами функции. Поэтому
источник правды — таблица events в базе: любое изменение пишет туда строку, а
клиенты читают её по курсору через Server-Sent Events. Экземпляры ничего не
знают друг о друге, при обрыве связи ничего не теряется (браузер сам присылает
Last-Event-ID), и ровно тот же код работает на своём сервере. Задержка — доли
секунды: пока идёт разговор, лента опрашивается часто, в тишине реже.
Файлы лежат в базе. У serverless нет постоянного диска, а заводить отдельное
хранилище ради школьного чата — лишняя деталь, которую придётся настраивать и
оплачивать. Файлы хранятся в Postgres с ограничением 8 МБ на файл. Если объёмы
вырастут, менять придётся только src/app/api/files/.
Место ограничено, и кончается оно сразу у всех. Бесплатная база — это
полгигабайта на всё, вместе с перепиской. Если её забить фотографиями, встанет
не только загрузка файлов: кончившееся место останавливает любую запись, то есть
и обычные сообщения. Поэтому пределов три (config.storage): на человека за всё
время, на сутки — чтобы место нельзя было занять за один вечер, — и 350 МБ на всю
гимназию, после которых загрузка закрывается для всех.
Первые два зависят от того, кто загружает: ученику 25 МБ всего и 8 МБ в сутки, учителю и администратору — 100 и 30. Дело не в доверии, а в счёте: учеников сотни, учителей десятки, и при равных ста мегабайтах общее место выбрали бы трое-четверо школьников. Ученику файлы нужны эпизодически — сфотографировать доску, переслать картинку; учителю нужно раздать классу скан или презентацию, и урезать его — значит сломать то, ради чего мессенджер в школе и заводят.
Каждый видит свою полоску в профиле, а отказ приходит до записи файла и объясняет, в какой именно предел упёрлись. Картинки вдобавок ужимаются ещё в браузере: сторона до 1600 точек и тот формат из двух, что вышел легче.
Пароли и сессии. Пароли хешируются scrypt со случайной солью. В куке лежит
случайный токен, в базе — только его HMAC, так что утечка таблицы сессий не даёт
готовых кук. Кука httpOnly, sameSite=lax, в продакшене secure.
Высота экрана на телефоне. Единицы dvh учитывают исчезающую адресную
строку, но не клавиатуру: на iOS при её появлении разметка остаётся прежней, и
строка ввода уезжает вниз. Настоящую видимую высоту знает только
visualViewport — приложение слушает его и держит свою высоту по нему
(src/lib/useViewport.ts).
Пороги защиты от перебора считаются на аккаунт, а не только на адрес: школа выходит в сеть через один IP, и класс, регистрирующийся на одном уроке, не должен блокировать сам себя.
Две разные роли под одним словом. В базе users.role — кто человек в
гимназии (member, teacher, admin), а conversation_members.role — кто он
в конкретной группе (owner, member). Наружу вторая уходит как role,
первая — как признак isTeacher, чтобы их нельзя было перепутать.
Уведомления показывает service worker, а не страница. На Android
конструктор new Notification запрещён вовсе — Chrome требует
ServiceWorkerRegistration.showNotification, и раньше уведомлений на телефонах
не было совсем: вызов молча падал в catch. Нажатие приходит в sw.js, а не на
страницу, поэтому оттуда окно поднимается и получает сообщение с номером чата;
если мессенджер был закрыт, чат передаётся в адресе (/?chat=…).
Своё окно на Windows — третий случай: WebView2 веб-уведомления не показывает
вовсе, их должна показать программа-хозяин. Страница узнаёт его по метке
window.__gimroomNative и вместо уведомления отправляет сообщение наружу, а
приложение мигает значком в панели задач.
Вход запоминается, но не везде одинаково. Обычный вход — кука на месяц,
которая продлевается при заходе (renewSession, не чаще раза в сутки, чтобы не
писать в базу на каждый запрос). «Чужой компьютер» — кука вообще без срока
жизни: браузер удалит её при закрытии, а сама сессия живёт восемь часов и не
продлевается. Одна галочка на входе, а разница принципиальная: в компьютерном
классе за тем же местом через урок сидит другой человек.
Освободившийся логин не отдаётся сразу. Логин можно менять, и без резерва
вышло бы так: человек сменил «ivanov» на «ivanov_i», а через минуту «ivanov»
занял кто-то другой — и пишет от его имени тем, кто помнит старый логин.
Поэтому прежний логин уходит в таблицу released_usernames и два месяца
достаётся только своему хозяину (config.usernameChange). Там же пауза между
сменами: логин — это то, по чему человека ищут, и мелькать он не должен.
Блокировка обрывает сессии. Заблокировать — значит удалить строки из
sessions, а не только пометить аккаунт: иначе открытая вкладка доработала бы
до конца срока куки. Проверка blocked_at IS NULL стоит и в чтении сессии
(src/lib/auth.ts) — на случай, если блокировка случится в ту же секунду, что и
запрос. Права администратора панель не выдаёт: их получает первый
зарегистрировавшийся или тот, кто указан в ADMIN_USERNAME.
Модульные проверки чистых функций — запускаются сразу:
npm test
Сквозные проверки API ходят по настоящему HTTP, поэтому им нужны отдельная база и запущенный сервер:
createdb gimroom_test
DATABASE_URL="postgresql://localhost/gimroom_test" npm run db:push
DATABASE_URL="postgresql://localhost/gimroom_test" AUTH_SECRET="test-secret-0123456789abcdef" \
npx next start -p 3100 &
BASE_URL=http://127.0.0.1:3100 npm run test:api
База должна быть пустой: набор проверяет админ-панель, а права администратора
достаются первому зарегистрировавшемуся. Перед повторным прогоном пересоздайте
базу (dropdb gimroom_test && createdb gimroom_test && npm run db:push).
Они проверяют в том числе то, что легко сломать незаметно: посторонний не читает чужую переписку и не скачивает чужие вложения, удалённое сообщение не уезжает клиенту вместе с текстом, загруженный HTML отдаётся как обычный текст и не может выполниться на нашем домене.
Отдельный раздел «Безопасность» повторяет атаки из аудита — попытки поднять себе права, подделать сессию, подставить мусор вместо идентификатора, перебрать пароль — и проверяет заголовки защиты. Он нужен, чтобы эти защиты нельзя было потерять незаметно при будущих правках.
Раздел «Админ-панель» проверяет, что в неё не попадает ни ученик, ни гость, что заблокированный не входит даже с верным паролем и теряет открытую сессию, что новый пароль закрывает старые устройства и что администратора нельзя завести через панель.
Приложение проверено настоящими атаками по HTTP — то есть ровно тем, что можно сделать из консоли браузера по F12. Ниже — что проверено и что осталось за рамками.
Всё, что приходит от браузера, — предположение, а не факт. В консоли F12 можно послать любой запрос, минуя интерфейс: поменять чужой идентификатор, дописать себе роль администратора, отправить сообщение от чужого имени. Поэтому права проверяются на сервере при каждом запросе, а не в интерфейсе.
Проверено атаками (tests/api.test.mjs, раздел «Безопасность»):
| Попытка | Что происходит |
|---|---|
Дописать role: "admin" в регистрацию или в профиль | Роль назначает сервер, поле из запроса игнорируется |
| Подменить себе логин через профиль | Логин менять нельзя |
| Открыть чужую переписку по её номеру | 404 — даже факт существования диалога не подтверждается |
| Написать в чужой диалог, отредактировать или удалить чужое сообщение | 404 / 403 |
Отправить сообщение от чужого имени (senderId в запросе) | Отправителем становится владелец сессии |
| Скачать чужое вложение по его номеру | 404; гостю — 401 |
| Подсмотреть чужие сообщения в ленте событий | Лента отдаёт только диалоги, где человек состоит |
| Подделать токен сессии | 401 |
Подставить 1 OR 1=1 вместо номера | 400 — разбор строгий, parseInt молча вернул бы 1 |
'; DROP TABLE users; -- в поиск | Запросы параметризованы, текст остаётся текстом |
Хешируются scrypt (N=32768, r=8, p=1, ключ 64 байта) со случайной солью в
16 байт на каждого. Сравнение — постоянного времени, чтобы по нему нельзя было
подбирать посимвольно.
Одна проверка стоит около 90 мс и 32 МБ памяти. Это специально: перебор миллиона вариантов занял бы порядка суток на ядро, а требование памяти делает малополезными видеокарты, на которых обычно и перебирают. Восемнадцать аккаунтов с одинаковым паролем дают восемнадцать разных хешей — соль работает.
Сам пароль нигде не хранится, не логируется и не возвращается в ответах.
Перебор ограничен на двух уровнях: 10 попыток входа за 15 минут на конкретный аккаунт и 5 попыток подтвердить текущий пароль при его смене. Второе важнее, чем кажется: добравшись до незапертого ноутбука, пароль можно подбирать, чтобы сменить его и увести аккаунт насовсем.
Существование аккаунта не выдаётся. Ответ одинаков для неверного пароля и несуществующего логина — и по тексту, и по времени. Изначально проверка пароля для несуществующего логина пропускалась, ответ приходил в 20 раз быстрее, и по одному этому можно было перебрать список зарегистрированных в гимназии. Теперь работа выполняется одинаковая в обоих случаях.
В куке лежит случайный токен на 256 бит, в базе — только HMAC от него: утечка
таблицы сессий не даёт готовых кук. Кука HttpOnly (скрипт со страницы её не
прочитает), SameSite=Lax (не уходит на чужие сайты — защита от CSRF), в
продакшене Secure. После выхода кука перестаёт действовать сразу, а смена
пароля гасит сессии на всех остальных устройствах.
React вставляет пользовательский текст как текст, а не как разметку, и ссылки в сообщениях собираются узлами React — сообщение физически не может стать HTML. Поверх этого работает Content-Security-Policy как последний рубеж:
nonce), своему на каждый
ответ. Ни unsafe-inline, ни unsafe-eval в рабочей сборке нет.<img onerror=…>) — классический XSS — браузер
выполнять отказывается. Проверено.frame-ancestors 'none' и X-Frame-Options: DENY — страницу нельзя открыть
в чужом фрейме и подменить нажатия.nosniff, Referrer-Policy, отключённые камера, микрофон и геолокация.Загруженные файлы отдаются отдельно и строже: текст и HTML — как text/plain
вложением, SVG — только скачиванием (это XML, из него исполняется скрипт), у
всех — запрещающий всё CSP и nosniff. Иначе загруженная страница выполнилась
бы на нашем домене и добралась до чужих сессий.
По дороге трафик шифруется TLS: Vercel выдаёт HTTPS сам, а Secure на куке
и upgrade-insecure-requests в политике не дают приложению работать по
открытому HTTP. Одноклассник в той же школьной сети переписку не перехватит.
В базе сообщения лежат открытым текстом. Диск у Neon шифруется, но это
защита от кражи диска, а не от того, у кого есть доступ к базе:
администратор гимназии, хостинг и любой, кто добудет DATABASE_URL, прочитают
переписку.
Сквозного шифрования (E2E) нет. Это осознанный выбор, а не упущение:
Практический вывод: обещать детям тайну переписки не нужно. «ГимРум» защищён от одноклассника с открытой консолью — но не от того, у кого есть доступ к базе. Так устроено большинство школьных и рабочих чатов; важно, чтобы это было сказано прямо.
Регистрация свободная. Зарегистрироваться может любой, кто узнает адрес.
Чтобы закрыть круг, поставьте INVITE_ONLY=true — тогда нужен код из таблицы
invites:
INSERT INTO invites (code, max_uses) VALUES ('SHKOLA-2026', 30);
Первый зарегистрировавшийся становится администратором. Зарегистрируйтесь сразу после развёртывания, до того как адрес разойдётся.
Код группы — это ключ от неё. Любой, кто узнал код, войдёт, пока код не сменили. Для класса это удобно; для чего-то закрытого лучше добавлять людей вручную и сменить код после набора.
Администратор может удалять чужие сообщения, но не читать чужие личные диалоги через интерфейс.
Уведомления по электронной почте не предусмотрены, поэтому и восстановления пароля по почте нет: забытый пароль сбрасывает администратор прямо в базе.