尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 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);
Первый зарегистрировавшийся становится администратором. Зарегистрируйтесь сразу после развёртывания, до того как адрес разойдётся.
Код группы — это ключ от неё. Любой, кто узнал код, войдёт, пока код не сменили. Для класса это удобно; для чего-то закрытого лучше добавлять людей вручную и сменить код после набора.
Администратор может удалять чужие сообщения, но не читать чужие личные диалоги через интерфейс.
Уведомления по электронной почте не предусмотрены, поэтому и восстановления пароля по почте нет: забытый пароль сбрасывает администратор прямо в базе.