Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
92 lines
2.6 KiB
Go
92 lines
2.6 KiB
Go
package api
|
|
|
|
import (
|
|
"math"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10.
|
|
// Остальные лимиты — этап 6.
|
|
const (
|
|
messagesPerMinute = 30
|
|
messagesBurst = 10
|
|
)
|
|
|
|
// sweepAt — с какого размера карты имеет смысл выкидывать полные вёдра.
|
|
const sweepAt = 1024
|
|
|
|
// buckets — token bucket в памяти сервера, по ведру на ключ (ник).
|
|
// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021).
|
|
type buckets struct {
|
|
mu sync.Mutex
|
|
rate float64 // токенов в секунду
|
|
burst float64
|
|
seen map[string]*bucket
|
|
}
|
|
|
|
type bucket struct {
|
|
tokens float64
|
|
at time.Time
|
|
}
|
|
|
|
func newBuckets(perMinute, burst int) *buckets {
|
|
return &buckets{
|
|
rate: float64(perMinute) / 60,
|
|
burst: float64(burst),
|
|
seen: make(map[string]*bucket),
|
|
}
|
|
}
|
|
|
|
// take забирает токен. Второе значение — можно ли; если нет, первое —
|
|
// сколько ждать до следующего токена.
|
|
func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
e, ok := b.seen[key]
|
|
if !ok {
|
|
if len(b.seen) >= sweepAt {
|
|
b.sweep(now)
|
|
}
|
|
e = &bucket{tokens: b.burst, at: now}
|
|
b.seen[key] = e
|
|
}
|
|
e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now))
|
|
e.at = now
|
|
if e.tokens < 1 {
|
|
return time.Duration((1 - e.tokens) / b.rate * float64(time.Second)), false
|
|
}
|
|
e.tokens--
|
|
return 0, true
|
|
}
|
|
|
|
// refill — сколько токенов набежало. Время назад не идёт: часы могли
|
|
// прыгнуть, но долг за это выставлять некому.
|
|
func (b *buckets) refill(since, now time.Time) float64 {
|
|
d := now.Sub(since)
|
|
if d <= 0 {
|
|
return 0
|
|
}
|
|
return d.Seconds() * b.rate
|
|
}
|
|
|
|
// sweep выкидывает полные вёдра: они уже ничего не помнят. Иначе карта
|
|
// росла бы на каждый новый ник и не уменьшалась никогда.
|
|
func (b *buckets) sweep(now time.Time) {
|
|
for key, e := range b.seen {
|
|
if e.tokens+b.refill(e.at, now) >= b.burst {
|
|
delete(b.seen, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
|
|
// ожидание после отказа сбивало бы клиента с толку.
|
|
func retryAfter(wait time.Duration) int {
|
|
if wait < time.Second {
|
|
return 1
|
|
}
|
|
return int(math.Ceil(wait.Seconds()))
|
|
}
|