diff --git a/cmd/bare/main.go b/cmd/bare/main.go index 17f5e7c..05be1fc 100644 --- a/cmd/bare/main.go +++ b/cmd/bare/main.go @@ -117,6 +117,10 @@ func serve() error { } }() fmt.Printf("bare слушает %s, origin %s\n", ln.Addr(), cfg.Origin) + // Молчащие пуши — худший вид поломки: снаружи она не видна вовсе. + if cfg.VAPIDPublic == "" || cfg.VAPIDPrivate == "" || cfg.VAPIDSubject == "" { + fmt.Println("bare: пуши выключены — нужны BARE_VAPID_PUBLIC, BARE_VAPID_PRIVATE и BARE_VAPID_SUBJECT") + } select { case err := <-failed: diff --git a/docs/decisions/021-sessions-csrf-limits.md b/docs/decisions/021-sessions-csrf-limits.md index 45bc2ba..ae4a569 100644 --- a/docs/decisions/021-sessions-csrf-limits.md +++ b/docs/decisions/021-sessions-csrf-limits.md @@ -21,7 +21,7 @@ ADR-005 задаёт «Argon2id, сессия в httpOnly cookie» без пар - остальные изменяющие запросы — 60 в минуту на пользователя. Превышение — `429` с `Retry-After`. IP берётся из `X-Real-IP`, только если соединение с `127.0.0.1` (nginx, ADR-022). -**Размеры.** Тело запроса — до 32 КиБ, текст сообщения — до 4000 символов, имя комнаты — до 64, ник — до 32. +**Размеры.** Тело запроса — до 32 КиБ, текст сообщения — до 4000 символов, имя комнаты — до 64, ник — до 32. Имя комнаты вдобавок не бывает пустым, из одних пробелов, с управляющими символами и с переопределениями направления письма (U+202A…U+202E, U+2066…U+2069): с этапа 4 оно уходит в заголовок системного уведомления (ADR-045), а там перевод строки и разворот текста выдают чужое имя за сообщение системы. ## Следствия diff --git a/docs/decisions/023-push-and-service-worker.md b/docs/decisions/023-push-and-service-worker.md index 6478172..98d1b45 100644 --- a/docs/decisions/023-push-and-service-worker.md +++ b/docs/decisions/023-push-and-service-worker.md @@ -1,5 +1,7 @@ # ADR-023: Правила пушей и service worker +Кому уходит пуш и тексты уведомления — [ADR-045](045-push-addressed-to-recipient.md). Адрес перехода, состояния настроек и жизнь подписки на клиенте — [ADR-046](046-push-client.md). + ## Контекст ADR-011 задаёт принцип «пуш — сигнал». Не определено, когда именно слать пуш, как он привязан к устройству и что кэширует service worker. @@ -10,7 +12,7 @@ ADR-011 задаёт принцип «пуш — сигнал». Не опред - Пуш отправляется при постановке сообщения в очередь устройства, если выполняются оба условия: устройство не подключено по SSE и у устройства не висит неотработанный пуш (`push_pending = 0`). После отправки `push_pending = 1`; сбрасывается при подключении SSE. Одно молчащее устройство получает один пуш, не ленту. - Полезная нагрузка: `{title, body: "новое сообщение", chat}` — `title` это `@nick` или `#имя комнаты`, `chat` — идентификатор для перехода. `TTL` 24 часа, urgency `normal`. Ответы 404/410 от push-сервиса удаляют подписку. - Service worker: `push` → `showNotification` с `tag = chat` (новое уведомление заменяет старое в том же чате); `notificationclick` → фокус открытого окна или открытие `/#/`. -- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`. +- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/manifest.json`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`. - Разрешение на уведомления запрашивается после первого отправленного сообщения (ADR-011). На iOS вне установленного PWA вместо запроса показывается баннер установки. ## Следствия diff --git a/docs/decisions/045-push-addressed-to-recipient.md b/docs/decisions/045-push-addressed-to-recipient.md new file mode 100644 index 0000000..45df929 --- /dev/null +++ b/docs/decisions/045-push-addressed-to-recipient.md @@ -0,0 +1,23 @@ +# ADR-045: Пуш адресован получателю + +Уточняет [ADR-023](023-push-and-service-worker.md). + +## Контекст + +ADR-023 задаёт правило отправки: пуш уходит при постановке сообщения в очередь устройства, если устройство не подключено по SSE и неотработанного пуша у него нет. Но конверт кладётся в очередь и другим устройствам отправителя (ADR-017) — по букве правила молчащий второй телефон автора получал бы пуш о собственном сообщении. + +Полезная нагрузка от этого рассыпается. Заголовок — `@nick` отправителя, адрес чата — `dm:`: и то и другое собрано с точки зрения получателя. У отправителя тот же чат называется именем собеседника, а уведомление «@marta: новое сообщение» на телефоне самой marta не значит ничего. + +Тексты уведомления при этом живут в ADR-023, а не в `docs/ui.md`, где место всему, что видит человек. + +## Решение + +- Пуш уходит только устройствам получателей. Устройства отправителя — и то, с которого он писал, и все остальные — пуша не получают; сообщение они забирают очередью, как и раньше. +- Заголовок и адрес чата собираются для получателя: `@nick` отправителя и `dm:` в личном чате, `#имя комнаты` и `room:` в комнате. В комнате адресация одна для всех получателей, в личном чате получатель один — значит, у сообщения одна нагрузка на всех. +- Тексты уведомления записаны в `docs/ui.md`, «Уведомления». + +## Следствия + +- Правило ADR-023 читается как «пуш уходит устройствам получателей, если …». Одно молчащее устройство получателя — один пуш. +- Сервер собирает пуш из того, что и так знает: ник отправителя, имя комнаты, идентификатор чата. Плейнтекста он не знает, шифротекст не пересылает — в пуше нет ни того ни другого. +- Автор, отошедший от одного своего устройства к другому, узнаёт о собственном сообщении не пушем, а очередью при открытии. Осознанно. diff --git a/docs/decisions/046-push-client.md b/docs/decisions/046-push-client.md new file mode 100644 index 0000000..4efcace --- /dev/null +++ b/docs/decisions/046-push-client.md @@ -0,0 +1,29 @@ +# ADR-046: Клиент пушей — адрес перехода, состояния и жизнь подписки + +Уточняет [ADR-023](023-push-and-service-worker.md). + +## Контекст + +Клиентская половина ADR-023 упирается в четыре места, где документ не договаривает. + +**Адрес перехода.** ADR-023 велит открывать по нажатию на уведомление `/#/`. Идентификатор чата — `dm:` или `room:` (`docs/storage.md`), а маршруты клиента — `#/dm/` и `#/room/` (`docs/ui.md`). Буквальное `/#/dm:marta` не разбирается роутером и открывает список: уведомление ведёт не туда, куда обещало. + +**Состояний больше трёх.** `docs/ui.md` знает три: `включены`, `выключены`, `запрещены в браузере`. Кроме них бывает браузер без `Notification` и `PushManager` (iOS вне установленного приложения — как раз такой) и сервер без VAPID-ключа: включить нельзя, а сказать про это нечем. + +**У кнопки установки нет надписи.** «Кнопка, если есть `beforeinstallprompt`» — а что на ней написано, не сказано. + +**Подписка переживает то, к чему привязана.** Подписка принадлежит устройству (ADR-023), но живёт в браузерном профиле и не знает ни про `deviceId`, ни про аккаунт. `deviceId` меняется при конфликте идентификаторов и при чистке IndexedDB (ADR-017), аккаунт на устройстве меняется при выходе. Что делать с подпиской в эти моменты, не записано нигде. + +## Решение + +- **Переход.** Service worker переводит идентификатор чата в маршрут: `dm:` → `/#/dm/`, `room:` → `/#/room/`. Идентификатор не той формы открывает `/#/`. Формулировка ADR-023 читается так. +- **Состояния.** Их по-прежнему три. `запрещены в браузере` — это отклонённое разрешение, отсутствие `Notification` или `PushManager` и пустой `vapidPublicKey`: включить нельзя, кнопки в этом состоянии нет. `выключены` — всё, что включается кнопкой. На iOS вне установленного приложения раздел показывает `выключены` и вместо кнопки текст про установку — тот же, что в баннере. +- **Надписи.** Кнопка установки — «установить». Крестик баннера — «×» с подписью «закрыть» для экранного диктора. Тексты записаны в `docs/ui.md`. +- **Жизнь подписки.** При каждом запуске синхронизации клиент переставляет имеющуюся подписку на текущее устройство (`PUT /api/devices/{id}/push`): запрос идемпотентен, и смена `deviceId` этим и лечится. Выход из аккаунта снимает подписку и у сервера, и у браузера — сначала `DELETE /api/devices/{id}/push`, пока сессия жива, потом `pushManager.unsubscribe` (ADR-049). Удаление аккаунта отписывается только у браузера: строку устройства вместе с подпиской уносит каскад. + +## Следствия + +- Уведомление открывает тот чат, о котором оно: `chat` в нагрузке остаётся идентификатором из ADR-023, разбирает его клиент. +- Пользователь, у которого пушей не бывает вовсе, видит `запрещены в браузере` и не видит кнопки, которая ничего не даст. +- Подписка, поставленная не тому устройству, чинится следующим запуском приложения, а не остаётся молчащей навсегда. +- Устройство, с которого вышли, пушей прежнего аккаунта не получает: адреса подписки у сервера больше нет, даже если отозвать её у push-сервиса не удалось. diff --git a/docs/decisions/047-push-endpoint.md b/docs/decisions/047-push-endpoint.md new file mode 100644 index 0000000..94e6db2 --- /dev/null +++ b/docs/decisions/047-push-endpoint.md @@ -0,0 +1,27 @@ +# ADR-047: Исходящий запрос к push-сервису + +Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md). + +## Контекст + +Адрес push-сервиса выбирает браузер получателя: клиент присылает `endpoint` из `PushSubscription`, сервер хранит его и на каждое сообщение сам открывает к нему соединение. Это единственное место, где сервер ходит наружу по адресу, который назвал пользователь. Свойство появилось на этапе 4, и в модели угроз его не было. + +Проверки «endpoint — абсолютный https-url» для него мало. `http.Client` по умолчанию идёт за редиректами: один ответ `307` с настоящего https-хоста уводит запрос на plain http и на любой внутренний адрес — вместе с заголовком `Authorization: vapid`. Адрес может указывать внутрь и сразу: `https://127.0.0.1:…`, `https://169.254.169.254/…`, `https://10.0.0.1/`. Ответ наружу не пересылается, но `404` и `410` снимают подписку, а это видно в `GET /api/devices` полем `hasPush`: получается побитовое сканирование внутренней сети двумя своими аккаунтами. + +Рядом — две недопроверки формы. Длина `endpoint` не ограничена ничем, кроме общего предела тела: адрес на 20 КиБ ложился в базу. `p256dh` проверялся только по длине, хотя 65 случайных байт точкой кривой не являются: отправка на такую подписку падает при каждом сообщении, а устройство остаётся с ней навсегда. + +И журнал: адрес подписки уходил в строку отказа. Развернуть `*url.Error` мало — host и DNS-имя остаются внутри `*net.OpError` и ошибки резолвера, а `docs/deploy.md` обещает, что данных пользователя в журнале нет. + +## Решение + +- Редиректы не выполняются: `CheckRedirect` возвращает `http.ErrUseLastResponse`. Push-сервисы редиректов не шлют, а без этого требование https не значит ничего. +- Соединение возможно только с публичным адресом. Проверка стоит на `Control` диалера, то есть на уже разрешённом адресе: имя, указывающее внутрь, не помогает. Непубличные — loopback, приватные сети (RFC 1918 и RFC 4193), link-local, multicast и неопределённый адрес. +- `PUT /api/devices/{id}/push` отвергает `400 invalid` литеральный непубличный адрес и `endpoint` длиннее 2 КиБ, а `p256dh` разбирает как точку P-256. Это ранний отсев формы; решает всё равно проверка при соединении. +- Отказ отправки пишется в журнал классом: «таймаут», «имя не разрешилось», «адрес подписки не публичный», «отправка не удалась». Текст ошибки транспорта не печатается вовсе — внутри него адрес подписки. +- Разрешение ходить на непубличные адреса есть в конфигурации, но из окружения не читается и в работе всегда выключено. Оно нужно тестам, где push-сервис вендора подменён сервером на `127.0.0.1`. + +## Следствия + +- Сервер остаётся отправителем пушей и не становится инструментом запросов внутрь периметра: оракула `hasPush` по внутренним адресам больше нет. +- Свой push-сервис на внутреннем адресе работать не будет. Для v1 это верно: подписку выдаёт браузер, а вендоры живут в интернете. +- Остаток риска записан в `docs/threat-model.md`: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет. diff --git a/docs/decisions/048-push-limits.md b/docs/decisions/048-push-limits.md new file mode 100644 index 0000000..38d3ba6 --- /dev/null +++ b/docs/decisions/048-push-limits.md @@ -0,0 +1,27 @@ +# ADR-048: Пределы отправки пушей + +Уточняет [ADR-023](023-push-and-service-worker.md). + +## Контекст + +ADR-023 говорит, кому и когда уходит пуш, но про пределы отправки не говорит ничего. Этап 4 сделал общую очередь на 256 заданий и четыре отправщика с таймаутом 10 секунд, без изоляции между аккаунтами. Прогон показал цену: аккаунт с сотней устройств на не отвечающем эндпоинте занимает всех отправщиков на минуты, и пуши посторонних пользователей в это время отбрасываются. Того же эффекта добивается не злой умысел, а медленный вендор. + +Рядом две лишние работы. В очередь ставились и устройства без подписки — отправить им нечего, а место они занимали. И на каждое отброшенное задание писалась строка в журнал, прямо из обработчика `POST /api/messages`: одно сообщение давало сотню строк — готовый усилитель для заливки журнала. + +Отдельно — само правило «пуш только молчащему устройству». Подключение проверялось в обработчике запроса, а право на пуш забиралось позже, в отправщике. Между этими моментами устройство успевает подключиться: подключение сбрасывает `push_pending`, отправщик тут же забирает его снова и шлёт пуш подключённому. Хуже последствие: право висит всю SSE-сессию и съедает первый пуш после ухода в офлайн. + +## Решение + +- Пуш ставится в очередь только устройству с подпиской: признак берётся тем же запросом, что и список устройств доставки. +- Заданий одного аккаунта в очереди и в работе — не больше четырёх. Лишние отбрасываются сразу, не занимая отправщика. +- Отправщиков восемь, таймаут запроса — 5 секунд, соединения — 3: вендоры отвечают за секунды, а таймаут задаёт потолок пропускной способности. +- Отброшенные пуши считаются, а не пишутся строкой каждый: в журнал уходит счётчик, не чаще раза в минуту. +- Подключение устройства проверяется в отправщике: до захвата права, сразу после захвата и после успешной отправки. Подключённому устройству право возвращается. +- Потолка на число устройств у аккаунта не вводим. Доля в отправке ограничена, устройства без подписки в очередь не попадают, а экран «устройства» — этап 5. + +## Следствия + +- Аккаунт с сотней молчащих устройств занимает не больше половины отправщиков: пуш постороннего уходит сразу. +- Отброшенный пуш не теряется навсегда: право на него не забиралось, `push_pending` устройства остался нулём, и следующее сообщение попробует снова. +- Подключённое устройство пуша не получает, а его право не остаётся висеть до конца сессии. +- Пропускная способность отправки — восемь заданий на пять секунд в худшем случае. Для маленького сервера это приемлемо; понадобится больше — менять числа, а не устройство. diff --git a/docs/decisions/049-notifications-off.md b/docs/decisions/049-notifications-off.md new file mode 100644 index 0000000..b2ee658 --- /dev/null +++ b/docs/decisions/049-notifications-off.md @@ -0,0 +1,25 @@ +# ADR-049: Выключенные уведомления остаются выключенными + +Уточняет [ADR-046](046-push-client.md). + +## Контекст + +Кнопка «выключить» снимала подписку у push-сервиса и у сервера, но следа о решении человека не оставляла. Дальше подписку возвращали два автоматических пути: `askOnce` после первого отправленного сообщения (разрешение уже дано — значит, ставим подписку) и `refresh` при каждом запуске приложения (подписка в браузере уцелела — переставим её на сервер). Человек нажимал «выключить», а уведомления включались обратно сами и молча. + +Рядом состояние «включены», которое считалось по одному факту наличия подписки. Подписка под прежней парой VAPID-ключей не работает: push-сервис отвечает на неё `403`, а это не `404` и не `410`, и сервер её не снимет. В настройках при этом написано «включены», а уведомлений нет. + +И выход из аккаунта. ADR-046 велел снимать подписку только у браузера, «сервер не спрашивая: сессии к этому моменту уже нет». Сессия на момент нажатия «выйти» ещё жива, а отписка у push-сервиса может не пройти — сети нет, вендор недоступен. Тогда строка `devices.push_subscription` остаётся живой, и пуши прежнего аккаунта рисуются на экране блокировки устройства, где уже вошёл другой человек. + +## Решение + +- В `meta` появляется `notificationsOff` — явный отказ. Его ставит «выключить», снимает «включить». При взведённом флаге `askOnce` и `refresh` не делают ничего, а раздел настроек показывает «выключены». +- «Включить» закрывает и вопрос о разрешении: `notificationsAsked` ставится здесь же — человек уже решил всё сам. +- Состояние «включены» требует подписки под текущим `vapidPublicKey`. Подписка под прежним ключом — «выключены», и кнопка «включить» переподпишет устройство. По той же причине `refresh` не переставляет на сервер подписку под чужим ключом. +- Выход из аккаунта сначала снимает подписку на сервере (`DELETE /api/devices/{id}/push`, пока сессия жива), потом закрывает сессию, потом отписывается у push-сервиса. Удаление аккаунта в этом не нуждается: строка устройства уходит каскадом вместе с подпиской. + +## Следствия + +- Выключенные уведомления включаются только кнопкой. +- Смена пары VAPID-ключей на сервере видна человеку как «выключены», а не как молчание при надписи «включены». +- Устройство, с которого вышли, пушей прежнего аккаунта не получает, даже если отписаться у push-сервиса не удалось: адреса подписки у сервера больше нет. +- В `meta` на один ключ больше — он записан в `docs/storage.md`. diff --git a/docs/deploy.md b/docs/deploy.md index ca226a1..e33d9cf 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -115,6 +115,8 @@ sudo systemctl daemon-reload && sudo systemctl enable --now bare ## Обновление — `scripts/deploy.sh` +Перед сборкой: если менялись `index.html`, `app.css`, `js/*`, `manifest.json` или иконки — сменить `VERSION` в `web/sw.js` (ADR-023). Без этого установленные приложения получат новую оболочку только вторым открытием, по ETag. + ```sh #!/bin/sh set -eu @@ -129,6 +131,7 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud - `curl -I https://bare.xmatic.team/` — 200, заголовки CSP и nosniff. - `curl -N https://bare.xmatic.team/api/events` — 401 (без cookie), без буферизации. +- `curl -s https://bare.xmatic.team/sw.js | grep VERSION` — версия та, что в репозитории. - `journalctl -u bare -f` — старт, применённые миграции, нет ошибок. ## Бэкап @@ -137,4 +140,4 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud ## Логи -Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. journald хранит по своим правилам. +Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам. diff --git a/docs/plan.md b/docs/plan.md index 718627f..bc73ee3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -32,6 +32,8 @@ web/ js/api.js fetch-обёртки, SSE, ACK js/crypto.js всё из crypto.md js/db.js IndexedDB из storage.md + js/sync.js устройство, поток событий, приём и отправка + js/pwa.js service worker, подписка на пуши, установка js/ulid.js ULID js/ui/*.js экраны из ui.md js/export.js .bare @@ -84,6 +86,30 @@ Go — последняя стабильная версия, маршрутиз Готово, когда закрытое PWA на iPhone и Android получает пуш и открывается на нужном чате; повторные сообщения до открытия пуш не порождают. +### Чеклист ручной проверки на устройствах + +Автоматически проверено всё, что проверяется без настоящих устройств: правило «одно +молчащее устройство — один пуш», сброс `push_pending` при подключении SSE, удаление +подписки на 404/410, расшифровка пуша по RFC 8291 в тесте, отсутствие плейнтекста +в нагрузке, кэш оболочки без `/api/*`, отказ ходить на непубличные адреса. Осталось +то, что требует рук и телефона: + +- [ ] **iPhone, установленное на «Домой» приложение**: пуш приходит при закрытом + приложении, нажатие открывает нужный чат. +- [ ] **Android Chrome, закрытое приложение**: то же самое. +- [ ] **Клик по системному уведомлению** в обоих случаях: в уже открытое окно + (фокус и переход) и при закрытом приложении (`/#/dm/`, `/#/room/`). +- [ ] **Повторные сообщения до открытия**: второе и третье пуша не порождают. +- [ ] **iOS вне PWA**: баннер установки над списком чатов, текст, крестик и то, + что он больше не появляется; в настройках «уведомления» — текст про установку + вместо кнопки. +- [ ] **`beforeinstallprompt`** в обычном Chrome: раздел «установить приложение» + появляется, после нажатия исчезает целиком. +- [ ] **Системный запрос разрешения** после первого отправленного сообщения: + headless-Chrome отвечает `denied` сам, живой диалог не проверялся. +- [ ] **Прогон сценариев «готово, когда»** этапов 1–5 в Safari (iOS и десктоп) + и Firefox — автоматика гоняла только Chrome. + ## Этап 5 — история - Экспорт и импорт `.bare` по `crypto.md` и `storage.md`; идемпотентность; «архив создан другим аккаунтом». diff --git a/docs/protocol.md b/docs/protocol.md index ab9c577..4934173 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -68,9 +68,9 @@ WrappedKey { to: nick, iv: string, ct: string } `DELETE /api/devices/{id}` → `204`. Удаляет очередь, подписку и сессии, привязанные к устройству. Подключённому по SSE устройству поток закрывается; его следующий запрос получает `401`. -`PUT /api/devices/{id}/push {subscription}` → `204`. `subscription` — объект `PushSubscription.toJSON()`. Сбрасывает `push_pending`. +`PUT /api/devices/{id}/push {subscription}` → `204`. `subscription` — объект `PushSubscription.toJSON()`: `endpoint` — абсолютный `https`-адрес до 2 КиБ на публичный адрес (литеральные loopback, link-local и приватные адреса — `400 invalid`, ADR-047), `keys.p256dh` — точка кривой P-256 в 65 байтах base64url, `keys.auth` — 16 байт base64url; прочие поля, включая `expirationTime`, сервер не хранит. Сбрасывает `push_pending`. Устройство в пути, как и `X-Device`, обязано принадлежать пользователю сессии. -`DELETE /api/devices/{id}/push` → `204`. +`DELETE /api/devices/{id}/push` → `204`; подписки не было — тот же `204`, чужое устройство — `403 unknown_device`. ## Контакты @@ -86,7 +86,7 @@ WrappedKey { to: nick, iv: string, ct: string } Проверки по порядку: формат полей (`400 invalid`); время ULID в пределах ±5 минут от серверного (`400 clock_skew`); для `dm` — существование ника (`404 unknown_user`), не себе (`400 self`); для `room` — членство (`403 not_member`), `keyId` среди ключей комнаты (`400 unknown_key`); лимит (`429`). -Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши по правилам ADR-023. +Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши устройствам получателей по правилам ADR-023 и ADR-045. `POST /api/ack {ids: string[]}` → `204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`. diff --git a/docs/storage.md b/docs/storage.md index e0d0d00..30e31aa 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -115,7 +115,7 @@ meta key: string → value deviceId, nick, publicKey (JWK), fingerprint, privateKey (CryptoKey ECDH, non-extractable), accountSecret (CryptoKey HKDF, non-extractable), - notificationsAsked (bool), installBannerDismissed (bool) + notificationsAsked (bool), notificationsOff (bool), installBannerDismissed (bool) chats key: id // "dm:" | "room:" {id, type: "dm"|"room", title, peer?, roomId?, owner?, members?: nick[], diff --git a/docs/threat-model.md b/docs/threat-model.md index 7973e2a..f476a52 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -41,3 +41,5 @@ **Вышедший участник до rekey.** После выхода участника сервер перестаёт доставлять ему сообщения, а новый ключ комнаты создаёт владелец при следующем появлении. В промежутке вышедший участник знает действующий ключ; прочитать новые сообщения он может только в сговоре с сервером. **Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша. + +**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — один POST на сообщение, в пределах общих лимитов. diff --git a/docs/ui.md b/docs/ui.md index d1716c0..4a1f4fe 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -57,8 +57,8 @@ ## Настройки (`#/settings`) - «ты: @nick», свой отпечаток. -- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка включить/выключить. На iOS вне PWA — текст про установку. -- «установить приложение»: кнопка, если есть `beforeinstallprompt`; на iOS — инструкция «поделиться → на экран «домой»». +- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка «включить» или «выключить». `запрещены в браузере` — разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояние `выключены` и вместо кнопки текст про установку, тот же, что в баннере. +- «установить приложение»: кнопка «установить», если есть `beforeinstallprompt`; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет. - «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить». - «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». - «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён». @@ -67,12 +67,16 @@ ## Баннер установки (iOS) -Показывается при `iPhone|iPad` и `navigator.standalone !== true`, над списком чатов: «уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»». Крестик — `installBannerDismissed`, повтор не показывается. +Показывается при `iPhone|iPad` и `navigator.standalone !== true`, над списком чатов: «уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»». Крестик — «×» с подписью «закрыть» для экранного диктора — ставит `installBannerDismissed`, повтор не показывается. ## Уведомления Запрос разрешения — после первого успешно отправленного сообщения, один раз (`notificationsAsked`). После `granted` — `pushManager.subscribe` с `vapidPublicKey` и `PUT /api/devices/{id}/push`. Отказ — молча; включить можно в настройках. +Выключенные кнопкой уведомления сами не включаются: ни первым сообщением, ни запуском приложения. Обратно их включает только кнопка (ADR-049). + +Уведомление: заголовок — `@nick` отправителя или `#имя комнаты`, текст — «новое сообщение», нажатие открывает этот чат. Содержимого сообщения в уведомлении нет: сервер его не знает (ADR-011). Пуш о собственном сообщении не приходит (ADR-045). + ## Сеть и состояния - SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`. diff --git a/go.mod b/go.mod index 6459dbb..2041e9b 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/xmatic-squad/bare go 1.27.0 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.57.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index e2c6978..70cbd37 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,10 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -12,16 +17,79 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= diff --git a/internal/api/api.go b/internal/api/api.go index aefbccc..7bb0c72 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -16,6 +16,7 @@ import ( "github.com/xmatic-squad/bare/internal/auth" "github.com/xmatic-squad/bare/internal/config" "github.com/xmatic-squad/bare/internal/hub" + "github.com/xmatic-squad/bare/internal/push" "github.com/xmatic-squad/bare/internal/store" ) @@ -34,27 +35,39 @@ type server struct { cfg *config.Config st *store.Store hub *hub.Hub + push *push.Sender msgs *buckets logw io.Writer } -// Handler — обработчик всех маршрутов и живые SSE-потоки за ним. +// Handler — обработчик всех маршрутов, живые SSE-потоки и очередь пушей +// за ним. type Handler struct { http.Handler - hub *hub.Hub + hub *hub.Hub + push *push.Sender } -// Close закрывает открытые потоки событий. Без него остановка сервера -// ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004). -func (h *Handler) Close() { h.hub.CloseAll() } +// Close закрывает открытые потоки событий и останавливает отправку +// пушей. Без него остановка сервера ждала бы, пока клиенты уйдут сами: +// у потока нет конца (ADR-004). +func (h *Handler) Close() { + h.hub.CloseAll() + h.push.Close() +} // New собирает обработчик: /api/, /healthz, всё остальное — статика. // logw — куда писать строки запросов и причины отказов; nil отключает лог. func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler { + // Отправитель пушей спрашивает у hub, подключено ли устройство: + // решение «пуш только молчащему» принимается в момент захвата права + // на него, а не при постановке в очередь (ADR-023). + live := hub.New() s := &server{ cfg: cfg, st: st, - hub: hub.New(), + hub: live, + push: push.New(cfg, st, live.Connected, logw), msgs: newBuckets(messagesPerMinute, messagesBurst), logw: logw, } @@ -79,6 +92,8 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice))) mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices))) mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice))) + mux.Handle("PUT /api/devices/{id}/push", private(http.HandlerFunc(s.setPush))) + mux.Handle("DELETE /api/devices/{id}/push", private(http.HandlerFunc(s.deletePush))) mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts))) mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact))) @@ -103,6 +118,7 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write return &Handler{ Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))), hub: s.hub, + push: s.push, } } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index f3df3e3..5542e3d 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -58,6 +58,12 @@ func newEnv(t *testing.T) *env { return invited(t, "") } // invited — сервер на временной базе; непустой code включает инвайты. func invited(t *testing.T, code string) *env { + t.Helper() + return envWith(t, func(cfg *config.Config) { cfg.InviteCode = code }) +} + +// envWith — сервер на временной базе; tweak правит конфигурацию до старта. +func envWith(t *testing.T, tweak func(*config.Config)) *env { t.Helper() static, err := web.New() if err != nil { @@ -74,10 +80,14 @@ func invited(t *testing.T, code string) *env { DB: "bare.db", Origin: origin, VAPIDPublic: "vapid", - InviteCode: code, } + tweak(cfg) e := &env{t: t, st: st, log: &syncLog{}} - e.h = api.New(cfg, st, static, e.log) + h := api.New(cfg, st, static, e.log) + // Обработчик закрывается раньше базы: отправщики пушей дописывают + // начатое, а база им ещё нужна. + t.Cleanup(h.Close) + e.h = h return e } diff --git a/internal/api/devices_test.go b/internal/api/devices_test.go index 21cc5b0..6c31768 100644 --- a/internal/api/devices_test.go +++ b/internal/api/devices_test.go @@ -80,11 +80,6 @@ func TestDevices(t *testing.T) { if list[0].Current || !list[1].Current { t.Errorf("текущее устройство второй сессии: %+v", list) } - - // Push-подписка — этап 4: пути ещё нет, а неизвестный путь отвечает - // 404 not_found (ADR-026). - expect(t, e.do(http.MethodPut, "/api/devices/"+id+"/push", map[string]any{}, with(c)), - http.StatusNotFound, "not_found") } // Занятый чужим идентификатор — 409: клиент берёт новый (ADR-017). diff --git a/internal/api/messages.go b/internal/api/messages.go index 929d5c1..ec6ab0f 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -8,6 +8,7 @@ import ( "github.com/xmatic-squad/bare/internal/auth" "github.com/xmatic-squad/bare/internal/hub" + "github.com/xmatic-squad/bare/internal/push" "github.com/xmatic-squad/bare/internal/store" ) @@ -76,22 +77,29 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { sess, _ := auth.From(r) room := in.To.Room != "" + // Заголовок и адрес чата для пуша: сервер собирает их из того, что + // и так знает, — из ников и имени комнаты (ADR-023). + var signal push.Payload if room { - member, knownKey, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID) + access, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID) if err != nil { s.internal(w, r, err) return } - if !member { + if !access.Member { Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты") return } - if !knownKey { + if !access.KnownKey { Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа") return } - } else if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { - return + signal = push.Payload{Title: "#" + access.Name, Chat: "room:" + in.To.Room} + } else { + if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { + return + } + signal = push.Payload{Title: "@" + sess.Nick, Chat: "dm:" + sess.Nick} } if wait, ok := s.msgs.take(sess.Nick, now); !ok { @@ -122,7 +130,7 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { Envelope: string(raw), Now: env.TS, } - var devices []string + var devices []store.Target if room { devices, err = s.st.DeliverRoom(r.Context(), delivery) } else { @@ -133,16 +141,39 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { return } // Очередь уже записана: подключённое устройство получает конверт - // сразу, остальные — при подключении. Пуши — этап 4. - for _, id := range devices { - s.hub.Send(id, hub.Event{Name: "msg", Data: string(raw)}) + // сразу, остальные — при подключении. + for _, target := range devices { + s.hub.Send(target.ID, hub.Event{Name: "msg", Data: string(raw)}) } + // Пуш — побочный эффект доставки, а не её часть: конверт уже + // в очереди, и ответ на запрос отправку пуша не ждёт (ADR-023). + s.push.Send(s.silent(devices, env.From), signal) writeJSON(w, http.StatusAccepted, struct { ID string `json:"id"` TS int64 `json:"ts"` }{env.ID, env.TS}) } +// silent — устройства, которым нужен пуш: чужие (устройства отправителя +// пуша не получают, ADR-045), подписанные и молчащие — те, что не держат +// поток событий (ADR-023). +// +// Устройство без подписки отсеивается здесь: отправить ему нечего, +// а место в очереди отправки оно заняло бы (ADR-048). Проверка на +// подключение — ранний отсев: решает её повтор в момент захвата права +// на пуш, потому что между этой строкой и отправкой устройство успевает +// подключиться (ADR-023). +func (s *server) silent(targets []store.Target, from string) []push.Target { + var out []push.Target + for _, target := range targets { + if target.Nick == from || !target.HasPush || s.hub.Connected(target.ID) { + continue + } + out = append(out, push.Target{Device: target.ID, Owner: target.Nick}) + } + return out +} + // checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер // проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан, // если вернулось false. diff --git a/internal/api/push.go b/internal/api/push.go new file mode 100644 index 0000000..0e8be40 --- /dev/null +++ b/internal/api/push.go @@ -0,0 +1,163 @@ +package api + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "net/http" + "net/netip" + "net/url" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/push" +) + +// Push-подписка принадлежит устройству (ADR-023): её ставит и снимает +// само устройство. Сервер хранит подписку как непрозрачный JSON и лезет +// в неё только при отправке. + +// Длины ключей подписки (RFC 8291): p256dh — несжатая точка P-256, +// auth — общий секрет. +const ( + p256dhLen = 65 + authLen = 16 + // maxEndpoint — предел длины адреса подписки. Адреса вендоров — + // две-три сотни символов; всё остальное push-сервисом не является, + // а прочие поля протокола ограничены явно (docs/protocol.md). + maxEndpoint = 2 << 10 +) + +// subscriptionIn — объект PushSubscription.toJSON(). Поле expirationTime +// браузеры кладут рядом; сервер его не читает и не хранит — хранится +// ровно то, что нужно для отправки. +type subscriptionIn struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` +} + +// PUT /api/devices/{id}/push — подписка устройства на пуши. Сбрасывает +// неотработанный пуш: устройство снова готово его принять (ADR-023). +// +// X-Device на этом маршруте обязателен, и устройство в пути тоже обязано +// быть своим: чужому устройству подписку не поставить (docs/protocol.md, +// «Общие правила»). +func (s *server) setPush(w http.ResponseWriter, r *http.Request) { + var in struct { + Subscription subscriptionIn `json:"subscription"` + } + if !decode(w, r, &in) { + return + } + // Форма проверяется раньше прав (ADR-043). + subscription, ok := checkSubscription(w, in.Subscription) + if !ok { + return + } + if _, ok := s.device(w, r); !ok { + return + } + sess, _ := auth.From(r) + set, err := s.st.SetPush(r.Context(), r.PathValue("id"), sess.Nick, subscription) + if err != nil { + s.internal(w, r, err) + return + } + if !set { + unknownDevice(w) + return + } + noContent(w) +} + +// DELETE /api/devices/{id}/push — снять подписку. Подписки не было — +// тот же 204: снимать нечего. Чужое устройство — 403, как и на PUT. +func (s *server) deletePush(w http.ResponseWriter, r *http.Request) { + if _, ok := s.device(w, r); !ok { + return + } + sess, _ := auth.From(r) + cleared, err := s.st.ClearPush(r.Context(), r.PathValue("id"), sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + if !cleared { + unknownDevice(w) + return + } + noContent(w) +} + +// checkSubscription проверяет форму подписки и отдаёт её канонический +// JSON: три поля и ничего больше. Ответ об ошибке уже написан, если +// вернулось false. +func checkSubscription(w http.ResponseWriter, in subscriptionIn) (string, bool) { + if !validEndpoint(in.Endpoint) { + Invalid(w, "subscription", "endpoint — не публичный https-url до 2 КиБ") + return "", false + } + if !pushPoint(in.Keys.P256dh) { + Invalid(w, "subscription", "keys.p256dh — не точка p-256 в 65 байтах base64url") + return "", false + } + if _, ok := pushKey(in.Keys.Auth, authLen); !ok { + Invalid(w, "subscription", "keys.auth — не 16 байт base64url") + return "", false + } + out, err := json.Marshal(in) + if err != nil { + return "", false + } + return string(out), true +} + +// validEndpoint — адрес push-сервиса. Выбирает его браузер, сервер знает +// о нём только то, что это абсолютный https-url разумной длины: без TLS +// пуш ушёл бы открытым текстом мимо всех обещаний. +// +// Литеральный непубличный адрес отвергается сразу: push-сервиса по нему +// не бывает, а внутренняя служба бывает (ADR-047). Имя здесь не +// разрешается — за именем всё равно может стоять внутренний адрес, +// поэтому решающая проверка идёт при соединении, в отправщике. +func validEndpoint(raw string) bool { + if raw == "" || len(raw) > maxEndpoint { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" { + return false + } + ip, err := netip.ParseAddr(u.Hostname()) + if err != nil { + // Не литерал, а имя: его разберёт отправщик. + return true + } + return push.Public(ip) +} + +// pushPoint — p256dh: несжатая точка кривой P-256. Одной длины мало: +// случайные 65 байт точкой не являются, отправка на них падает при +// каждом сообщении, а устройство остаётся с подпиской, которая никогда +// не заработает (docs/protocol.md, «Устройства»). +func pushPoint(s string) bool { + raw, ok := pushKey(s, p256dhLen) + if !ok { + return false + } + _, err := ecdh.P256().NewPublicKey(raw) + return err == nil +} + +// pushKey — ключ подписки: ровно n байт base64url. Push API задаёт форму +// без паддинга, но браузер, добавивший паддинг, не должен остаться без +// уведомлений: webpush-go разбирает обе формы, и сервер принимает обе. +func pushKey(s string, n int) ([]byte, bool) { + if raw, err := b64.DecodeString(s); err == nil { + return raw, len(raw) == n + } + raw, err := base64.URLEncoding.DecodeString(s) + return raw, err == nil && len(raw) == n +} diff --git a/internal/api/push_test.go b/internal/api/push_test.go new file mode 100644 index 0000000..5ab79b1 --- /dev/null +++ b/internal/api/push_test.go @@ -0,0 +1,806 @@ +package api_test + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/xmatic-squad/bare/internal/config" +) + +// quiet — сколько ждём, чтобы убедиться, что пуша нет. Всё локально, +// задержек быть не должно. +const quiet = 300 * time.Millisecond + +// pushEnv — сервер с настоящей парой VAPID-ключей: без неё пуши выключены +// (docs/deploy.md). +func pushEnv(t *testing.T) *env { return pushEnvWith(t, true) } + +// pushEnvWith — то же; local разрешает отправку на 127.0.0.1, где живёт +// подменный push-сервис. Настоящий сервер ходит только по публичным +// адресам (ADR-047), и это проверяется отдельно. +func pushEnvWith(t *testing.T, local bool) *env { + t.Helper() + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("vapid: %v", err) + } + return envWith(t, func(cfg *config.Config) { + cfg.VAPIDPublic = raw64(key.PublicKey().Bytes()) + cfg.VAPIDPrivate = raw64(key.Bytes()) + cfg.VAPIDSubject = "mailto:bare@bare.test" + // Push-сервис вендора подменён сервером на 127.0.0.1: в работе + // отправщик ходит только по публичным адресам (ADR-047). + cfg.PushLocal = local + }) +} + +func raw64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// padded64 — то же, что bytesOf, но с паддингом: браузер вправе прислать +// ключи подписки и в такой форме. +func padded64(n int, seed byte) string { + raw := make([]byte, n) + for i := range raw { + raw[i] = seed + byte(i) + } + return base64.URLEncoding.EncodeToString(raw) +} + +// point65 — p256dh настоящей подписки: несжатая точка P-256 в 65 байтах. +// Случайные байты той же длины точкой не являются, и сервер их не примет +// (docs/protocol.md, «Устройства»). +func point65(t *testing.T) []byte { + t.Helper() + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ключ подписки: %v", err) + } + return key.PublicKey().Bytes() +} + +// pushService — push-сервис вендора в тесте. Настоящий FCM тестам не нужен +// и не годится: проверяется, что уходит и что сервер делает с ответом. +type pushService struct { + t *testing.T + url string + got chan delivered + status atomic.Int32 +} + +// delivered — то, что увидел push-сервис. +type delivered struct { + device string // хвост endpoint: по нему видно, чей это пуш + ttl string + urgency string + encoding string + auth string + record []byte +} + +func newPushService(t *testing.T) *pushService { + t.Helper() + open := make(chan struct{}) + close(open) + return pushServiceWith(t, open) +} + +// newSlowPushService — push-сервис, который принимает запрос и молчит, +// пока тест не отпустит его. Так видно, что делает сервер, пока отправка +// ещё идёт. Отпускать обязательно: иначе остановка сервера ждёт таймаута. +func newSlowPushService(t *testing.T) (*pushService, func()) { + t.Helper() + gate := make(chan struct{}) + var once sync.Once + return pushServiceWith(t, gate), func() { once.Do(func() { close(gate) }) } +} + +// pushServiceWith — push-сервис, отвечающий не раньше, чем закроется gate. +func pushServiceWith(t *testing.T, gate <-chan struct{}) *pushService { + t.Helper() + p := &pushService{t: t, got: make(chan delivered, 512)} + p.status.Store(http.StatusCreated) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + record, _ := io.ReadAll(r.Body) + got := delivered{ + device: strings.TrimPrefix(r.URL.Path, "/push/"), + ttl: r.Header.Get("TTL"), + urgency: r.Header.Get("Urgency"), + encoding: r.Header.Get("Content-Encoding"), + auth: r.Header.Get("Authorization"), + record: record, + } + select { + case p.got <- got: + default: + } + select { + case <-gate: + case <-r.Context().Done(): + return + } + w.WriteHeader(int(p.status.Load())) + })) + t.Cleanup(srv.Close) + p.url = srv.URL + return p +} + +// next — следующий пуш; его отсутствие — ошибка теста. +func (p *pushService) next() delivered { + p.t.Helper() + select { + case got := <-p.got: + return got + case <-time.After(wait): + p.t.Fatal("пуш не пришёл") + } + return delivered{} +} + +// silent требует, чтобы других пушей не было. +func (p *pushService) silent() { + p.t.Helper() + select { + case got := <-p.got: + p.t.Fatalf("лишний пуш устройству %s", got.device) + case <-time.After(quiet): + } +} + +// subscriber — устройство с push-подпиской. Ключи настоящие: тест +// расшифровывает пуш ровно так, как это сделал бы браузер (RFC 8291), +// и потому видит, что в нём лежит. +type subscriber struct { + device string + key *ecdh.PrivateKey + auth []byte +} + +// subscribe кладёт подписку устройства прямо в базу. Через PUT её сюда +// не поставить: тестовый push-сервис живёт на http, а эндпоинт принимает +// только https. Форму подписки проверяют TestPushSubscription +// и TestPushSubscriptionForm, правила отправки от неё не зависят. +func (e *env) subscribe(nick, device string, p *pushService) *subscriber { + e.t.Helper() + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + e.t.Fatalf("ключ подписки: %v", err) + } + auth := make([]byte, 16) + if _, err := rand.Read(auth); err != nil { + e.t.Fatalf("секрет подписки: %v", err) + } + raw, err := json.Marshal(map[string]any{ + "endpoint": p.url + "/push/" + device, + "keys": map[string]string{ + "p256dh": raw64(key.PublicKey().Bytes()), + "auth": raw64(auth), + }, + }) + if err != nil { + e.t.Fatalf("подписка: %v", err) + } + set, err := e.st.SetPush(context.Background(), device, nick, string(raw)) + if err != nil || !set { + e.t.Fatalf("SetPush: %v (поставлена: %v)", err, set) + } + return &subscriber{device: device, key: key, auth: auth} +} + +// open расшифровывает пуш: aes128gcm по RFC 8291, как это делает браузер. +// Без расшифровки нельзя утверждать, что в пуше нет ничего лишнего. +func (s *subscriber) open(t *testing.T, record []byte) map[string]string { + t.Helper() + check := func(what string, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", what, err) + } + } + // Заголовок записи: соль, размер записи, длина открытого ключа. + const header = 16 + 4 + 1 + if len(record) < header { + t.Fatalf("запись короче заголовка: %d байт", len(record)) + } + salt := record[:16] + keyLen := int(record[20]) + if len(record) < header+keyLen { + t.Fatalf("запись короче ключа отправителя: %d байт", len(record)) + } + sender, ct := record[header:header+keyLen], record[header+keyLen:] + + remote, err := ecdh.P256().NewPublicKey(sender) + check("ключ отправителя", err) + shared, err := s.key.ECDH(remote) + check("ecdh", err) + + info := append([]byte("WebPush: info\x00"), s.key.PublicKey().Bytes()...) + info = append(info, sender...) + ikm, err := hkdf.Key(sha256.New, shared, s.auth, string(info), 32) + check("ikm", err) + cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16) + check("ключ записи", err) + nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12) + check("nonce", err) + + block, err := aes.NewCipher(cek) + check("aes", err) + gcm, err := cipher.NewGCM(block) + check("gcm", err) + plain, err := gcm.Open(nil, nonce, ct, nil) + check("расшифровка", err) + + // Хвост записи — набивка: нули после разделителя 0x02. + plain = bytes.TrimSuffix(bytes.TrimRight(plain, "\x00"), []byte{2}) + var out map[string]string + if err := json.Unmarshal(plain, &out); err != nil { + t.Fatalf("нагрузка %q: %v", plain, err) + } + return out +} + +// hasPush — что о подписке устройства говорит GET /api/devices. +func (e *env) hasPush(c *http.Cookie, device string) bool { + e.t.Helper() + rec := e.do(http.MethodGet, "/api/devices", nil, with(c)) + expect(e.t, rec, http.StatusOK, "") + var list []struct { + ID string `json:"id"` + HasPush bool `json:"hasPush"` + } + decodeBody(e.t, rec, &list) + for _, got := range list { + if got.ID == device { + return got.HasPush + } + } + e.t.Fatalf("устройства %s нет в списке", device) + return false +} + +// waitPushGone ждёт, пока подписка исчезнет: снимает её отправщик, уже +// после того, как push-сервис ответил. +func (e *env) waitPushGone(c *http.Cookie, device string) { + e.t.Helper() + for deadline := time.Now().Add(wait); time.Now().Before(deadline); { + if !e.hasPush(c, device) { + return + } + time.Sleep(5 * time.Millisecond) + } + e.t.Fatalf("подписка устройства %s не снята", device) +} + +// send — обычная отправка личного сообщения. +func (e *env) send(c *http.Cookie, device, to string, seed byte) { + e.t.Helper() + expect(e.t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), seed), to), + with(c), withDevice(device)), http.StatusAccepted, "") +} + +// pushEventually шлёт сообщения, пока не придёт пуш. И разрыв потока, +// и возврат права на пуш случаются после ответа на запрос: момент их +// наступления не назначить, поэтому попытка повторяется. +func (e *env) pushEventually(p *pushService, c *http.Cookie, device, to string) delivered { + e.t.Helper() + for i := 0; i < 8; i++ { + e.send(c, device, to, byte(50+i)) + select { + case got := <-p.got: + return got + case <-time.After(200 * time.Millisecond): + } + } + e.t.Fatal("пуш так и не пришёл") + return delivered{} +} + +// subscription — тело PUT /api/devices/{id}/push в форме +// PushSubscription.toJSON(). +func subscription(t *testing.T) map[string]any { + t.Helper() + return map[string]any{ + "endpoint": "https://push.example/one", + "expirationTime": nil, + "keys": map[string]string{"p256dh": raw64(point65(t)), "auth": bytesOf(16, 7)}, + } +} + +// Подписка ставится и снимается, hasPush честный, чужое устройство — 403 +// (docs/protocol.md, «Устройства», «Общие правила»). +func TestPushSubscription(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + body := map[string]any{"subscription": subscription(t)} + + if e.hasPush(marta, m1) { + t.Error("hasPush до подписки: true") + } + expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, with(marta), withDevice(m1)), + http.StatusNoContent, "") + if !e.hasPush(marta, m1) { + t.Error("hasPush после подписки: false") + } + // Подписка принадлежит устройству: у соседа её не появилось. + if e.hasPush(petya, p1) { + t.Error("подписка досталась чужому устройству") + } + + expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)), + http.StatusNoContent, "") + if e.hasPush(marta, m1) { + t.Error("hasPush после снятия: true") + } + // Снимать нечего — тот же 204. + expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)), + http.StatusNoContent, "") + + // Чужое устройство в пути — 403, и подписки у него не появилось. + expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", body, with(marta), withDevice(m1)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodDelete, "/api/devices/"+p1+"/push", nil, with(marta), withDevice(m1)), + http.StatusForbidden, "unknown_device") + if e.hasPush(petya, p1) { + t.Error("подписка поставлена чужому устройству") + } + + // X-Device обязателен и обязан быть своим. + for _, opts := range [][]func(*http.Request){ + {with(marta)}, + {with(marta), withDevice(p1)}, + {with(marta), withDevice("мусор")}, + {with(marta), withDevice(deviceOf(9))}, + } { + expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, opts...), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, opts...), + http.StatusForbidden, "unknown_device") + } + if e.hasPush(marta, m1) { + t.Error("подписка появилась после отказа") + } +} + +// Форма подписки: абсолютный https-адрес и два ключа нужной длины. +func TestPushSubscriptionForm(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + + cases := []struct { + name string + change func(map[string]any) + }{ + {"нет endpoint", func(s map[string]any) { delete(s, "endpoint") }}, + {"endpoint без tls", func(s map[string]any) { s["endpoint"] = "http://push.example/one" }}, + {"endpoint без хоста", func(s map[string]any) { s["endpoint"] = "https:///one" }}, + {"endpoint не url", func(s map[string]any) { s["endpoint"] = "какой же это url" }}, + {"нет ключей", func(s map[string]any) { delete(s, "keys") }}, + {"endpoint на loopback", func(s map[string]any) { s["endpoint"] = "https://127.0.0.1:9/push" }}, + {"endpoint на link-local", func(s map[string]any) { + s["endpoint"] = "https://169.254.169.254/latest/meta-data/" + }}, + {"endpoint в приватной сети", func(s map[string]any) { s["endpoint"] = "https://10.0.0.1/push" }}, + {"endpoint на ::1", func(s map[string]any) { s["endpoint"] = "https://[::1]:8411/api/me" }}, + {"endpoint длиннее 2 КиБ", func(s map[string]any) { + s["endpoint"] = "https://push.example/" + strings.Repeat("a", 2048) + }}, + {"p256dh не 65 байт", func(s map[string]any) { + s["keys"] = map[string]string{"p256dh": bytesOf(32, 5), "auth": bytesOf(16, 7)} + }}, + {"p256dh не точка на кривой", func(s map[string]any) { + s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(16, 7)} + }}, + {"auth не 16 байт", func(s map[string]any) { + s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(32, 7)} + }}, + {"ключ не base64url", func(s map[string]any) { + s["keys"] = map[string]string{"p256dh": strings.Repeat("!", 87), "auth": bytesOf(16, 7)} + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sub := subscription(t) + c.change(sub) + rec := e.do(http.MethodPut, "/api/devices/"+m1+"/push", + map[string]any{"subscription": sub}, with(marta), withDevice(m1)) + expect(t, rec, http.StatusBadRequest, "invalid") + var field struct { + Field string `json:"field"` + } + decodeBody(t, rec, &field) + if field.Field != "subscription" { + t.Errorf("field: получено %q, ожидалось \"subscription\"", field.Field) + } + }) + } + if e.hasPush(marta, m1) { + t.Error("подписка не по форме поставилась") + } + + // Паддинг в base64url тоже принимается: браузер вправе его добавить. + padded := subscription(t) + padded["keys"] = map[string]string{ + "p256dh": base64.URLEncoding.EncodeToString(point65(t)), + "auth": padded64(16, 7), + } + expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", + map[string]any{"subscription": padded}, with(marta), withDevice(m1)), http.StatusNoContent, "") + + // Форма проверяется раньше прав: на запрос к чужому устройству + // приходит отказ по форме, а не по правам (ADR-043). + expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", "не json", with(marta), withDevice(m1)), + http.StatusBadRequest, "bad_json") + broken := subscription(t) + broken["endpoint"] = "http://push.example/one" + expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", + map[string]any{"subscription": broken}, with(marta), withDevice(m1)), http.StatusBadRequest, "invalid") +} + +// Пуш уходит только отключённому устройству с подпиской (ADR-023). +// Он несёт заголовок, «новое сообщение» и адрес чата — и ничего больше. +func TestPushToSilentDevice(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + p2 := e.addDevice(petya, deviceOf(3)) + e.subscribe("petya", p1, svc) // подключено по SSE + silent := e.subscribe("petya", p2, svc) // молчит + + stream := e.open(p1, petya) + stream.untilReady() + e.send(marta, m1, "petya", 4) + + got := svc.next() + if got.device != p2 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p2) + } + // TTL сутки, urgency normal (ADR-023). + if got.ttl != "86400" { + t.Errorf("TTL: получено %q, ожидалось \"86400\"", got.ttl) + } + if got.urgency != "normal" { + t.Errorf("Urgency: получено %q, ожидалось \"normal\"", got.urgency) + } + if got.encoding != "aes128gcm" { + t.Errorf("Content-Encoding: получено %q, ожидалось \"aes128gcm\"", got.encoding) + } + if !strings.HasPrefix(got.auth, "vapid t=") { + t.Errorf("Authorization: получено %q, ожидался vapid", got.auth) + } + + payload := silent.open(t, got.record) + if len(payload) != 3 { + t.Errorf("поля нагрузки: %v", payload) + } + if payload["title"] != "@marta" { + t.Errorf("title: получено %q, ожидалось \"@marta\"", payload["title"]) + } + if payload["body"] != "новое сообщение" { + t.Errorf("body: получено %q, ожидалось \"новое сообщение\"", payload["body"]) + } + if payload["chat"] != "dm:marta" { + t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"]) + } + // Шифротекста сообщения в пуше нет ни в каком виде: сервер его + // не пересылает, а плейнтекста он и не знает (ADR-011). + if bytes.Contains(got.record, []byte(bytesOf(48, 23))) { + t.Error("шифротекст сообщения попал в пуш") + } + svc.silent() +} + +// Одно молчащее устройство получает один пуш, а не ленту (ADR-023). +func TestPushOncePerSilentDevice(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + if got := svc.next(); got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } + // Второе и третье сообщение подряд пуша не порождают. + e.send(marta, m1, "petya", 4) + e.send(marta, m1, "petya", 5) + svc.silent() +} + +// Подключение по SSE сбрасывает неотработанный пуш: следующее сообщение +// молчащему устройству снова даёт пуш (ADR-023). +func TestPushAgainAfterStream(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.next() + e.send(marta, m1, "petya", 4) + svc.silent() + + stream := e.open(p1, petya) + stream.untilReady() + stream.close() + + if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } +} + +// 404 и 410 от push-сервиса означают, что подписки больше нет (ADR-011). +func TestPushDeadSubscription(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusGone} { + t.Run(http.StatusText(status), func(t *testing.T) { + svc := newPushService(t) + svc.status.Store(int32(status)) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.next() + e.waitPushGone(petya, p1) + }) + } +} + +// Прочие отказы push-сервиса подписку не трогают и доставку сообщения +// не роняют. Право на пуш при этом возвращается: иначе одна ошибка +// затыкала бы уведомления устройства до самого подключения. +func TestPushServiceError(t *testing.T) { + svc := newPushService(t) + svc.status.Store(http.StatusInternalServerError) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.next() + if !e.hasPush(petya, p1) { + t.Error("подписка снята по ответу 500") + } + + svc.status.Store(http.StatusCreated) + if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } + if !e.hasPush(petya, p1) { + t.Error("подписка снята после успешного пуша") + } +} + +// Отправитель пуша о собственном сообщении не получает — ни на то +// устройство, с которого писал, ни на остальные свои (ADR-045). +func TestPushNotToSender(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + _, p1 := e.join("petya", 3) + e.subscribe("marta", m1, svc) + e.subscribe("marta", m2, svc) + to := e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 4) + + got := svc.next() + if got.device != p1 { + t.Fatalf("пуш ушёл устройству отправителя %s", got.device) + } + svc.silent() + if payload := to.open(t, got.record); payload["chat"] != "dm:marta" { + t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"]) + } +} + +// Пуш из комнаты: заголовок — имя комнаты, адрес чата — её идентификатор +// (ADR-023). +func TestPushFromRoom(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40, withDevice(m1)) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 41), + http.StatusOK, "") + member := e.subscribe("petya", p1, svc) + + expect(t, e.do(http.MethodPost, "/api/messages", + roomMessage(ulid(nowMillis(), 5), room.ID, keyID(41)), with(marta), withDevice(m1)), + http.StatusAccepted, "") + + got := svc.next() + if got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } + payload := member.open(t, got.record) + if payload["title"] != "#общая" { + t.Errorf("title: получено %q, ожидалось \"#общая\"", payload["title"]) + } + if payload["chat"] != "room:"+room.ID { + t.Errorf("chat: получено %q, ожидалось %q", payload["chat"], "room:"+room.ID) + } + if payload["body"] != "новое сообщение" { + t.Errorf("body: получено %q", payload["body"]) + } + svc.silent() +} + +// Без VAPID-ключей пуши выключены: подписка ставится, отправки нет. +func TestPushOffWithoutKeys(t *testing.T) { + svc := newPushService(t) + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.silent() +} + +// Аккаунт с молчащим push-сервисом не отбирает отправку у остальных: +// доля одного аккаунта в отправщиках ограничена (ADR-048). +func TestPushShareBetweenAccounts(t *testing.T) { + e := pushEnv(t) + stuck, release := newSlowPushService(t) + defer release() + live := newPushService(t) + + marta, m1 := e.join("marta", 1) + greedy, g1 := e.join("greedy", 2) + e.subscribe("greedy", g1, stuck) + for seed := byte(10); seed < 30; seed++ { + e.subscribe("greedy", e.addDevice(greedy, deviceOf(seed)), stuck) + } + _, c1 := e.join("carol", 3) + e.subscribe("carol", c1, live) + + // Двадцать одно молчащее устройство одного аккаунта: часть заданий + // отбрасывается сразу, остальные занимают не больше своей доли. + e.send(marta, m1, "greedy", 40) + e.send(marta, m1, "carol", 41) + + select { + case got := <-live.got: + if got.device != c1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, c1) + } + case <-time.After(wait): + t.Fatal("пуш постороннему аккаунту не ушёл: отправщики заняты чужим") + } +} + +// Устройство, подключившееся по SSE во время отправки, не остаётся +// с неотработанным пушем: право возвращается, и следующее сообщение +// после ухода в офлайн снова даёт пуш (ADR-023). +func TestPushReleasedWhenDeviceConnects(t *testing.T) { + e := pushEnv(t) + svc, release := newSlowPushService(t) + defer release() + + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + // Отправка уже началась: push-сервис получил запрос и держит его. + if got := svc.next(); got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } + // Пока пуш в пути, устройство подключилось: подключение сбрасывает + // неотработанный пуш, а отправщик поставил его позже. + stream := e.open(p1, petya) + stream.untilReady() + release() + + // Право на пуш свободно: захват удаётся. + for deadline := time.Now().Add(wait); ; { + claimed, ok, err := e.st.ClaimPush(context.Background(), p1) + if err != nil { + t.Fatalf("ClaimPush: %v", err) + } + if ok { + if claimed == "" { + t.Error("подписка пуста") + } + return + } + if time.Now().After(deadline) { + t.Fatal("неотработанный пуш остался висеть на подключённом устройстве") + } + time.Sleep(5 * time.Millisecond) + } +} + +// Пуш на непубличный адрес не уходит вовсе: соединения не случается, +// право на пуш возвращается, а адрес подписки в журнал не попадает +// (ADR-047, docs/deploy.md, «Логи»). +func TestPushSkipsLocalEndpoint(t *testing.T) { + svc := newPushService(t) + e := pushEnvWith(t, false) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.silent() + + if !e.hasPush(petya, p1) { + t.Error("подписка снята, хотя push-сервис не отвечал") + } + // Право на пуш вернулось: следующее сообщение попробует снова. + claimed, ok, err := e.st.ClaimPush(context.Background(), p1) + if err != nil { + t.Fatalf("ClaimPush: %v", err) + } + if !ok || claimed == "" { + t.Error("право на пуш осталось захваченным") + } + log := e.log.String() + if !strings.Contains(log, "адрес подписки не публичный") { + t.Errorf("в журнале нет причины отказа: %q", log) + } + if strings.Contains(log, "127.0.0.1") || strings.Contains(log, strings.TrimPrefix(svc.url, "http://")) { + t.Errorf("адрес подписки попал в журнал: %q", log) + } +} + +// Одно сообщение — несколько молчащих устройств: каждое получает свою +// расшифровываемую нагрузку. Нагрузка на всех одна (ADR-045), но +// шифруется она для каждой подписки отдельно. +func TestPushPayloadPerDevice(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + p2 := e.addDevice(petya, deviceOf(3)) + p3 := e.addDevice(petya, deviceOf(4)) + subs := map[string]*subscriber{ + p1: e.subscribe("petya", p1, svc), + p2: e.subscribe("petya", p2, svc), + p3: e.subscribe("petya", p3, svc), + } + + e.send(marta, m1, "petya", 5) + seen := make(map[string]bool) + for i := 0; i < len(subs); i++ { + got := svc.next() + to, ok := subs[got.device] + if !ok { + t.Fatalf("пуш ушёл неизвестному устройству %s", got.device) + } + if seen[got.device] { + t.Fatalf("устройство %s получило второй пуш", got.device) + } + seen[got.device] = true + payload := to.open(t, got.record) + if payload["title"] != "@marta" || payload["chat"] != "dm:marta" || payload["body"] != "новое сообщение" { + t.Errorf("нагрузка устройства %s: %v", got.device, payload) + } + } + svc.silent() +} diff --git a/internal/api/rooms.go b/internal/api/rooms.go index 5e884cd..eb91ea3 100644 --- a/internal/api/rooms.go +++ b/internal/api/rooms.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "time" + "unicode" "unicode/utf8" "github.com/xmatic-squad/bare/internal/auth" @@ -379,7 +380,32 @@ func uniqueNicks(list []string) ([]string, bool) { return out, true } -// validRoomName — имя комнаты: непустое, до 64 символов (ADR-021). +// validRoomName — имя комнаты: непустое, до 64 рун, без управляющих +// символов, без переопределений направления письма и не из одних +// пробелов (ADR-021). +// +// Форма строже, чем «до 64 символов», с этапа 4: имя комнаты уходит +// в заголовок системного уведомления (ADR-045), а туда нельзя ни перевод +// строки, ни разворот текста — на экране блокировки такое имя выглядит +// не строкой списка, а сообщением от системы. func validRoomName(name string) bool { - return name != "" && utf8.RuneCountInString(name) <= maxRoomName + if name == "" || utf8.RuneCountInString(name) > maxRoomName { + return false + } + blank := true + for _, r := range name { + if unicode.IsControl(r) || bidi(r) { + return false + } + if !unicode.IsSpace(r) { + blank = false + } + } + return !blank +} + +// bidi — переопределения направления письма: U+202A…U+202E и U+2066…U+2069. +// Они переставляют текст на экране местами, оставаясь невидимыми. +func bidi(r rune) bool { + return (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) } diff --git a/internal/api/rooms_test.go b/internal/api/rooms_test.go index ec6379f..a731c46 100644 --- a/internal/api/rooms_test.go +++ b/internal/api/rooms_test.go @@ -225,6 +225,17 @@ func TestCreateRoomRejects(t *testing.T) { {"имя длиннее 64", func(m map[string]any) { m["name"] = strings.Repeat("я", 65) }, http.StatusBadRequest, "invalid", "name"}, + // Имя уходит в заголовок системного уведомления (ADR-045): + // ни перевода строки, ни разворота текста в нём быть не должно. + {"имя с переводом строки", func(m map[string]any) { + m["name"] = "общая\nсрочно: перезагрузите телефон" + }, http.StatusBadRequest, "invalid", "name"}, + {"имя с bidi", func(m map[string]any) { + m["name"] = "общая\u202eяандекс" + }, http.StatusBadRequest, "invalid", "name"}, + {"имя из пробелов", func(m map[string]any) { + m["name"] = " " + }, http.StatusBadRequest, "invalid", "name"}, {"кривой keyId", func(m map[string]any) { m["keyId"] = "dm" }, http.StatusBadRequest, "invalid", "keyId"}, {"нет ключа", func(m map[string]any) { m["keys"] = []any{} }, http.StatusBadRequest, "keys_mismatch", ""}, {"ключ чужому", func(m map[string]any) { diff --git a/internal/config/config.go b/internal/config/config.go index 411e370..67579ad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,12 @@ type Config struct { VAPIDPrivate string // BARE_VAPID_PRIVATE VAPIDSubject string // BARE_VAPID_SUBJECT InviteCode string // BARE_INVITE_CODE — пусто означает открытую регистрацию + + // PushLocal разрешает отправку пушей на непубличные адреса. Из + // окружения не читается и в работе всегда false: сервер ходит + // только по публичным адресам (ADR-047). Поле существует ради + // тестов, где push-сервис вендора подменён сервером на 127.0.0.1. + PushLocal bool } // Значения по умолчанию — локальный запуск без окружения. diff --git a/internal/hub/hub.go b/internal/hub/hub.go index b39f4f5..23780e9 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -75,6 +75,14 @@ func (h *Hub) Send(device string, ev Event) { } } +// Connected — держит ли устройство открытый поток. Пуш уходит только +// молчащему устройству (ADR-023). +func (h *Hub) Connected(device string) bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.streams[device] != nil +} + // Close закрывает поток устройства: устройство удалили (docs/protocol.md, // «Устройства»). func (h *Hub) Close(device string) { diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..c758adb --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,467 @@ +// Package push отправляет веб-пуши устройствам (ADR-011, ADR-023). +// +// Пуш — сигнал, а не транспорт: он говорит, что для устройства что-то +// есть, а содержимое устройство забирает очередью при подключении. +// Плейнтекста сервер не знает, поэтому текст пуша — константа, а не поле. +package push + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "sync" + "syscall" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" + "github.com/xmatic-squad/bare/internal/config" +) + +// Параметры отправки из ADR-023. +const ( + ttl = 24 * time.Hour + urgency = webpush.UrgencyNormal +) + +// body — текст пуша. Константа, а не поле полезной нагрузки: сервер +// не знает плейнтекста сообщения и не может положить его в пуш даже +// по ошибке (ADR-011, docs/ui.md, «Уведомления»). +const body = "новое сообщение" + +// Пределы отправки (ADR-048). Пуш — побочный эффект доставки, ответа на +// POST /api/messages он не ждёт, но и «выстрелил и забыл» без границ +// не годится: недоступный push-сервис держит соединение до таймаута, +// и без предела такие отправки копились бы горутинами и сокетами, +// пока хватает памяти. Поэтому фиксированная очередь, фиксированное +// число отправщиков и доля одного аккаунта в них. +const ( + workers = 8 + // queueSize — сколько пушей ждут отправщика. Переполнение означает, + // что push-сервисы не справляются; лишний пуш отбрасывается, а не + // копится. Потери в этом нет: право на пуш забирается перед самой + // отправкой, поэтому у отброшенного устройства push_pending остаётся + // нулём и следующее сообщение попробует снова. + queueSize = 256 + // perAccount — сколько заданий одного аккаунта бывает в очереди и в + // работе одновременно. Без этой доли аккаунт с сотней устройств на + // молчащем эндпоинте занимал бы всех отправщиков, и пуши остальных + // пользователей отбрасывались бы (ADR-048). + perAccount = 4 + // requestTimeout — сколько ждём push-сервис. Вендоры отвечают за + // секунды; всё, что дольше, — уже недоступный сервис, а таймаут + // на задание задаёт пропускную способность отправки. + requestTimeout = 5 * time.Second + // dialTimeout — сколько ждём соединения с push-сервисом. + dialTimeout = 3 * time.Second + // storeTimeout — сколько ждём базу, когда правим подписку по итогам + // отправки. + storeTimeout = 5 * time.Second + // dropEvery — как часто в журнал уходит счётчик отброшенных пушей. + // Строка на каждый отброшенный пуш была бы усилителем заливки + // журнала: одно сообщение аккаунту с сотней устройств давало бы + // сотню строк (ADR-048). + dropEvery = time.Minute +) + +// Devices — что отправителю нужно от хранилища. Правило «одно молчащее +// устройство — один пуш» держится на атомарном захвате (ADR-023). +type Devices interface { + ClaimPush(ctx context.Context, device string) (subscription string, claimed bool, err error) + ReleasePush(ctx context.Context, device string) error + DropPush(ctx context.Context, device string) error +} + +// Payload — полезная нагрузка пуша (ADR-023). Заголовок — «@nick» +// отправителя или «#имя комнаты», chat — идентификатор чата для +// перехода: «dm:» или «room:». Текста сообщения здесь нет +// и быть не может. +type Payload struct { + Title string + Chat string +} + +// Target — кому нужен пуш: устройство и аккаунт, которому оно +// принадлежит. Аккаунт нужен, чтобы отмерить его долю в отправке +// (ADR-048). +type Target struct { + Device string + Owner string +} + +// wire — полезная нагрузка на проводе. +type wire struct { + Title string `json:"title"` + Body string `json:"body"` + Chat string `json:"chat"` +} + +// Sender — очередь отправки и отправщики за ней. +type Sender struct { + devices Devices + // connected — держит ли устройство поток событий. Спрашивается + // в момент захвата права на пуш, а не при постановке в очередь + // (ADR-023). + connected func(device string) bool + public string + private string + subject string + client *http.Client + logw io.Writer + + jobs chan job + done chan struct{} + stop sync.Once + wg sync.WaitGroup + + mu sync.Mutex + // share — сколько заданий аккаунта в очереди и в работе. + share map[string]int + // dropped — сколько пушей отброшено с прошлой строки в журнале. + dropped int + reported time.Time +} + +// job — один пуш: кому, от чьего имени доля и что. +type job struct { + device string + owner string + payload []byte +} + +// New собирает отправителя. Без полной пары VAPID-ключей и subject пуши +// выключены: отправлять их всё равно нечем (ADR-022, docs/deploy.md). +// Выключенный отправитель не заводит горутин и молча ничего не делает. +// +// connected отвечает, подключено ли устройство по SSE; nil означает +// «никто не подключён». +func New(cfg *config.Config, devices Devices, connected func(device string) bool, logw io.Writer) *Sender { + if connected == nil { + connected = func(string) bool { return false } + } + s := &Sender{ + devices: devices, + connected: connected, + public: cfg.VAPIDPublic, + private: cfg.VAPIDPrivate, + subject: cfg.VAPIDSubject, + client: &http.Client{ + Timeout: requestTimeout, + // Push-сервисы редиректов не шлют. Следование за ними + // означало бы, что проверка «endpoint — https» ничего + // не значит: один 307 уводит запрос вместе с VAPID-заголовком + // куда угодно, в том числе на plain http внутрь периметра + // (ADR-047). + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + Transport: transport(cfg.PushLocal), + }, + logw: logw, + share: make(map[string]int), + } + if !s.on() { + return s + } + s.jobs = make(chan job, queueSize) + s.done = make(chan struct{}) + s.wg.Add(workers) + for i := 0; i < workers; i++ { + go s.work() + } + return s +} + +// on — есть ли чем подписывать пуши. +func (s *Sender) on() bool { + return s.public != "" && s.private != "" && s.subject != "" +} + +// Send ставит пуш каждому из устройств в очередь отправки и возвращается +// сразу: конверт уже в очереди устройства, ответ на POST /api/messages +// пуша не ждёт (ADR-023). +// +// Заданий одного аккаунта в работе не больше perAccount: лишние +// отбрасываются здесь же, не занимая отправщика (ADR-048). +func (s *Sender) Send(targets []Target, p Payload) { + if !s.on() || len(targets) == 0 { + return + } + raw, err := json.Marshal(wire{Title: p.Title, Body: body, Chat: p.Chat}) + if err != nil { + s.report("сборка нагрузки: %v", err) + return + } + for _, t := range targets { + if !s.reserve(t.Owner) { + continue + } + // У каждого задания своя копия нагрузки: webpush-go дописывает + // набивку прямо в переданный срез, а одно сообщение уходит сразу + // нескольким устройствам и в разных отправщиках. + select { + case s.jobs <- job{device: t.Device, owner: t.Owner, payload: bytes.Clone(raw)}: + default: + s.free(t.Owner) + s.countDrop() + } + } + s.reportDrops(dropEvery) +} + +// Close останавливает отправщиков и дожидается начатых отправок. +func (s *Sender) Close() { + if !s.on() { + return + } + s.stop.Do(func() { close(s.done) }) + s.wg.Wait() + s.reportDrops(0) +} + +func (s *Sender) work() { + defer s.wg.Done() + for { + select { + case <-s.done: + return + case j := <-s.jobs: + s.deliver(j) + s.free(j.owner) + } + } +} + +// deliver забирает право на пуш и отправляет его. Контекст здесь свой: +// запрос, породивший пуш, к этому моменту давно отвечен. +func (s *Sender) deliver(j job) { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + + // Подключённому устройству пуш не нужен, и права на пуш ему брать + // нельзя: захваченное право сбрасывается только подключением, и на + // подключённом устройстве оно провисело бы всю сессию, съев пуш + // после ухода в офлайн. Поэтому проверка идёт здесь, рядом + // с захватом, а не при постановке в очередь (ADR-023). + if s.connected(j.device) { + return + } + subscription, claimed, err := s.devices.ClaimPush(ctx, j.device) + if err != nil { + s.report("захват: %v", err) + return + } + // Права нет: устройство без подписки или с неотработанным пушем. + // Одно молчащее устройство получает один пуш, не ленту (ADR-023). + if !claimed { + return + } + // Между проверкой и захватом устройство успевает подключиться: + // подключение сбрасывает право, а мы забрали его следом. + if s.connected(j.device) { + s.release(j.device) + return + } + + var to webpush.Subscription + if err := json.Unmarshal([]byte(subscription), &to); err != nil { + // Подписку в таком виде мог записать только сервер, и всё же: + // неразбираемая подписка не заработает никогда, снимаем. + s.report("подписка не разобрана") + s.drop(j.device) + return + } + + resp, err := webpush.SendNotificationWithContext(ctx, j.payload, &to, &webpush.Options{ + HTTPClient: s.client, + Subscriber: s.subject, + VAPIDPublicKey: s.public, + VAPIDPrivateKey: s.private, + TTL: int(ttl.Seconds()), + Urgency: urgency, + }) + if err != nil { + s.report("отправка: %s", reason(err)) + s.release(j.device) + return + } + defer resp.Body.Close() + // Тело ответа push-сервиса нам не нужно, но дочитать его стоит: + // иначе соединение не переиспользуется. + io.Copy(io.Discard, resp.Body) + + switch { + case resp.StatusCode < 300: + // Пуш принят: у устройства висит неотработанный пуш. Если оно + // успело подключиться, пока шла отправка, право возвращается: + // подключение сбрасывает его раньше, чем мы поставили. + if s.connected(j.device) { + s.release(j.device) + } + case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone: + // Подписки больше нет — чистим мёртвую (ADR-011). + s.drop(j.device) + default: + s.report("push-сервис ответил %d", resp.StatusCode) + s.release(j.device) + } +} + +// reserve занимает долю аккаунта в отправке. Доля израсходована — пуш +// отбрасывается: устройству от этого ничего не грозит, право на пуш +// ещё не забрано (ADR-048). +func (s *Sender) reserve(owner string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.share[owner] >= perAccount { + s.dropped++ + return false + } + s.share[owner]++ + return true +} + +// free возвращает долю аккаунта. +func (s *Sender) free(owner string) { + s.mu.Lock() + defer s.mu.Unlock() + if n := s.share[owner]; n > 1 { + s.share[owner] = n - 1 + } else { + delete(s.share, owner) + } +} + +// countDrop считает отброшенный пуш. +func (s *Sender) countDrop() { + s.mu.Lock() + defer s.mu.Unlock() + s.dropped++ +} + +// reportDrops пишет счётчик отброшенных пушей, но не чаще чем раз +// в every (ADR-048). +func (s *Sender) reportDrops(every time.Duration) { + s.mu.Lock() + n := s.dropped + if n == 0 || time.Since(s.reported) < every { + s.mu.Unlock() + return + } + s.dropped = 0 + s.reported = time.Now() + s.mu.Unlock() + s.report("отброшено пушей: %d", n) +} + +// release возвращает право на пуш: отправка не состоялась, ждать +// устройству нечего. Контекст здесь свой: отправка могла кончиться +// именно таймаутом, а на просроченном контексте запись не прошла бы +// и push_pending остался бы висеть. +func (s *Sender) release(device string) { + ctx, cancel := context.WithTimeout(context.Background(), storeTimeout) + defer cancel() + if err := s.devices.ReleasePush(ctx, device); err != nil { + s.report("возврат: %v", err) + } +} + +// drop снимает подписку по той же причине со своим контекстом. +func (s *Sender) drop(device string) { + ctx, cancel := context.WithTimeout(context.Background(), storeTimeout) + defer cancel() + if err := s.devices.DropPush(ctx, device); err != nil { + s.report("снятие подписки: %v", err) + } +} + +// report пишет строку в журнал. Ни идентификатора устройства, ни адреса +// подписки в ней нет: и то и другое — данные пользователя +// (docs/deploy.md, «Логи»). +func (s *Sender) report(format string, args ...any) { + if s.logw == nil { + return + } + fmt.Fprintf(s.logw, "%s пуш: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) +} + +// errLocalAddress — попытка соединиться с непубличным адресом (ADR-047). +var errLocalAddress = errors.New("push: адрес не публичный") + +// reason сводит отказ отправки к классу. Текст ошибки транспорта +// в журнал не идёт вовсе: внутри него лежит адрес подписки — host, порт +// или имя, — а это данные пользователя (docs/deploy.md, «Логи»). Класс +// отвечает на вопрос «что чинить», адрес для этого не нужен. +func reason(err error) string { + if errors.Is(err, errLocalAddress) { + return "адрес подписки не публичный" + } + if errors.Is(err, context.DeadlineExceeded) { + return "таймаут" + } + var dns *net.DNSError + if errors.As(err, &dns) { + return "имя не разрешилось" + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return "таймаут сети" + } + return "отправка не удалась" +} + +// transport — транспорт отправщика. Адрес push-сервиса выбирает браузер +// получателя, а сервер стоит во внутренней сети за nginx (ADR-022): +// без проверки любой вошедший пользователь заставил бы его стучаться +// внутрь периметра. Проверяется адрес соединения, то есть уже +// разрешённое имя, — подмена DNS не помогает (ADR-047). +// +// local снимает проверку и включается только в тестах: настоящий +// push-сервис в них подменён сервером на 127.0.0.1. Из окружения этот +// флаг не читается. +func transport(local bool) http.RoundTripper { + dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second} + if !local { + dialer.Control = onlyPublic + } + t := http.DefaultTransport.(*http.Transport).Clone() + t.DialContext = dialer.DialContext + return t +} + +// onlyPublic отказывает в соединении с непубличным адресом. +func onlyPublic(network, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return errLocalAddress + } + ip, err := netip.ParseAddr(host) + if err != nil { + return errLocalAddress + } + if !Public(ip) { + return errLocalAddress + } + return nil +} + +// Public — публичный ли адрес. Непубличными считаются loopback, +// link-local, приватные сети (RFC 1918 и RFC 4193), multicast +// и неопределённый адрес: push-сервиса по таким адресам не бывает, +// а внутренние службы бывают (ADR-047). +func Public(ip netip.Addr) bool { + ip = ip.Unmap() + if !ip.IsValid() { + return false + } + return !ip.IsLoopback() && + !ip.IsPrivate() && + !ip.IsLinkLocalUnicast() && + !ip.IsLinkLocalMulticast() && + !ip.IsInterfaceLocalMulticast() && + !ip.IsMulticast() && + !ip.IsUnspecified() +} diff --git a/internal/push/push_test.go b/internal/push/push_test.go new file mode 100644 index 0000000..1a0e9cf --- /dev/null +++ b/internal/push/push_test.go @@ -0,0 +1,145 @@ +package push + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "strings" + "testing" + + "github.com/xmatic-squad/bare/internal/config" +) + +// sender без VAPID-ключей: отправщиков он не заводит, а клиент собирает — +// именно клиент здесь и проверяется. +func client(t *testing.T, local bool) *http.Client { + t.Helper() + return New(&config.Config{PushLocal: local}, nil, nil, nil).client +} + +// Публичный адрес отличается от того, по которому push-сервиса не бывает +// (ADR-047). +func TestPublicAddress(t *testing.T) { + cases := []struct { + addr string + public bool + }{ + {"93.184.216.34", true}, + {"2606:2800:220:1:248:1893:25c8:1946", true}, + {"127.0.0.1", false}, + {"::1", false}, + {"10.0.0.1", false}, + {"172.16.5.4", false}, + {"192.168.1.1", false}, + {"169.254.169.254", false}, + {"fe80::1", false}, + {"fc00::1", false}, + {"0.0.0.0", false}, + {"::", false}, + {"224.0.0.1", false}, + {"::ffff:127.0.0.1", false}, + {"::ffff:10.0.0.1", false}, + } + for _, c := range cases { + ip, err := netip.ParseAddr(c.addr) + if err != nil { + t.Fatalf("%s: %v", c.addr, err) + } + if got := Public(ip); got != c.public { + t.Errorf("Public(%s): получено %v, ожидалось %v", c.addr, got, c.public) + } + } +} + +// Соединения с непубличным адресом не случается: проверка стоит на самом +// dial, поэтому её не обойти ни именем, ни редиректом (ADR-047). +func TestClientRefusesLocalAddress(t *testing.T) { + got := make(chan struct{}, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- struct{}{} + })) + defer srv.Close() + + resp, err := client(t, false).Get(srv.URL) + if err == nil { + resp.Body.Close() + t.Fatal("соединение с 127.0.0.1 состоялось") + } + if !errors.Is(err, errLocalAddress) { + t.Errorf("ошибка: %v, ожидался отказ по адресу", err) + } + if reason(err) != "адрес подписки не публичный" { + t.Errorf("класс отказа: %q", reason(err)) + } + select { + case <-got: + t.Error("внутренняя служба получила запрос") + default: + } +} + +// Редиректы push-сервиса не выполняются: иначе один 307 уводил бы запрос +// вместе с VAPID-заголовком куда угодно, и проверка «endpoint — https» +// не значила бы ничего (ADR-047). +func TestClientDoesNotFollowRedirect(t *testing.T) { + inside := make(chan struct{}, 1) + internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + inside <- struct{}{} + w.WriteHeader(http.StatusGone) + })) + defer internal.Close() + + vendor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, internal.URL+"/latest/meta-data/", http.StatusTemporaryRedirect) + })) + defer vendor.Close() + + // local: сами тестовые серверы живут на 127.0.0.1, проверяется здесь + // именно политика редиректов. + resp, err := client(t, true).Get(vendor.URL + "/push") + if err != nil { + t.Fatalf("запрос: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusTemporaryRedirect { + t.Errorf("статус: получено %d, ожидалось 307", resp.StatusCode) + } + select { + case <-inside: + t.Error("запрос ушёл по редиректу на внутренний адрес") + default: + } +} + +// Отказ отправки сводится к классу: адреса подписки в журнале нет +// (docs/deploy.md, «Логи»). +func TestReasonWithoutEndpoint(t *testing.T) { + const endpoint = "secret-host.push.example" + cases := []struct { + err error + want string + }{ + {fmt.Errorf("dial: %w", errLocalAddress), "адрес подписки не публичный"}, + {fmt.Errorf("post: %w", context.DeadlineExceeded), "таймаут"}, + {&url.Error{Op: "Post", URL: "https://" + endpoint + "/x", + Err: &net.DNSError{Err: "no such host", Name: endpoint}}, "имя не разрешилось"}, + {&url.Error{Op: "Post", URL: "https://" + endpoint + "/x", + Err: &net.OpError{Op: "read", Net: "tcp", + Addr: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 443}, + Err: errors.New("connection reset by peer")}}, "отправка не удалась"}, + } + for _, c := range cases { + got := reason(c.err) + if got != c.want { + t.Errorf("reason(%v): получено %q, ожидалось %q", c.err, got, c.want) + } + if strings.Contains(got, endpoint) || strings.Contains(got, "10.1.2.3") { + t.Errorf("адрес подписки попал в журнал: %q", got) + } + } +} diff --git a/internal/store/devices.go b/internal/store/devices.go index 5da765e..7cd83eb 100644 --- a/internal/store/devices.go +++ b/internal/store/devices.go @@ -127,3 +127,81 @@ func (s *Store) TouchDevice(ctx context.Context, id string, now int64) error { } return nil } + +// Push-подписка принадлежит устройству (ADR-023). Сервер хранит её как +// непрозрачный JSON: разбирает его только отправитель пушей. + +// SetPush ставит подписку устройства и сбрасывает неотработанный пуш: +// устройство снова готово его принять (ADR-023). Первое значение — было +// ли такое устройство у этого пользователя. +func (s *Store) SetPush(ctx context.Context, id, nick, subscription string) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_subscription = ?, push_pending = 0 + WHERE id = ? AND nick = ?`, subscription, id, nick) + if err != nil { + return false, fmt.Errorf("store: push-подписка: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: push-подписка: %w", err) + } + return n > 0, nil +} + +// ClearPush снимает подписку устройства. Подписки не было — это не +// ошибка: снимать нечего. +func (s *Store) ClearPush(ctx context.Context, id, nick string) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_subscription = NULL WHERE id = ? AND nick = ?`, id, nick) + if err != nil { + return false, fmt.Errorf("store: снятие push-подписки: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: снятие push-подписки: %w", err) + } + return n > 0, nil +} + +// ClaimPush забирает право на пуш: устройству с подпиской и без +// неотработанного пуша ставит push_pending = 1 и отдаёт подписку. +// Второе значение — досталось ли право. +// +// Захват и проверка — один запрос: два сообщения подряд приходят +// в разных горутинах, а молчащее устройство получает один пуш, не ленту +// (ADR-023). Проигравший запрос уходит ни с чем. +func (s *Store) ClaimPush(ctx context.Context, id string) (string, bool, error) { + var subscription string + err := s.db.QueryRowContext(ctx, ` + UPDATE devices SET push_pending = 1 + WHERE id = ? AND push_pending = 0 AND push_subscription IS NOT NULL + RETURNING push_subscription`, id).Scan(&subscription) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("store: захват пуша: %w", err) + } + return subscription, true, nil +} + +// ReleasePush возвращает право на пуш: отправка не состоялась, значит +// и неотработанного пуша у устройства нет. Иначе одна ошибка push-сервиса +// затыкала бы уведомления устройства до следующего подключения по SSE. +func (s *Store) ReleasePush(ctx context.Context, id string) error { + if _, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_pending = 0 WHERE id = ?`, id); err != nil { + return fmt.Errorf("store: возврат пуша: %w", err) + } + return nil +} + +// DropPush снимает мёртвую подписку: push-сервис ответил 404 или 410 +// (ADR-011). Неотработанного пуша заодно не остаётся — он никуда не ушёл. +func (s *Store) DropPush(ctx context.Context, id string) error { + if _, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_subscription = NULL, push_pending = 0 WHERE id = ?`, id); err != nil { + return fmt.Errorf("store: снятие мёртвой push-подписки: %w", err) + } + return nil +} diff --git a/internal/store/queue.go b/internal/store/queue.go index c84ae6c..e65f9af 100644 --- a/internal/store/queue.go +++ b/internal/store/queue.go @@ -55,6 +55,17 @@ func (s *Store) Ack(ctx context.Context, device string, ids []string) error { return nil } +// Target — устройство, которому конверт лёг в очередь. Ник рядом +// с идентификатором нужен пушу: устройства отправителя пуша не получают +// (ADR-045), а доля аккаунта в отправке ограничена (ADR-048). Признак +// подписки — оттуда же: устройству без неё пуш не отправить, и место +// в очереди отправки на него не тратится. +type Target struct { + ID string + Nick string + HasPush bool +} + // Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы // разложить его по очередям. Envelope сервер не разбирает, поэтому id // приходит отдельным полем. Заполнено ровно одно из To и Room — адресат @@ -78,7 +89,7 @@ type Delivery struct { // не хранит, повтор порождает повторную доставку, а склеивает её клиент // (ADR-017). Поэтому вставка молча пропускает уже лежащую в очереди // строку, а список устройств от этого не зависит. -func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { +func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]Target, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("store: доставка: %w", err) @@ -97,11 +108,11 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { if err != nil { return nil, err } - for _, id := range devices { + for _, device := range devices { if _, err := tx.ExecContext(ctx, ` INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(device_id, msg_id) DO NOTHING`, - id, d.MsgID, d.Envelope, d.Now); err != nil { + device.ID, d.MsgID, d.Envelope, d.Now); err != nil { return nil, fmt.Errorf("store: доставка (очередь): %w", err) } } @@ -118,7 +129,7 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { // Членство и keyId проверены раньше, отдельным запросом: между проверкой // и этой транзакцией состав мог измениться, поэтому получателей она берёт // из состава на момент доставки. -func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) { +func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]Target, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("store: доставка в комнату: %w", err) @@ -129,11 +140,11 @@ func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) { if err != nil { return nil, err } - for _, id := range devices { + for _, device := range devices { if _, err := tx.ExecContext(ctx, ` INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(device_id, msg_id) DO NOTHING`, - id, d.MsgID, d.Envelope, d.Now); err != nil { + device.ID, d.MsgID, d.Envelope, d.Now); err != nil { return nil, fmt.Errorf("store: доставка в комнату (очередь): %w", err) } } @@ -144,48 +155,49 @@ func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) { } // deviceIDs — устройства обоих собеседников, кроме отправившего. -func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]string, error) { +func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]Target, error) { rows, err := tx.QueryContext(ctx, ` - SELECT id FROM devices WHERE nick IN (?, ?) AND id <> ? ORDER BY id`, from, to, exclude) + SELECT id, nick, push_subscription IS NOT NULL + FROM devices WHERE nick IN (?, ?) AND id <> ? ORDER BY id`, from, to, exclude) if err != nil { return nil, fmt.Errorf("store: доставка (устройства): %w", err) } defer rows.Close() - var out []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("store: доставка (устройства): %w", err) - } - out = append(out, id) - } - if err := rows.Err(); err != nil { + out, err := targets(rows) + if err != nil { return nil, fmt.Errorf("store: доставка (устройства): %w", err) } return out, nil } // roomDeviceIDs — устройства всех участников комнаты, кроме отправившего. -func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]string, error) { +func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]Target, error) { rows, err := tx.QueryContext(ctx, ` - SELECT d.id FROM devices d JOIN room_members m ON m.nick = d.nick + SELECT d.id, d.nick, d.push_subscription IS NOT NULL + FROM devices d JOIN room_members m ON m.nick = d.nick WHERE m.room_id = ? AND d.id <> ? ORDER BY d.id`, room, exclude) if err != nil { return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) } defer rows.Close() - var out []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) - } - out = append(out, id) - } - if err := rows.Err(); err != nil { + out, err := targets(rows) + if err != nil { return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) } return out, nil } + +// targets собирает устройства получателей из выборки «id, nick, подписка». +func targets(rows *sql.Rows) ([]Target, error) { + var out []Target + for rows.Next() { + var t Target + if err := rows.Scan(&t.ID, &t.Nick, &t.HasPush); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} diff --git a/internal/store/rooms.go b/internal/store/rooms.go index 8255d77..7393a56 100644 --- a/internal/store/rooms.go +++ b/internal/store/rooms.go @@ -501,18 +501,33 @@ func (s *Store) DeleteRoom(ctx context.Context, roomID, owner string) (RoomChang return change, nil } -// RoomAccess — что сервер проверяет перед отправкой в комнату -// (docs/protocol.md, «Сообщения»). keyId считается ключом комнаты, если -// есть хоть одна строка room_keys с таким key_id (docs/storage.md). -func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (member, knownKey bool, err error) { - err = s.db.QueryRowContext(ctx, ` - SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?), - EXISTS(SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ?)`, - roomID, nick, roomID, keyID).Scan(&member, &knownKey) - if err != nil { - return false, false, fmt.Errorf("store: доступ к комнате: %w", err) +// Access — что сервер знает о комнате перед отправкой в неё +// (docs/protocol.md, «Сообщения»). Имя нужно заголовку пуша: «#имя +// комнаты» (ADR-023). +type Access struct { + Member bool + KnownKey bool + Name string +} + +// RoomAccess — что сервер проверяет перед отправкой в комнату. keyId +// считается ключом комнаты, если есть хоть одна строка room_keys с таким +// key_id (docs/storage.md). Несуществующая комната отвечает пустым +// Access: снаружи она неотличима от чужой. +func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (Access, error) { + var a Access + err := s.db.QueryRowContext(ctx, ` + SELECT r.name, + EXISTS(SELECT 1 FROM room_members WHERE room_id = r.id AND nick = ?), + EXISTS(SELECT 1 FROM room_keys WHERE room_id = r.id AND key_id = ?) + FROM rooms r WHERE r.id = ?`, nick, keyID, roomID).Scan(&a.Name, &a.Member, &a.KnownKey) + if errors.Is(err, sql.ErrNoRows) { + return Access{}, nil } - return member, knownKey, nil + if err != nil { + return Access{}, fmt.Errorf("store: доступ к комнате: %w", err) + } + return a, nil } // currentKeysQuery — текущий ключ участника: строка room_keys с максимальным diff --git a/internal/store/rooms_test.go b/internal/store/rooms_test.go index 866bff8..5923891 100644 --- a/internal/store/rooms_test.go +++ b/internal/store/rooms_test.go @@ -155,9 +155,12 @@ func TestRoomKeysWithinOneMillisecond(t *testing.T) { } } // Свежим ключом можно писать: он остался ключом комнаты. - member, known, err := s.RoomAccess(ctx, "room-1", "marta", "aaa") - if err != nil || !member || !known { - t.Errorf("доступ по свежему ключу: member=%v known=%v err=%v", member, known, err) + access, err := s.RoomAccess(ctx, "room-1", "marta", "aaa") + if err != nil || !access.Member || !access.KnownKey { + t.Errorf("доступ по свежему ключу: %+v, %v", access, err) + } + if access.Name != "общая" { + t.Errorf("имя комнаты: получено %q, ожидалось \"общая\"", access.Name) } } diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..af42cfa --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,68 @@ +package web_test + +import ( + "io/fs" + "regexp" + "testing" + + bare "github.com/xmatic-squad/bare" +) + +// shellRe — массив SHELL из web/sw.js: перечень оболочки списком строк. +var shellRe = regexp.MustCompile(`(?s)const SHELL = \[(.*?)\];`) + +var pathRe = regexp.MustCompile(`"([^"]+)"`) + +// Оболочка в sw.js перечислена вручную (у Cache API нет масок), и забытый +// в ней файл ломает только офлайн — молча. Поэтому список сверяется +// с содержимым embed: оболочка — всё из web/, кроме самого sw.js; +// index.html лежит в кэше под адресом «/» (ADR-023). +func TestShellCoversStatic(t *testing.T) { + root, err := fs.Sub(bare.Web, "web") + if err != nil { + t.Fatalf("embed: %v", err) + } + worker, err := fs.ReadFile(root, "sw.js") + if err != nil { + t.Fatalf("sw.js: %v", err) + } + block := shellRe.FindSubmatch(worker) + if block == nil { + t.Fatal("в sw.js нет массива SHELL") + } + shell := make(map[string]bool) + for _, m := range pathRe.FindAllSubmatch(block[1], -1) { + shell[string(m[1])] = true + } + + want := make(map[string]bool) + err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + switch { + case d.IsDir(), p == "sw.js": + // Обновление воркера ведёт браузер, в кэш он не кладётся. + return nil + case p == "index.html": + want["/"] = true + default: + want["/"+p] = true + } + return nil + }) + if err != nil { + t.Fatalf("обход embed: %v", err) + } + + for p := range want { + if !shell[p] { + t.Errorf("%s есть в web/, но не в SHELL: офлайн он не откроется", p) + } + } + for p := range shell { + if !want[p] { + t.Errorf("%s есть в SHELL, но не в web/: install воркера упадёт целиком", p) + } + } +} diff --git a/web/app.css b/web/app.css index 3bb9a83..322a781 100644 --- a/web/app.css +++ b/web/app.css @@ -473,6 +473,58 @@ input[type="password"] { color: var(--mute); } +/* уведомления и установка — docs/ui.md, «Настройки» */ + +.state { + margin: 0 0 12px; + font-size: 13px; + color: var(--text2); +} + +.install { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--mute); +} + +/* баннер установки — docs/ui.md, «Баннер установки (iOS)». + Цель нажатия у крестика — 44 px, отрицательные поля не дают ей + растянуть сам баннер */ + +.banner-slot { + flex: none; +} + +.banner { + display: flex; + align-items: flex-start; + padding: 12px 20px; + border-bottom: 1px solid var(--line); +} + +.banner__text { + margin: 0; + font-size: 11px; + line-height: 1.5; + color: var(--mute); +} + +.banner__close { + flex: none; + width: 44px; + height: 44px; + margin: -12px -14px -12px auto; + padding: 0; + border: 0; + background: none; + color: var(--stone); + font: inherit; + font-size: 15px; + line-height: 1; + cursor: pointer; +} + /* участники комнаты — docs/ui.md, «Участники» */ .members { diff --git a/web/js/api.js b/web/js/api.js index d2f13d2..46060a0 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -165,6 +165,17 @@ export function removeDevice(id) { return request("DELETE", `/api/devices/${encodeURIComponent(id)}`); } +// setPush и clearPush — push-подписка устройства (ADR-023). Подписка +// принадлежит устройству, поэтому устройство идёт и в пути, и в заголовке: +// чужому подписку не поставить (docs/protocol.md, «Устройства»). +export function setPush(device, subscription) { + return request("PUT", `/api/devices/${encodeURIComponent(device)}/push`, { subscription }, { device }); +} + +export function clearPush(device) { + return request("DELETE", `/api/devices/${encodeURIComponent(device)}/push`, undefined, { device }); +} + // --- контакты ---------------------------------------------------------- export function contacts() { diff --git a/web/js/main.js b/web/js/main.js index c8453ee..821e693 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -5,7 +5,9 @@ // и отдаётся экранам через ctx. import * as api from "./api.js"; +import { NetworkError } from "./api.js"; import * as db from "./db.js"; +import * as pwa from "./pwa.js"; import * as sync from "./sync.js"; import { deriveAccountKeys, @@ -183,12 +185,20 @@ async function ensureConfig() { // restore отвечает на вопрос «вошли ли мы»: сессия у сервера и ключи // на устройстве нужны вместе. Ключей нет — нужен вход, он их и вернёт. +// +// Запрос, который не дошёл, — это «нет соединения», а не «мы не вошли» +// (docs/ui.md, «Сеть и состояния»): офлайн-старт установленного +// приложения поднимается из кэша с ключами и историей устройства, +// а полосу «нет соединения» рисует sync. Если сессии и правда нет, +// первый дошедший запрос ответит 401 unauthenticated и уведёт на вход. async function restore() { - let who; + let who = null; try { who = await api.me(); - } catch { - return null; + } catch (err) { + if (!(err instanceof NetworkError)) { + return null; + } } let meta; try { @@ -196,7 +206,10 @@ async function restore() { } catch { return null; } - if (!meta.privateKey || meta.nick !== who.nick) { + if (!meta.privateKey || !meta.nick) { + return null; + } + if (who !== null && meta.nick !== who.nick) { return null; } return { nick: meta.nick, publicKey: meta.publicKey, fingerprint: meta.fingerprint }; @@ -320,8 +333,12 @@ async function adopt(nick, priv, secret) { // connect поднимает поток событий и синхронизацию. Отказы разбирает сам // sync: экран входа их уже не касается. +// +// Подписка на пуши переставляется на текущее устройство сразу после +// того, как оно завелось: она живёт в браузерном профиле и про смену +// deviceId сама не узнаёт (ADR-046). function connect() { - sync.start().catch(() => {}); + sync.start().then(() => pwa.refresh(state.config?.vapidPublicKey)).catch(() => {}); } // raise — автоматическое повышение итераций сразу после входа, молча @@ -380,6 +397,11 @@ async function deleteAccount(password) { } async function signOut() { + // Подписка снимается на сервере, пока сессия ещё жива: устройство + // остаётся у аккаунта, и живая строка в базе слала бы пуши прежнего + // аккаунта человеку, который вошёл на этом устройстве под другим + // (ADR-046). + await dropPush(); try { await api.dropSession(); } catch { @@ -388,10 +410,29 @@ async function signOut() { await forget(); } +// dropPush снимает подписку у сервера. Отказ ничего не меняет: 401 +// означает, что сессии и так нет, а всё прочее чинится отпиской +// у push-сервиса и правилом 404/410 (ADR-011). +async function dropPush() { + const device = sync.deviceId(); + if (!device) { + return; + } + try { + await api.clearPush(device); + } catch { + // Не сняли — снимет push-сервис и правило мёртвых подписок. + } +} + // forget уносит историю: она на этом устройстве единственная копия -// (docs/storage.md, docs/ui.md). +// (docs/storage.md, docs/ui.md). Подписка на пуши уходит вместе с ней: +// она принадлежала устройству этого аккаунта (ADR-046). async function forget() { sync.stop(); + // Подписка снимается своим ходом: выход ждёт стирания истории, + // а не push-сервиса. + pwa.detach().catch(() => {}); await db.destroy(); state.me = null; } @@ -407,6 +448,15 @@ function errorText(err) { async function boot() { db.persist(); + // Service worker ставится с первой секунды: кэш оболочки нужен и до + // входа, а пуши приходят в него же (ADR-023). Отказ ничего не ломает. + pwa.register(); + // Первое успешно отправленное сообщение за всю историю устройства — + // единственный повод спросить разрешение на уведомления (ADR-011); + // «один раз» считает pwa.js. + sync.onSent(() => { + pwa.askOnce(state.config?.vapidPublicKey).catch(() => {}); + }); // Обработчик ставится раньше первого запроса: 401 unauthenticated // на любом из них — на экран входа, IndexedDB цела. api.onSessionExpired(() => { diff --git a/web/js/pwa.js b/web/js/pwa.js new file mode 100644 index 0000000..b91c723 --- /dev/null +++ b/web/js/pwa.js @@ -0,0 +1,380 @@ +// PWA: service worker, подписка на пуши и установка приложения +// (ADR-011, ADR-023, ADR-046). +// +// Экраны спрашивают отсюда состояние и сюда же отдают действия; в +// pushManager, IndexedDB и сеть они не ходят — как и с чатом, это делает +// один модуль. +// +// Пуш — сигнал: он говорит, что для устройства что-то есть, а содержимое +// приезжает очередью при подключении (ADR-011). Поэтому здесь нет ни +// сообщений, ни ключей — только подписка и разрешение. + +import * as api from "./api.js"; +import { NetworkError } from "./api.js"; +import * as db from "./db.js"; +import { b64url, unb64url } from "./crypto.js"; +import { deviceId } from "./sync.js"; + +const WORKER = "/sw.js"; + +// iOS: пуши работают только у приложения, установленного на экран «Домой» +// (ADR-011). Признак — docs/ui.md, «Баннер установки»: iPhone|iPad +// и navigator.standalone !== true. +const IOS = /iPhone|iPad/; + +const state = { + // Регистрация service worker: одна на страницу, ждут её все. + registering: null, + // beforeinstallprompt приходит один раз и ждёт кнопки в настройках + // (docs/ui.md, «Настройки»). + prompt: null, + // Разрешение спрашивается один раз за всю историю устройства + // (docs/ui.md, «Уведомления»); флаг в памяти закрывает вкладку от + // повторного вопроса, флаг в meta — устройство. + asked: false, + // Вопрос идёт прямо сейчас: два сообщения подряд не должны дать + // два запроса разрешения. + asking: false, +}; + +// Приглашение установки ловится с первой секунды: браузер показывает его +// сам и только раз. Предотвращённое событие оживает кнопкой в настройках. +addEventListener("beforeinstallprompt", (event) => { + event.preventDefault(); + state.prompt = event; +}); + +// --- service worker ----------------------------------------------------- + +// register ставит service worker. Отдаёт регистрацию или null: браузер +// без service worker — это просто клиент без кэша оболочки и пушей, +// а не сломанный клиент. +export function register() { + if (!("serviceWorker" in navigator)) { + return Promise.resolve(null); + } + if (state.registering === null) { + state.registering = navigator.serviceWorker.register(WORKER).catch(() => null); + } + return state.registering; +} + +// ready — регистрация с работающим service worker: подписка ставится +// только на неё. navigator.serviceWorker.ready ждёт вечно, если +// регистрации нет, — поэтому сначала register. +async function ready() { + const registration = await register(); + if (registration === null) { + return null; + } + try { + return await navigator.serviceWorker.ready; + } catch { + return null; + } +} + +// --- уведомления -------------------------------------------------------- + +// supported — есть ли в браузере то, из чего складывается пуш. iOS вне +// установленного приложения сюда не проходит: там нет ни Notification, +// ни PushManager. +function supported() { + return "serviceWorker" in navigator + && "PushManager" in self + && "Notification" in self; +} + +// notifications — состояние раздела «уведомления» (docs/ui.md): +// "on" — «включены», "off" — «выключены», "denied" — «запрещены +// в браузере». В "denied" сходится всё, чего кнопкой не включить: +// отклонённое разрешение, браузер без уведомлений, сервер без +// VAPID-ключа (ADR-046). +export async function notifications(key) { + if (!supported() || !key || Notification.permission === "denied") { + return "denied"; + } + if (await turnedOff()) { + return "off"; + } + const subscription = await current(); + if (subscription === null) { + return "off"; + } + // Подписка под прежней парой VAPID-ключей не работает и не починится + // сама: push-сервис отвечает на неё 403, а это не 404 и не 410, и + // сервер её не снимет. Для человека это «выключены», а кнопка + // «включить» подпишет заново под текущим ключом (ADR-049). + return sameKey(subscription, key) ? "on" : "off"; +} + +// turnedOff — уведомления выключены кнопкой в настройках. Явный отказ +// сильнее любой оставшейся подписки: сама она больше не включается +// (ADR-049). +async function turnedOff() { + try { + return (await db.meta(["notificationsOff"])).notificationsOff === true; + } catch { + return false; + } +} + +// enable — «включить». Разрешение спрашивается по нажатию, подписка +// ставится после granted (docs/ui.md, «Уведомления»). Отдаёт новое +// состояние. +export async function enable(key) { + if (!supported() || !key) { + return "denied"; + } + let permission; + try { + permission = await Notification.requestPermission(); + } catch { + return "denied"; + } + if (permission !== "granted") { + return permission === "denied" ? "denied" : "off"; + } + // Человек решил всё сам: отказа больше нет, и спрашивать после первого + // сообщения не о чем (ADR-049). + state.asked = true; + await db.putMeta({ notificationsAsked: true, notificationsOff: false }).catch(() => {}); + return (await attach(key)) ? "on" : "denied"; +} + +// disable — «выключить». Подписка снимается у push-сервиса и у сервера: +// первое действует сразу, второе убирает мёртвую строку из базы. +// +// Отказ запоминается раньше всего остального: без него первое же +// отправленное сообщение вернуло бы подписку через askOnce, а запуск +// приложения — через refresh (ADR-049). +export async function disable() { + await db.putMeta({ notificationsOff: true }).catch(() => {}); + const subscription = await current(); + if (subscription !== null) { + try { + await subscription.unsubscribe(); + } catch { + // Отписаться не дали: сервер уберёт подписку по 404/410 (ADR-011). + } + } + const device = deviceId(); + if (device) { + await api.clearPush(device); + } +} + +// askOnce — запрос разрешения после первого успешно отправленного +// сообщения за всю историю устройства, один раз (ADR-011, docs/ui.md, +// «Уведомления»). Отказ — молча: включить можно в настройках. +// +// На iOS вне установленного приложения вопроса нет вовсе: там вместо +// него баннер установки (ADR-023), а флаг не ставится — установят, +// спросим после следующего сообщения. +export async function askOnce(key) { + if (state.asked || state.asking) { + return; + } + state.asking = true; + try { + let meta; + try { + meta = await db.meta(["notificationsAsked", "notificationsOff"]); + } catch { + return; + } + // Выключенные в настройках уведомления сами не включаются: вопрос + // закрыт человеком, а не нами (ADR-049). + if (meta.notificationsOff === true) { + return; + } + if (meta.notificationsAsked === true) { + state.asked = true; + return; + } + if (iosBrowser() || !supported() || !key) { + return; + } + // Спрашивать нечего: разрешение уже дано или уже отклонено. Данное — + // повод поставить подписку, если её нет. + if (Notification.permission !== "default") { + await remember(); + if (Notification.permission === "granted") { + await attach(key).catch(() => {}); + } + return; + } + let permission; + try { + permission = await Notification.requestPermission(); + } catch { + // Браузер требует нажатия, а между отправкой и ответом сервера оно + // истекло. Вопроса не было — значит, «один раз» ещё не потрачено: + // попробуем после следующего сообщения. + return; + } + await remember(); + if (permission === "granted") { + await attach(key).catch(() => {}); + } + } finally { + state.asking = false; + } +} + +// remember — вопрос задан, второй раз не спрашиваем ни в этой вкладке, +// ни на этом устройстве (docs/ui.md, «Уведомления»). +async function remember() { + state.asked = true; + await db.putMeta({ notificationsAsked: true }).catch(() => {}); +} + +// refresh переставляет подписку на текущее устройство. Подписка живёт +// в браузерном профиле, а принадлежит устройству (ADR-023): deviceId +// меняется при конфликте идентификаторов и после чистки IndexedDB, +// и запуск приложения это чинит (ADR-046). +// +// Выключенные уведомления запуск не включает, а подписку под прежним +// ключом сервера не переставляет: она всё равно не работает, и место ей +// не в базе, а в кнопке «включить» (ADR-049). +export async function refresh(key) { + if (await turnedOff()) { + return; + } + const subscription = await current(); + if (subscription === null) { + return; + } + if (key && !sameKey(subscription, key)) { + return; + } + try { + await put(subscription); + } catch { + // Не переставили — переставим при следующем запуске. + } +} + +// detach снимает подписку у push-сервиса при выходе из аккаунта +// и при его удалении: подписка принадлежит устройству, а устройство — +// аккаунту (ADR-046). Сервер не спрашиваем: сессии к этому моменту +// уже нет, а мёртвую подписку он уберёт сам по 404/410. +export async function detach() { + const subscription = await current(); + if (subscription === null) { + return; + } + try { + await subscription.unsubscribe(); + } catch { + // Не отписались — пуши всё равно некуда доставлять: истории на + // устройстве больше нет. + } +} + +// current — подписка этого браузера или null. +async function current() { + const registration = await ready(); + if (registration === null || !registration.pushManager) { + return null; + } + try { + return await registration.pushManager.getSubscription(); + } catch { + return null; + } +} + +// attach ставит подписку и отдаёт её серверу. Ключ сервера вплетён +// в подписку: сменился ключ — прежняя подписка не годится, push-сервис +// подпишет заново. +async function attach(key) { + const registration = await ready(); + if (registration === null || !registration.pushManager) { + return false; + } + let subscription = await registration.pushManager.getSubscription(); + if (subscription !== null && !sameKey(subscription, key)) { + try { + await subscription.unsubscribe(); + } catch { + // Старая подписка останется у push-сервиса; сервер её не знает. + } + subscription = null; + } + if (subscription === null) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: unb64url(key), + }); + } + await put(subscription); + return true; +} + +// put отдаёт подписку серверу. Без устройства запрос невозможен: +// подписка принадлежит устройству, а его заводит подключение +// (ADR-017). Это то же состояние, что и не дошедший запрос. +async function put(subscription) { + const device = deviceId(); + if (!device) { + throw new NetworkError(); + } + await api.setPush(device, subscription.toJSON()); +} + +// sameKey — та ли пара VAPID-ключей, под которую выдана подписка. +function sameKey(subscription, key) { + const applied = subscription.options?.applicationServerKey; + if (!applied) { + return false; + } + try { + return b64url(new Uint8Array(applied)) === key; + } catch { + return false; + } +} + +// --- установка ---------------------------------------------------------- + +// iosBrowser — iPhone или iPad вне установленного приложения. Ровно этот +// признак показывает баннер установки (docs/ui.md). +export function iosBrowser() { + return IOS.test(navigator.userAgent) && navigator.standalone !== true; +} + +// installable — поймано ли приглашение установки. +export function installable() { + return state.prompt !== null; +} + +// install показывает приглашение установки. Оно одноразовое: показали — +// кнопки больше нет. +export async function install() { + const prompt = state.prompt; + if (prompt === null) { + return; + } + state.prompt = null; + try { + await prompt.prompt(); + await prompt.userChoice; + } catch { + // Приглашение протухло: браузер покажет своё, когда сочтёт нужным. + } +} + +// bannerHidden — баннер установки уже закрывали (docs/ui.md). +export async function bannerHidden() { + try { + return (await db.meta(["installBannerDismissed"])).installBannerDismissed === true; + } catch { + return true; + } +} + +// hideBanner — крестик: повтор не показывается. +export async function hideBanner() { + await db.putMeta({ installBannerDismissed: true }).catch(() => {}); +} diff --git a/web/js/sync.js b/web/js/sync.js index 870207c..b18fbf2 100644 --- a/web/js/sync.js +++ b/web/js/sync.js @@ -338,6 +338,17 @@ export function deviceId() { return state.device; } +// onSent ставит обработчик успешной отправки: по первой из них клиент +// один раз просит разрешение на уведомления (docs/ui.md, «Уведомления»). +// «Первой за всю историю устройства» это делает не здесь: транспорт +// не знает ни про разрешения, ни про то, о чём уже спрашивали. Ставит +// обработчик main.js. +let sentHandler = () => {}; + +export function onSent(handler) { + sentHandler = handler; +} + // --- устройство --------------------------------------------------------- // ensureDevice — deviceId устройства: 16 случайных байт base64url, @@ -1518,6 +1529,13 @@ async function post(message, peer, roomId) { const sent = { ...message, status: "sent", ts: answer?.ts ?? message.ts }; await db.saveMessages({ messages: [sent], me: state.nick }); notify([sent]); + // Сообщение ушло: обработчик решает, спрашивать ли разрешение + // на уведомления. Отправку он не задерживает и сорвать не может. + try { + sentHandler(); + } catch { + // Дело обработчика; отправка состоялась. + } return null; } catch (err) { // Ответ с кодом — то же доказательство, что запрос дошёл, что и 202: diff --git a/web/js/ui/dom.js b/web/js/ui/dom.js index 9e98695..57e8468 100644 --- a/web/js/ui/dom.js +++ b/web/js/ui/dom.js @@ -12,6 +12,11 @@ export function wide() { return matchMedia(DESKTOP).matches; } +// INSTALL_IOS — текст про установку на iOS. Он один и тот же в баннере +// над списком чатов и в настройках (docs/ui.md), поэтому и живёт в одном +// месте. +export const INSTALL_IOS = "уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»"; + export function el(tag, className, text) { const node = document.createElement(tag); if (className) { diff --git a/web/js/ui/settings.js b/web/js/ui/settings.js index 4130793..d69da30 100644 --- a/web/js/ui/settings.js +++ b/web/js/ui/settings.js @@ -1,15 +1,30 @@ // Настройки — docs/ui.md, «Настройки». На этом этапе только разделы, -// которые уже работают: кто ты, смена пароля, выход, удаление аккаунта. -// Уведомления, устройства, история и установка приложения — дальше по плану. +// которые уже работают: кто ты, уведомления, установка приложения, смена +// пароля, выход, удаление аккаунта. Устройства и история — дальше по плану. import { ApiError } from "../api.js"; import { fingerprintGroups } from "../crypto.js"; -import { button, confirmPanel, el, field, message, setError, setNote } from "./dom.js"; +import * as pwa from "../pwa.js"; +import { INSTALL_IOS, button, confirmPanel, el, field, message, setError, setNote } from "./dom.js"; + +// Состояния уведомлений и инструкция установки — docs/ui.md, «Настройки». +const NOTIFICATIONS = { + on: "включены", + off: "выключены", + denied: "запрещены в браузере", +}; + +const INSTALL_HINT = "поделиться → на экран «домой»"; export function renderSettings(root, ctx) { root.append(head(ctx)); const body = el("div", "body settings"); - body.append(identity(ctx), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx)); + body.append(identity(ctx), notificationsBlock(ctx)); + const install = installBlock(); + if (install !== null) { + body.append(install); + } + body.append(passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx)); root.append(body); } @@ -31,6 +46,97 @@ function identity(ctx) { return box; } +// notificationsBlock — «уведомления»: состояние и одна кнопка +// (docs/ui.md, «Настройки»). Состояний три; «запрещены в браузере» — +// это и отклонённое разрешение, и браузер без уведомлений, и сервер без +// VAPID-ключа: включать нечем, кнопки нет (ADR-046). +function notificationsBlock(ctx) { + const box = block("уведомления"); + const status = el("p", "state", ""); + // На iOS вне установленного приложения кнопки нет: там пуши работают + // только у приложения на экране «Домой» (ADR-011). + const ios = pwa.iosBrowser(); + const action = button("включить"); + action.hidden = true; + const note = message(); + if (ios) { + box.append(status, el("p", "install", INSTALL_IOS), note); + } else { + box.append(status, action, note); + } + + let mode = "denied"; + const paint = async () => { + if (ios) { + status.textContent = NOTIFICATIONS.off; + return; + } + mode = await pwa.notifications(await vapidKey(ctx)); + status.textContent = NOTIFICATIONS[mode]; + action.textContent = mode === "on" ? "выключить" : "включить"; + action.hidden = mode === "denied"; + }; + + action.addEventListener("click", async () => { + if (action.disabled) { + return; + } + action.disabled = true; + setNote(note, ""); + try { + if (mode === "on") { + await pwa.disable(); + } else { + await pwa.enable(await vapidKey(ctx)); + } + } catch (err) { + setError(note, ctx.errorText(err)); + } finally { + action.disabled = false; + await paint(); + } + }); + + paint(); + return box; +} + +// vapidKey — публичный ключ сервера для подписки (docs/protocol.md, +// «Публичные»). Конфигурации нет — подписаться нечем. +async function vapidKey(ctx) { + try { + return (await ctx.ensureConfig()).vapidPublicKey ?? ""; + } catch { + return ""; + } +} + +// installBlock — «установить приложение» (docs/ui.md, «Настройки»). +// Кнопка есть, если поймано beforeinstallprompt; на iOS вместо неё +// инструкция. Устанавливать нечего — раздела нет. +function installBlock() { + const ios = pwa.iosBrowser(); + if (!ios && !pwa.installable()) { + return null; + } + const box = block("установить приложение"); + if (ios) { + box.append(el("p", "install", INSTALL_HINT)); + return box; + } + const action = button("установить"); + action.addEventListener("click", () => { + // Приглашение одноразовое: показали — устанавливать этим разделом + // больше нечего, и раздела нет (docs/ui.md, «Настройки»). Раздел + // уходит сразу: дальше человек отвечает браузеру, а не нам, и ждать + // его ответа кнопке незачем. + pwa.install(); + box.remove(); + }); + box.append(action); + return box; +} + function passwordBlock(ctx) { const box = block("сменить пароль"); const form = el("form", "form"); diff --git a/web/js/ui/shell.js b/web/js/ui/shell.js index eb9d2d0..10a9652 100644 --- a/web/js/ui/shell.js +++ b/web/js/ui/shell.js @@ -1,7 +1,8 @@ // Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас» // и «Список чатов». -import { el, mark } from "./dom.js"; +import * as pwa from "../pwa.js"; +import { INSTALL_IOS, clear, el, mark } from "./dom.js"; import { mount } from "./chats.js"; // frame отдаёт корень, место под экран и отписку списка чатов. @@ -23,6 +24,12 @@ function side(ctx, active) { brand.append(mark(), el("span", null, "bare")); nav.append(brand); + // Баннер установки — над списком чатов (docs/ui.md, «Баннер установки»). + // Место под него занимается сразу, содержимое приезжает из meta. + const place = el("div", "banner-slot"); + nav.append(place); + banner(place); + const list = el("div", "list"); const add = el("button", "item item--new", "+ новый чат"); add.type = "button"; @@ -41,3 +48,22 @@ function side(ctx, active) { return { nav, dispose: mount(items, ctx, active) }; } + +// banner — баннер установки на iOS: пуши там работают только +// у установленного приложения (ADR-011). Крестик закрывает его насовсем. +async function banner(place) { + if (!pwa.iosBrowser() || await pwa.bannerHidden()) { + return; + } + const box = el("div", "banner"); + box.append(el("p", "banner__text", INSTALL_IOS)); + const close = el("button", "banner__close", "×"); + close.type = "button"; + close.setAttribute("aria-label", "закрыть"); + close.addEventListener("click", () => { + clear(place); + pwa.hideBanner(); + }); + box.append(close); + place.append(box); +} diff --git a/web/sw.js b/web/sw.js index 9f2a8df..7e59f29 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,8 +1,213 @@ -// service worker. пока пустой: кэш оболочки и пуши — этап 4 (ADR-023). -// версия кэша меняется при релизе. +// Service worker: кэш оболочки, пуши и переход по уведомлению (ADR-023). +// +// Оболочка отдаётся stale-while-revalidate: сначала из кэша, следом — +// проверка у сервера. Всё под /api/ не кэшируется никогда: ни ответы, +// ни ошибки — это чужая почта, а не оболочка. +// +// Имя кэша содержит версию; версия — константа, она меняется при релизе, +// и старые кэши уходят в activate. -const CACHE = "bare-v1"; +const VERSION = "v2"; +const CACHE = `bare-${VERSION}`; -self.addEventListener("install", () => {}); +// Оболочка — всё, из чего клиент поднимается без сети. Список явный: +// у Cache API нет масок, а угадывать нечего — файлов немного и они +// перечислены в docs/plan.md (ADR-001). +const SHELL = [ + "/", + "/app.css", + "/manifest.json", + "/js/api.js", + "/js/crypto.js", + "/js/db.js", + "/js/main.js", + "/js/pwa.js", + "/js/sync.js", + "/js/ulid.js", + "/js/ui/auth.js", + "/js/ui/chat.js", + "/js/ui/chats.js", + "/js/ui/contact.js", + "/js/ui/dom.js", + "/js/ui/members.js", + "/js/ui/new.js", + "/js/ui/settings.js", + "/js/ui/shell.js", + "/icons/icon.svg", + "/icons/mark.svg", + "/icons/icon-180.png", + "/icons/icon-192.png", + "/icons/icon-512.png", +]; -self.addEventListener("activate", () => {}); +// Иконка уведомления — знак из /icons/ (ADR-023). +const ICON = "/icons/icon-192.png"; + +// Идентификатор чата из нагрузки пуша (ADR-023) и маршруты клиента +// (docs/ui.md, «Каркас») — разные формы одного и того же; перевод +// одной в другую — ADR-046. +const DM = /^dm:([a-z0-9_]{2,32})$/; +const ROOM = /^room:([A-Za-z0-9_-]{22})$/; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); +}); + +// activate уносит кэши прежних версий: имя кэша содержит версию, и всё, +// что названо иначе, — прошлый релиз. clients.claim берёт под контроль +// уже открытую страницу: без этого первый запуск остался бы без кэша +// и без перехода по уведомлению. +self.addEventListener("activate", (event) => { + event.waitUntil((async () => { + for (const name of await caches.keys()) { + if (name !== CACHE) { + await caches.delete(name); + } + } + await self.clients.claim(); + })()); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") { + return; + } + const url = new URL(request.url); + if (url.origin !== self.location.origin || !shell(url.pathname)) { + return; + } + event.respondWith(revalidate(event, request, url.origin + url.pathname + url.search)); +}); + +// shell — что относится к оболочке. Всё прочее идёт в сеть мимо кэша: +// /api/ — потому что это данные (ADR-023), /sw.js — потому что его +// обновление ведёт браузер, /healthz — потому что он про сервер. +function shell(path) { + return path === "/" + || path === "/app.css" + || path === "/manifest.json" + || path.startsWith("/js/") + || path.startsWith("/icons/"); +} + +// revalidate — stale-while-revalidate. Ответ из кэша уходит сразу, запрос +// к серверу идёт своим ходом и обновляет кэш. Сервер отдаёт статику +// с ETag и Cache-Control: no-cache (docs/protocol.md), поэтому обычный +// fetch — это условный запрос: неизменившийся файл стоит одного 304. +// +// Ключ кэша — адрес без фрагмента: у навигационного запроса в url лежит +// маршрут (`/#/dm/marta`), и по самому запросу запись `/` подменялась бы +// адресом последней перезагрузки. Сопоставление фрагмент и так +// игнорирует, а вот caches.keys() должен говорить правду о том, что +// лежит в оболочке (ADR-001). +function revalidate(event, request, key) { + return caches.open(CACHE).then(async (cache) => { + const cached = await cache.match(key); + const network = fetch(request).then((response) => { + // Кладём только цельный свой ответ: чужие и частичные в оболочке + // не бывают. + if (response.ok && response.type === "basic") { + cache.put(key, response.clone()); + } + return response; + }); + if (cached) { + // Проверка переживёт ответ: без waitUntil браузер вправе усыпить + // service worker сразу после отдачи страницы. + event.waitUntil(network.catch(() => {})); + return cached; + } + return network; + }); +} + +// push → уведомление. Содержимого сообщения в нагрузке нет и быть +// не может: сервер его не знает (ADR-011). tag — идентификатор чата: +// новое уведомление заменяет старое в том же чате (ADR-023). +self.addEventListener("push", (event) => { + const data = payload(event); + if (data === null) { + return; + } + event.waitUntil(self.registration.showNotification(data.title, { + body: data.body, + tag: data.chat, + data: { chat: data.chat }, + icon: ICON, + lang: "ru", + })); +}); + +// payload разбирает нагрузку пуша: {title, body, chat} (ADR-023). +// Чужого здесь не бывает — пуш подписан ключом сервера, — но показывать +// неразобранное всё равно нечем. +function payload(event) { + let data = null; + try { + data = event.data ? event.data.json() : null; + } catch { + return null; + } + if (data === null || typeof data !== "object") { + return null; + } + const { title, body, chat } = data; + if (typeof title !== "string" || typeof body !== "string" || typeof chat !== "string") { + return null; + } + if (title === "" || body === "" || chat === "") { + return null; + } + return { title, body, chat }; +} + +// notificationclick — фокус уже открытого окна с переходом на нужный чат +// либо открытие нового (ADR-023). +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil(open(route(event.notification.data?.chat))); +}); + +// route переводит идентификатор чата в маршрут клиента (ADR-046). +// Идентификатор не той формы открывает список. +function route(chat) { + if (typeof chat === "string") { + const dm = DM.exec(chat); + if (dm) { + return `/#/dm/${dm[1]}`; + } + const room = ROOM.exec(chat); + if (room) { + return `/#/room/${room[1]}`; + } + } + return "/#/"; +} + +async function open(path) { + const target = new URL(path, self.location.origin); + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of windows) { + if (new URL(client.url).origin !== target.origin) { + continue; + } + try { + await client.focus(); + } catch { + // Поднять окно не дали. Чат всё равно откроем: человек вернётся + // в приложение сам и увидит его открытым там, где нужно. + } + try { + // Маршрут живёт в hash: переход к нему — не перезагрузка, + // а событие hashchange, которое разбирает роутер клиента. + if (new URL(client.url).hash !== target.hash && typeof client.navigate === "function") { + await client.navigate(target.href); + } + } catch { + // Окно не наше или им уже распоряжаются: останется, где было. + } + return; + } + await self.clients.openWindow(target.href); +}