Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.
Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.
ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.
Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.
Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.
Чеклист ручной проверки на iPhone и Android — в docs/plan.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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), а там перевод строки и разворот текста выдают чужое имя за сообщение системы.
|
||||
|
||||
## Следствия
|
||||
|
||||
|
||||
@@ -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` → фокус открытого окна или открытие `/#/<chat>`.
|
||||
- Кэш: 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 вместо запроса показывается баннер установки.
|
||||
|
||||
## Следствия
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-045: Пуш адресован получателю
|
||||
|
||||
Уточняет [ADR-023](023-push-and-service-worker.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-023 задаёт правило отправки: пуш уходит при постановке сообщения в очередь устройства, если устройство не подключено по SSE и неотработанного пуша у него нет. Но конверт кладётся в очередь и другим устройствам отправителя (ADR-017) — по букве правила молчащий второй телефон автора получал бы пуш о собственном сообщении.
|
||||
|
||||
Полезная нагрузка от этого рассыпается. Заголовок — `@nick` отправителя, адрес чата — `dm:<nick>`: и то и другое собрано с точки зрения получателя. У отправителя тот же чат называется именем собеседника, а уведомление «@marta: новое сообщение» на телефоне самой marta не значит ничего.
|
||||
|
||||
Тексты уведомления при этом живут в ADR-023, а не в `docs/ui.md`, где место всему, что видит человек.
|
||||
|
||||
## Решение
|
||||
|
||||
- Пуш уходит только устройствам получателей. Устройства отправителя — и то, с которого он писал, и все остальные — пуша не получают; сообщение они забирают очередью, как и раньше.
|
||||
- Заголовок и адрес чата собираются для получателя: `@nick` отправителя и `dm:<nick>` в личном чате, `#имя комнаты` и `room:<id>` в комнате. В комнате адресация одна для всех получателей, в личном чате получатель один — значит, у сообщения одна нагрузка на всех.
|
||||
- Тексты уведомления записаны в `docs/ui.md`, «Уведомления».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Правило ADR-023 читается как «пуш уходит устройствам получателей, если …». Одно молчащее устройство получателя — один пуш.
|
||||
- Сервер собирает пуш из того, что и так знает: ник отправителя, имя комнаты, идентификатор чата. Плейнтекста он не знает, шифротекст не пересылает — в пуше нет ни того ни другого.
|
||||
- Автор, отошедший от одного своего устройства к другому, узнаёт о собственном сообщении не пушем, а очередью при открытии. Осознанно.
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-046: Клиент пушей — адрес перехода, состояния и жизнь подписки
|
||||
|
||||
Уточняет [ADR-023](023-push-and-service-worker.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
Клиентская половина ADR-023 упирается в четыре места, где документ не договаривает.
|
||||
|
||||
**Адрес перехода.** ADR-023 велит открывать по нажатию на уведомление `/#/<chat>`. Идентификатор чата — `dm:<nick>` или `room:<id>` (`docs/storage.md`), а маршруты клиента — `#/dm/<nick>` и `#/room/<id>` (`docs/ui.md`). Буквальное `/#/dm:marta` не разбирается роутером и открывает список: уведомление ведёт не туда, куда обещало.
|
||||
|
||||
**Состояний больше трёх.** `docs/ui.md` знает три: `включены`, `выключены`, `запрещены в браузере`. Кроме них бывает браузер без `Notification` и `PushManager` (iOS вне установленного приложения — как раз такой) и сервер без VAPID-ключа: включить нельзя, а сказать про это нечем.
|
||||
|
||||
**У кнопки установки нет надписи.** «Кнопка, если есть `beforeinstallprompt`» — а что на ней написано, не сказано.
|
||||
|
||||
**Подписка переживает то, к чему привязана.** Подписка принадлежит устройству (ADR-023), но живёт в браузерном профиле и не знает ни про `deviceId`, ни про аккаунт. `deviceId` меняется при конфликте идентификаторов и при чистке IndexedDB (ADR-017), аккаунт на устройстве меняется при выходе. Что делать с подпиской в эти моменты, не записано нигде.
|
||||
|
||||
## Решение
|
||||
|
||||
- **Переход.** Service worker переводит идентификатор чата в маршрут: `dm:<nick>` → `/#/dm/<nick>`, `room:<id>` → `/#/room/<id>`. Идентификатор не той формы открывает `/#/`. Формулировка 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-сервиса не удалось.
|
||||
@@ -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`: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет.
|
||||
@@ -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` устройства остался нулём, и следующее сообщение попробует снова.
|
||||
- Подключённое устройство пуша не получает, а его право не остаётся висеть до конца сессии.
|
||||
- Пропускная способность отправки — восемь заданий на пять секунд в худшем случае. Для маленького сервера это приемлемо; понадобится больше — менять числа, а не устройство.
|
||||
@@ -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`.
|
||||
+4
-1
@@ -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 хранит по своим правилам.
|
||||
|
||||
@@ -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/<nick>`, `/#/room/<id>`).
|
||||
- [ ] **Повторные сообщения до открытия**: второе и третье пуша не порождают.
|
||||
- [ ] **iOS вне PWA**: баннер установки над списком чатов, текст, крестик и то,
|
||||
что он больше не появляется; в настройках «уведомления» — текст про установку
|
||||
вместо кнопки.
|
||||
- [ ] **`beforeinstallprompt`** в обычном Chrome: раздел «установить приложение»
|
||||
появляется, после нажатия исчезает целиком.
|
||||
- [ ] **Системный запрос разрешения** после первого отправленного сообщения:
|
||||
headless-Chrome отвечает `denied` сам, живой диалог не проверялся.
|
||||
- [ ] **Прогон сценариев «готово, когда»** этапов 1–5 в Safari (iOS и десктоп)
|
||||
и Firefox — автоматика гоняла только Chrome.
|
||||
|
||||
## Этап 5 — история
|
||||
|
||||
- Экспорт и импорт `.bare` по `crypto.md` и `storage.md`; идемпотентность; «архив создан другим аккаунтом».
|
||||
|
||||
+3
-3
@@ -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`.
|
||||
|
||||
|
||||
+1
-1
@@ -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:<peer>" | "room:<roomId>"
|
||||
{id, type: "dm"|"room", title, peer?, roomId?, owner?, members?: nick[],
|
||||
|
||||
@@ -41,3 +41,5 @@
|
||||
**Вышедший участник до rekey.** После выхода участника сервер перестаёт доставлять ему сообщения, а новый ключ комнаты создаёт владелец при следующем появлении. В промежутке вышедший участник знает действующий ключ; прочитать новые сообщения он может только в сговоре с сервером.
|
||||
|
||||
**Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша.
|
||||
|
||||
**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — один POST на сообщение, в пределах общих лимитов.
|
||||
|
||||
+7
-3
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+21
-5
@@ -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
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,23 +77,30 @@ 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 {
|
||||
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 {
|
||||
s.rateLimited(w, wait)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+28
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// Значения по умолчанию — локальный запуск без окружения.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:<nick>» или «room:<id>». Текста сообщения здесь нет
|
||||
// и быть не может.
|
||||
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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+40
-28
@@ -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()
|
||||
}
|
||||
|
||||
+26
-11
@@ -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
|
||||
}
|
||||
return member, knownKey, nil
|
||||
|
||||
// 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
|
||||
}
|
||||
if err != nil {
|
||||
return Access{}, fmt.Errorf("store: доступ к комнате: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// currentKeysQuery — текущий ключ участника: строка room_keys с максимальным
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+55
-5
@@ -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,20 +185,31 @@ async function ensureConfig() {
|
||||
|
||||
// restore отвечает на вопрос «вошли ли мы»: сессия у сервера и ключи
|
||||
// на устройстве нужны вместе. Ключей нет — нужен вход, он их и вернёт.
|
||||
//
|
||||
// Запрос, который не дошёл, — это «нет соединения», а не «мы не вошли»
|
||||
// (docs/ui.md, «Сеть и состояния»): офлайн-старт установленного
|
||||
// приложения поднимается из кэша с ключами и историей устройства,
|
||||
// а полосу «нет соединения» рисует sync. Если сессии и правда нет,
|
||||
// первый дошедший запрос ответит 401 unauthenticated и уведёт на вход.
|
||||
async function restore() {
|
||||
let who;
|
||||
let who = null;
|
||||
try {
|
||||
who = await api.me();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!(err instanceof NetworkError)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let meta;
|
||||
try {
|
||||
meta = await db.meta(["nick", "publicKey", "fingerprint", "privateKey"]);
|
||||
} 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(() => {
|
||||
|
||||
+380
@@ -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(() => {});
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+110
-4
@@ -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");
|
||||
|
||||
+27
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user