e4ffbb05a5
Бэкенд текстовой связи (AGENT.md 7.6, 7.15, 7.16, 8.6):
- POST/GET/PATCH/DELETE /channels/{id}/messages, ответы и упоминания,
окно правки 24 часа, права автора или MANAGE_MESSAGES;
- реакции (PUT/DELETE .../reactions/{emoji}) с агрегацией и отметкой «моя»;
- закрепления (GET/PUT/DELETE .../pins) под MANAGE_MESSAGES;
- typing с лимитом 1/3 с и адресной рассылкой всем, кроме автора;
- read states: POST /channels/{id}/ack, синхронизация READ_STATE_UPDATE между
устройствами пользователя;
- поиск FTS5 по комнате (10/мин) с экранированием запроса;
- лимиты отправки 5/5 с (burst 10) и slowmode комнаты; администратор инстанса
обходит и то, и другое (AGENT.md 7.19);
- Gateway: DispatchToChannel/DispatchToChannelExcept доставляют события комнат
только тем, кто видит комнату (VIEW_CHANNEL), права считает общий движок;
- store: messages, message_reactions, channel_read_states, files (загрузка
файлов появится вместе с вложениями).
Тесты: жизненный цикл сообщения, видимость скрытой комнаты (404 участнику,
200 админу), slowmode, упоминания и ответы, typing и read state, фильтрация
событий комнаты между двумя WS-клиентами.
473 lines
14 KiB
Go
473 lines
14 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/coder/websocket"
|
||
|
||
"glchat/internal/store"
|
||
)
|
||
|
||
// clientSession — одно подключение клиента (AGENT.md 8.3: сессий может быть
|
||
// несколько, события доставляются во все).
|
||
type clientSession struct {
|
||
id string
|
||
userID uint64
|
||
conn *websocket.Conn
|
||
send chan outbound
|
||
closeOnce sync.Once
|
||
closed chan struct{}
|
||
buffer *resumeBuffer
|
||
// preAuth — сессия, восстановленная из cookie на рукопожатии: браузерный
|
||
// клиент не имеет доступа к токену (AGENT.md 8.1, 8.3).
|
||
preAuth *store.Session
|
||
// user — профиль на момент IDENTIFY: нужен для фильтрации событий
|
||
// (инстанс-админ видит все комнаты, AGENT.md 7.19).
|
||
user *store.User
|
||
}
|
||
|
||
// SessionCookieName — имя cookie сессии. Значение должно совпадать с
|
||
// server.sessionCookieName: браузерный Gateway-клиент аутентифицируется
|
||
// cookie, а не токеном в IDENTIFY (AGENT.md 8.1).
|
||
const SessionCookieName = "__Host-session"
|
||
|
||
// outbound — кадр в очереди на отправку. done используется, когда отправитель
|
||
// ждёт фактической записи в сокет (например, перед закрытием соединения).
|
||
type outbound struct {
|
||
payload []byte
|
||
done chan error
|
||
}
|
||
|
||
func (s *clientSession) write(payload []byte) error {
|
||
return s.enqueue(outbound{payload: payload})
|
||
}
|
||
|
||
// writeSync ставит кадр в очередь и дожидается результата записи.
|
||
func (s *clientSession) writeSync(ctx context.Context, payload []byte) error {
|
||
item := outbound{payload: payload, done: make(chan error, 1)}
|
||
if err := s.enqueue(item); err != nil {
|
||
return err
|
||
}
|
||
select {
|
||
case err := <-item.done:
|
||
return err
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-s.closed:
|
||
return errors.New("session closed")
|
||
}
|
||
}
|
||
|
||
func (s *clientSession) enqueue(item outbound) error {
|
||
select {
|
||
case s.send <- item:
|
||
return nil
|
||
case <-s.closed:
|
||
return errors.New("session closed")
|
||
case <-time.After(WriteTimeout):
|
||
// Медленный потребитель: отключаем, чтобы не держать буфер (AGENT.md 8.3).
|
||
s.close()
|
||
return errors.New("slow consumer")
|
||
}
|
||
}
|
||
|
||
func (s *clientSession) close() {
|
||
s.closeOnce.Do(func() {
|
||
close(s.closed)
|
||
_ = s.conn.Close(websocket.StatusNormalClosure, "closed")
|
||
})
|
||
}
|
||
|
||
// ServeHTTP обрабатывает подключение к /gateway.
|
||
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
origin := r.Header.Get("Origin")
|
||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||
OriginPatterns: s.originPatterns(),
|
||
// Клиенты десктопа обращаются без Origin: проверяем вручную ниже.
|
||
InsecureSkipVerify: origin == "",
|
||
CompressionMode: websocket.CompressionDisabled,
|
||
})
|
||
if err != nil {
|
||
s.logger.DebugContext(r.Context(), "gateway accept failed", slog.Any("error", err))
|
||
return
|
||
}
|
||
defer func() { _ = conn.CloseNow() }()
|
||
|
||
ctx, cancel := context.WithCancel(r.Context())
|
||
defer cancel()
|
||
|
||
session := &clientSession{
|
||
id: newSessionID(),
|
||
conn: conn,
|
||
send: make(chan outbound, 128),
|
||
closed: make(chan struct{}),
|
||
}
|
||
// Cookie сессии проверяем до HELLO: ошибку вернёт IDENTIFY.
|
||
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
|
||
if _, authSession, err := s.auth.ResolveSession(ctx, cookie.Value); err == nil {
|
||
session.preAuth = authSession
|
||
}
|
||
}
|
||
conn.SetReadLimit(MaxIncomingFrame)
|
||
|
||
// HELLO с интервалом heartbeat и идентификатором сессии.
|
||
hello, err := frame(OpHello, map[string]any{
|
||
"heartbeat_interval_ms": HeartbeatInterval.Milliseconds(),
|
||
"session_id": session.id,
|
||
})
|
||
if err != nil {
|
||
return
|
||
}
|
||
if err := writeDirect(ctx, conn, hello); err != nil {
|
||
return
|
||
}
|
||
|
||
writerDone := make(chan struct{})
|
||
go func() {
|
||
defer close(writerDone)
|
||
s.writeLoop(ctx, session)
|
||
}()
|
||
|
||
s.readLoop(ctx, session)
|
||
session.close()
|
||
cancel()
|
||
<-writerDone
|
||
|
||
s.unregister(session)
|
||
}
|
||
|
||
func (s *Service) writeLoop(ctx context.Context, session *clientSession) {
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-session.closed:
|
||
return
|
||
case item := <-session.send:
|
||
err := writeDirect(ctx, session.conn, item.payload)
|
||
if item.done != nil {
|
||
item.done <- err
|
||
close(item.done)
|
||
}
|
||
if err != nil {
|
||
session.close()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// readLoop читает кадры клиента: IDENTIFY, RESUME, HEARTBEAT.
|
||
func (s *Service) readLoop(ctx context.Context, session *clientSession) {
|
||
identified := false
|
||
lastIdentify := time.Time{}
|
||
|
||
for {
|
||
_, data, err := session.conn.Read(ctx)
|
||
if err != nil {
|
||
return
|
||
}
|
||
var incoming serverFrame
|
||
if err := json.Unmarshal(data, &incoming); err != nil {
|
||
s.sendInvalidSession(session, "malformed frame")
|
||
return
|
||
}
|
||
|
||
switch incoming.Op {
|
||
case OpHeartbeat:
|
||
ack, err := frame(OpHeartbeatAck, nil)
|
||
if err == nil {
|
||
_ = session.write(ack)
|
||
}
|
||
if !identified {
|
||
// До IDENTIFY сердцебиение не подтверждает сессию: ждём идентификацию.
|
||
continue
|
||
}
|
||
case OpIdentify, OpResume:
|
||
if time.Since(lastIdentify) < IdentifyRateLimit {
|
||
s.sendInvalidSession(session, "identify rate limited")
|
||
return
|
||
}
|
||
lastIdentify = time.Now()
|
||
|
||
payload, err := decodeIdentify(incoming.D)
|
||
if err != nil {
|
||
s.sendInvalidSession(session, "malformed identify")
|
||
return
|
||
}
|
||
if err := s.handleIdentify(ctx, session, payload, incoming.Op == OpResume); err != nil {
|
||
s.logger.DebugContext(ctx, "gateway identify failed", slog.Any("error", err))
|
||
return
|
||
}
|
||
identified = true
|
||
default:
|
||
// Неизвестные оп-коды игнорируем: протокол расширяемый.
|
||
}
|
||
if !identified {
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
|
||
// sendInvalidSession сообщает об ошибке идентификации и дожидается отправки
|
||
// кадра: соединение закрывается сразу после этого (AGENT.md 8.3).
|
||
func (s *Service) sendInvalidSession(session *clientSession, reason string) {
|
||
s.sendInvalidSessionResumable(session, reason, false)
|
||
}
|
||
|
||
// sendInvalidSessionResumable отдаёт INVALID_SESSION с признаком resumable:
|
||
// при true клиент продолжает работу (сервер пришлёт полный READY), при false —
|
||
// уходит на экран входа.
|
||
func (s *Service) sendInvalidSessionResumable(session *clientSession, reason string, resumable bool) {
|
||
payload, err := frame(OpInvalidSess, map[string]any{"reason": reason, "resumable": resumable})
|
||
if err != nil {
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), WriteTimeout)
|
||
defer cancel()
|
||
if err := session.writeSync(ctx, payload); err != nil {
|
||
s.logger.DebugContext(ctx, "gateway invalid session not delivered", slog.Any("error", err))
|
||
}
|
||
}
|
||
|
||
// handleIdentify проверяет токен, регистрирует сессию и отправляет READY
|
||
// либо догоняет пропущенные события при RESUME.
|
||
func (s *Service) handleIdentify(ctx context.Context, session *clientSession, payload identifyPayload, resume bool) error {
|
||
var (
|
||
user *store.User
|
||
authSession *store.Session
|
||
err error
|
||
)
|
||
switch {
|
||
case payload.Token != "":
|
||
user, authSession, err = s.auth.ResolveSession(ctx, payload.Token)
|
||
case session.preAuth != nil:
|
||
// Токен не передан: доверяем cookie, проверенной на рукопожатии.
|
||
user, err = s.store.GetUser(ctx, session.preAuth.UserID)
|
||
authSession = session.preAuth
|
||
default:
|
||
err = errors.New("identify without token and without session cookie")
|
||
}
|
||
if err != nil {
|
||
s.sendInvalidSession(session, "invalid token")
|
||
return err
|
||
}
|
||
session.userID = user.ID
|
||
session.user = user
|
||
session.buffer = s.bufferFor(authSession.TokenHash)
|
||
|
||
if resume && payload.ResumeSeq > 0 && session.buffer != nil {
|
||
if missed, ok := session.buffer.since(payload.ResumeSeq); ok {
|
||
for _, item := range missed {
|
||
if err := session.write(item); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Подтверждаем продолжение сессии: клиент знает, что состояние
|
||
// восстановлено без полного READY (AGENT.md 8.3).
|
||
s.mu.Lock()
|
||
s.seq++
|
||
resumed := Envelope{Op: OpDispatch, T: "RESUMED", S: s.seq}
|
||
s.mu.Unlock()
|
||
encoded, err := json.Marshal(resumed)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := session.write(encoded); err != nil {
|
||
return err
|
||
}
|
||
s.register(session, authSession.TokenHash)
|
||
return nil
|
||
}
|
||
// Буфер не содержит нужную последовательность: по AGENT.md 8.3 клиенту
|
||
// сообщаем INVALID_SESSION с признаком resumable и сразу отдаём полный
|
||
// READY, чтобы состояние восстановилось без повторного входа.
|
||
s.sendInvalidSessionResumable(session, "resume failed", true)
|
||
}
|
||
|
||
snapshot, err := s.readiness.Build(ctx, user)
|
||
if err != nil {
|
||
s.sendInvalidSession(session, "ready failed")
|
||
return err
|
||
}
|
||
snapshot.SessionID = session.id
|
||
snapshot.HeartbeatMS = int(HeartbeatInterval.Milliseconds())
|
||
|
||
raw, err := json.Marshal(snapshot)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s.mu.Lock()
|
||
s.seq++
|
||
envelope := Envelope{Op: OpDispatch, T: "READY", D: raw, S: s.seq}
|
||
encoded, err := json.Marshal(envelope)
|
||
s.mu.Unlock()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := session.write(encoded); err != nil {
|
||
return err
|
||
}
|
||
s.register(session, authSession.TokenHash)
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) register(session *clientSession, tokenHash string) {
|
||
s.mu.Lock()
|
||
s.sessions[session.id] = session
|
||
buffer, ok := s.buffers[tokenHash]
|
||
if !ok {
|
||
buffer = newResumeBuffer(ResumeBufferSize)
|
||
s.buffers[tokenHash] = buffer
|
||
}
|
||
// Владелец буфера нужен, чтобы доставлять адресные события (SendToUser)
|
||
// даже когда все сессии пользователя отключены.
|
||
buffer.owner = session.userID
|
||
session.buffer = buffer
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
func (s *Service) unregister(session *clientSession) {
|
||
s.mu.Lock()
|
||
delete(s.sessions, session.id)
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
func (s *Service) bufferFor(tokenHash string) *resumeBuffer {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if buffer, ok := s.buffers[tokenHash]; ok {
|
||
return buffer
|
||
}
|
||
buffer := newResumeBuffer(ResumeBufferSize)
|
||
s.buffers[tokenHash] = buffer
|
||
return buffer
|
||
}
|
||
|
||
// originPatterns разрешает подключения с домена инстанса (AGENT.md 9.7).
|
||
// coder/websocket сопоставляет шаблон с хостом из Origin, поэтому из
|
||
// настроенных адресов берём только хост; пустой список означает проверку
|
||
// на совпадение с Host запроса.
|
||
func (s *Service) originPatterns() []string {
|
||
patterns := make([]string, 0, len(s.allowedOrigins))
|
||
for _, origin := range s.allowedOrigins {
|
||
origin = strings.TrimSpace(origin)
|
||
if origin == "" {
|
||
continue
|
||
}
|
||
if origin == "*" {
|
||
return []string{"*"}
|
||
}
|
||
if parsed, err := url.Parse(origin); err == nil && parsed.Host != "" {
|
||
patterns = append(patterns, parsed.Host)
|
||
continue
|
||
}
|
||
patterns = append(patterns, origin)
|
||
}
|
||
return patterns
|
||
}
|
||
|
||
func writeDirect(ctx context.Context, conn *websocket.Conn, payload []byte) error {
|
||
writeCtx, cancel := context.WithTimeout(ctx, WriteTimeout)
|
||
defer cancel()
|
||
return conn.Write(writeCtx, websocket.MessageText, payload)
|
||
}
|
||
|
||
func decodeIdentify(raw json.RawMessage) (identifyPayload, error) {
|
||
var payload identifyPayload
|
||
if len(raw) == 0 {
|
||
// Пустой payload допустим: браузер аутентифицируется cookie.
|
||
return payload, nil
|
||
}
|
||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||
return payload, err
|
||
}
|
||
return payload, nil
|
||
}
|
||
|
||
func newSessionID() string {
|
||
const alphabet = "0123456789abcdef"
|
||
buf := make([]byte, 16)
|
||
now := time.Now().UnixNano()
|
||
for i := range buf {
|
||
buf[i] = alphabet[(now>>uint(i*4))&0xf]
|
||
}
|
||
return string(buf)
|
||
}
|
||
|
||
// resumeBuffer хранит последние события для RESUME (AGENT.md 8.3).
|
||
type resumeBuffer struct {
|
||
mu sync.Mutex
|
||
owner uint64
|
||
limit int
|
||
items []bufferedEvent
|
||
updated time.Time
|
||
}
|
||
|
||
type bufferedEvent struct {
|
||
seq int64
|
||
payload []byte
|
||
}
|
||
|
||
func newResumeBuffer(limit int) *resumeBuffer {
|
||
return &resumeBuffer{limit: limit, updated: time.Now()}
|
||
}
|
||
|
||
func (b *resumeBuffer) append(seq int64, payload []byte) {
|
||
if b == nil {
|
||
return
|
||
}
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.items = append(b.items, bufferedEvent{seq: seq, payload: payload})
|
||
if len(b.items) > b.limit {
|
||
b.items = b.items[len(b.items)-b.limit:]
|
||
}
|
||
b.updated = time.Now()
|
||
}
|
||
|
||
// since возвращает события после указанной последовательности; ok=false,
|
||
// если буфер уже не содержит их (нужен полный READY).
|
||
func (b *resumeBuffer) since(seq int64) ([][]byte, bool) {
|
||
if b == nil {
|
||
return nil, false
|
||
}
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
if len(b.items) == 0 {
|
||
return nil, false
|
||
}
|
||
if b.items[0].seq > seq+1 {
|
||
return nil, false
|
||
}
|
||
// Клиент заявляет последовательность впереди сервера: состояние разошлось
|
||
// (например, сервер перезапускался) — нужен полный READY.
|
||
if seq > b.items[len(b.items)-1].seq {
|
||
return nil, false
|
||
}
|
||
result := make([][]byte, 0, len(b.items))
|
||
for _, item := range b.items {
|
||
if item.seq > seq {
|
||
result = append(result, item.payload)
|
||
}
|
||
}
|
||
return result, true
|
||
}
|
||
|
||
func (b *resumeBuffer) lastSeen() time.Time {
|
||
if b == nil {
|
||
return time.Time{}
|
||
}
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return b.updated
|
||
}
|