Files
glchat/internal/gateway/session.go
T
grendervill e4ffbb05a5 feat(messages): сообщения, реакции, пины, typing, read states и поиск
Бэкенд текстовой связи (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-клиентами.
2026-09-19 23:32:11 +03:00

473 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}