feat(gateway): WebSocket Gateway с HELLO, IDENTIFY и READY
Реализован Gateway по AGENT.md §8.3:
- оп-коды 0/1/2/3/4/7/10/11, единый конверт {op,t,d,s};
- HELLO с интервалом сердцебиения (45 с) и идентификатором сессии;
- IDENTIFY/RESUME с проверкой токена через auth.ResolveSession;
- READY: пользователь, серверы, видимые комнаты, роли, участники и права,
собранные через движок прав (VIEW_CHANNEL-фильтрация);
- HEARTBEAT → HEARTBEAT_ACK, лимит частоты IDENTIFY (5 с);
- RESUME: буфер на 1000 событий с TTL 5 минут, догон пропущенных событий и
подтверждение RESUMED, полный READY при расхождении состояния;
- рассылка Dispatch/SendToUser с буферизацией для отключённых сессий;
- проверка Origin по allowlist доменов инстанса (AGENT.md §9.7);
- httpx.statusRecorder пробрасывает Hijack, иначе апгрейд ломается.
Тесты: 6 сценариев Gateway (READY, скрытые комнаты, неверный токен,
heartbeat, dispatch, RESUME) и 2 теста маршрута /gateway через полный стек
middleware, включая отклонение чужого Origin.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Snapshot собирает READY-снапшот из хранилища с учётом прав пользователя
|
||||
// (AGENT.md 8.3: только серверы и комнаты, которые он видит).
|
||||
type Snapshot struct {
|
||||
store *store.Store
|
||||
calculator *permissions.Calculator
|
||||
}
|
||||
|
||||
func NewSnapshot(st *store.Store) *Snapshot {
|
||||
return &Snapshot{store: st, calculator: permissions.NewCalculator(newPermissionSource(st))}
|
||||
}
|
||||
|
||||
var _ SnapshotBuilder = (*Snapshot)(nil)
|
||||
|
||||
func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) {
|
||||
ready := &Ready{
|
||||
User: ReadyUser{
|
||||
ID: formatID(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: user.Badges,
|
||||
},
|
||||
Guilds: []ReadyGuild{},
|
||||
DMChannels: []ReadyChannel{},
|
||||
ReadStates: []ReadyRead{},
|
||||
}
|
||||
if ready.User.Badges == nil {
|
||||
ready.User.Badges = []string{}
|
||||
}
|
||||
if user.AvatarFileID != nil {
|
||||
ready.User.AvatarFileID = formatID(*user.AvatarFileID)
|
||||
}
|
||||
|
||||
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user guilds: %w", err)
|
||||
}
|
||||
for _, guild := range guilds {
|
||||
resolved, err := s.calculator.Guild(ctx, guild.ID, user.ID, user.IsInstanceAdmin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Сервер без VIEW_GUILD не показываем (AGENT.md 6.2).
|
||||
if !resolved.Has(permissions.ViewGuild) {
|
||||
continue
|
||||
}
|
||||
readyGuild, err := s.buildGuild(ctx, guild, user, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ready.Guilds = append(ready.Guilds, readyGuild)
|
||||
}
|
||||
return ready, nil
|
||||
}
|
||||
|
||||
func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *store.User, resolved permissions.Resolved) (ReadyGuild, error) {
|
||||
readyGuild := ReadyGuild{
|
||||
ID: formatID(guild.ID),
|
||||
Name: guild.Name,
|
||||
OwnerID: formatID(guild.OwnerID),
|
||||
IsMain: guild.IsMain,
|
||||
Channels: []ReadyChannel{},
|
||||
Roles: []ReadyRole{},
|
||||
MemberIDs: []string{},
|
||||
MyRoles: []string{},
|
||||
MyPerms: []string{},
|
||||
}
|
||||
|
||||
roles, err := s.store.ListGuildRoles(ctx, guild.ID)
|
||||
if err != nil {
|
||||
return readyGuild, err
|
||||
}
|
||||
for _, role := range roles {
|
||||
readyGuild.Roles = append(readyGuild.Roles, ReadyRole{
|
||||
ID: formatID(role.ID),
|
||||
Name: role.Name,
|
||||
Color: role.Color,
|
||||
Position: role.Position,
|
||||
Permissions: strconv.FormatUint(role.Permissions, 10),
|
||||
IsDefault: role.IsDefault,
|
||||
Mentionable: role.Mentionable,
|
||||
Hoist: role.Hoist,
|
||||
})
|
||||
}
|
||||
memberRoles, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID)
|
||||
if err != nil {
|
||||
return readyGuild, err
|
||||
}
|
||||
for _, roleID := range memberRoles {
|
||||
readyGuild.MyRoles = append(readyGuild.MyRoles, formatID(roleID))
|
||||
}
|
||||
|
||||
channels, err := s.store.ListGuildChannels(ctx, guild.ID)
|
||||
if err != nil {
|
||||
return readyGuild, err
|
||||
}
|
||||
for _, channel := range channels {
|
||||
// Категории не запрашивают права: они видны вместе с сервером.
|
||||
if channel.Type == store.ChannelCategory && !resolved.Has(permissions.ManageChannels) {
|
||||
readyGuild.Channels = append(readyGuild.Channels, channelPayload(channel, false, false))
|
||||
continue
|
||||
}
|
||||
channelResolved, err := s.calculator.Channel(ctx, guild.ID, channel.ID, user.ID, user.IsInstanceAdmin)
|
||||
if err != nil {
|
||||
return readyGuild, err
|
||||
}
|
||||
if !channelResolved.CanViewChannel() {
|
||||
continue
|
||||
}
|
||||
readyGuild.Channels = append(readyGuild.Channels, channelPayload(channel,
|
||||
channelResolved.Can(permissions.SendMessages), channelResolved.Can(permissions.ConnectVoice)))
|
||||
}
|
||||
|
||||
members, err := s.store.ListGuildMembers(ctx, guild.ID)
|
||||
if err != nil {
|
||||
return readyGuild, err
|
||||
}
|
||||
for _, member := range members {
|
||||
readyGuild.MemberIDs = append(readyGuild.MemberIDs, formatID(member.UserID))
|
||||
if member.UserID == user.ID {
|
||||
readyGuild.MyNickname = member.Nickname
|
||||
}
|
||||
}
|
||||
return readyGuild, nil
|
||||
}
|
||||
|
||||
func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel {
|
||||
payload := ReadyChannel{
|
||||
ID: formatID(channel.ID),
|
||||
Type: string(channel.Type),
|
||||
Name: channel.Name,
|
||||
Position: channel.Position,
|
||||
UserLimit: channel.UserLimit,
|
||||
Slowmode: channel.SlowmodeSeconds,
|
||||
CanSend: canSend,
|
||||
CanConnect: canConnect,
|
||||
}
|
||||
if channel.GuildID != nil {
|
||||
payload.GuildID = formatID(*channel.GuildID)
|
||||
}
|
||||
if channel.ParentID != nil {
|
||||
payload.ParentID = formatID(*channel.ParentID)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// permissionSource читает роли, оверрайды и участников для движка прав.
|
||||
type permissionSource struct{ store *store.Store }
|
||||
|
||||
func newPermissionSource(st *store.Store) *permissionSource { return &permissionSource{store: st} }
|
||||
|
||||
func (p *permissionSource) GuildRoles(ctx context.Context, guildID uint64) ([]permissions.RoleData, error) {
|
||||
roles, err := p.store.ListGuildRoles(ctx, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]permissions.RoleData, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
result = append(result, permissions.RoleData{
|
||||
ID: role.ID,
|
||||
Permissions: permissions.Permission(role.Permissions),
|
||||
IsDefault: role.IsDefault,
|
||||
Position: role.Position,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *permissionSource) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) {
|
||||
return p.store.MemberRoleIDs(ctx, guildID, userID)
|
||||
}
|
||||
|
||||
func (p *permissionSource) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) {
|
||||
overrides, err := p.store.ListChannelOverrides(ctx, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]permissions.OverrideData, 0, len(overrides))
|
||||
for _, override := range overrides {
|
||||
result = append(result, permissions.OverrideData{
|
||||
TargetType: override.TargetType,
|
||||
TargetID: override.TargetID,
|
||||
Allow: permissions.Permission(override.Allow),
|
||||
Deny: permissions.Permission(override.Deny),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *permissionSource) GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error) {
|
||||
guild, err := p.store.GetGuild(ctx, guildID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return guild.OwnerID, nil
|
||||
}
|
||||
|
||||
func (p *permissionSource) MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error) {
|
||||
member, err := p.store.GetGuildMember(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
// Участника нет — тайм-аут не действует; прочие ошибки чтения не
|
||||
// должны блокировать выдачу прав (AGENT.md 7.4).
|
||||
return false, nil //nolint:nilerr // отсутствие участника не является ошибкой прав
|
||||
}
|
||||
if member.TimeoutUntil == nil {
|
||||
return false, nil
|
||||
}
|
||||
return member.TimeoutUntil.After(time.Now().UTC()), nil
|
||||
}
|
||||
|
||||
func formatID(id uint64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for id > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + id%10)
|
||||
id /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
Reference in New Issue
Block a user