This commit is contained in:
2026-10-11 13:47:22 +07:00
parent 6f2b63511c
commit 63f570aad1
17 changed files with 2038 additions and 247 deletions
@@ -0,0 +1,269 @@
// Package device_presence_service tracks browser connections in venue memory.
package device_presence_service
import (
"context"
"errors"
"sort"
"strconv"
"strings"
"sync"
"time"
"evening_detective_server/internal/services/game_model"
"evening_detective_server/internal/services/screen_service"
"github.com/google/uuid"
)
var (
ErrInvalid = errors.New("некорректные данные устройства")
ErrDisabled = errors.New("учёт устройств доступен только на площадке")
ErrPermission = errors.New("нет доступа к устройствам")
ErrUnauthorized = errors.New("требуется авторизация")
ErrFull = errors.New("слишком много подключений")
)
const (
connectedFor = time.Minute
retainedFor = 15 * time.Minute
maxConnections = 1024
)
type IGamePresence interface {
GetGame(context.Context, int) (*game_model.Game, error)
}
type ITeamPresence interface {
CheckPresence(context.Context, int, string) (int, error)
}
type IScreenPresence interface {
Get(context.Context, int, string) (*screen_service.ScreenSession, error)
}
// Heartbeat contains transient credentials; only the verified connection is retained.
type Heartbeat struct {
DeviceID string
ConnectionID string
OrganizerGameID *int
TeamID *int
Password string
ScreenID string
ScreenTokenHash string
IP string
UserAgent string
IsAuthenticated bool
CanManage bool
}
type Connection struct {
ID string
DeviceID string
FirstSeenAt time.Time
LastSeenAt time.Time
IP string
UserAgent string
Kind string
GameID int
TeamID int
ScreenID int
Connected bool
}
type Device struct {
ID string
FirstSeenAt time.Time
LastSeenAt time.Time
Connected bool
Connections []Connection
}
type DevicePresenceService struct {
enabled bool
games IGamePresence
teams ITeamPresence
screens IScreenPresence
now func() time.Time
mu sync.Mutex
connections map[string]Connection
}
func NewDevicePresenceService(enabled bool, games IGamePresence, teams ITeamPresence, screens IScreenPresence) *DevicePresenceService {
return &DevicePresenceService{
enabled: enabled,
games: games,
teams: teams,
screens: screens,
now: time.Now,
connections: make(map[string]Connection),
}
}
func (s *DevicePresenceService) Heartbeat(ctx context.Context, input Heartbeat) (bool, time.Time, error) {
if err := ctx.Err(); err != nil {
return false, time.Time{}, err
}
if !s.enabled {
return false, s.now(), nil
}
connection, err := s.verify(ctx, input)
if err != nil {
return true, time.Time{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
s.prune(now)
old, exists := s.connections[connection.ID]
if exists && old.DeviceID != connection.DeviceID {
return true, now, ErrInvalid
}
if !exists && len(s.connections) >= maxConnections {
return true, now, ErrFull
}
connection.FirstSeenAt = now
if exists {
connection.FirstSeenAt = old.FirstSeenAt
}
connection.LastSeenAt = now
s.connections[connection.ID] = connection
return true, now, nil
}
func (s *DevicePresenceService) List(ctx context.Context, gameID *int, canManage bool) ([]Device, time.Time, error) {
if err := ctx.Err(); err != nil {
return nil, time.Time{}, err
}
if !s.enabled {
return nil, time.Time{}, ErrDisabled
}
if !canManage {
return nil, time.Time{}, ErrPermission
}
if gameID != nil && *gameID <= 0 {
return nil, time.Time{}, ErrInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
s.prune(now)
devices := make(map[string]*Device)
for _, connection := range s.connections {
if gameID != nil && connection.GameID != *gameID {
continue
}
connection.Connected = now.Sub(connection.LastSeenAt) < connectedFor
device := devices[connection.DeviceID]
if device == nil {
device = &Device{
ID: connection.DeviceID,
FirstSeenAt: connection.FirstSeenAt,
LastSeenAt: connection.LastSeenAt,
}
devices[connection.DeviceID] = device
}
if connection.FirstSeenAt.Before(device.FirstSeenAt) {
device.FirstSeenAt = connection.FirstSeenAt
}
if connection.LastSeenAt.After(device.LastSeenAt) {
device.LastSeenAt = connection.LastSeenAt
}
device.Connected = device.Connected || connection.Connected
device.Connections = append(device.Connections, connection)
}
result := make([]Device, 0, len(devices))
for _, device := range devices {
sort.Slice(device.Connections, func(i, j int) bool { return device.Connections[i].ID < device.Connections[j].ID })
result = append(result, *device)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result, now, nil
}
func (s *DevicePresenceService) prune(now time.Time) {
for id, connection := range s.connections {
if now.Sub(connection.LastSeenAt) >= retainedFor {
delete(s.connections, id)
}
}
}
func (s *DevicePresenceService) verify(ctx context.Context, input Heartbeat) (Connection, error) {
deviceID, err := parseUUID(input.DeviceID)
if err != nil {
return Connection{}, err
}
connectionID, err := parseUUID(input.ConnectionID)
if err != nil {
return Connection{}, err
}
organizer := input.OrganizerGameID != nil
team := input.TeamID != nil || input.Password != ""
screen := input.ScreenID != "" || input.ScreenTokenHash != ""
contexts := 0
for _, present := range []bool{organizer, team, screen} {
if present {
contexts++
}
}
if contexts > 1 || organizer && *input.OrganizerGameID <= 0 || team && (input.TeamID == nil || *input.TeamID <= 0 || input.Password == "") || screen && (input.ScreenID == "" || input.ScreenTokenHash == "") {
return Connection{}, ErrInvalid
}
connection := Connection{
ID: connectionID,
DeviceID: deviceID,
Kind: "browser",
IP: input.IP,
UserAgent: input.UserAgent,
}
// Bound untrusted metadata as well as the number of records.
if len(connection.UserAgent) > 1024 {
connection.UserAgent = connection.UserAgent[:1024]
}
connection.UserAgent = strings.ToValidUTF8(connection.UserAgent, "")
if organizer {
if !input.IsAuthenticated {
return Connection{}, ErrUnauthorized
}
if !input.CanManage {
return Connection{}, ErrPermission
}
if _, err := s.games.GetGame(ctx, *input.OrganizerGameID); err != nil {
return Connection{}, err
}
connection.Kind = "organizer"
connection.GameID = *input.OrganizerGameID
}
if team {
gameID, err := s.teams.CheckPresence(ctx, *input.TeamID, input.Password)
if err != nil {
return Connection{}, err
}
connection.Kind = "team"
connection.TeamID = *input.TeamID
connection.GameID = gameID
}
if screen {
id, err := strconv.Atoi(input.ScreenID)
if err != nil || id <= 0 || id > 2147483647 {
return Connection{}, ErrInvalid
}
session, err := s.screens.Get(ctx, id, input.ScreenTokenHash)
if err != nil {
return Connection{}, err
}
connection.Kind = "screen"
connection.ScreenID = session.ID
if session.GameID != nil {
connection.GameID = *session.GameID
}
}
return connection, nil
}
func parseUUID(value string) (string, error) {
parsed, err := uuid.Parse(value)
if err != nil || len(value) != 36 || parsed == uuid.Nil {
return "", ErrInvalid
}
return parsed.String(), nil
}
@@ -0,0 +1,258 @@
package device_presence_service
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"evening_detective_server/internal/services/game_model"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/screen_service"
"evening_detective_server/internal/services/team_service"
"github.com/google/uuid"
)
type presenceFixture struct{}
func (presenceFixture) GetGame(_ context.Context, id int) (*game_model.Game, error) {
if id != 7 {
return nil, game_service.ErrGameNotFound
}
return &game_model.Game{}, nil
}
func (presenceFixture) CheckPresence(_ context.Context, id int, password string) (int, error) {
if id != 2 || password != "secret" {
return 0, team_service.ErrTeamNotFound
}
return 7, nil
}
func (presenceFixture) Get(_ context.Context, id int, hash string) (*screen_service.ScreenSession, error) {
if id != 3 || hash != "hash" {
return nil, screen_service.ErrScreenNotFound
}
gameID := 7
return &screen_service.ScreenSession{ID: 3, GameID: &gameID}, nil
}
func newFixture() (*DevicePresenceService, Heartbeat) {
fixture := presenceFixture{}
return NewDevicePresenceService(true, fixture, fixture, fixture), Heartbeat{DeviceID: uuid.NewString(), ConnectionID: uuid.NewString()}
}
func pointer(id int) *int { return &id }
func send(t *testing.T, s *DevicePresenceService, input Heartbeat) {
t.Helper()
enabled, _, err := s.Heartbeat(context.Background(), input)
if !enabled || err != nil {
t.Fatalf("heartbeat: %v %v", enabled, err)
}
}
func list(t *testing.T, s *DevicePresenceService, gameID *int) []Device {
t.Helper()
devices, _, err := s.List(context.Background(), gameID, true)
if err != nil {
t.Fatal(err)
}
return devices
}
func TestRegistrationAndContext(t *testing.T) {
s, input := newFixture()
now := time.Date(2026, 10, 11, 0, 0, 0, 0, time.UTC)
s.now = func() time.Time { return now }
input.TeamID, input.Password = pointer(2), "secret"
send(t, s, input)
now = now.Add(time.Second)
input.IP, input.UserAgent = "192.0.2.1", "updated"
send(t, s, input)
devices := list(t, s, pointer(7))
if len(devices) != 1 || len(devices[0].Connections) != 1 {
t.Fatal(devices)
}
connection := devices[0].Connections[0]
if connection.IP != input.IP || connection.TeamID != 2 || connection.FirstSeenAt.Equal(connection.LastSeenAt) {
t.Fatal(connection)
}
tab := input
tab.ConnectionID = uuid.NewString()
tab.TeamID, tab.Password = nil, ""
tab.OrganizerGameID, tab.IsAuthenticated, tab.CanManage = pointer(7), true, true
send(t, s, tab)
if len(list(t, s, nil)[0].Connections) != 2 {
t.Fatal("tabs not grouped")
}
input.TeamID, input.Password = nil, ""
input.ScreenID, input.ScreenTokenHash = "3", "hash"
send(t, s, input)
connection = s.connections[input.ConnectionID]
if connection.TeamID != 0 || connection.ScreenID != 3 || connection.GameID != 7 {
t.Fatal(connection)
}
input.ScreenID, input.ScreenTokenHash = "", ""
send(t, s, input)
connection = s.connections[input.ConnectionID]
if connection.TeamID != 0 || connection.ScreenID != 0 || connection.GameID != 0 || connection.Kind != "browser" {
t.Fatal(connection)
}
if len(list(t, s, pointer(7))[0].Connections) != 1 {
t.Fatal("filter includes other contexts")
}
devices[0].Connections[0].GameID = 99
if s.connections[input.ConnectionID].GameID != 0 {
t.Fatal("list exposes registry memory")
}
}
func TestTimeBoundaries(t *testing.T) {
s, input := newFixture()
start := time.Now()
now := start
s.now = func() time.Time { return now }
send(t, s, input)
for _, test := range []struct {
age time.Duration
exists, connected bool
}{
{time.Minute - time.Nanosecond, true, true},
{time.Minute, true, false},
{15*time.Minute - time.Nanosecond, true, false},
{15 * time.Minute, false, false},
} {
now = start.Add(test.age)
devices := list(t, s, nil)
if (len(devices) > 0) != test.exists {
t.Fatalf("retention at %v: %v", test.age, devices)
}
if test.exists && (devices[0].Connected != test.connected || devices[0].Connections[0].Connected != test.connected) {
t.Fatalf("connection at %v", test.age)
}
}
send(t, s, input)
now = now.Add(59 * time.Second)
tab := input
tab.ConnectionID = uuid.NewString()
send(t, s, tab)
now = now.Add(time.Second)
if !list(t, s, nil)[0].Connected {
t.Fatal("live tab must keep device connected")
}
}
func TestCapacityAndPruning(t *testing.T) {
s, input := newFixture()
now := time.Now()
s.now = func() time.Time { return now }
for range maxConnections {
input.ConnectionID = uuid.NewString()
send(t, s, input)
}
send(t, s, input)
extra := input
extra.ConnectionID = uuid.NewString()
_, _, err := s.Heartbeat(context.Background(), extra)
if !errors.Is(err, ErrFull) || len(s.connections) != maxConnections {
t.Fatal(err)
}
now = now.Add(retainedFor)
send(t, s, extra)
if len(s.connections) != 1 {
t.Fatal("expired records not pruned on update")
}
}
func TestInvalidContextDoesNotOverwrite(t *testing.T) {
tests := []struct {
name string
mutate func(*Heartbeat)
want error
}{
{"device UUID", func(h *Heartbeat) { h.DeviceID = "invalid" }, ErrInvalid},
{"connection UUID", func(h *Heartbeat) { h.ConnectionID = "" }, ErrInvalid},
{"nil UUID", func(h *Heartbeat) { h.DeviceID = uuid.Nil.String() }, ErrInvalid},
{"device reassignment", func(h *Heartbeat) { h.DeviceID = uuid.NewString() }, ErrInvalid},
{"mixed", func(h *Heartbeat) { h.TeamID = pointer(2); h.Password = "secret"; h.OrganizerGameID = pointer(7) }, ErrInvalid},
{"password only", func(h *Heartbeat) { h.Password = "secret" }, ErrInvalid},
{"team only", func(h *Heartbeat) { h.TeamID = pointer(2) }, ErrInvalid},
{"screen only", func(h *Heartbeat) { h.ScreenID = "3" }, ErrInvalid},
{"token only", func(h *Heartbeat) { h.ScreenTokenHash = "hash" }, ErrInvalid},
{"screen ID", func(h *Heartbeat) { h.ScreenID = "oops"; h.ScreenTokenHash = "hash" }, ErrInvalid},
{"negative game", func(h *Heartbeat) { h.OrganizerGameID = pointer(-1) }, ErrInvalid},
{"anonymous organizer", func(h *Heartbeat) { h.OrganizerGameID = pointer(7) }, ErrUnauthorized},
{"user organizer", func(h *Heartbeat) { h.OrganizerGameID = pointer(7); h.IsAuthenticated = true }, ErrPermission},
{"missing game", func(h *Heartbeat) { h.OrganizerGameID = pointer(8); h.IsAuthenticated = true; h.CanManage = true }, game_service.ErrGameNotFound},
{"bad team", func(h *Heartbeat) { h.TeamID = pointer(2); h.Password = "bad" }, team_service.ErrTeamNotFound},
{"bad screen", func(h *Heartbeat) { h.ScreenID = "3"; h.ScreenTokenHash = "bad" }, screen_service.ErrScreenNotFound},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
s, input := newFixture()
send(t, s, input)
before := s.connections[input.ConnectionID]
test.mutate(&input)
_, _, err := s.Heartbeat(context.Background(), input)
if !errors.Is(err, test.want) {
t.Fatalf("got %v, want %v", err, test.want)
}
if s.connections[before.ID] != before || len(s.connections) != 1 {
t.Fatal("invalid request modified registry")
}
})
}
}
func TestModeAndReadAccess(t *testing.T) {
s, input := newFixture()
if _, _, err := s.List(context.Background(), nil, false); !errors.Is(err, ErrPermission) {
t.Fatal(err)
}
if _, _, err := s.List(context.Background(), pointer(0), true); !errors.Is(err, ErrInvalid) {
t.Fatal(err)
}
s.enabled = false
enabled, now, err := s.Heartbeat(context.Background(), Heartbeat{})
if enabled || now.IsZero() || err != nil || len(s.connections) != 0 {
t.Fatal("prod registered device")
}
if _, _, err = s.List(context.Background(), nil, true); !errors.Is(err, ErrDisabled) {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, _, err = s.Heartbeat(ctx, input); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}
func TestConcurrentAccess(t *testing.T) {
s, input := newFixture()
var wg sync.WaitGroup
for range 20 {
wg.Go(func() {
tab := input
tab.ConnectionID = uuid.NewString()
for range 50 {
send(t, s, tab)
list(t, s, nil)
}
})
}
wg.Wait()
if len(list(t, s, nil)[0].Connections) != 20 {
t.Fatal("lost connections")
}
}
func TestMetadataUTF8(t *testing.T) {
for _, agent := range []string{strings.Repeat("x", 1023) + "я", "bad\xffagent"} {
s, input := newFixture()
input.UserAgent = agent
send(t, s, input)
stored := list(t, s, nil)[0].Connections[0].UserAgent
if len(stored) > 1024 || !utf8.ValidString(stored) {
t.Fatal("metadata must serialize safely")
}
}
}