Files
glchat/internal/gateway/session.go
T
grendervill 1b1a679827 feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
2026-09-19 21:50:06 +03:00

459 lines
13 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
}
// 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) {
payload, err := frame(OpInvalidSess, map[string]any{"reason": reason})
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.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
}
// Буфер не содержит нужную последовательность: полный READY (AGENT.md 8.3).
}
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
}