This commit is contained in:
2026-10-23 00:45:30 +07:00
parent 60119a07e8
commit 146700cf4c
34 changed files with 622 additions and 124 deletions
+36 -32
View File
@@ -1450,8 +1450,8 @@ message UploadPlaylistTrackRsp {
} }
message ReorderPlaylistTracksReq { message ReorderPlaylistTracksReq {
int32 playlistID = 1; int32 playlistID = 1;
repeated int32 trackIDs = 2; repeated int32 trackIDs = 2;
} }
message ReorderPlaylistTracksRsp { message ReorderPlaylistTracksRsp {
@@ -1727,18 +1727,20 @@ message GetGamesRsp {
} }
message Game { message Game {
int32 ID = 1; int32 ID = 1;
string name = 2; string name = 2;
string description = 3; string description = 3;
google.protobuf.Timestamp start_at = 4; google.protobuf.Timestamp start_at = 4;
string status = 5; string status = 5;
Scenario scenario = 6; Scenario scenario = 6;
repeated Team teams = 7; repeated Team teams = 7;
optional google.protobuf.Timestamp started_at = 8; optional google.protobuf.Timestamp started_at = 8;
optional google.protobuf.Timestamp ended_at = 9; optional google.protobuf.Timestamp ended_at = 9;
string format = 10; string format = 10;
string location = 11; string location = 11;
GameExport export = 12; GameExport export = 12;
string wifi_name = 13; // Management settings only.
string wifi_password = 14;
} }
// Download ready ZIP through the authenticated streaming HTTP route: // Download ready ZIP through the authenticated streaming HTTP route:
@@ -1774,13 +1776,13 @@ message GameExportRsp { GameExport export = 1; }
enum ScreenMode { enum ScreenMode {
reserved 3, 5; reserved 3, 5;
reserved "SCREEN_MODE_INTRO_DONE", "SCREEN_MODE_CONCLUSION_DONE"; reserved "SCREEN_MODE_INTRO_DONE", "SCREEN_MODE_CONCLUSION_DONE";
SCREEN_MODE_UNSPECIFIED = 0; SCREEN_MODE_UNSPECIFIED = 0;
SCREEN_MODE_COVER = 1; SCREEN_MODE_COVER = 1;
SCREEN_MODE_INTRO = 2; SCREEN_MODE_INTRO = 2;
SCREEN_MODE_CONCLUSION = 4; SCREEN_MODE_CONCLUSION = 4;
SCREEN_MODE_RESULTS = 6; SCREEN_MODE_RESULTS = 6;
SCREEN_MODE_TIMER = 7; SCREEN_MODE_TIMER = 7;
SCREEN_MODE_TEXT = 8; SCREEN_MODE_TEXT = 8;
} }
enum ScreenTimerState { enum ScreenTimerState {
@@ -1906,10 +1908,10 @@ message AdvanceScreenMusicRsp {
} }
message BindScreenReq { message BindScreenReq {
string screenID = 1; string screenID = 1;
string accessToken = 2; string accessToken = 2;
int32 gameID = 3; int32 gameID = 3;
bool initializeAudio = 4; bool initializeAudio = 4;
} }
message BindScreenRsp { message BindScreenRsp {
@@ -2183,13 +2185,15 @@ message ModerateGameReviewRsp {
} }
message UpdateGameReq { message UpdateGameReq {
int32 ID = 1; int32 ID = 1;
string name = 2; string name = 2;
string description = 3; string description = 3;
google.protobuf.Timestamp start_at = 4; google.protobuf.Timestamp start_at = 4;
int32 scenarioID = 5; int32 scenarioID = 5;
string format = 6; string format = 6;
string location = 7; string location = 7;
optional string wifi_name = 8;
optional string wifi_password = 9;
} }
message UpdateGameRsp { message UpdateGameRsp {
@@ -4487,6 +4487,12 @@
}, },
"location": { "location": {
"type": "string" "type": "string"
},
"wifiName": {
"type": "string"
},
"wifiPassword": {
"type": "string"
} }
} }
}, },
@@ -5137,6 +5143,13 @@
}, },
"export": { "export": {
"$ref": "#/definitions/evening_detective_serverGameExport" "$ref": "#/definitions/evening_detective_serverGameExport"
},
"wifiName": {
"type": "string",
"description": "Management settings only."
},
"wifiPassword": {
"type": "string"
} }
} }
}, },
+6
View File
@@ -17,6 +17,12 @@ MIME и SHA-256 в нижнем регистре. В перечень входи
не входит. Все остальные ZIP-записи должны быть заявлены и использованы не входит. Все остальные ZIP-записи должны быть заявлены и использованы
ссылками из данных игры. Вложенные архивы и каталоги не используются. ссылками из данных игры. Вложенные архивы и каталоги не используются.
В объекте `game` необязательные `wifi_name` и `wifi_password` содержат настройки
сети для печати карточек команд и чеков. Они сохраняются при экспорте и импорте;
пакеты без этих полей используют пустые значения. Пароль входит в пакет открытым
текстом, как и данные входа команд. После изменения настроек для переноса нужен
новый экспорт: готовый пакет остаётся снимком на момент подготовки.
Полный перечень JSON-полей задан типами в `model.go`; неизвестные поля и Полный перечень JSON-полей задан типами в `model.go`; неизвестные поля и
неподдерживаемые версии при чтении отклоняются. Время записывается как RFC3339, неподдерживаемые версии при чтении отклоняются. Время записывается как RFC3339,
`token_hash` — как стандартный Base64 от исходных 32 байтов SHA-256. `token_hash` — как стандартный Base64 от исходных 32 байтов SHA-256.
+2 -1
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"errors" "errors"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/services/game_service" "evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/scenarios_service" "evening_detective_server/internal/services/scenarios_service"
@@ -14,7 +15,7 @@ func gameErrorStatus(err error, internalMessage string) error {
switch { switch {
case errors.Is(err, game_service.ErrGameNotFound), errors.Is(err, scenarios_service.ErrScenarioNotFound): case errors.Is(err, game_service.ErrGameNotFound), errors.Is(err, scenarios_service.ErrScenarioNotFound):
return status.Error(codes.NotFound, err.Error()) return status.Error(codes.NotFound, err.Error())
case errors.Is(err, game_service.ErrInvalidGameFormat): case errors.Is(err, game_service.ErrInvalidGameFormat), errors.Is(err, team_qr_pdf.ErrInvalidWiFi):
return status.Error(codes.InvalidArgument, err.Error()) return status.Error(codes.InvalidArgument, err.Error())
default: default:
return status.Error(codes.Internal, internalMessage) return status.Error(codes.Internal, internalMessage)
+7
View File
@@ -68,3 +68,10 @@ func mapGameSummary(game *game_model.Game) *proto.Game {
Status: game.Status, Status: game.Status,
} }
} }
func mapGameSettings(game *game_model.Game) *proto.Game {
result := mapGame(game)
result.WifiName = game.WiFiName
result.WifiPassword = game.WiFiPassword
return result
}
+48
View File
@@ -0,0 +1,48 @@
package app
import (
"testing"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/services/game_model"
"evening_detective_server/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
)
func TestGameWiFiVisibility(t *testing.T) {
game := &game_model.Game{WiFiName: "Детектив", WiFiPassword: "secret"}
settings := mapGameSettings(game)
if settings.WifiName != game.WiFiName || settings.WifiPassword != game.WiFiPassword {
t.Fatal("management settings missing")
}
for _, result := range []*proto.Game{mapGame(game), mapGameSummary(game)} {
if result.WifiName != "" || result.WifiPassword != "" {
t.Fatal("Wi-Fi exposed outside management settings")
}
}
}
func TestGameWiFiOptionalUpdate(t *testing.T) {
for _, tc := range []struct {
body string
present bool
}{
{`{}`, false},
{`{"wifiName":"", "wifiPassword":""}`, true},
{`{"wifiName":"Venue", "wifiPassword":" secret "}`, true},
} {
var req proto.UpdateGameReq
if err := protojson.Unmarshal([]byte(tc.body), &req); err != nil {
t.Fatal(err)
}
if (req.WifiName != nil) != tc.present || (req.WifiPassword != nil) != tc.present {
t.Fatal("missing fields cannot be distinguished from clearing")
}
}
if got := status.Code(gameErrorStatus(team_qr_pdf.ErrInvalidWiFi, "save failed")); got != codes.InvalidArgument {
t.Fatalf("code = %v", got)
}
}
+3 -1
View File
@@ -93,7 +93,7 @@ func (s *server) GetGame(ctx context.Context, req *proto.GetGameReq) (*proto.Get
} }
return &proto.GetGameRsp{ return &proto.GetGameRsp{
Game: mapGame(game), Game: mapGameSettings(game),
}, nil }, nil
} }
@@ -184,6 +184,8 @@ func (s *server) UpdateGame(ctx context.Context, req *proto.UpdateGameReq) (*pro
req.StartAt.AsTime(), req.StartAt.AsTime(),
req.ScenarioID, req.ScenarioID,
req.Format, req.Format,
req.WifiName,
req.WifiPassword,
) )
if err != nil { if err != nil {
return nil, gameErrorStatus(err, "не удалось изменить игру") return nil, gameErrorStatus(err, "не удалось изменить игру")
+36 -12
View File
@@ -18,8 +18,10 @@ var ErrNoCards = errors.New("нет карточек")
// Card contains the data rendered on a team QR-code card. // Card contains the data rendered on a team QR-code card.
type Card struct { type Card struct {
Name string Name string
URL string URL string
WiFiName string
WiFiPassword string
} }
//go:embed fonts/DejaVuSansCondensed.ttf //go:embed fonts/DejaVuSansCondensed.ttf
@@ -45,7 +47,18 @@ func Generate(cards []Card) ([]byte, error) {
pdf.SetCellMargin(0) pdf.SetCellMargin(0)
RegisterFont(pdf) RegisterFont(pdf)
pdf.SetFillColor(0, 0, 0) pdf.SetFillColor(0, 0, 0)
const margin, width, height, qrSize = 10.0, 190.0 / 3, 277.0 / 4, 36.0 const margin, qrSize = 10.0, 36.0
columns, rows := 3, 4
for _, card := range cards {
if err := ValidateWiFi(card.WiFiName, card.WiFiPassword); err != nil {
return nil, err
}
if card.WiFiName != "" {
columns, rows = 2, 3
}
}
width, height := 190.0/float64(columns), 277.0/float64(rows)
perPage := columns * rows
for i, card := range cards { for i, card := range cards {
u, err := url.Parse(card.URL) u, err := url.Parse(card.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
@@ -55,22 +68,22 @@ func Generate(cards []Card) ([]byte, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("qr команды %d: %w", i+1, err) return nil, fmt.Errorf("qr команды %d: %w", i+1, err)
} }
if i%12 == 0 { if i%perPage == 0 {
pdf.AddPage() pdf.AddPage()
pdf.SetDrawColor(100, 100, 100) pdf.SetDrawColor(100, 100, 100)
pdf.SetLineWidth(0.2) pdf.SetLineWidth(0.2)
pdf.SetDashPattern([]float64{1, 1}, 0) pdf.SetDashPattern([]float64{1, 1}, 0)
for col := 0; col <= 3; col++ { for col := 0; col <= columns; col++ {
x := margin + float64(col)*width x := margin + float64(col)*width
pdf.Line(x, margin, x, margin+4*height) pdf.Line(x, margin, x, margin+float64(rows)*height)
} }
for row := 0; row <= 4; row++ { for row := 0; row <= rows; row++ {
y := margin + float64(row)*height y := margin + float64(row)*height
pdf.Line(margin, y, margin+3*width, y) pdf.Line(margin, y, margin+float64(columns)*width, y)
} }
} }
x := margin + float64(i%3)*width x := margin + float64(i%columns)*width
y := margin + float64(i%12/3)*height y := margin + float64(i%perPage/columns)*height
bitmap := qr.Bitmap() bitmap := qr.Bitmap()
pixel := qrSize / float64(len(bitmap)) pixel := qrSize / float64(len(bitmap))
for row, cells := range bitmap { for row, cells := range bitmap {
@@ -86,23 +99,34 @@ func Generate(cards []Card) ([]byte, error) {
return nil, fmt.Errorf("название команды %d содержит неподдерживаемый символ %q", i+1, r) return nil, fmt.Errorf("название команды %d содержит неподдерживаемый символ %q", i+1, r)
} }
} }
var instructions []string
pdf.SetFont("teams", "", 8)
for _, paragraph := range WiFiInstructions(card.WiFiName, card.WiFiPassword) {
instructions = append(instructions, pdf.SplitText(paragraph, width-6)...)
}
instructionHeight := float64(len(instructions)) * 3.3
var lines []string var lines []string
var lineHeight float64 var lineHeight float64
for size := 12.0; size >= 6; size-- { for size := 12.0; size >= 6; size-- {
pdf.SetFont("teams", "", size) pdf.SetFont("teams", "", size)
lineHeight = size * 0.3528 * 1.15 lineHeight = size * 0.3528 * 1.15
lines = pdf.SplitText(name, width-6) lines = pdf.SplitText(name, width-6)
if float64(len(lines))*lineHeight <= height-qrSize-8 { if float64(len(lines))*lineHeight <= height-qrSize-8-instructionHeight {
break break
} }
} }
if float64(len(lines))*lineHeight > height-qrSize-8 { if float64(len(lines))*lineHeight > height-qrSize-8-instructionHeight {
return nil, fmt.Errorf("название команды %d не помещается на карточке (%d строк, %f мм)", i+1, len(lines), lineHeight) return nil, fmt.Errorf("название команды %d не помещается на карточке (%d строк, %f мм)", i+1, len(lines), lineHeight)
} }
for line, text := range lines { for line, text := range lines {
pdf.SetXY(x+3, y+qrSize+5+float64(line)*lineHeight) pdf.SetXY(x+3, y+qrSize+5+float64(line)*lineHeight)
pdf.CellFormat(width-6, lineHeight, text, "", 0, "C", false, 0, "") pdf.CellFormat(width-6, lineHeight, text, "", 0, "C", false, 0, "")
} }
pdf.SetFont("teams", "", 8)
for line, text := range instructions {
pdf.SetXY(x+3, y+qrSize+5+float64(len(lines))*lineHeight+float64(line)*3.3)
pdf.CellFormat(width-6, 3.3, text, "", 0, "L", false, 0, "")
}
} }
var result bytes.Buffer var result bytes.Buffer
if err := pdf.Output(&result); err != nil { if err := pdf.Output(&result); err != nil {
@@ -52,3 +52,28 @@ func TestGenerate(t *testing.T) {
} }
} }
} }
func TestGenerateWiFiCards(t *testing.T) {
for _, password := range []string{"", strings.Repeat("W", 64), " secret "} {
cards := make([]Card, 7)
for i := range cards {
cards[i] = Card{
Name: strings.Repeat("Ш", 255),
URL: "https://example.org/team-story/019a1234-5678-7abc-8def-000000000007?password=abc123",
WiFiName: strings.Repeat("W", 32),
WiFiPassword: password,
}
}
data, err := Generate(cards)
if err != nil {
t.Fatal(err)
}
if got := len(regexp.MustCompile(`/Type /Page\b`).FindAll(data, -1)); got != 2 {
t.Fatalf("got %d pages, want 2", got)
}
}
_, err := Generate([]Card{{Name: "Team", URL: "https://example.org", WiFiName: "bad\n"}})
if !errors.Is(err, ErrInvalidWiFi) {
t.Fatal("invalid Wi-Fi accepted")
}
}
+59
View File
@@ -0,0 +1,59 @@
package team_qr_pdf
import (
"errors"
"fmt"
"unicode"
"unicode/utf8"
"golang.org/x/image/font/sfnt"
)
// ErrInvalidWiFi indicates Wi-Fi settings that cannot be printed reliably.
var ErrInvalidWiFi = errors.New("некорректные параметры Wi-Fi")
// ValidateWiFi checks lengths, printable characters and the embedded font.
func ValidateWiFi(name, password string) error {
if len(name) > 32 || !utf8.ValidString(name) {
return fmt.Errorf("%w: название сети — до 32 байт UTF-8", ErrInvalidWiFi)
}
if len(password) > 64 {
return fmt.Errorf("%w: пароль — до 64 печатных ASCII-символов", ErrInvalidWiFi)
}
for _, r := range password {
if r < 32 || r > 126 {
return fmt.Errorf("%w: пароль — только печатные ASCII-символы", ErrInvalidWiFi)
}
}
if name == "" {
return nil
}
ttf, err := sfnt.Parse(font)
if err != nil {
return fmt.Errorf("parse Wi-Fi font: %w", err)
}
for _, r := range name {
glyph, err := ttf.GlyphIndex(nil, r)
if err != nil || glyph == 0 || r > 0xffff || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
return fmt.Errorf("%w: название сети содержит непечатаемый символ", ErrInvalidWiFi)
}
}
return nil
}
// WiFiInstructions returns print paragraphs, or none when no network is configured.
func WiFiInstructions(name, password string) []string {
if name == "" {
return nil
}
access := "Без пароля"
if password != "" {
access = "Пароль: «" + password + "»"
}
return []string{
"1. Подключитесь к Wi-Fi «" + name + "».",
access,
"2. Отсканируйте QR-код команды.",
"Если телефон сообщает, что сеть без интернета, оставайтесь подключены.",
}
}
+57
View File
@@ -0,0 +1,57 @@
package team_qr_pdf
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestValidateWiFi(t *testing.T) {
for _, tc := range []struct {
name, password string
invalid bool
}{
{},
{name: "Детектив", password: " secret password "},
{name: strings.Repeat("Ш", 16), password: strings.Repeat("~", 64)},
{name: strings.Repeat("x", 33), invalid: true},
{name: strings.Repeat("Ш", 17), invalid: true},
{name: "сеть\n", invalid: true},
{name: "сеть\u2028", invalid: true},
{name: "сеть\u200b", invalid: true},
{name: "сеть🕵", invalid: true},
{name: string([]byte{0xff}), invalid: true},
{password: strings.Repeat("x", 65), invalid: true},
{password: "пароль", invalid: true},
{password: "secret\t", invalid: true},
{password: "secret\x7f", invalid: true},
} {
err := ValidateWiFi(tc.name, tc.password)
if tc.invalid && !errors.Is(err, ErrInvalidWiFi) {
t.Fatal("invalid Wi-Fi accepted")
}
if !tc.invalid && err != nil {
t.Fatal(err)
}
}
}
func TestWiFiInstructions(t *testing.T) {
if got := WiFiInstructions("", "secret"); len(got) != 0 {
t.Fatal("instructions without a network")
}
got := WiFiInstructions(" Venue ", " secret ")
want := []string{
"1. Подключитесь к Wi-Fi « Venue ».",
"Пароль: « secret »",
"2. Отсканируйте QR-код команды.",
"Если телефон сообщает, что сеть без интернета, оставайтесь подключены.",
}
if !reflect.DeepEqual(got, want) {
t.Fatal("instructions changed credentials")
}
if got := WiFiInstructions("Venue", ""); got[1] != "Без пароля" {
t.Fatal("open network requires a password")
}
}
+15 -13
View File
@@ -6,17 +6,19 @@ import (
// Game describes a game and its scenario and teams. // Game describes a game and its scenario and teams.
type Game struct { type Game struct {
Export *GameExport Export *GameExport
Format string Format string
ID int ID int
Name string Name string
Description string WiFiName string
Location *string WiFiPassword string
StartAt time.Time Description string
Status string Location *string
ScenarioID int StartAt time.Time
Scenario *Scenario Status string
Teams []*Team ScenarioID int
StartedAt *time.Time Scenario *Scenario
EndedAt *time.Time Teams []*Team
StartedAt *time.Time
EndedAt *time.Time
} }
@@ -14,6 +14,7 @@ func captureSnapshot(ctx context.Context, tx pgx.Tx, gameID int) ([]byte, error)
`SELECT jsonb_build_object( `SELECT jsonb_build_object(
'game', jsonb_build_object( 'game', jsonb_build_object(
'id', g.id, 'name', g.name, 'description', COALESCE(g.description, ''), 'id', g.id, 'name', g.name, 'description', COALESCE(g.description, ''),
'wifi_name', g.wifi_name, 'wifi_password', g.wifi_password,
'location', g.location, 'start_at', g.start_at AT TIME ZONE 'UTC', 'location', g.location, 'start_at', g.start_at AT TIME ZONE 'UTC',
'status', g.status, 'format', g.format, 'scenario_id', g.scenario_id, 'status', g.status, 'format', g.format, 'scenario_id', g.scenario_id,
'playlist_id', COALESCE(m.playlist_id, g.playlist_id), 'playlist_id', COALESCE(m.playlist_id, g.playlist_id),
+5 -3
View File
@@ -89,7 +89,7 @@ func (r *GameImportsRepo) Complete(ctx context.Context, id string, data []byte)
_, err = tx.Exec( _, err = tx.Exec(
ctx, ctx,
`INSERT INTO games ( `INSERT INTO games (
id, name, description, location, start_at, status, format, scenario_id, playlist_id id, name, description, location, start_at, status, format, scenario_id, playlist_id, wifi_name, wifi_password
) )
SELECT SELECT
g.id, g.id,
@@ -100,10 +100,12 @@ func (r *GameImportsRepo) Complete(ctx context.Context, id string, data []byte)
g.status, g.status,
g.format, g.format,
$2, $2,
($3::jsonb->>g.playlist_id::text)::integer ($3::jsonb->>g.playlist_id::text)::integer,
COALESCE(g.wifi_name, ''),
COALESCE(g.wifi_password, '')
FROM jsonb_to_record($1::jsonb->'game') AS g( FROM jsonb_to_record($1::jsonb->'game') AS g(
id integer, name text, description text, location text, start_at timestamptz, id integer, name text, description text, location text, start_at timestamptz,
status text, format text, playlist_id integer status text, format text, playlist_id integer, wifi_name text, wifi_password text
)`, )`,
data, data,
mappings.ScenarioID, mappings.ScenarioID,
+9
View File
@@ -77,6 +77,8 @@ func (r *GamesRepo) GetGameByID(
games.scenario_id, games.scenario_id,
games.started_at, games.started_at,
games.ended_at, games.ended_at,
games.wifi_name,
games.wifi_password,
(SELECT (SELECT
CASE WHEN e.released_at IS NOT NULL OR EXISTS ( CASE WHEN e.released_at IS NOT NULL OR EXISTS (
SELECT 1 SELECT 1
@@ -105,6 +107,8 @@ func (r *GamesRepo) GetGameByID(
&game.ScenarioID, &game.ScenarioID,
&game.StartedAt, &game.StartedAt,
&game.EndedAt, &game.EndedAt,
&game.WiFiName,
&game.WiFiPassword,
&game.Export, &game.Export,
) )
if err != nil { if err != nil {
@@ -207,6 +211,7 @@ func (r *GamesRepo) UpdateGame(
startAt time.Time, startAt time.Time,
scenarioID int32, scenarioID int32,
format string, format string,
wifiName, wifiPassword *string,
) error { ) error {
tag, err := r.pool.Exec( tag, err := r.pool.Exec(
ctx, ctx,
@@ -218,6 +223,8 @@ func (r *GamesRepo) UpdateGame(
start_at = $4, start_at = $4,
scenario_id = $5, scenario_id = $5,
format = $6, format = $6,
wifi_name = COALESCE($8, wifi_name),
wifi_password = COALESCE($9, wifi_password),
updated_at = NOW() updated_at = NOW()
WHERE id = $7`, WHERE id = $7`,
name, name,
@@ -227,6 +234,8 @@ func (r *GamesRepo) UpdateGame(
scenarioID, scenarioID,
format, format,
id, id,
wifiName,
wifiPassword,
) )
if err != nil { if err != nil {
return err return err
@@ -34,7 +34,8 @@ func TestAnswerAccessPostgres(t *testing.T) {
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP); format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT, CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT,
status TEXT, password TEXT, game_id INT); status TEXT, password TEXT, game_id INT);
INSERT INTO games VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL); ALTER TABLE games ADD COLUMN wifi_name TEXT NOT NULL DEFAULT '', ADD COLUMN wifi_password TEXT NOT NULL DEFAULT '';
INSERT INTO games (id, name, description, location, start_at, format, status, scenario_id, started_at, ended_at) VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1); INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1);
ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '', ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '',
ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`) ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`)
@@ -46,7 +46,8 @@ func TestExportAnyStatusProducesFreshSetup(t *testing.T) {
t.Run(format+"/"+state, func(t *testing.T) { t.Run(format+"/"+state, func(t *testing.T) {
ctx := context.Background() ctx := context.Background()
f := newServiceFixture(t) f := newServiceFixture(t)
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now(), int32(f.scenarioID), format)) wifiName, wifiPassword := "Детектив", " secret "
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now(), int32(f.scenarioID), format, &wifiName, &wifiPassword))
if state != "draft" { if state != "draft" {
check(t, f.games.StartGame(ctx, f.gameID)) check(t, f.games.StartGame(ctx, f.gameID))
if state == "pause" { if state == "pause" {
@@ -102,7 +103,7 @@ func TestExportAnyStatusProducesFreshSetup(t *testing.T) {
check(t, err) check(t, err)
local, err := venue.games.GetGameByID(ctx, imported.GameID) local, err := venue.games.GetGameByID(ctx, imported.GameID)
check(t, err) check(t, err)
if local.Status != "draft" || local.StartedAt != nil || local.EndedAt != nil { if local.Status != "draft" || local.StartedAt != nil || local.EndedAt != nil || local.WiFiName != wifiName || local.WiFiPassword != wifiPassword {
t.Fatalf("import did not start fresh: %+v", local) t.Fatalf("import did not start fresh: %+v", local)
} }
actions, err = actions_repo.NewActionsRepo(venue.pool).GetActionsByTeamID(ctx, f.teamID) actions, err = actions_repo.NewActionsRepo(venue.pool).GetActionsByTeamID(ctx, f.teamID)
@@ -42,7 +42,7 @@ func newReturnFixture(t *testing.T, format string) returnFixture {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
f := newServiceFixture(t) f := newServiceFixture(t)
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now().Add(time.Hour), int32(f.scenarioID), format)) check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now().Add(time.Hour), int32(f.scenarioID), format, nil, nil))
check(t, f.scenarios.UpdateStoryByScenarioID(ctx, f.scenarioID, `{"places":[{"code":"p","name":"Place","text":"Text","applications":[{"name":"Clue","image":"original.png"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"},{"code":"q2","text":"Why?","answer":"Money"}]}`)) check(t, f.scenarios.UpdateStoryByScenarioID(ctx, f.scenarioID, `{"places":[{"code":"p","name":"Place","text":"Text","applications":[{"name":"Clue","image":"original.png"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"},{"code":"q2","text":"Why?","answer":"Money"}]}`))
_, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Removed", "removed-qr") _, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Removed", "removed-qr")
check(t, err) check(t, err)
@@ -125,7 +125,7 @@ func TestReturnAtomicLifecycle(t *testing.T) {
mapRepo := game_map_repo.NewGameMapRepo(f.pool) mapRepo := game_map_repo.NewGameMapRepo(f.pool)
check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.data.DeletedTeamIDs[0])) check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.data.DeletedTeamIDs[0]))
check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.teamID)) check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.teamID))
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now().Add(72*time.Hour), int32(f.scenarioID), format)) check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now().Add(72*time.Hour), int32(f.scenarioID), format, nil, nil))
token := hex.EncodeToString(bytes.Repeat([]byte{9}, 32)) token := hex.EncodeToString(bytes.Repeat([]byte{9}, 32))
check(t, f.teams.SetParticipationToken(ctx, f.teamID, token)) check(t, f.teams.SetParticipationToken(ctx, f.teamID, token))
raw := f.pack(t) raw := f.pack(t)
@@ -454,7 +454,7 @@ func TestReturnOverwritesOnlineEditsAndIsolatesScenario(t *testing.T) {
check(t, f.scenarios.UpdateStoryByScenarioID(ctx, selected, changedStory)) check(t, f.scenarios.UpdateStoryByScenarioID(ctx, selected, changedStory))
other, err := f.games.AddGame(ctx, "Other game", "", nil, time.Now(), int32(selected), "offline") other, err := f.games.AddGame(ctx, "Other game", "", nil, time.Now(), int32(selected), "offline")
check(t, err) check(t, err)
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now(), int32(selected), "offline")) check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now(), int32(selected), "offline", nil, nil))
check(t, f.teams.UpdateTeam(ctx, f.teamID, "Online team name")) check(t, f.teams.UpdateTeam(ctx, f.teamID, "Online team name"))
extraTeam, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Online extra team", "online-qr") extraTeam, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Online extra team", "online-qr")
check(t, err) check(t, err)
+14 -12
View File
@@ -9,16 +9,18 @@ import (
// Game describes a game exposed by the service layer. // Game describes a game exposed by the service layer.
type Game struct { type Game struct {
Export *repos.GameExport Export *repos.GameExport
Format string Format string
ID int ID int
Name string Name string
Description string WiFiName string
Location *string WiFiPassword string
StartAt time.Time Description string
Status string Location *string
Scenario *scenarios_service.Scenario StartAt time.Time
Teams []*Team Status string
StartedAt *time.Time Scenario *scenarios_service.Scenario
EndedAt *time.Time Teams []*Team
StartedAt *time.Time
EndedAt *time.Time
} }
+14 -12
View File
@@ -11,18 +11,20 @@ func MapGame(
domain string, domain string,
) *Game { ) *Game {
return &Game{ return &Game{
ID: o.ID, ID: o.ID,
Export: o.Export, Export: o.Export,
Name: o.Name, Name: o.Name,
Description: o.Description, Description: o.Description,
Location: o.Location, WiFiName: o.WiFiName,
StartAt: o.StartAt, WiFiPassword: o.WiFiPassword,
Status: o.Status, Location: o.Location,
Format: o.Format, StartAt: o.StartAt,
Scenario: scenarios_service.MapScenarioLight(o.Scenario, domain), Status: o.Status,
Teams: MapTeams(o.Teams), Format: o.Format,
StartedAt: o.StartedAt, Scenario: scenarios_service.MapScenarioLight(o.Scenario, domain),
EndedAt: o.EndedAt, Teams: MapTeams(o.Teams),
StartedAt: o.StartedAt,
EndedAt: o.EndedAt,
} }
} }
+13 -11
View File
@@ -51,17 +51,19 @@ type Snapshot struct {
} }
type Game struct { type Game struct {
ID int `json:"id"` WiFiName string `json:"wifi_name,omitempty"`
Name string `json:"name"` WiFiPassword string `json:"wifi_password,omitempty"`
Description string `json:"description"` ID int `json:"id"`
Location *string `json:"location,omitempty"` Name string `json:"name"`
StartAt time.Time `json:"start_at"` Description string `json:"description"`
Status string `json:"status"` Location *string `json:"location,omitempty"`
Format string `json:"format"` StartAt time.Time `json:"start_at"`
ScenarioID int `json:"scenario_id"` Status string `json:"status"`
PlaylistID *int `json:"playlist_id,omitempty"` Format string `json:"format"`
StartedAt *time.Time `json:"started_at,omitempty"` ScenarioID int `json:"scenario_id"`
EndedAt *time.Time `json:"ended_at,omitempty"` PlaylistID *int `json:"playlist_id,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
} }
type Scenario struct { type Scenario struct {
@@ -90,6 +90,10 @@ func TestPackagePreservesPregameData(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture() snapshot, files := fixture()
snapshot.Data.Game.Format = tc.format snapshot.Data.Game.Format = tc.format
if tc.format == "offline" {
snapshot.Data.Game.WiFiName = "Детектив"
snapshot.Data.Game.WiFiPassword = " secret "
}
snapshot.Data.Map = append(snapshot.Data.Map, MapItem{GameID: 10, Kind: "screen", ItemID: "019a1234-5678-7abc-8def-000000000063"}) snapshot.Data.Map = append(snapshot.Data.Map, MapItem{GameID: 10, Kind: "screen", ItemID: "019a1234-5678-7abc-8def-000000000063"})
before, _ := json.Marshal(snapshot) before, _ := json.Marshal(snapshot)
calls := map[string]int{} calls := map[string]int{}
@@ -9,6 +9,8 @@ import (
"strings" "strings"
"unicode" "unicode"
"evening_detective_server/internal/modules/team_qr_pdf"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -18,6 +20,9 @@ func invalid(reason string) error {
func validateData(data *Data) error { func validateData(data *Data) error {
g := data.Game g := data.Game
if err := team_qr_pdf.ValidateWiFi(g.WiFiName, g.WiFiPassword); err != nil {
return invalid("некорректные параметры Wi-Fi")
}
if g.ID <= 0 || strings.TrimSpace(g.Name) == "" || g.StartAt.IsZero() || if g.ID <= 0 || strings.TrimSpace(g.Name) == "" || g.StartAt.IsZero() ||
g.Status != "draft" || g.StartedAt != nil || g.EndedAt != nil || g.Status != "draft" || g.StartedAt != nil || g.EndedAt != nil ||
(g.Format != "online" && g.Format != "offline") { (g.Format != "online" && g.Format != "offline") {
@@ -64,7 +64,7 @@ func TestProdFlowReturns(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
games := games_repo.NewGamesRepo(pool) games := games_repo.NewGamesRepo(pool)
if err := games.UpdateGame(ctx, gameID, "TEST Online title after export", "TEST Online description", nil, time.Now().Add(72*time.Hour), int32(original.Game.ScenarioID), original.Game.Format); err != nil { if err := games.UpdateGame(ctx, gameID, "TEST Online title after export", "TEST Online description", nil, time.Now().Add(72*time.Hour), int32(original.Game.ScenarioID), original.Game.Format, nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} else if err != nil { } else if err != nil {
@@ -58,7 +58,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
CREATE TEMP TABLE team_answers(team_id UUID, question_code TEXT, answer TEXT, score INT, updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(team_id, question_code)); CREATE TEMP TABLE team_answers(team_id UUID, question_code TEXT, answer TEXT, score INT, updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(team_id, question_code));
INSERT INTO scenarios(id,name,scenario) VALUES(1,'Case','{"introduction":{"text":"Intro"},"places":[{"code":"p","name":"Place","applications":[{"name":"Clue","image":"clue.pdf"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"}]}');`) INSERT INTO scenarios(id,name,scenario) VALUES(1,'Case','{"introduction":{"text":"Intro"},"places":[{"code":"p","name":"Place","applications":[{"name":"Clue","image":"clue.pdf"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"}]}');`)
check(err) check(err)
for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} { for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql", "20261023120000_game_wifi.sql"} {
data, err := os.ReadFile("../../../migrations/" + name) data, err := os.ReadFile("../../../migrations/" + name)
check(err) check(err)
_, err = pool.Exec(ctx, strings.Split(string(data), "-- +goose Down")[0]) _, err = pool.Exec(ctx, strings.Split(string(data), "-- +goose Down")[0])
@@ -77,7 +77,23 @@ func TestServicesLifecyclePostgres(t *testing.T) {
start := time.Now().Add(48 * time.Hour) start := time.Now().Add(48 * time.Hour)
id, err := game.AddGame(ctx, "Game", "", nil, start, 1, "online") id, err := game.AddGame(ctx, "Game", "", nil, start, 1, "online")
check(err) check(err)
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "")) check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "", nil, nil))
wifiName, wifiPassword := "Детектив", " secret "
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &wifiName, &wifiPassword))
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", nil, nil))
stored, err := game.GetGame(ctx, id)
check(err)
if stored.WiFiName != wifiName || stored.WiFiPassword != wifiPassword {
t.Fatal("old client erased Wi-Fi settings")
}
empty := ""
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &empty, &empty))
stored, err = game.GetGame(ctx, id)
check(err)
if stored.WiFiName != "" || stored.WiFiPassword != "" {
t.Fatal("Wi-Fi settings not cleared")
}
check(game.UpdateGame(ctx, id, "Game", "Updated", nil, start, 1, "online", &wifiName, &wifiPassword))
registration := team_service.Registration{Name: "Team", Email: "captain@example.org", ParticipantsCount: 3} registration := team_service.Registration{Name: "Team", Email: "captain@example.org", ParticipantsCount: 3}
teamID, err := team.RegisterTeam(ctx, id, "delivery", registration) teamID, err := team.RegisterTeam(ctx, id, "delivery", registration)
check(err) check(err)
+15 -2
View File
@@ -3,6 +3,9 @@ package game_service
import ( import (
"context" "context"
"errors" "errors"
"time"
"evening_detective_server/internal/modules/team_qr_pdf"
"evening_detective_server/internal/repos/actions_repo" "evening_detective_server/internal/repos/actions_repo"
"evening_detective_server/internal/repos/applications_repo" "evening_detective_server/internal/repos/applications_repo"
"evening_detective_server/internal/repos/games_repo" "evening_detective_server/internal/repos/games_repo"
@@ -10,7 +13,6 @@ import (
"evening_detective_server/internal/repos/teams_repo" "evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/internal/services/game_model" "evening_detective_server/internal/services/game_model"
"evening_detective_server/internal/services/scenarios_service" "evening_detective_server/internal/services/scenarios_service"
"time"
) )
// GameService handles game lifecycle operations. // GameService handles game lifecycle operations.
@@ -174,7 +176,18 @@ func (s *GameService) UpdateGame(
startAt time.Time, startAt time.Time,
scenarioID int32, scenarioID int32,
format string, format string,
wifiName, wifiPassword *string,
) error { ) error {
nameValue, passwordValue := "", ""
if wifiName != nil {
nameValue = *wifiName
}
if wifiPassword != nil {
passwordValue = *wifiPassword
}
if err := team_qr_pdf.ValidateWiFi(nameValue, passwordValue); err != nil {
return err
}
if format == "" { if format == "" {
game, err := s.gamesRepo.GetGameByID(ctx, id) game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil { if err != nil {
@@ -185,7 +198,7 @@ func (s *GameService) UpdateGame(
if format != "online" && format != "offline" { if format != "online" && format != "offline" {
return ErrInvalidGameFormat return ErrInvalidGameFormat
} }
return mapGameError(s.gamesRepo.UpdateGame(ctx, id, name, description, location, startAt, scenarioID, format)) return mapGameError(s.gamesRepo.UpdateGame(ctx, id, name, description, location, startAt, scenarioID, format, wifiName, wifiPassword))
} }
// StartGame marks a game as started. // StartGame marks a game as started.
@@ -0,0 +1,24 @@
package game_service
import (
"context"
"errors"
"strings"
"testing"
"time"
"evening_detective_server/internal/modules/team_qr_pdf"
)
func TestUpdateGameRejectsInvalidWiFiBeforeWriting(t *testing.T) {
s := &GameService{}
for _, tc := range []struct{ name, password string }{
{name: strings.Repeat("x", 33)},
{password: "bad\n"},
} {
err := s.UpdateGame(context.Background(), 1, "Game", "", nil, time.Now(), 1, "online", &tc.name, &tc.password)
if !errors.Is(err, team_qr_pdf.ErrInvalidWiFi) {
t.Fatal("invalid settings reached repository")
}
}
}
@@ -37,7 +37,8 @@ func TestFinishedTeamStoryPostgres(t *testing.T) {
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP); format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT, CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT,
status TEXT, password TEXT, game_id INT); status TEXT, password TEXT, game_id INT);
INSERT INTO games VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL); ALTER TABLE games ADD COLUMN wifi_name TEXT NOT NULL DEFAULT '', ADD COLUMN wifi_password TEXT NOT NULL DEFAULT '';
INSERT INTO games (id, name, description, location, start_at, format, status, scenario_id, started_at, ended_at) VALUES (1, 'Game', '', NULL, NOW(), 'online', 'finish', 1, NULL, NULL);
INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1); INSERT INTO teams VALUES ('019a1234-5678-7abc-8def-000000000001', 'Team', '', 1, 'register', 'secret', 1);
ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '', ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '',
ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`) ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`)
+64 -5
View File
@@ -103,6 +103,9 @@ func (s *TeamService) teamReceipt(ctx context.Context, gameID int, teamID string
} }
func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) { func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
if err := team_qr_pdf.ValidateWiFi(card.WiFiName, card.WiFiPassword); err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidReceipt, err)
}
name := strings.Join(strings.Fields(card.Name), " ") name := strings.Join(strings.Fields(card.Name), " ")
if name == "" || len([]rune(name)) > 255 { if name == "" || len([]rune(name)) > 255 {
return nil, ErrInvalidReceipt return nil, ErrInvalidReceipt
@@ -111,17 +114,34 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("parse receipt font: %w", err) return nil, fmt.Errorf("parse receipt font: %w", err)
} }
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 26, DPI: 72, Hinting: font.HintingFull}) nameSize, nameLineHeight := 26.0, 34
if card.WiFiName != "" {
nameSize, nameLineHeight = 20, 26
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: nameSize, DPI: 72, Hinting: font.HintingFull})
if err != nil { if err != nil {
return nil, fmt.Errorf("open receipt font: %w", err) return nil, fmt.Errorf("open receipt font: %w", err)
} }
defer face.Close() defer func() {
if face != nil {
_ = face.Close()
}
}()
for _, r := range name { for _, r := range name {
if _, ok := face.GlyphAdvance(r); !ok { if _, ok := face.GlyphAdvance(r); !ok {
return nil, ErrInvalidReceipt return nil, ErrInvalidReceipt
} }
} }
lines := wrapReceiptName(name, face) lines := wrapReceiptName(name, face)
instructionFace, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 20, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("open Wi-Fi receipt font: %w", err)
}
defer instructionFace.Close()
var instructions []string
for _, paragraph := range team_qr_pdf.WiFiInstructions(card.WiFiName, card.WiFiPassword) {
instructions = append(instructions, wrapReceiptText(paragraph, instructionFace)...)
}
qr, err := qrcode.New(card.URL, qrcode.Medium) qr, err := qrcode.New(card.URL, qrcode.Medium)
if err != nil { if err != nil {
return nil, ErrInvalidReceipt return nil, ErrInvalidReceipt
@@ -132,8 +152,20 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
return nil, ErrInvalidReceipt return nil, ErrInvalidReceipt
} }
qrSize := len(bitmap) * scale qrSize := len(bitmap) * scale
qrY := 16 + len(lines)*34 + 16 qrY := 16 + len(lines)*nameLineHeight + 16
height := qrY + qrSize + 24 height := qrY + qrSize + 24 + len(instructions)*26
for card.WiFiName != "" && height > receiptMaxHeight && nameSize > 16 {
_ = face.Close()
nameSize -= 2
nameLineHeight = int(nameSize) + 6
face, err = opentype.NewFace(ttf, &opentype.FaceOptions{Size: nameSize, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, fmt.Errorf("resize receipt font: %w", err)
}
lines = wrapReceiptName(name, face)
qrY = 16 + len(lines)*nameLineHeight + 16
height = qrY + qrSize + 24 + len(instructions)*26
}
if height > receiptMaxHeight { if height > receiptMaxHeight {
return nil, ErrInvalidReceipt return nil, ErrInvalidReceipt
} }
@@ -142,7 +174,7 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
drawer := font.Drawer{Dst: img, Src: image.Black, Face: face} drawer := font.Drawer{Dst: img, Src: image.Black, Face: face}
for i, line := range lines { for i, line := range lines {
width := drawer.MeasureString(line).Ceil() width := drawer.MeasureString(line).Ceil()
drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*34) drawer.Dot = fixed.P((receiptWidth-width)/2, 16+face.Metrics().Ascent.Ceil()+i*nameLineHeight)
drawer.DrawString(line) drawer.DrawString(line)
} }
x := (receiptWidth - qrSize) / 2 x := (receiptWidth - qrSize) / 2
@@ -153,6 +185,11 @@ func renderReceipt(card team_qr_pdf.Card) (*image.Gray, error) {
} }
} }
} }
drawer.Face = instructionFace
for i, line := range instructions {
drawer.Dot = fixed.P(16, qrY+qrSize+instructionFace.Metrics().Ascent.Ceil()+i*26)
drawer.DrawString(line)
}
for i, pixel := range img.Pix { for i, pixel := range img.Pix {
if pixel < 128 { if pixel < 128 {
img.Pix[i] = 0 img.Pix[i] = 0
@@ -211,3 +248,25 @@ func validateReceiptRaster(data []byte) error {
} }
return nil return nil
} }
// Preserve spaces in credentials when wrapping the printed instructions.
func wrapReceiptText(text string, face font.Face) []string {
var lines []string
line := ""
for _, r := range text {
for line != "" && font.MeasureString(face, line+string(r)).Ceil() > receiptWidth-32 {
if space := strings.LastIndexByte(line, ' '); space >= 0 {
lines = append(lines, line[:space+1])
line = line[space+1:]
} else {
lines = append(lines, line)
line = ""
}
}
line += string(r)
}
if line != "" {
lines = append(lines, line)
}
return lines
}
+53 -4
View File
@@ -14,18 +14,21 @@ import (
"evening_detective_server/internal/repos" "evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/games_repo" "evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/teams_repo" "evening_detective_server/internal/repos/teams_repo"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
) )
const receiptTestTeamID = "019a1234-5678-7abc-8def-000000000007" const receiptTestTeamID = "019a1234-5678-7abc-8def-000000000007"
type receiptReposStub struct { type receiptReposStub struct {
ITeamsRepository ITeamsRepository
team *repos.Team team *repos.Team
gameErr, teamErr error gameErr, teamErr error
wifiName, wifiPassword string
} }
func (s *receiptReposStub) GetGameByID(context.Context, int) (*repos.Game, error) { func (s *receiptReposStub) GetGameByID(context.Context, int) (*repos.Game, error) {
return &repos.Game{ID: 1, Name: "Проверка чеков"}, s.gameErr return &repos.Game{ID: 1, Name: "Проверка чеков", WiFiName: s.wifiName, WiFiPassword: s.wifiPassword}, s.gameErr
} }
func (s *receiptReposStub) GetTeamByID(context.Context, string) (*repos.Team, error) { func (s *receiptReposStub) GetTeamByID(context.Context, string) (*repos.Team, error) {
@@ -81,7 +84,7 @@ func TestTeamReceiptValidation(t *testing.T) {
func TestTeamReceiptPreviewMatchesPrintedRaster(t *testing.T) { func TestTeamReceiptPreviewMatchesPrintedRaster(t *testing.T) {
for _, name := range []string{"Вечерние сыщики", "Очень длинное название команды БезПробеловОченьДлинноеНазваниеКоманды"} { for _, name := range []string{"Вечерние сыщики", "Очень длинное название команды БезПробеловОченьДлинноеНазваниеКоманды"} {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
repos := &receiptReposStub{team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}} repos := &receiptReposStub{wifiName: "Детектив", wifiPassword: " secret ", team: &repos.Team{ID: receiptTestTeamID, GameID: 1, Name: name, Password: "a+b&c?", ParticipationStatus: "confirmed"}}
printer := &receiptPrinterStub{} printer := &receiptPrinterStub{}
s := NewTeamService(repos, repos, nil, "https://game.test/app", nil, true, printer) s := NewTeamService(repos, repos, nil, "https://game.test/app", nil, true, printer)
data, err := s.PreviewTeamReceipt(context.Background(), 1, receiptTestTeamID) data, err := s.PreviewTeamReceipt(context.Background(), 1, receiptTestTeamID)
@@ -149,3 +152,49 @@ func TestReceiptPrinterErrorsRoundTrip(t *testing.T) {
}) })
} }
} }
func TestReceiptWiFiFitsAndPreservesCredentials(t *testing.T) {
for _, name := range []string{"Команда", strings.Repeat("Ш", 255), strings.Repeat("W", 255)} {
for _, password := range []string{"", " secret ", strings.Repeat("W", 64)} {
card := team_qr_pdf.Card{
Name: name,
URL: "https://example.org/team-story/019a1234-5678-7abc-8def-000000000007?password=abc123",
WiFiName: strings.Repeat("W", 32),
WiFiPassword: password,
}
img, err := renderReceipt(card)
if err != nil {
t.Fatalf("name runes=%d, password bytes=%d: %v", len([]rune(name)), len(password), err)
}
if img.Bounds().Dy() > receiptMaxHeight {
t.Fatal("receipt exceeds printer limit")
}
if err := validateReceiptRaster(packReceipt(img)); err != nil {
t.Fatal(err)
}
}
}
}
func TestWrapReceiptTextPreservesCredentials(t *testing.T) {
ttf, err := opentype.Parse(team_qr_pdf.FontData())
if err != nil {
t.Fatal(err)
}
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{Size: 20, DPI: 72, Hinting: font.HintingFull})
if err != nil {
t.Fatal(err)
}
defer face.Close()
for _, text := range []string{" secret password ", " " + strings.Repeat("W", 64), "Пароль: «" + strings.Repeat("W", 64) + "»", "Подключитесь к Wi-Fi организатора."} {
lines := wrapReceiptText(text, face)
if strings.Join(lines, "") != text {
t.Fatal("wrapping changed credentials")
}
for _, line := range lines {
if font.MeasureString(face, line).Ceil() > receiptWidth-32 {
t.Fatal("text clipped at receipt edge")
}
}
}
}
+6 -1
View File
@@ -29,7 +29,12 @@ func teamQRData(baseURL string, game *repos.Game, teams []*repos.Team) ([]team_q
link.Path = strings.TrimRight(base.Path, "/") + "/team-story/" + team.ID link.Path = strings.TrimRight(base.Path, "/") + "/team-story/" + team.ID
link.RawPath = "" link.RawPath = ""
link.RawQuery = url.Values{"password": {team.Password}}.Encode() link.RawQuery = url.Values{"password": {team.Password}}.Encode()
cards = append(cards, team_qr_pdf.Card{Name: team.Name, URL: link.String()}) cards = append(cards, team_qr_pdf.Card{
Name: team.Name,
URL: link.String(),
WiFiName: game.WiFiName,
WiFiPassword: game.WiFiPassword,
})
} }
name := strings.Map(func(r rune) rune { name := strings.Map(func(r rune) rune {
if unicode.IsControl(r) || strings.ContainsRune(`\/:*?"<>|`, r) { if unicode.IsControl(r) || strings.ContainsRune(`\/:*?"<>|`, r) {
@@ -9,7 +9,7 @@ import (
) )
func TestTeamQRData(t *testing.T) { func TestTeamQRData(t *testing.T) {
game := &repos.Game{Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)} game := &repos.Game{WiFiName: "Детектив", WiFiPassword: " secret ", Name: "Дело / №1\r\n", StartAt: time.Date(2026, 9, 8, 14, 0, 0, 0, time.UTC)}
teams := []*repos.Team{{ID: "019a1234-5678-7abc-8def-000000000007", Name: "Знатоки", Password: "a+b&c?"}} teams := []*repos.Team{{ID: "019a1234-5678-7abc-8def-000000000007", Name: "Знатоки", Password: "a+b&c?"}}
cards, filename, err := teamQRData("https://example.org/app/", game, teams) cards, filename, err := teamQRData("https://example.org/app/", game, teams)
if err != nil { if err != nil {
@@ -18,6 +18,9 @@ func TestTeamQRData(t *testing.T) {
if len(cards) != 1 || cards[0].Name != "Знатоки" { if len(cards) != 1 || cards[0].Name != "Знатоки" {
t.Fatalf("cards: %+v", cards) t.Fatalf("cards: %+v", cards)
} }
if cards[0].WiFiName != game.WiFiName || cards[0].WiFiPassword != game.WiFiPassword {
t.Fatal("Wi-Fi settings missing from print card")
}
u, err := url.Parse(cards[0].URL) u, err := url.Parse(cards[0].URL)
if err != nil || u.Path != "/app/team-story/019a1234-5678-7abc-8def-000000000007" || u.Query().Get("password") != teams[0].Password { if err != nil || u.Path != "/app/team-story/019a1234-5678-7abc-8def-000000000007" || u.Query().Get("password") != teams[0].Password {
t.Fatalf("wrong team link: %s", cards[0].URL) t.Fatalf("wrong team link: %s", cards[0].URL)
+11
View File
@@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE games
ADD COLUMN wifi_name TEXT NOT NULL DEFAULT '',
ADD COLUMN wifi_password TEXT NOT NULL DEFAULT '',
ADD CONSTRAINT games_wifi_name_length CHECK (octet_length(wifi_name) <= 32),
ADD CONSTRAINT games_wifi_password_length CHECK (octet_length(wifi_password) <= 64);
-- +goose Down
ALTER TABLE games
DROP COLUMN wifi_name,
DROP COLUMN wifi_password;
+44 -4
View File
@@ -5369,6 +5369,8 @@ type Game struct {
Format string `protobuf:"bytes,10,opt,name=format,proto3" json:"format,omitempty"` Format string `protobuf:"bytes,10,opt,name=format,proto3" json:"format,omitempty"`
Location string `protobuf:"bytes,11,opt,name=location,proto3" json:"location,omitempty"` Location string `protobuf:"bytes,11,opt,name=location,proto3" json:"location,omitempty"`
Export *GameExport `protobuf:"bytes,12,opt,name=export,proto3" json:"export,omitempty"` Export *GameExport `protobuf:"bytes,12,opt,name=export,proto3" json:"export,omitempty"`
WifiName string `protobuf:"bytes,13,opt,name=wifi_name,json=wifiName,proto3" json:"wifi_name,omitempty"` // Management settings only.
WifiPassword string `protobuf:"bytes,14,opt,name=wifi_password,json=wifiPassword,proto3" json:"wifi_password,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -5487,6 +5489,20 @@ func (x *Game) GetExport() *GameExport {
return nil return nil
} }
func (x *Game) GetWifiName() string {
if x != nil {
return x.WifiName
}
return ""
}
func (x *Game) GetWifiPassword() string {
if x != nil {
return x.WifiPassword
}
return ""
}
// Download ready ZIP through the authenticated streaming HTTP route: // Download ready ZIP through the authenticated streaming HTTP route:
// GET /api/games/{ID}/export/download (not an HttpBody RPC). // GET /api/games/{ID}/export/download (not an HttpBody RPC).
type GameExport struct { type GameExport struct {
@@ -9428,6 +9444,8 @@ type UpdateGameReq struct {
ScenarioID int32 `protobuf:"varint,5,opt,name=scenarioID,proto3" json:"scenarioID,omitempty"` ScenarioID int32 `protobuf:"varint,5,opt,name=scenarioID,proto3" json:"scenarioID,omitempty"`
Format string `protobuf:"bytes,6,opt,name=format,proto3" json:"format,omitempty"` Format string `protobuf:"bytes,6,opt,name=format,proto3" json:"format,omitempty"`
Location string `protobuf:"bytes,7,opt,name=location,proto3" json:"location,omitempty"` Location string `protobuf:"bytes,7,opt,name=location,proto3" json:"location,omitempty"`
WifiName *string `protobuf:"bytes,8,opt,name=wifi_name,json=wifiName,proto3,oneof" json:"wifi_name,omitempty"`
WifiPassword *string `protobuf:"bytes,9,opt,name=wifi_password,json=wifiPassword,proto3,oneof" json:"wifi_password,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -9511,6 +9529,20 @@ func (x *UpdateGameReq) GetLocation() string {
return "" return ""
} }
func (x *UpdateGameReq) GetWifiName() string {
if x != nil && x.WifiName != nil {
return *x.WifiName
}
return ""
}
func (x *UpdateGameReq) GetWifiPassword() string {
if x != nil && x.WifiPassword != nil {
return *x.WifiPassword
}
return ""
}
type UpdateGameRsp struct { type UpdateGameRsp struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
@@ -13370,7 +13402,7 @@ const file_main_proto_rawDesc = "" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\"\r\n" + "\x02ID\x18\x01 \x01(\x05R\x02ID\"\r\n" +
"\vGetGamesReq\"I\n" + "\vGetGamesReq\"I\n" +
"\vGetGamesRsp\x12:\n" + "\vGetGamesRsp\x12:\n" +
"\x05games\x18\x01 \x03(\v2$.crabs.evening_detective_server.GameR\x05games\"\xad\x04\n" + "\x05games\x18\x01 \x03(\v2$.crabs.evening_detective_server.GameR\x05games\"\xef\x04\n" +
"\x04Game\x12\x0e\n" + "\x04Game\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x12\n" + "\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" +
@@ -13385,7 +13417,9 @@ const file_main_proto_rawDesc = "" +
"\x06format\x18\n" + "\x06format\x18\n" +
" \x01(\tR\x06format\x12\x1a\n" + " \x01(\tR\x06format\x12\x1a\n" +
"\blocation\x18\v \x01(\tR\blocation\x12B\n" + "\blocation\x18\v \x01(\tR\blocation\x12B\n" +
"\x06export\x18\f \x01(\v2*.crabs.evening_detective_server.GameExportR\x06exportB\r\n" + "\x06export\x18\f \x01(\v2*.crabs.evening_detective_server.GameExportR\x06export\x12\x1b\n" +
"\twifi_name\x18\r \x01(\tR\bwifiName\x12#\n" +
"\rwifi_password\x18\x0e \x01(\tR\fwifiPasswordB\r\n" +
"\v_started_atB\v\n" + "\v_started_atB\v\n" +
"\t_ended_at\"\x96\x01\n" + "\t_ended_at\"\x96\x01\n" +
"\n" + "\n" +
@@ -13666,7 +13700,7 @@ const file_main_proto_rawDesc = "" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x16\n" + "\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\"[\n" + "\x06status\x18\x02 \x01(\tR\x06status\"[\n" +
"\x15ModerateGameReviewRsp\x12B\n" + "\x15ModerateGameReviewRsp\x12B\n" +
"\x06review\x18\x01 \x01(\v2*.crabs.evening_detective_server.GameReviewR\x06review\"\xe0\x01\n" + "\x06review\x18\x01 \x01(\v2*.crabs.evening_detective_server.GameReviewR\x06review\"\xcc\x02\n" +
"\rUpdateGameReq\x12\x0e\n" + "\rUpdateGameReq\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x12\n" + "\x02ID\x18\x01 \x01(\x05R\x02ID\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" +
@@ -13676,7 +13710,12 @@ const file_main_proto_rawDesc = "" +
"scenarioID\x18\x05 \x01(\x05R\n" + "scenarioID\x18\x05 \x01(\x05R\n" +
"scenarioID\x12\x16\n" + "scenarioID\x12\x16\n" +
"\x06format\x18\x06 \x01(\tR\x06format\x12\x1a\n" + "\x06format\x18\x06 \x01(\tR\x06format\x12\x1a\n" +
"\blocation\x18\a \x01(\tR\blocation\"\x0f\n" + "\blocation\x18\a \x01(\tR\blocation\x12 \n" +
"\twifi_name\x18\b \x01(\tH\x00R\bwifiName\x88\x01\x01\x12(\n" +
"\rwifi_password\x18\t \x01(\tH\x01R\fwifiPassword\x88\x01\x01B\f\n" +
"\n" +
"_wifi_nameB\x10\n" +
"\x0e_wifi_password\"\x0f\n" +
"\rUpdateGameRsp\"\x1e\n" + "\rUpdateGameRsp\"\x1e\n" +
"\fStartGameReq\x12\x0e\n" + "\fStartGameReq\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\"\x0e\n" + "\x02ID\x18\x01 \x01(\x05R\x02ID\"\x0e\n" +
@@ -14875,6 +14914,7 @@ func file_main_proto_init() {
file_main_proto_msgTypes[74].OneofWrappers = []any{} file_main_proto_msgTypes[74].OneofWrappers = []any{}
file_main_proto_msgTypes[106].OneofWrappers = []any{} file_main_proto_msgTypes[106].OneofWrappers = []any{}
file_main_proto_msgTypes[114].OneofWrappers = []any{} file_main_proto_msgTypes[114].OneofWrappers = []any{}
file_main_proto_msgTypes[176].OneofWrappers = []any{}
file_main_proto_msgTypes[209].OneofWrappers = []any{} file_main_proto_msgTypes[209].OneofWrappers = []any{}
file_main_proto_msgTypes[220].OneofWrappers = []any{} file_main_proto_msgTypes[220].OneofWrappers = []any{}
file_main_proto_msgTypes[221].OneofWrappers = []any{} file_main_proto_msgTypes[221].OneofWrappers = []any{}