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
+10
View File
@@ -98,6 +98,16 @@ make test
восстановление worker и команды проверки описаны в
[`docs/GAME_PACKAGE.md`](docs/GAME_PACKAGE.md#экспорт-со-статусом-офлайн-режима-этап-5).
## Учёт устройств площадки
В `APP_MODE=venue` публичный `POST /api/devices/heartbeat` учитывает подключения
браузеров в памяти; `GET /api/devices` доступен организатору, администратору и
разработчику, с необязательным `gameID`. В prod heartbeat возвращает
`enabled=false`, список недоступен. TTL связи — менее 60 секунд, удаление —
через 15 минут, предел — 1024 подключения. Credentials не сохраняются.
Подробности и ограничения описаны в
[workspace README](../README.md#учёт-устройств-этап-12).
## Документация и юридические документы
- [`docs/TERMS.md`](docs/TERMS.md) — Пользовательское соглашение (оферта); раздаётся сервером по постоянному URL `/api/terms`;
+60
View File
@@ -41,6 +41,23 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
};
service EveningDetectiveServer {
rpc DeviceHeartbeat(DeviceHeartbeatReq) returns (DeviceHeartbeatRsp) {
option (google.api.http) = { post: "/api/devices/heartbeat" body: "*" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Устройства";
summary : "Обновить присутствие подключения браузера";
description: "Публичный endpoint. Контексты взаимоисключающие и проверяются сервером. В prod enabled=false без регистрации. UUID обозначает браузерный профиль, не физический телефон.";
security : {};
};
}
rpc GetDevices(GetDevicesReq) returns (GetDevicesRsp) {
option (google.api.http) = { get: "/api/devices" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Устройства";
summary : "Получить устройства площадки";
description: "Только venue; организатор, администратор или разработчик. Связь актуальна менее 60 секунд; подключения удаляются через 15 минут. Фильтр gameID оставляет только подключения указанной игры.";
};
}
rpc GetTasks(GetTasksReq) returns (GetTasksRsp) {
option (google.api.http) = { get: "/api/tasks" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
@@ -2382,3 +2399,46 @@ message GameResultsPackageRsp {
string packageID = 2;
string appliedAt = 3;
}
message DeviceHeartbeatReq {
string deviceID = 1;
string connectionID = 2;
optional int32 organizerGameID = 3;
optional int32 teamID = 4;
string password = 5;
string screenID = 6;
string accessToken = 7;
}
message DeviceHeartbeatRsp {
optional bool enabled = 1;
google.protobuf.Timestamp serverTime = 2;
}
message GetDevicesReq { optional int32 gameID = 1; }
message GetDevicesRsp {
repeated VenueDevice devices = 1;
google.protobuf.Timestamp serverTime = 2;
}
message VenueDevice {
string ID = 1;
google.protobuf.Timestamp firstSeenAt = 2;
google.protobuf.Timestamp lastSeenAt = 3;
bool connected = 4;
repeated DeviceConnection connections = 5;
}
message DeviceConnection {
string ID = 1;
google.protobuf.Timestamp firstSeenAt = 2;
google.protobuf.Timestamp lastSeenAt = 3;
string IP = 4;
string userAgent = 5;
bool connected = 6;
optional int32 gameID = 7;
optional int32 teamID = 8;
optional int32 screenID = 9;
string kind = 10; // browser, organizer, team, screen
}
Binary file not shown.
+5
View File
@@ -35,6 +35,7 @@ import (
"evening_detective_server/internal/repos/tickets_repo"
"evening_detective_server/internal/repos/users_repo"
"evening_detective_server/internal/services/answer_service"
"evening_detective_server/internal/services/device_presence_service"
"evening_detective_server/internal/services/file_service"
"evening_detective_server/internal/services/game_export_service"
"evening_detective_server/internal/services/game_import_service"
@@ -228,6 +229,8 @@ func main() {
log.Fatalln("Failed to listen:", err)
}
devicePresenceService := device_presence_service.NewDevicePresenceService(venueMode, gameService, teamService, screenService)
s := grpc.NewServer(
grpc.MaxRecvMsgSize(grpcMsgLimit),
grpc.UnaryInterceptor(
@@ -237,6 +240,7 @@ func main() {
"/crabs.evening_detective_server.EveningDetectiveServer/GetParticipation": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetTicket": true,
"/crabs.evening_detective_server.EveningDetectiveServer/AnswerParticipation": true,
"/crabs.evening_detective_server.EveningDetectiveServer/DeviceHeartbeat": true,
"/crabs.evening_detective_server.EveningDetectiveServer/Ping": true,
"/crabs.evening_detective_server.EveningDetectiveServer/Echo": true,
"/crabs.evening_detective_server.EveningDetectiveServer/Signup": true,
@@ -287,6 +291,7 @@ func main() {
gameImportService,
gameResultsService,
gameResultsApplyService,
devicePresenceService,
),
)
@@ -333,6 +333,74 @@
]
}
},
"/api/devices": {
"get": {
"summary": "Получить устройства площадки",
"description": "Только venue; организатор, администратор или разработчик. Связь актуальна менее 60 секунд; подключения удаляются через 15 минут. Фильтр gameID оставляет только подключения указанной игры.",
"operationId": "EveningDetectiveServer_GetDevices",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/evening_detective_serverGetDevicesRsp"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "gameID",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
}
],
"tags": [
"Устройства"
]
}
},
"/api/devices/heartbeat": {
"post": {
"summary": "Обновить присутствие подключения браузера",
"description": "Публичный endpoint. Контексты взаимоисключающие и проверяются сервером. В prod enabled=false без регистрации. UUID обозначает браузерный профиль, не физический телефон.",
"operationId": "EveningDetectiveServer_DeviceHeartbeat",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/evening_detective_serverDeviceHeartbeatRsp"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/evening_detective_serverDeviceHeartbeatReq"
}
}
],
"tags": [
"Устройства"
],
"security": []
}
},
"/api/files/upload": {
"post": {
"summary": "Загрузить файл",
@@ -4700,6 +4768,87 @@
},
"description": "Developer tasks use new, in_progress, review and done statuses."
},
"evening_detective_serverDeviceConnection": {
"type": "object",
"properties": {
"ID": {
"type": "string"
},
"firstSeenAt": {
"type": "string",
"format": "date-time"
},
"lastSeenAt": {
"type": "string",
"format": "date-time"
},
"IP": {
"type": "string"
},
"userAgent": {
"type": "string"
},
"connected": {
"type": "boolean"
},
"gameID": {
"type": "integer",
"format": "int32"
},
"teamID": {
"type": "integer",
"format": "int32"
},
"screenID": {
"type": "integer",
"format": "int32"
},
"kind": {
"type": "string",
"title": "browser, organizer, team, screen"
}
}
},
"evening_detective_serverDeviceHeartbeatReq": {
"type": "object",
"properties": {
"deviceID": {
"type": "string"
},
"connectionID": {
"type": "string"
},
"organizerGameID": {
"type": "integer",
"format": "int32"
},
"teamID": {
"type": "integer",
"format": "int32"
},
"password": {
"type": "string"
},
"screenID": {
"type": "string"
},
"accessToken": {
"type": "string"
}
}
},
"evening_detective_serverDeviceHeartbeatRsp": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"serverTime": {
"type": "string",
"format": "date-time"
}
}
},
"evening_detective_serverDoor": {
"type": "object",
"properties": {
@@ -4894,6 +5043,22 @@
}
}
},
"evening_detective_serverGetDevicesRsp": {
"type": "object",
"properties": {
"devices": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/evening_detective_serverVenueDevice"
}
},
"serverTime": {
"type": "string",
"format": "date-time"
}
}
},
"evening_detective_serverGetGameAnswersRsp": {
"type": "object",
"properties": {
@@ -6155,6 +6320,32 @@
}
}
},
"evening_detective_serverVenueDevice": {
"type": "object",
"properties": {
"ID": {
"type": "string"
},
"firstSeenAt": {
"type": "string",
"format": "date-time"
},
"lastSeenAt": {
"type": "string",
"format": "date-time"
},
"connected": {
"type": "boolean"
},
"connections": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/evening_detective_serverDeviceConnection"
}
}
}
},
"protobufAny": {
"type": "object",
"properties": {
+59
View File
@@ -0,0 +1,59 @@
package app
import (
"context"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/roles"
"evening_detective_server/internal/services/device_presence_service"
proto "evening_detective_server/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *server) DeviceHeartbeat(ctx context.Context, req *proto.DeviceHeartbeatReq) (*proto.DeviceHeartbeatRsp, error) {
if req == nil {
req = &proto.DeviceHeartbeatReq{}
}
ip, userAgent := clientInfoFromContext(ctx)
claims, authenticated := processor_jwt.ClaimsFromContext(ctx)
input := device_presence_service.Heartbeat{
DeviceID: req.DeviceID,
ConnectionID: req.ConnectionID,
OrganizerGameID: deviceOptionalID(req.OrganizerGameID),
TeamID: deviceOptionalID(req.TeamID),
Password: req.Password,
ScreenID: req.ScreenID,
IP: ip,
UserAgent: userAgent,
IsAuthenticated: authenticated,
CanManage: authenticated && (roles.HasRole(claims, roles.Organizer) || roles.HasRole(claims, roles.Author)),
}
if req.AccessToken != "" {
input.ScreenTokenHash = hashScreenToken(req.AccessToken)
}
enabled, now, err := s.devicePresenceService.Heartbeat(ctx, input)
if err != nil {
return nil, deviceErrorStatus(err)
}
return &proto.DeviceHeartbeatRsp{
Enabled: &enabled,
ServerTime: timestamppb.New(now),
}, nil
}
func (s *server) GetDevices(ctx context.Context, req *proto.GetDevicesReq) (*proto.GetDevicesRsp, error) {
if req == nil {
req = &proto.GetDevicesReq{}
}
claims, authenticated := processor_jwt.ClaimsFromContext(ctx)
canRead := authenticated && (roles.HasRole(claims, roles.Organizer) || roles.HasRole(claims, roles.Developer))
devices, now, err := s.devicePresenceService.List(ctx, deviceOptionalID(req.GameID), canRead)
if err != nil {
return nil, deviceErrorStatus(err)
}
return &proto.GetDevicesRsp{
Devices: mapVenueDevices(devices),
ServerTime: timestamppb.New(now),
}, nil
}
+43
View File
@@ -0,0 +1,43 @@
package app
import (
"context"
"errors"
"log"
"evening_detective_server/internal/services/device_presence_service"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/screen_service"
"evening_detective_server/internal/services/team_service"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func deviceErrorStatus(err error) error {
switch {
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, "запрос отменён")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "время запроса истекло")
case errors.Is(err, device_presence_service.ErrInvalid):
return status.Error(codes.InvalidArgument, device_presence_service.ErrInvalid.Error())
case errors.Is(err, device_presence_service.ErrDisabled):
return status.Error(codes.FailedPrecondition, device_presence_service.ErrDisabled.Error())
case errors.Is(err, device_presence_service.ErrPermission):
return status.Error(codes.PermissionDenied, device_presence_service.ErrPermission.Error())
case errors.Is(err, device_presence_service.ErrUnauthorized):
return status.Error(codes.Unauthenticated, device_presence_service.ErrUnauthorized.Error())
case errors.Is(err, device_presence_service.ErrFull):
return status.Error(codes.ResourceExhausted, device_presence_service.ErrFull.Error())
case errors.Is(err, team_service.ErrTeamNotFound):
return status.Error(codes.NotFound, team_service.ErrTeamNotFound.Error())
case errors.Is(err, game_service.ErrGameNotFound):
return status.Error(codes.NotFound, game_service.ErrGameNotFound.Error())
case errors.Is(err, screen_service.ErrScreenNotFound):
return status.Error(codes.Unauthenticated, "недействительная сессия экрана")
default:
log.Printf("device presence: %v", err)
return status.Error(codes.Internal, "не удалось обновить учёт устройств")
}
}
+53
View File
@@ -0,0 +1,53 @@
package app
import (
"evening_detective_server/internal/services/device_presence_service"
proto "evening_detective_server/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
func deviceOptionalID(id *int32) *int {
if id == nil {
return nil
}
value := int(*id)
return &value
}
func devicePublicID(id int) *int32 {
if id == 0 {
return nil
}
value := int32(id)
return &value
}
func mapVenueDevices(devices []device_presence_service.Device) []*proto.VenueDevice {
result := make([]*proto.VenueDevice, 0, len(devices))
for _, device := range devices {
connections := make([]*proto.DeviceConnection, 0, len(device.Connections))
for _, connection := range device.Connections {
connections = append(connections, &proto.DeviceConnection{
ID: connection.ID,
FirstSeenAt: timestamppb.New(connection.FirstSeenAt),
LastSeenAt: timestamppb.New(connection.LastSeenAt),
IP: connection.IP,
UserAgent: connection.UserAgent,
Connected: connection.Connected,
GameID: devicePublicID(connection.GameID),
TeamID: devicePublicID(connection.TeamID),
ScreenID: devicePublicID(connection.ScreenID),
Kind: connection.Kind,
})
}
result = append(result, &proto.VenueDevice{
ID: device.ID,
FirstSeenAt: timestamppb.New(device.FirstSeenAt),
LastSeenAt: timestamppb.New(device.LastSeenAt),
Connected: device.Connected,
Connections: connections,
})
}
return result
}
+99
View File
@@ -0,0 +1,99 @@
package app
import (
"context"
"errors"
"strings"
"testing"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/services/device_presence_service"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/screen_service"
"evening_detective_server/internal/services/team_service"
proto "evening_detective_server/proto"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
)
func TestDeviceTransport(t *testing.T) {
s := &server{devicePresenceService: device_presence_service.NewDevicePresenceService(true, nil, nil, nil)}
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-real-ip", "192.0.2.3", "grpcgateway-user-agent", "test-browser"))
req := &proto.DeviceHeartbeatReq{DeviceID: uuid.NewString(), ConnectionID: uuid.NewString()}
rsp, err := s.DeviceHeartbeat(ctx, req)
if err != nil || !rsp.GetEnabled() || rsp.ServerTime == nil {
t.Fatal(rsp, err)
}
for _, role := range []string{"organizer", "admin", "developer", "author", "user", ""} {
t.Run(role, func(t *testing.T) {
roleCtx := processor_jwt.ContextWithClaims(ctx, &processor_jwt.JWTClaims{Roles: []string{role}})
devices, err := s.GetDevices(roleCtx, &proto.GetDevicesReq{})
if role == "author" || role == "user" || role == "" {
if status.Code(err) != codes.PermissionDenied {
t.Fatal(err)
}
return
}
if err != nil || len(devices.Devices) != 1 {
t.Fatal(devices, err)
}
connection := devices.Devices[0].Connections[0]
if connection.IP != "192.0.2.3" || connection.UserAgent != "test-browser" {
t.Fatal(connection)
}
raw, err := protojson.Marshal(devices)
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{"password", "accessToken", "ScreenTokenHash", "email", "URL"} {
if strings.Contains(string(raw), secret) {
t.Fatal("secret field in response")
}
}
})
}
req.DeviceID = "bad"
if _, err = s.DeviceHeartbeat(ctx, req); status.Code(err) != codes.InvalidArgument {
t.Fatal(err)
}
s.devicePresenceService = device_presence_service.NewDevicePresenceService(false, nil, nil, nil)
rsp, err = s.DeviceHeartbeat(ctx, req)
if err != nil || rsp.GetEnabled() {
t.Fatal(rsp, err)
}
raw, err := protojson.Marshal(rsp)
if err != nil || !strings.Contains(string(raw), `"enabled":false`) {
t.Fatal("disabled flag must be explicit", string(raw), err)
}
if _, err = s.GetDevices(ctx, &proto.GetDevicesReq{}); status.Code(err) != codes.FailedPrecondition {
t.Fatal(err)
}
}
func TestDeviceErrors(t *testing.T) {
for _, test := range []struct {
err error
code codes.Code
}{
{context.Canceled, codes.Canceled},
{context.DeadlineExceeded, codes.DeadlineExceeded},
{device_presence_service.ErrInvalid, codes.InvalidArgument},
{device_presence_service.ErrDisabled, codes.FailedPrecondition},
{device_presence_service.ErrPermission, codes.PermissionDenied},
{device_presence_service.ErrUnauthorized, codes.Unauthenticated},
{device_presence_service.ErrFull, codes.ResourceExhausted},
{team_service.ErrTeamNotFound, codes.NotFound},
{game_service.ErrGameNotFound, codes.NotFound},
{screen_service.ErrScreenNotFound, codes.Unauthenticated},
{errors.New("SQL password=secret /private/path"), codes.Internal},
} {
err := deviceErrorStatus(test.err)
if status.Code(err) != test.code || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "/private") {
t.Fatal(err)
}
}
}
+4
View File
@@ -2,6 +2,7 @@ package app
import (
"evening_detective_server/internal/services/answer_service"
"evening_detective_server/internal/services/device_presence_service"
"evening_detective_server/internal/services/file_service"
"evening_detective_server/internal/services/game_export_service"
"evening_detective_server/internal/services/game_import_service"
@@ -26,6 +27,7 @@ import (
type server struct {
proto.UnsafeEveningDetectiveServerServer
devicePresenceService *device_presence_service.DevicePresenceService
usersService *users_service.UsersService
uiService *ui_service.UIService
taskService *task_service.TaskService
@@ -70,8 +72,10 @@ func NewServer(
gameImportService *game_import_service.GameImportService,
gameResultsService *game_results_package_service.GameResultsPackageService,
gameResultsApplyService *game_results_package_service.GameResultsApplyService,
devicePresenceService *device_presence_service.DevicePresenceService,
) proto.EveningDetectiveServerServer {
return &server{
devicePresenceService: devicePresenceService,
gameImportService: gameImportService,
gameResultsService: gameResultsService,
gameResultsApplyService: gameResultsApplyService,
@@ -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")
}
}
}
@@ -377,7 +377,7 @@ func verifyExportedGameReads(t *testing.T, f serviceFixture, worker *game_export
games := game_service.NewGameService(f.games, f.teams, f.scenarios, nil, "", actions_repo.NewActionsRepo(f.pool), applications_repo.NewApplicationsRepo(f.pool))
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
server := app.NewServer(nil, nil, nil, nil, games, nil, nil, nil, nil, nil, nil,
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil)
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil, nil)
ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{UserID: f.userID, Roles: []string{"organizer"}})
game, err := server.GetGame(ctx, &proto.GetGameReq{ID: int32(f.gameID)})
check(t, err)
+15
View File
@@ -166,3 +166,18 @@ func (s *TeamService) DownloadTeamQR(ctx context.Context, id int) ([]byte, strin
data, err := team_qr_pdf.Generate(cards)
return data, filename, err
}
// CheckPresence validates team credentials without loading game progress or a scenario.
func (s *TeamService) CheckPresence(ctx context.Context, teamID int, password string) (int, error) {
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
if errors.Is(err, teams_repo.ErrTeamNotFound) {
return 0, ErrTeamNotFound
}
if err != nil {
return 0, err
}
if team.Password != password || team.ParticipationStatus == "cancelled" {
return 0, ErrTeamNotFound
}
return team.GameID, nil
}
+755 -246
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -36,6 +36,68 @@ var (
_ = metadata.Join
)
func request_EveningDetectiveServer_DeviceHeartbeat_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq DeviceHeartbeatReq
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.DeviceHeartbeat(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_EveningDetectiveServer_DeviceHeartbeat_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq DeviceHeartbeatReq
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.DeviceHeartbeat(ctx, &protoReq)
return msg, metadata, err
}
var filter_EveningDetectiveServer_GetDevices_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
func request_EveningDetectiveServer_GetDevices_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetDevicesReq
metadata runtime.ServerMetadata
)
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_EveningDetectiveServer_GetDevices_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.GetDevices(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_EveningDetectiveServer_GetDevices_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetDevicesReq
metadata runtime.ServerMetadata
)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_EveningDetectiveServer_GetDevices_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.GetDevices(ctx, &protoReq)
return msg, metadata, err
}
func request_EveningDetectiveServer_GetTasks_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetTasksReq
@@ -4235,6 +4297,46 @@ func local_request_EveningDetectiveServer_SetTeamAnswerScore_0(ctx context.Conte
// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterEveningDetectiveServerHandlerFromEndpoint instead.
// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call.
func RegisterEveningDetectiveServerHandlerServer(ctx context.Context, mux *runtime.ServeMux, server EveningDetectiveServerServer) error {
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_DeviceHeartbeat_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DeviceHeartbeat", runtime.WithHTTPPathPattern("/api/devices/heartbeat"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_EveningDetectiveServer_DeviceHeartbeat_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_DeviceHeartbeat_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_GetDevices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/GetDevices", runtime.WithHTTPPathPattern("/api/devices"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_EveningDetectiveServer_GetDevices_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_GetDevices_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_GetTasks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -6455,6 +6557,40 @@ func RegisterEveningDetectiveServerHandler(ctx context.Context, mux *runtime.Ser
// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in
// "EveningDetectiveServerClient" to call the correct interceptors. This client ignores the HTTP middlewares.
func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runtime.ServeMux, client EveningDetectiveServerClient) error {
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_DeviceHeartbeat_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DeviceHeartbeat", runtime.WithHTTPPathPattern("/api/devices/heartbeat"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_EveningDetectiveServer_DeviceHeartbeat_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_DeviceHeartbeat_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_GetDevices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/GetDevices", runtime.WithHTTPPathPattern("/api/devices"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_EveningDetectiveServer_GetDevices_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_GetDevices_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_GetTasks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -8312,6 +8448,8 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
}
var (
pattern_EveningDetectiveServer_DeviceHeartbeat_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "devices", "heartbeat"}, ""))
pattern_EveningDetectiveServer_GetDevices_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "devices"}, ""))
pattern_EveningDetectiveServer_GetTasks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "tasks"}, ""))
pattern_EveningDetectiveServer_GetTaskAssignees_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "tasks", "assignees"}, ""))
pattern_EveningDetectiveServer_CreateTask_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "tasks"}, ""))
@@ -8424,6 +8562,8 @@ var (
)
var (
forward_EveningDetectiveServer_DeviceHeartbeat_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetDevices_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetTasks_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetTaskAssignees_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_CreateTask_0 = runtime.ForwardResponseMessage
+76
View File
@@ -26,6 +26,8 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
EveningDetectiveServer_DeviceHeartbeat_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeviceHeartbeat"
EveningDetectiveServer_GetDevices_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetDevices"
EveningDetectiveServer_GetTasks_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetTasks"
EveningDetectiveServer_GetTaskAssignees_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetTaskAssignees"
EveningDetectiveServer_CreateTask_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/CreateTask"
@@ -141,6 +143,8 @@ const (
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type EveningDetectiveServerClient interface {
DeviceHeartbeat(ctx context.Context, in *DeviceHeartbeatReq, opts ...grpc.CallOption) (*DeviceHeartbeatRsp, error)
GetDevices(ctx context.Context, in *GetDevicesReq, opts ...grpc.CallOption) (*GetDevicesRsp, error)
GetTasks(ctx context.Context, in *GetTasksReq, opts ...grpc.CallOption) (*GetTasksRsp, error)
GetTaskAssignees(ctx context.Context, in *GetTaskAssigneesReq, opts ...grpc.CallOption) (*GetTaskAssigneesRsp, error)
CreateTask(ctx context.Context, in *CreateTaskReq, opts ...grpc.CallOption) (*TaskRsp, error)
@@ -272,6 +276,26 @@ func NewEveningDetectiveServerClient(cc grpc.ClientConnInterface) EveningDetecti
return &eveningDetectiveServerClient{cc}
}
func (c *eveningDetectiveServerClient) DeviceHeartbeat(ctx context.Context, in *DeviceHeartbeatReq, opts ...grpc.CallOption) (*DeviceHeartbeatRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeviceHeartbeatRsp)
err := c.cc.Invoke(ctx, EveningDetectiveServer_DeviceHeartbeat_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *eveningDetectiveServerClient) GetDevices(ctx context.Context, in *GetDevicesReq, opts ...grpc.CallOption) (*GetDevicesRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDevicesRsp)
err := c.cc.Invoke(ctx, EveningDetectiveServer_GetDevices_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *eveningDetectiveServerClient) GetTasks(ctx context.Context, in *GetTasksReq, opts ...grpc.CallOption) (*GetTasksRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetTasksRsp)
@@ -1366,6 +1390,8 @@ func (c *eveningDetectiveServerClient) SetTeamAnswerScore(ctx context.Context, i
// All implementations must embed UnimplementedEveningDetectiveServerServer
// for forward compatibility.
type EveningDetectiveServerServer interface {
DeviceHeartbeat(context.Context, *DeviceHeartbeatReq) (*DeviceHeartbeatRsp, error)
GetDevices(context.Context, *GetDevicesReq) (*GetDevicesRsp, error)
GetTasks(context.Context, *GetTasksReq) (*GetTasksRsp, error)
GetTaskAssignees(context.Context, *GetTaskAssigneesReq) (*GetTaskAssigneesRsp, error)
CreateTask(context.Context, *CreateTaskReq) (*TaskRsp, error)
@@ -1497,6 +1523,12 @@ type EveningDetectiveServerServer interface {
// pointer dereference when methods are called.
type UnimplementedEveningDetectiveServerServer struct{}
func (UnimplementedEveningDetectiveServerServer) DeviceHeartbeat(context.Context, *DeviceHeartbeatReq) (*DeviceHeartbeatRsp, error) {
return nil, status.Error(codes.Unimplemented, "method DeviceHeartbeat not implemented")
}
func (UnimplementedEveningDetectiveServerServer) GetDevices(context.Context, *GetDevicesReq) (*GetDevicesRsp, error) {
return nil, status.Error(codes.Unimplemented, "method GetDevices not implemented")
}
func (UnimplementedEveningDetectiveServerServer) GetTasks(context.Context, *GetTasksReq) (*GetTasksRsp, error) {
return nil, status.Error(codes.Unimplemented, "method GetTasks not implemented")
}
@@ -1846,6 +1878,42 @@ func RegisterEveningDetectiveServerServer(s grpc.ServiceRegistrar, srv EveningDe
s.RegisterService(&EveningDetectiveServer_ServiceDesc, srv)
}
func _EveningDetectiveServer_DeviceHeartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeviceHeartbeatReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EveningDetectiveServerServer).DeviceHeartbeat(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EveningDetectiveServer_DeviceHeartbeat_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EveningDetectiveServerServer).DeviceHeartbeat(ctx, req.(*DeviceHeartbeatReq))
}
return interceptor(ctx, in, info, handler)
}
func _EveningDetectiveServer_GetDevices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDevicesReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EveningDetectiveServerServer).GetDevices(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EveningDetectiveServer_GetDevices_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EveningDetectiveServerServer).GetDevices(ctx, req.(*GetDevicesReq))
}
return interceptor(ctx, in, info, handler)
}
func _EveningDetectiveServer_GetTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetTasksReq)
if err := dec(in); err != nil {
@@ -3815,6 +3883,14 @@ var EveningDetectiveServer_ServiceDesc = grpc.ServiceDesc{
ServiceName: "crabs.evening_detective_server.EveningDetectiveServer",
HandlerType: (*EveningDetectiveServerServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "DeviceHeartbeat",
Handler: _EveningDetectiveServer_DeviceHeartbeat_Handler,
},
{
MethodName: "GetDevices",
Handler: _EveningDetectiveServer_GetDevices_Handler,
},
{
MethodName: "GetTasks",
Handler: _EveningDetectiveServer_GetTasks_Handler,