generated from VLADIMIR/template
up
This commit is contained in:
+36
-32
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "не удалось изменить игру")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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-код команды.",
|
||||||
|
"Если телефон сообщает, что сеть без интернета, оставайтесь подключены.",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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{}
|
||||||
|
|||||||
Reference in New Issue
Block a user