This commit is contained in:
2026-10-10 18:32:41 +07:00
parent 6bda622d8b
commit 4243adeae9
7 changed files with 2062 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
# Пакет игры, версия 1 — ED-029, этап 4
Пакет — самостоятельный ZIP для одной игры до старта. Сборка получает готовый
снимок данных и файлы; чтение не обращается к сети, БД или хранилищу приложения.
Реализация: `internal/services/game_package_service`.
## Контракт
| Запись | Содержание |
| --- | --- |
| `manifest.json` | `version=1`, случайный `package_id` (32 hex-символа), `created_at` в UTC, `source_server`, исходный `game_id`, массив `files` |
| `game.json` | Объект `Data`: `game`, `scenario`, `teams`, `tickets`, `playlists`, необязательный `screen_plan`, `map` |
| `files/<sha256>.<extension>` | Медиа с именем по SHA-256 содержимого; одинаковые байты хранятся один раз |
Каждая запись `files` манифеста описывает путь, размер распакованных байтов,
MIME и SHA-256 в нижнем регистре. В перечень входит `game.json`, сам манифест
не входит. Все остальные ZIP-записи должны быть заявлены и использованы
ссылками из данных игры. Вложенные архивы и каталоги не используются.
Полный перечень JSON-полей задан типами в `model.go`; неизвестные поля и
неподдерживаемые версии при чтении отклоняются. Время записывается как RFC3339,
`token_hash` — как стандартный Base64 от исходных 32 байтов SHA-256.
Структура истории используется существующая — `storytelling.Story`;
поля шагов экранов совпадают с `game_screen_plans_repo.Step`.
### Переносимые данные
- Игра: исходные ID, название, описание, место, запланированное время, статус,
формат `online`/`offline`, ссылки на сценарий и плейлист. Допускается только
`draft`, без `started_at`, `ended_at` и игрового прогресса.
- Сценарий: описание, обложка, полная история с правильными ответами, статус,
даты, исходный ID автора и назначенный плейлист.
- Все команды игры, включая неподтверждённые и отменённые: ID, неизменённый
пароль QR, название, статус, email капитана, число участников, данные
подтверждения участия, исходный ID создателя, ID доставки регистрации и дата
создания. Эти данные нужны для сохранения связей и защиты от повторной регистрации.
- Активные и использованные билеты: ID, исходный хеш токена, статус, даты,
исходный ID пользователя, использовавшего билет. `used_by` может отсутствовать,
например после удаления пользователя; статус и `used_at` сохраняются.
- Оба назначенных плейлиста — игры и сценария, если они различаются; треки с
исходными ID, именами и позициями. План экранов с выбранным шагом; карта команд
и единственный стол организатора `organizer_desk/1`.
`game.playlist_id` — выбранный для переноса плейлист игры. При подготовке снимка
экспортёр должен учесть назначение в `game_music_states.playlist_id`: существующий
интерфейс выбора музыки сохраняет его именно там. При отсутствии такого назначения
используется `games.playlist_id`, затем default сценария. Текущая позиция,
состояние проигрывания и сессии экранов в пакет не включаются.
ID пользователей — ссылки на исходный сервер, а не существующих пользователей
площадки. Импортёр обязан обработать их отдельно; кодек не создаёт аккаунты.
ID игры, команд, сценария, билетов, плейлистов и треков также относятся к исходному
серверу. Кодек их не перенумеровывает. Импорт должен сохранить значения ID команд
и пароль, используемые в уже напечатанных ссылках `/team-story/<id>?password=...`,
а также хеши токенов билетов. Конфликты с локальными ID решаются на этапе 6.
Почтовые задания, токены подтверждения участия, аккаунты, пароли пользователей,
JWT/refresh-токены, действия, полученные улики, ответы команд и результаты не
входят в формат. Подключённые экраны и их позиции на карте при сборке исключаются;
на площадке экраны подключаются заново. Состояние проигрывания музыки не переносится.
### Медиа
Собираются все файловые ссылки: обложка, фон, аудио вступления, видео заключения,
изображения мест, файлы улик и треки плейлистов. Данные проверяются по сигнатуре
содержимого; поддерживаются PDF, JPEG, PNG, GIF, WebP, MP3, Ogg, WAV, MP4 и WebM.
Ссылки внутри копии `Data` заменяются на пути `files/...`; входные данные не меняются.
Относительное имя и полный URL с настроенным префиксом хранилища открываются
через переданный источник локальных файлов. Остальные HTTP/HTTPS-ссылки
скачиваются при сборке. Один URL скачивается один раз; одинаковое содержимое
разных URL хранится один раз. Файл по недоступной ссылке, пустой файл или ответ
без поддерживаемой сигнатуры завершают сборку ошибкой.
Внешний клиент не использует системный proxy. Адреса проверяются перед
соединением и после перенаправлений; соединение идёт к проверенному IP без
повторного DNS-разрешения. Частные, локальные и служебные сети запрещены.
HTTPS использует штатную проверку сертификатов. До пяти перенаправлений,
до 120 секунд на файл; отмена контекста прерывает операцию.
## Использование и временные файлы
```go
service := game_package_service.NewGamePackageService(fileDomain)
openLocal := func(ctx context.Context, name string) (io.ReadCloser, error) {
file, err := fileStorage.Get(ctx, name)
if err != nil {
return nil, err
}
return io.NopCloser(bytes.NewReader(file.Data)), nil
}
err := service.Pack(ctx, snapshot, openLocal, destination)
// На ошибке destination нужно отбросить, включая ошибку записи результата.
bundle, err := service.Unpack(ctx, sourceReaderAt, archiveSize, tempParent)
if err != nil {
return err
}
defer os.RemoveAll(bundle.Dir)
// bundle.Files: путь внутри ZIP → проверенный временный файл с расширением.
```
`Snapshot.SourceServer` содержит исходный адрес онлайн-сервера.
`Snapshot.HasProgress` заполняется экспортёром по наличию действий, полученных
улик и ответов. Кодек не читает БД, поэтому достоверность снимка и этого признака
обеспечивает этап 5. `Data` не имеет полей для игрового прогресса.
`Pack` сначала собирает ZIP в приватном временном каталоге, затем копирует его
в `destination`. Ошибка получения файлов не выдаёт частичный пакет. Ошибка
записи или отмена во время выдачи может оставить часть ZIP в destination;
вызывающий код обязан её удалить. Все внутренние файлы `Pack` удаляются при
завершении операции. Адаптер существующего `IFileStorage.Get` держит в памяти
один объект; потоковый источник может возвращать reader без этого буфера.
`Unpack` создаёт отдельный каталог внутри `tempParent` (или системного временного
каталога при пустой строке), с правами `0700`, файлы — `0600`. Файлы создаются со
случайными именами, независимо от путей ZIP. При любой ошибке собственный каталог
удаляется. После успеха очистка `bundle.Dir` обязательна для вызывающего кода.
Временные файлы и пакеты нельзя хранить в Git: они содержат ответы сценария,
пароли QR и email капитанов.
## Лимиты и проверки
| Ограничение | Предел |
| --- | --- |
| Размер ZIP | 1 ГиБ |
| Общий распакованный размер, включая JSON | 1 ГиБ |
| Один медиафайл | 64 МиБ |
| Каждый JSON и каталог ZIP | 8 МиБ |
| Количество ZIP-записей, включая JSON | 4096 |
Обработка медиа использует ограниченные буферы и временные файлы, не загружает
весь пакет в память. Перед `zip.NewReader` проверяется каталог, чтобы количество
записей нельзя было скрыть ложным заголовком. ZIP64 и многотомные ZIP не поддерживаются.
При чтении отклоняются опасные пути, симлинки, каталоги, дубли ZIP/манифеста,
незаявленные и неиспользуемые файлы, отсутствие файлов, несовпадение размеров,
MIME и SHA-256, некорректные связи данных и состояние начатой игры.
Ошибки проверяются через `errors.Is`: `ErrInvalidPackage`,
`ErrUnsupportedVersion`, `ErrLimitExceeded`, `ErrMediaUnavailable`,
`ErrUnsafeURL`; отмена сохраняет `context.Canceled`/`context.DeadlineExceeded`.
Транспортных handlers на этапе 4 нет; внутренние причины ошибок нельзя напрямую
выдавать в будущем API.
SHA-256 защищает от повреждения, но не подтверждает автора пакета. Подпись и
шифрование не реализованы. Кодек не блокирует игру и не делает импорт; повторные
импорты, сохранность QR на площадке и полный перенос проверяются на этапах 5–6.
Проверка: `go test ./internal/services/game_package_service -count=1` и полный
`go test ./...`. Тесты покрывают сохранение данных, автономное чтение, загрузку
медиа, дедупликацию, потоковую обработку, отмену, очистку, лимиты и повреждённые пакеты.
@@ -0,0 +1,158 @@
package game_package_service
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
// Special-use networks must not be reachable through externally supplied media URLs.
var blockedNetworks = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("2001::/23"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("3fff::/20"),
}
func publicIP(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.Zone() != "" {
return false
}
if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) {
return false
}
for _, network := range blockedNetworks {
if network.Contains(ip) {
return false
}
}
return true
}
func validateURL(u *url.URL) error {
if u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" ||
u.User != nil || u.Fragment != "" || u.Opaque != "" || strings.Contains(u.Hostname(), "%") {
return ErrUnsafeURL
}
if ip, err := netip.ParseAddr(u.Hostname()); err == nil && !publicIP(ip) {
return ErrUnsafeURL
}
return nil
}
// dialPublic resolves once and connects to the checked IP, preventing DNS rebinding.
func dialPublic(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, ErrUnsafeURL
}
addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
if len(addresses) == 0 {
return nil, ErrUnsafeURL
}
for _, ip := range addresses {
if !publicIP(ip) {
return nil, ErrUnsafeURL
}
}
dialer := net.Dialer{Timeout: 10 * time.Second}
var dialErr error
for _, ip := range addresses {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
dialErr = err
}
return nil, dialErr
}
func newMediaClient() *http.Client {
return &http.Client{
Timeout: 120 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: dialPublic,
DisableCompression: true,
DisableKeepAlives: true,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
MaxResponseHeaderBytes: 64 << 10,
},
CheckRedirect: checkMediaRedirect,
}
}
func checkMediaRedirect(req *http.Request, via []*http.Request) error {
if len(via) > 5 {
return ErrUnsafeURL
}
return validateURL(req.URL)
}
func (s *GamePackageService) openMedia(ctx context.Context, ref string, openLocal OpenLocalFile) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if s.fileDomain != "" && strings.HasPrefix(ref, s.fileDomain) {
ref = strings.TrimPrefix(ref, s.fileDomain)
}
u, err := url.Parse(ref)
if err != nil {
return nil, ErrUnsafeURL
}
if u.Scheme == "" && u.Host == "" && u.RawQuery == "" && u.Fragment == "" && validPath(ref) {
if openLocal == nil {
return nil, ErrMediaUnavailable
}
reader, err := openLocal(ctx, ref)
if err != nil {
return nil, errors.Join(ErrMediaUnavailable, err)
}
if reader == nil {
return nil, ErrMediaUnavailable
}
return reader, nil
}
if err := validateURL(u); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, errors.Join(ErrMediaUnavailable, err)
}
req.Header.Set("Accept-Encoding", "identity")
rsp, err := s.client.Do(req)
if err != nil {
return nil, errors.Join(ErrMediaUnavailable, err)
}
if rsp.StatusCode != http.StatusOK {
_ = rsp.Body.Close()
return nil, fmt.Errorf("%w: HTTP %d", ErrMediaUnavailable, rsp.StatusCode)
}
if rsp.ContentLength > s.limits.file {
_ = rsp.Body.Close()
return nil, ErrLimitExceeded
}
return rsp.Body, nil
}
@@ -0,0 +1,158 @@
// Package game_package_service builds and validates portable pre-game packages.
package game_package_service
import (
"context"
"errors"
"io"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos/game_screen_plans_repo"
)
const (
Version = 1
MaxArchiveSize int64 = 1 << 30
MaxFileSize int64 = 64 << 20
MaxJSONSize int64 = 8 << 20
MaxEntries = 4096
)
var (
ErrInvalidPackage = errors.New("некорректный пакет игры")
ErrUnsupportedVersion = errors.New("неподдерживаемая версия пакета игры")
ErrLimitExceeded = errors.New("превышен лимит пакета игры")
ErrMediaUnavailable = errors.New("не удалось получить файл пакета игры")
ErrUnsafeURL = errors.New("недопустимый адрес внешнего файла")
)
// OpenLocalFile opens an object by its original storage name. The service closes it.
// A caller can adapt IFileStorage.Get with io.NopCloser(bytes.NewReader(file.Data)).
type OpenLocalFile func(context.Context, string) (io.ReadCloser, error)
// Data is the version 1 game.json contract, independent of database row models.
type Data struct {
Game Game `json:"game"`
Scenario Scenario `json:"scenario"`
Teams []Team `json:"teams"`
Tickets []Ticket `json:"tickets"`
Playlists []Playlist `json:"playlists"`
ScreenPlan *ScreenPlan `json:"screen_plan,omitempty"`
Map []MapItem `json:"map"`
}
// Snapshot is supplied by the exporter after reading a consistent database snapshot.
// HasProgress must account for actions, acquired evidence and team answers.
type Snapshot struct {
SourceServer string
Data Data
HasProgress bool
}
type Game struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Location *string `json:"location,omitempty"`
StartAt time.Time `json:"start_at"`
Status string `json:"status"`
Format string `json:"format"`
ScenarioID int `json:"scenario_id"`
PlaylistID *int `json:"playlist_id,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
EndedAt *time.Time `json:"ended_at,omitempty"`
}
type Scenario struct {
ID int `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
Story *storytelling.Story `json:"story"`
AuthorID *int `json:"author_id,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PublishedAt *time.Time `json:"published_at,omitempty"`
PlaylistID *int `json:"playlist_id,omitempty"`
}
type Team struct {
ID int `json:"id"`
GameID int `json:"game_id"`
Name string `json:"name"`
Status string `json:"status"`
Password string `json:"password"`
CreatorID *int `json:"creator_id,omitempty"`
CaptainEmail string `json:"captain_email"`
ParticipantsCount int32 `json:"participants_count"`
ParticipationStatus string `json:"participation_status"`
ParticipationSource string `json:"participation_source"`
ParticipationAnsweredAt *time.Time `json:"participation_answered_at,omitempty"`
RegistrationDeliveryID *string `json:"registration_delivery_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Ticket struct {
ID int `json:"id"`
GameID int `json:"game_id"`
TokenHash []byte `json:"token_hash"`
Status string `json:"status"`
UsedAt *time.Time `json:"used_at,omitempty"`
UsedBy *int `json:"used_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Playlist struct {
ID int `json:"id"`
Name string `json:"name"`
Tracks []Track `json:"tracks"`
}
type Track struct {
ID int `json:"id"`
Name string `json:"name"`
Filename string `json:"filename"`
Position int `json:"position"`
}
type ScreenPlan struct {
GameID int `json:"game_id"`
Steps []game_screen_plans_repo.Step `json:"steps"`
CurrentStep int `json:"current_step"`
}
type MapItem struct {
GameID int `json:"game_id"`
Kind string `json:"kind"`
ItemID int `json:"item_id"`
X int `json:"x"`
Y int `json:"y"`
Hidden bool `json:"hidden"`
}
type Manifest struct {
Version int `json:"version"`
PackageID string `json:"package_id"`
CreatedAt time.Time `json:"created_at"`
SourceServer string `json:"source_server"`
GameID int `json:"game_id"`
Files []FileInfo `json:"files"`
}
// FileInfo also describes game.json, protecting its bytes from accidental changes.
type FileInfo struct {
Path string `json:"path"`
Size int64 `json:"size"`
MIME string `json:"mime"`
SHA256 string `json:"sha256"`
}
// Bundle owns a private directory. The caller must remove Dir after successful use.
type Bundle struct {
Manifest Manifest
Data Data
Dir string
Files map[string]string
}
@@ -0,0 +1,343 @@
package game_package_service
import (
"archive/zip"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
)
type packageLimits struct {
archive int64
file int64
json int64
entries int
}
// GamePackageService has no database or transport side effects.
type GamePackageService struct {
fileDomain string
client *http.Client
limits packageLimits
}
// NewGamePackageService accepts the existing public file URL prefix, or an empty prefix.
func NewGamePackageService(fileDomain string) *GamePackageService {
if fileDomain != "" {
fileDomain = strings.TrimRight(fileDomain, "/") + "/"
}
return &GamePackageService{
fileDomain: fileDomain,
client: newMediaClient(),
limits: packageLimits{
archive: MaxArchiveSize,
file: MaxFileSize,
json: MaxJSONSize,
entries: MaxEntries,
},
}
}
// Pack stages the complete archive before writing it. The caller discards output on error.
// Snapshot IDs and secrets are preserved; the input snapshot is never mutated.
func (s *GamePackageService) Pack(ctx context.Context, snapshot *Snapshot, openLocal OpenLocalFile, output io.Writer) error {
if err := ctx.Err(); err != nil {
return err
}
if snapshot == nil || output == nil || !validServer(snapshot.SourceServer) || snapshot.HasProgress {
return invalid("отсутствует исходный сервер или игра уже имеет прогресс")
}
if s.fileDomain != "" && !validServer(s.fileDomain) {
return invalid("некорректный адрес хранилища")
}
data, err := cloneData(&snapshot.Data, s.limits.json)
if err != nil {
return err
}
data.Map = portableMap(data.Map)
if err := validateData(data); err != nil {
return err
}
dir, err := os.MkdirTemp("", "game-package-pack-")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(dir) }()
archive, err := os.CreateTemp(dir, "archive-")
if err != nil {
return err
}
defer func() { _ = archive.Close() }()
zw := zip.NewWriter(&limitedWriter{writer: archive, remaining: s.limits.archive})
defer func() { _ = zw.Close() }()
manifest, err := newManifest(snapshot.SourceServer, data.Game.ID)
if err != nil {
return err
}
byRef := map[string]string{}
byPath := map[string]bool{}
var total int64
for _, ref := range mediaRefs(data) {
if *ref == "" {
continue
}
if name, ok := byRef[*ref]; ok {
*ref = name
continue
}
info, filename, err := s.stageMedia(ctx, dir, *ref, openLocal, s.limits.archive-total)
if err != nil {
return err
}
if !byPath[info.Path] {
if len(manifest.Files)+2 >= s.limits.entries {
return ErrLimitExceeded
}
if err := writeFileEntry(ctx, zw, info.Path, filename, info.Size); err != nil {
return err
}
manifest.Files = append(manifest.Files, info)
byPath[info.Path] = true
total += info.Size
}
if err := os.Remove(filename); err != nil {
return err
}
byRef[*ref] = info.Path
*ref = info.Path
}
raw, err := encodeJSON(data, s.limits.json)
if err != nil {
return err
}
info := describeBytes("game.json", "application/json", raw)
manifest.Files = append(manifest.Files, info)
total += info.Size
sort.Slice(manifest.Files, func(i, j int) bool { return manifest.Files[i].Path < manifest.Files[j].Path })
manifestRaw, err := encodeJSON(manifest, s.limits.json)
if err != nil {
return err
}
if total+int64(len(manifestRaw)) > s.limits.archive || len(manifest.Files)+1 > s.limits.entries {
return ErrLimitExceeded
}
if err := writeBytesEntry(zw, "game.json", raw); err != nil {
return err
}
if err := writeBytesEntry(zw, "manifest.json", manifestRaw); err != nil {
return err
}
if err := zw.Close(); err != nil {
return err
}
if _, err := archive.Seek(0, io.SeekStart); err != nil {
return err
}
_, err = copyLimited(ctx, output, archive, s.limits.archive)
return err
}
func newManifest(server string, gameID int) (*Manifest, error) {
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
return nil, err
}
return &Manifest{
Version: Version,
PackageID: hex.EncodeToString(id[:]),
CreatedAt: time.Now().UTC(),
SourceServer: server,
GameID: gameID,
}, nil
}
func cloneData(data *Data, limit int64) (*Data, error) {
raw, err := encodeJSON(data, limit)
if err != nil {
return nil, err
}
var clone Data
if err := decodeJSON(raw, &clone); err != nil {
return nil, err
}
return &clone, nil
}
func portableMap(items []MapItem) []MapItem {
result := make([]MapItem, 0, len(items))
for _, item := range items {
if item.Kind != "screen" {
result = append(result, item)
}
}
return result
}
func encodeJSON(value any, limit int64) ([]byte, error) {
var buffer bytes.Buffer
if err := json.NewEncoder(&limitedWriter{writer: &buffer, remaining: limit}).Encode(value); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func decodeJSON(raw []byte, value any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
return errors.Join(ErrInvalidPackage, err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return invalid("лишние данные JSON")
}
return nil
}
func describeBytes(name, mime string, raw []byte) FileInfo {
hash := sha256.Sum256(raw)
return FileInfo{
Path: name,
Size: int64(len(raw)),
MIME: mime,
SHA256: hex.EncodeToString(hash[:]),
}
}
func writeBytesEntry(zw *zip.Writer, name string, raw []byte) error {
w, err := zw.Create(name)
if err != nil {
return err
}
_, err = w.Write(raw)
return err
}
func writeFileEntry(ctx context.Context, zw *zip.Writer, name, filename string, size int64) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
w, err := zw.Create(name)
if err != nil {
return err
}
n, err := copyLimited(ctx, w, file, size)
if err == nil && n != size {
err = invalid("размер файла изменился")
}
return err
}
type limitedWriter struct {
writer io.Writer
remaining int64
}
func (w *limitedWriter) Write(data []byte) (int, error) {
if int64(len(data)) > w.remaining {
return 0, ErrLimitExceeded
}
n, err := w.writer.Write(data)
w.remaining -= int64(n)
return n, err
}
type contextReader struct {
ctx context.Context
reader io.Reader
}
func (r *contextReader) Read(data []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(data)
}
func copyLimited(ctx context.Context, dst io.Writer, src io.Reader, limit int64) (int64, error) {
if limit < 0 {
return 0, ErrLimitExceeded
}
n, err := io.CopyBuffer(dst, io.LimitReader(&contextReader{ctx: ctx, reader: src}, limit+1), make([]byte, 32<<10))
if err != nil {
return n, err
}
if n > limit {
return n, ErrLimitExceeded
}
return n, ctx.Err()
}
func (s *GamePackageService) stageMedia(ctx context.Context, dir, ref string, openLocal OpenLocalFile, remaining int64) (FileInfo, string, error) {
r, err := s.openMedia(ctx, ref, openLocal)
if err != nil {
return FileInfo{}, "", err
}
defer func() { _ = r.Close() }()
file, err := os.CreateTemp(dir, "media-")
if err != nil {
return FileInfo{}, "", err
}
defer func() { _ = file.Close() }()
hash := sha256.New()
n, err := copyLimited(ctx, io.MultiWriter(file, hash), r, min(s.limits.file, remaining))
if err != nil {
return FileInfo{}, "", err
}
if n == 0 {
return FileInfo{}, "", invalid("пустой медиафайл")
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return FileInfo{}, "", err
}
header := make([]byte, 512)
nread, err := file.Read(header)
if err != nil && !errors.Is(err, io.EOF) {
return FileInfo{}, "", err
}
mime, ext := mediaType(header[:nread])
if ext == "" {
return FileInfo{}, "", invalid("неподдерживаемое содержимое медиафайла")
}
digest := hex.EncodeToString(hash.Sum(nil))
return FileInfo{
Path: "files/" + digest + ext,
Size: n,
MIME: mime,
SHA256: digest,
}, file.Name(), nil
}
func mediaType(header []byte) (string, string) {
mime := http.DetectContentType(header)
if len(header) >= 4 && header[0] == 0xff && header[1]&0xe0 == 0xe0 &&
header[1]&0x18 != 0x08 && header[1]&0x06 != 0 && header[2]&0xf0 != 0 && header[2]&0xf0 != 0xf0 && header[2]&0x0c != 0x0c {
mime = "audio/mpeg"
}
extensions := map[string]string{
"application/pdf": ".pdf",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"audio/mpeg": ".mp3",
"application/ogg": ".ogg",
"audio/wave": ".wav",
"audio/x-wav": ".wav",
"video/mp4": ".mp4",
"video/webm": ".webm",
}
return mime, extensions[mime]
}
@@ -0,0 +1,800 @@
package game_package_service
import (
"archive/zip"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"reflect"
"strconv"
"strings"
"testing"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos/game_screen_plans_repo"
)
func fixture() (*Snapshot, map[string][]byte) {
now := time.Date(2026, 10, 10, 12, 30, 0, 0, time.UTC)
user, playlistID := 7, 30
return &Snapshot{
SourceServer: "https://game.example",
Data: Data{
Game: Game{ID: 10, Name: "Game", StartAt: now, Status: "draft", Format: "offline", ScenarioID: 20, PlaylistID: &playlistID},
Scenario: Scenario{
ID: 20, Name: "Story", Image: "cover.png", AuthorID: &user, Status: "public", CreatedAt: now, UpdatedAt: now,
Story: &storytelling.Story{
Background: "cover.png",
Introduction: &storytelling.Introduction{Text: "Intro", Audio: "music.mp3"},
Conclusion: &storytelling.Conclusion{Text: "End", Video: "video.webm"},
Places: []*storytelling.Place{{Code: "p", Name: "Place", Image: "cover.png", Applications: []*storytelling.Application{{Name: "Evidence", Image: "evidence.pdf", FileType: "pdf"}}}},
Questions: []*storytelling.Question{{Code: "q", Text: "Question", Answer: "Secret answer"}},
},
},
Teams: []Team{
{ID: 40, GameID: 10, Name: "Confirmed", Status: "register", Password: "original-qr-password", CaptainEmail: "captain@example.org", ParticipantsCount: 4, ParticipationStatus: "confirmed", CreatorID: &user},
{ID: 41, GameID: 10, Name: "Cancelled", Status: "register", Password: "cancelled-password", ParticipationStatus: "cancelled"},
},
Tickets: []Ticket{
{ID: 50, GameID: 10, TokenHash: bytes.Repeat([]byte{1}, 32), Status: "active", CreatedAt: now},
{ID: 51, GameID: 10, TokenHash: bytes.Repeat([]byte{2}, 32), Status: "used", UsedAt: &now, UsedBy: &user, CreatedAt: now},
},
Playlists: []Playlist{{ID: 30, Name: "Music", Tracks: []Track{{ID: 60, Name: "Track", Filename: "music.mp3", Position: 0}}}},
ScreenPlan: &ScreenPlan{GameID: 10, CurrentStep: 1, Steps: []game_screen_plans_repo.Step{{Mode: 1}, {Mode: 7, TimerDurationSeconds: 30, TimerLabel: "Break"}}},
Map: []MapItem{{GameID: 10, Kind: "team", ItemID: 40, X: 123, Y: 456}, {GameID: 10, Kind: "organizer_desk", ItemID: 1, X: 80, Y: 90}},
},
}, map[string][]byte{
"cover.png": []byte("\x89PNG\r\n\x1a\nfixture-image"),
"music.mp3": []byte("ID3\x04\x00\x00fixture-music"),
"video.webm": []byte("\x1a\x45\xdf\xa3fixture-video"),
"evidence.pdf": []byte("%PDF-1.7\nfixture-evidence"),
}
}
func localFiles(files map[string][]byte, calls map[string]int) OpenLocalFile {
return func(ctx context.Context, name string) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if calls != nil {
calls[name]++
}
data, ok := files[name]
if !ok {
return nil, os.ErrNotExist
}
return io.NopCloser(bytes.NewReader(data)), nil
}
}
func buildPackage(t *testing.T, service *GamePackageService, snapshot *Snapshot, files map[string][]byte) []byte {
t.Helper()
var archive bytes.Buffer
if err := service.Pack(context.Background(), snapshot, localFiles(files, nil), &archive); err != nil {
t.Fatal(err)
}
return archive.Bytes()
}
func TestPackagePreservesPregameData(t *testing.T) {
for _, tc := range []struct{ name, format string }{{"offline", "offline"}, {"online", "online"}} {
t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture()
snapshot.Data.Game.Format = tc.format
snapshot.Data.Map = append(snapshot.Data.Map, MapItem{GameID: 10, Kind: "screen", ItemID: 99})
before, _ := json.Marshal(snapshot)
calls := map[string]int{}
service := NewGamePackageService("")
var archive bytes.Buffer
if err := service.Pack(context.Background(), snapshot, localFiles(files, calls), &archive); err != nil {
t.Fatal(err)
}
after, _ := json.Marshal(snapshot)
if !bytes.Equal(before, after) {
t.Fatal("Pack mutated input")
}
// Unpack must not consult any downloader, including for originally external files.
service.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { t.Fatal("network during Unpack"); return nil, nil })
bundle, err := service.Unpack(context.Background(), bytes.NewReader(archive.Bytes()), int64(archive.Len()), t.TempDir())
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(bundle.Dir) }()
if !reflect.DeepEqual(bundle.Data.Game, snapshot.Data.Game) || !reflect.DeepEqual(bundle.Data.Teams, snapshot.Data.Teams) ||
!reflect.DeepEqual(bundle.Data.Tickets, snapshot.Data.Tickets) || !reflect.DeepEqual(bundle.Data.ScreenPlan, snapshot.Data.ScreenPlan) ||
!reflect.DeepEqual(bundle.Data.Map, snapshot.Data.Map[:2]) {
t.Fatal("game, QR credentials, ticket hashes or settings changed")
}
if bundle.Data.Scenario.Story.Questions[0].Answer != "Secret answer" || bundle.Manifest.GameID != 10 || bundle.Manifest.SourceServer != snapshot.SourceServer {
t.Fatal("scenario or origin changed")
}
if len(bundle.Files) != len(files) || len(bundle.Manifest.Files) != len(files)+1 {
t.Fatal("incorrect media count")
}
for _, count := range calls {
if count != 1 {
t.Fatal("duplicate file fetched")
}
}
wantRefs := []string{"cover.png", "cover.png", "music.mp3", "video.webm", "cover.png", "evidence.pdf", "music.mp3"}
for i, ref := range mediaRefs(&bundle.Data) {
content, err := os.ReadFile(bundle.Files[*ref])
if err != nil || !bytes.Equal(content, files[wantRefs[i]]) {
t.Fatalf("media %d did not survive round trip: %v", i, err)
}
}
mode, err := os.Stat(bundle.Dir)
if err != nil || mode.Mode().Perm() != 0700 {
t.Fatal("bundle directory is not private")
}
for _, filename := range bundle.Files {
mode, err := os.Stat(filename)
if err != nil || mode.Mode().Perm() != 0600 {
t.Fatal("extracted file is not private")
}
}
})
}
}
func TestPackRejectsInvalidSnapshot(t *testing.T) {
for _, tc := range []struct {
name string
change func(*Snapshot)
}{
{"progress", func(s *Snapshot) { s.HasProgress = true }},
{"running", func(s *Snapshot) { s.Data.Game.Status = "run" }},
{"paused", func(s *Snapshot) { s.Data.Game.Status = "pause" }},
{"finished", func(s *Snapshot) { s.Data.Game.Status = "finish" }},
{"started draft", func(s *Snapshot) { s.Data.Game.StartedAt = &s.Data.Game.StartAt }},
{"ended draft", func(s *Snapshot) { s.Data.Game.EndedAt = &s.Data.Game.StartAt }},
{"origin", func(s *Snapshot) { s.SourceServer = "file:///tmp" }},
{"scenario link", func(s *Snapshot) { s.Data.Game.ScenarioID++ }},
{"nil story", func(s *Snapshot) { s.Data.Scenario.Story = nil }},
{"nil place", func(s *Snapshot) { s.Data.Scenario.Story.Places[0] = nil }},
{"nil application", func(s *Snapshot) { s.Data.Scenario.Story.Places[0].Applications[0] = nil }},
{"nil question", func(s *Snapshot) { s.Data.Scenario.Story.Questions[0] = nil }},
{"nil door", func(s *Snapshot) { s.Data.Scenario.Story.Places[0].Doors = []*storytelling.Door{nil} }},
{"nil key", func(s *Snapshot) { s.Data.Scenario.Story.Places[0].Keys = []*storytelling.Key{nil} }},
{"nil door key", func(s *Snapshot) {
s.Data.Scenario.Story.Places[0].Doors = []*storytelling.Door{{Code: "p", Keys: []*storytelling.Key{nil}}}
}},
{"missing door target", func(s *Snapshot) { s.Data.Scenario.Story.Places[0].Doors = []*storytelling.Door{{Code: "missing"}} }},
{"duplicate place code", func(s *Snapshot) {
s.Data.Scenario.Story.Places = append(s.Data.Scenario.Story.Places, s.Data.Scenario.Story.Places[0])
}},
{"duplicate team", func(s *Snapshot) { s.Data.Teams = append(s.Data.Teams, s.Data.Teams[0]) }},
{"missing QR", func(s *Snapshot) { s.Data.Teams[0].Password = "" }},
{"foreign team", func(s *Snapshot) { s.Data.Teams[0].GameID++ }},
{"participation", func(s *Snapshot) { s.Data.Teams[0].ParticipationStatus = "other" }},
{"duplicate ticket", func(s *Snapshot) { s.Data.Tickets = append(s.Data.Tickets, s.Data.Tickets[0]) }},
{"short ticket hash", func(s *Snapshot) { s.Data.Tickets[0].TokenHash = []byte{1} }},
{"duplicate ticket hash", func(s *Snapshot) { s.Data.Tickets[1].TokenHash = s.Data.Tickets[0].TokenHash }},
{"active used ticket", func(s *Snapshot) { s.Data.Tickets[0].UsedAt = &s.Data.Game.StartAt }},
{"used ticket without time", func(s *Snapshot) { s.Data.Tickets[1].UsedAt = nil }},
{"missing playlist", func(s *Snapshot) { s.Data.Playlists = nil }},
{"duplicate track", func(s *Snapshot) {
s.Data.Playlists[0].Tracks = append(s.Data.Playlists[0].Tracks, s.Data.Playlists[0].Tracks[0])
}},
{"foreign map team", func(s *Snapshot) { s.Data.Map[0].ItemID = 99 }},
{"foreign map game", func(s *Snapshot) { s.Data.Map[0].GameID++ }},
{"duplicate map item", func(s *Snapshot) { s.Data.Map = append(s.Data.Map, s.Data.Map[0]) }},
{"invalid organizer desk", func(s *Snapshot) { s.Data.Map[1].ItemID = 0 }},
{"invalid plan index", func(s *Snapshot) { s.Data.ScreenPlan.CurrentStep = 10 }},
{"invalid plan mode", func(s *Snapshot) { s.Data.ScreenPlan.Steps[0].Mode = 9 }},
} {
t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture()
tc.change(snapshot)
var output bytes.Buffer
err := NewGamePackageService("").Pack(context.Background(), snapshot, localFiles(files, nil), &output)
if !errors.Is(err, ErrInvalidPackage) || output.Len() != 0 {
t.Fatalf("got %v, output %d", err, output.Len())
}
})
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestPackExternalMediaAndLocalURLs(t *testing.T) {
for _, tc := range []struct {
name, ref string
external bool
}{
{"external", "https://media.example/image", true},
{"storage URL", "https://storage.example/api/files/cover.png", false},
} {
t.Run(tc.name, func(t *testing.T) {
snapshot, files := fixture()
snapshot.Data.Scenario.Image = tc.ref
snapshot.Data.Scenario.Story.Background = tc.ref
service := NewGamePackageService("https://storage.example/api/files/")
calls := 0
service.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
if r.URL.String() != tc.ref || r.Header.Get("Accept-Encoding") != "identity" {
t.Fatal("unexpected request")
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(files["cover.png"])), ContentLength: int64(len(files["cover.png"]))}, nil
})
archive := buildPackage(t, service, snapshot, files)
if (calls == 1) != tc.external || calls > 1 {
t.Fatalf("external fetches: %d", calls)
}
bundle, err := service.Unpack(context.Background(), bytes.NewReader(archive), int64(len(archive)), t.TempDir())
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(bundle.Dir) }()
if len(bundle.Files) != len(files) {
t.Fatal("identical content was not deduplicated")
}
})
}
}
func TestMediaRequestsRejectUnsafeAddresses(t *testing.T) {
for _, tc := range []struct {
address string
safe bool
}{
{"https://example.org/media", true}, {"http://8.8.8.8/media", true},
{"http://127.0.0.1/x", false}, {"http://10.0.0.1/x", false}, {"http://192.168.10.155/x", false},
{"http://169.254.169.254/x", false}, {"http://[::1]/x", false}, {"http://[::ffff:127.0.0.1]/x", false},
{"http://[64:ff9b::7f00:1]/x", false}, {"https://user:password@example.org/x", false},
{"file:///tmp/x", false}, {"//example.org/x", false}, {"https://example.org/x#fragment", false},
} {
t.Run(tc.address, func(t *testing.T) {
u, err := url.Parse(tc.address)
if err != nil {
t.Fatal(err)
}
err = validateURL(u)
if tc.safe && err != nil || !tc.safe && !errors.Is(err, ErrUnsafeURL) {
t.Fatalf("got %v", err)
}
})
}
for _, host := range []string{"127.0.0.1:80", "localhost:80", "[::1]:80", "192.168.10.155:443"} {
t.Run(host, func(t *testing.T) {
conn, err := dialPublic(context.Background(), "tcp", host)
if conn != nil {
_ = conn.Close()
t.Fatal("unsafe connection")
}
if !errors.Is(err, ErrUnsafeURL) {
t.Fatalf("got %v", err)
}
})
}
}
func TestPublicIPRejectsSpecialNetworks(t *testing.T) {
for _, tc := range []struct {
ip string
safe bool
}{
{"8.8.8.8", true}, {"2606:4700:4700::1111", true}, {"::ffff:8.8.8.8", true},
{"0.1.2.3", false}, {"100.64.0.1", false}, {"192.0.0.1", false}, {"192.0.2.1", false},
{"198.18.0.1", false}, {"198.51.100.1", false}, {"203.0.113.1", false}, {"240.0.0.1", false},
{"224.0.0.1", false}, {"2001:db8::1", false}, {"2002:7f00:1::", false}, {"3fff::1", false},
{"fc00::1", false}, {"fe80::1", false}, {"ff02::1", false},
} {
t.Run(tc.ip, func(t *testing.T) {
if publicIP(netip.MustParseAddr(tc.ip)) != tc.safe {
t.Fatal("incorrect address classification")
}
})
}
}
func TestMediaRedirects(t *testing.T) {
for _, tc := range []struct {
name, target string
hops int
want error
}{
{"public redirect", "https://example.org/x", 1, nil},
{"fifth redirect", "https://example.org/x", 5, nil},
{"sixth redirect", "https://example.org/x", 6, ErrUnsafeURL},
{"LAN redirect", "http://192.168.10.1/x", 1, ErrUnsafeURL},
} {
t.Run(tc.name, func(t *testing.T) {
u, _ := url.Parse(tc.target)
err := checkMediaRedirect(&http.Request{URL: u}, make([]*http.Request, tc.hops))
if !errors.Is(err, tc.want) {
t.Fatalf("got %v", err)
}
})
}
for _, tc := range []struct {
name string
redirect bool
}{{"direct loopback", false}, {"loopback redirect", true}} {
t.Run(tc.name, func(t *testing.T) {
hits := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++; w.WriteHeader(200) }))
defer server.Close()
service := NewGamePackageService("")
ref := server.URL
if tc.redirect {
ref = "http://media.example/x"
service.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 302, Header: http.Header{"Location": {server.URL}}, Body: io.NopCloser(strings.NewReader(""))}, nil
})
}
r, err := service.openMedia(context.Background(), ref, nil)
if r != nil {
_ = r.Close()
}
if !errors.Is(err, ErrUnsafeURL) || hits != 0 {
t.Fatalf("got %v, hits %d", err, hits)
}
})
}
}
func TestPackLimitsErrorsAndCleanup(t *testing.T) {
for _, tc := range []struct {
name string
setup func(*GamePackageService, *Snapshot, map[string][]byte)
want error
}{
{"file limit", func(s *GamePackageService, _ *Snapshot, _ map[string][]byte) { s.limits.file = 5 }, ErrLimitExceeded},
{"JSON limit", func(s *GamePackageService, _ *Snapshot, _ map[string][]byte) { s.limits.json = 5 }, ErrLimitExceeded},
{"ZIP limit", func(s *GamePackageService, _ *Snapshot, _ map[string][]byte) { s.limits.archive = 100 }, ErrLimitExceeded},
{"entry limit", func(s *GamePackageService, _ *Snapshot, _ map[string][]byte) { s.limits.entries = 3 }, ErrLimitExceeded},
{"missing local", func(_ *GamePackageService, _ *Snapshot, files map[string][]byte) { delete(files, "music.mp3") }, ErrMediaUnavailable},
{"empty file", func(_ *GamePackageService, _ *Snapshot, files map[string][]byte) { files["music.mp3"] = nil }, ErrInvalidPackage},
{"HTML as media", func(_ *GamePackageService, _ *Snapshot, files map[string][]byte) {
files["music.mp3"] = []byte("<html>not music</html>")
}, ErrInvalidPackage},
{"unsafe ref", func(_ *GamePackageService, snap *Snapshot, _ map[string][]byte) {
snap.Data.Scenario.Image = "../secret"
}, ErrUnsafeURL},
{"HTTP failure", func(s *GamePackageService, snap *Snapshot, _ map[string][]byte) {
snap.Data.Scenario.Image = "https://media.example/x"
s.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(""))}, nil
})
}, ErrMediaUnavailable},
{"HTTP declared size", func(s *GamePackageService, snap *Snapshot, _ map[string][]byte) {
snap.Data.Scenario.Image = "https://media.example/x"
s.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, ContentLength: MaxFileSize + 1, Body: io.NopCloser(strings.NewReader(""))}, nil
})
}, ErrLimitExceeded},
{"HTTP actual size", func(s *GamePackageService, snap *Snapshot, _ map[string][]byte) {
snap.Data.Scenario.Image = "https://media.example/x"
s.limits.file = 16
s.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, ContentLength: -1, Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 17)))}, nil
})
}, ErrLimitExceeded},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
t.Setenv("TMPDIR", root)
service := NewGamePackageService("")
snapshot, files := fixture()
tc.setup(service, snapshot, files)
var output bytes.Buffer
err := service.Pack(context.Background(), snapshot, localFiles(files, nil), &output)
if !errors.Is(err, tc.want) || output.Len() != 0 {
t.Fatalf("got %v, output %d", err, output.Len())
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 0 {
t.Fatal("temporary files leaked")
}
})
}
}
func TestPackageCancellation(t *testing.T) {
for _, tc := range []struct {
name string
during bool
}{{"before pack", false}, {"during local read", true}} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
t.Setenv("TMPDIR", root)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
snapshot, files := fixture()
open := localFiles(files, nil)
if tc.during {
open = func(context.Context, string) (io.ReadCloser, error) {
cancel()
return io.NopCloser(strings.NewReader("unused")), nil
}
} else {
cancel()
}
var output bytes.Buffer
err := NewGamePackageService("").Pack(ctx, snapshot, open, &output)
if !errors.Is(err, context.Canceled) || output.Len() != 0 {
t.Fatalf("got %v", err)
}
entries, _ := os.ReadDir(root)
if len(entries) != 0 {
t.Fatal("temporary files leaked")
}
})
}
}
type zipEntry struct {
name string
body []byte
mode os.FileMode
}
func rewriteArchive(t *testing.T, raw []byte, change func([]zipEntry) []zipEntry) []byte {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
entries := []zipEntry{}
for _, f := range zr.File {
r, err := f.Open()
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(r)
_ = r.Close()
if err != nil {
t.Fatal(err)
}
entries = append(entries, zipEntry{name: f.Name, body: body, mode: f.Mode()})
}
var output bytes.Buffer
zw := zip.NewWriter(&output)
for _, entry := range change(entries) {
header := &zip.FileHeader{Name: entry.name, Method: zip.Deflate}
header.SetMode(entry.mode)
w, err := zw.CreateHeader(header)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(entry.body); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
func changeManifest(entries []zipEntry, change func(*Manifest)) []zipEntry {
for i := range entries {
if entries[i].name == "manifest.json" {
var manifest Manifest
_ = json.Unmarshal(entries[i].body, &manifest)
change(&manifest)
entries[i].body, _ = json.Marshal(manifest)
}
}
return entries
}
func changeGame(entries []zipEntry, change func(*Data)) []zipEntry {
var updated FileInfo
for i := range entries {
if entries[i].name == "game.json" {
var data Data
_ = json.Unmarshal(entries[i].body, &data)
change(&data)
entries[i].body, _ = json.Marshal(data)
updated = describeBytes("game.json", "application/json", entries[i].body)
}
}
return changeManifest(entries, func(m *Manifest) {
for i := range m.Files {
if m.Files[i].Path == "game.json" {
m.Files[i] = updated
}
}
})
}
func TestUnpackRejectsDamagedPackages(t *testing.T) {
snapshot, files := fixture()
raw := buildPackage(t, NewGamePackageService(""), snapshot, files)
for _, tc := range []struct {
name string
change func([]zipEntry) []zipEntry
want error
}{
{"version", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.Version++ }) }, ErrUnsupportedVersion},
{"wrong game ID", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.GameID++ }) }, ErrInvalidPackage},
{"bad MIME", func(e []zipEntry) []zipEntry {
return changeManifest(e, func(m *Manifest) { m.Files[0].MIME = "text/html" })
}, ErrInvalidPackage},
{"invalid game relation", func(e []zipEntry) []zipEntry { return changeGame(e, func(d *Data) { d.Teams[0].GameID++ }) }, ErrInvalidPackage},
{"missing referenced file", func(e []zipEntry) []zipEntry {
return changeGame(e, func(d *Data) { d.Scenario.Image = "files/missing.png" })
}, ErrInvalidPackage},
{"external reference", func(e []zipEntry) []zipEntry {
return changeGame(e, func(d *Data) { d.Scenario.Image = "https://example.org/x" })
}, ErrInvalidPackage},
{"unreferenced file", func(e []zipEntry) []zipEntry {
return changeGame(e, func(d *Data) { d.Scenario.Story.Conclusion.Video = "" })
}, ErrInvalidPackage},
{"nil question in archive", func(e []zipEntry) []zipEntry {
return changeGame(e, func(d *Data) { d.Scenario.Story.Questions[0] = nil })
}, ErrInvalidPackage},
{"bad SHA", func(e []zipEntry) []zipEntry {
return changeManifest(e, func(m *Manifest) { m.Files[0].SHA256 = strings.Repeat("0", 64) })
}, ErrInvalidPackage},
{"bad size", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.Files[0].Size++ }) }, ErrInvalidPackage},
{"duplicate manifest path", func(e []zipEntry) []zipEntry { return changeManifest(e, func(m *Manifest) { m.Files[1] = m.Files[0] }) }, ErrInvalidPackage},
{"missing media", func(e []zipEntry) []zipEntry { return e[1:] }, ErrInvalidPackage},
{"duplicate ZIP path", func(e []zipEntry) []zipEntry { return append(e, e[0]) }, ErrInvalidPackage},
{"undeclared file", func(e []zipEntry) []zipEntry {
return append(e, zipEntry{name: "files/extra.png", body: []byte("extra"), mode: 0600})
}, ErrInvalidPackage},
{"traversal", func(e []zipEntry) []zipEntry { e[0].name = "../escape.png"; return e }, ErrInvalidPackage},
{"absolute", func(e []zipEntry) []zipEntry { e[0].name = "/escape.png"; return e }, ErrInvalidPackage},
{"backslash", func(e []zipEntry) []zipEntry { e[0].name = "files\\escape.png"; return e }, ErrInvalidPackage},
{"symlink", func(e []zipEntry) []zipEntry { e[0].mode = os.ModeSymlink | 0600; return e }, ErrInvalidPackage},
{"directory", func(e []zipEntry) []zipEntry { e[0].mode = os.ModeDir | 0700; return e }, ErrInvalidPackage},
{"missing manifest", func(e []zipEntry) []zipEntry { return e[:len(e)-1] }, ErrInvalidPackage},
{"changed media", func(e []zipEntry) []zipEntry { e[0].body[0] ^= 1; return e }, ErrInvalidPackage},
{"changed game", func(e []zipEntry) []zipEntry {
for i := range e {
if e[i].name == "game.json" {
e[i].body = append(e[i].body, ' ')
}
}
return e
}, ErrInvalidPackage},
} {
t.Run(tc.name, func(t *testing.T) {
archive := rewriteArchive(t, raw, tc.change)
parent := t.TempDir()
bundle, err := NewGamePackageService("").Unpack(context.Background(), bytes.NewReader(archive), int64(len(archive)), parent)
if bundle != nil || !errors.Is(err, tc.want) {
t.Fatalf("got %v", err)
}
entries, _ := os.ReadDir(parent)
if len(entries) != 0 {
t.Fatal("failed Unpack leaked files")
}
})
}
}
func TestUnpackLimitsAndCancellation(t *testing.T) {
snapshot, files := fixture()
raw := buildPackage(t, NewGamePackageService(""), snapshot, files)
for _, tc := range []struct {
name string
setup func(*GamePackageService)
canceled bool
want error
}{
{"archive limit", func(s *GamePackageService) { s.limits.archive = int64(len(raw)) - 1 }, false, ErrLimitExceeded},
{"file limit", func(s *GamePackageService) { s.limits.file = 5 }, false, ErrLimitExceeded},
{"JSON limit", func(s *GamePackageService) { s.limits.json = 5 }, false, ErrLimitExceeded},
{"entry limit", func(s *GamePackageService) { s.limits.entries = 2 }, false, ErrLimitExceeded},
{"cancellation", func(*GamePackageService) {}, true, context.Canceled},
} {
t.Run(tc.name, func(t *testing.T) {
service := NewGamePackageService("")
tc.setup(service)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if tc.canceled {
cancel()
}
bundle, err := service.Unpack(ctx, bytes.NewReader(raw), int64(len(raw)), t.TempDir())
if bundle != nil || !errors.Is(err, tc.want) {
t.Fatalf("got %v", err)
}
})
}
for _, tc := range []struct {
name string
mutate func([]byte)
want error
}{
{"false entry count", func(b []byte) {
binary.LittleEndian.PutUint16(b[len(b)-12:len(b)-10], 1)
binary.LittleEndian.PutUint16(b[len(b)-14:len(b)-12], 1)
}, ErrInvalidPackage},
{"ZIP64 marker", func(b []byte) {
binary.LittleEndian.PutUint16(b[len(b)-12:len(b)-10], 65535)
binary.LittleEndian.PutUint16(b[len(b)-14:len(b)-12], 65535)
}, ErrInvalidPackage},
} {
t.Run(tc.name, func(t *testing.T) {
b := bytes.Clone(raw)
tc.mutate(b)
_, err := NewGamePackageService("").Unpack(context.Background(), bytes.NewReader(b), int64(len(b)), t.TempDir())
if !errors.Is(err, tc.want) {
t.Fatalf("got %v", err)
}
})
}
}
// A generated source and a checking writer detect accidental ReadAll of large media.
type generatedMedia struct {
remaining int64
prefix []byte
largestRead int
}
func (r *generatedMedia) Read(data []byte) (int, error) {
if len(data) > r.largestRead {
r.largestRead = len(data)
}
if r.remaining == 0 {
return 0, io.EOF
}
n := int(min(int64(len(data)), r.remaining))
clear(data[:n])
if len(r.prefix) > 0 {
used := copy(data[:n], r.prefix)
r.prefix = r.prefix[used:]
}
r.remaining -= int64(n)
return n, nil
}
func (*generatedMedia) Close() error { return nil }
func TestPackStreamsMedia(t *testing.T) {
for _, size := range []int64{1 << 20, 16 << 20} {
t.Run(strconv.FormatInt(size>>20, 10)+"MiB", func(t *testing.T) {
snapshot, files := fixture()
source := &generatedMedia{remaining: size, prefix: files["music.mp3"]}
fallback := localFiles(files, nil)
open := func(ctx context.Context, name string) (io.ReadCloser, error) {
if name == "music.mp3" {
return source, nil
}
return fallback(ctx, name)
}
if err := NewGamePackageService("").Pack(context.Background(), snapshot, open, io.Discard); err != nil {
t.Fatal(err)
}
if source.largestRead > 32<<10 || source.remaining != 0 {
t.Fatal("media was not streamed")
}
})
}
}
func TestMediaClientPolicy(t *testing.T) {
for _, name := range []string{"default"} {
t.Run(name, func(t *testing.T) {
client := newMediaClient()
transport := client.Transport.(*http.Transport)
if client.Timeout != 120*time.Second || transport.Proxy != nil || !transport.DisableCompression || transport.DialContext == nil {
t.Fatal("unsafe downloader defaults")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
conn, err := transport.DialContext(ctx, "tcp", "8.8.8.8:443")
if conn != nil {
_ = conn.Close()
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v", err)
}
})
}
}
type cancelReaderAt struct {
reader io.ReaderAt
offset int64
cancel context.CancelFunc
}
func (r cancelReaderAt) ReadAt(data []byte, offset int64) (int, error) {
n, err := r.reader.ReadAt(data, offset)
if offset == r.offset {
r.cancel()
}
return n, err
}
func TestUnpackCancellationDuringExtraction(t *testing.T) {
for _, name := range []string{"media extraction"} {
t.Run(name, func(t *testing.T) {
snapshot, files := fixture()
raw := buildPackage(t, NewGamePackageService(""), snapshot, files)
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
offset, err := zr.File[0].DataOffset()
if err != nil {
t.Fatal(err)
}
parent := t.TempDir()
marker := parent + "/keep"
if err := os.WriteFile(marker, []byte("keep"), 0600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
reader := cancelReaderAt{reader: bytes.NewReader(raw), offset: offset, cancel: cancel}
bundle, err := NewGamePackageService("").Unpack(ctx, reader, int64(len(raw)), parent)
if bundle != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("got %v", err)
}
entries, err := os.ReadDir(parent)
if err != nil || len(entries) != 1 || entries[0].Name() != "keep" {
t.Fatal("failed cleanup or parent modified")
}
})
}
}
func TestPackagePreservesBothPlaylistAssignments(t *testing.T) {
for _, name := range []string{"game override and scenario default"} {
t.Run(name, func(t *testing.T) {
snapshot, files := fixture()
defaultID := 31
snapshot.Data.Scenario.PlaylistID = &defaultID
snapshot.Data.Playlists = append(snapshot.Data.Playlists, Playlist{
ID: defaultID,
Name: "Default music",
Tracks: []Track{{ID: 61, Name: "Default track", Filename: "music.mp3", Position: 5}},
})
service := NewGamePackageService("")
raw := buildPackage(t, service, snapshot, files)
bundle, err := service.Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw)), t.TempDir())
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(bundle.Dir) }()
if *bundle.Data.Game.PlaylistID != 30 || *bundle.Data.Scenario.PlaylistID != defaultID || len(bundle.Data.Playlists) != 2 ||
bundle.Data.Playlists[1].Tracks[0].Position != 5 {
t.Fatal("playlist assignments changed")
}
})
}
}
func TestRawSizeLimitCountsJSONAndMedia(t *testing.T) {
for _, name := range []string{"highly compressed media"} {
t.Run(name, func(t *testing.T) {
snapshot, files := fixture()
files["music.mp3"] = append(files["music.mp3"], make([]byte, 64<<10)...)
service := NewGamePackageService("")
raw := buildPackage(t, service, snapshot, files)
service.limits.archive = 32 << 10
if len(raw) >= int(service.limits.archive) {
t.Fatal("fixture does not distinguish ZIP and raw limits")
}
_, err := service.Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw)), t.TempDir())
if !errors.Is(err, ErrLimitExceeded) {
t.Fatalf("Unpack got %v", err)
}
var output bytes.Buffer
err = service.Pack(context.Background(), snapshot, localFiles(files, nil), &output)
if !errors.Is(err, ErrLimitExceeded) || output.Len() != 0 {
t.Fatalf("Pack got %v", err)
}
})
}
}
@@ -0,0 +1,253 @@
package game_package_service
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
)
// Unpack performs no network or database operations. Only a fully validated bundle
// is returned. Failed attempts remove their private directory, never the parent.
func (s *GamePackageService) Unpack(ctx context.Context, input io.ReaderAt, size int64, tempDir string) (bundle *Bundle, err error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if input == nil || size <= 0 {
return nil, invalid("пустой архив")
}
if size > s.limits.archive {
return nil, ErrLimitExceeded
}
if err := s.checkDirectory(input, size); err != nil {
return nil, err
}
zr, err := zip.NewReader(input, size)
if err != nil {
return nil, errors.Join(ErrInvalidPackage, err)
}
if len(zr.File) > s.limits.entries {
return nil, ErrLimitExceeded
}
entries := map[string]*zip.File{}
var total int64
for _, file := range zr.File {
if !validPath(file.Name) || !file.Mode().IsRegular() || entries[file.Name] != nil || file.Flags&1 != 0 ||
(file.Name != "manifest.json" && file.Name != "game.json" && !mediaPath(file.Name)) {
return nil, invalid("недопустимая или повторная запись ZIP")
}
limit := s.limits.file
if file.Name == "manifest.json" || file.Name == "game.json" {
limit = s.limits.json
}
if file.UncompressedSize64 > uint64(limit) || file.UncompressedSize64 > uint64(s.limits.archive-total) {
return nil, ErrLimitExceeded
}
total += int64(file.UncompressedSize64)
entries[file.Name] = file
}
manifestRaw, err := readJSONEntry(ctx, entries["manifest.json"], s.limits.json)
if err != nil {
return nil, err
}
var manifest Manifest
if err := decodeJSON(manifestRaw, &manifest); err != nil {
return nil, err
}
if manifest.Version != Version {
return nil, ErrUnsupportedVersion
}
id, idErr := hex.DecodeString(manifest.PackageID)
if idErr != nil || len(id) != 16 || manifest.CreatedAt.IsZero() || !validServer(manifest.SourceServer) || manifest.GameID <= 0 ||
len(manifest.Files) != len(entries)-1 {
return nil, invalid("некорректный манифест")
}
declared := map[string]FileInfo{}
for _, info := range manifest.Files {
file := entries[info.Path]
if info.Path == "manifest.json" || file == nil || info.Size < 0 || uint64(info.Size) != file.UncompressedSize64 ||
!validHash(info.SHA256) || info.MIME == "" {
return nil, invalid("некорректная запись манифеста")
}
if _, duplicate := declared[info.Path]; duplicate {
return nil, invalid("повторная запись манифеста")
}
declared[info.Path] = info
}
gameRaw, err := readJSONEntry(ctx, entries["game.json"], s.limits.json)
if err != nil {
return nil, err
}
gameInfo, ok := declared["game.json"]
if !ok || gameInfo.MIME != "application/json" || describeBytes("game.json", "application/json", gameRaw).SHA256 != gameInfo.SHA256 {
return nil, invalid("неверная контрольная сумма данных игры")
}
var data Data
if err := decodeJSON(gameRaw, &data); err != nil {
return nil, err
}
if data.Game.ID != manifest.GameID {
return nil, invalid("игра не соответствует манифесту")
}
if err := validateData(&data); err != nil {
return nil, err
}
used := map[string]bool{}
for _, ref := range mediaRefs(&data) {
if *ref == "" {
continue
}
if !mediaPath(*ref) || entries[*ref] == nil {
return nil, invalid("отсутствует файл по ссылке")
}
used[*ref] = true
}
if len(used)+1 != len(declared) {
return nil, invalid("пакет содержит посторонние файлы")
}
dir, err := os.MkdirTemp(tempDir, "game-package-unpack-")
if err != nil {
return nil, err
}
defer func() {
if err != nil {
_ = os.RemoveAll(dir)
}
}()
bundle = &Bundle{
Manifest: manifest,
Data: data,
Dir: dir,
Files: make(map[string]string, len(used)),
}
for name := range used {
filename, err := s.extractMedia(ctx, dir, entries[name], declared[name])
if err != nil {
return nil, err
}
bundle.Files[name] = filename
}
if err := ctx.Err(); err != nil {
return nil, err
}
return bundle, nil
}
func readJSONEntry(ctx context.Context, entry *zip.File, limit int64) ([]byte, error) {
if entry == nil {
return nil, invalid("отсутствует обязательный JSON")
}
r, err := entry.Open()
if err != nil {
return nil, errors.Join(ErrInvalidPackage, err)
}
defer func() { _ = r.Close() }()
var buffer bytes.Buffer
if _, err := copyLimited(ctx, &buffer, r, limit); err != nil {
return nil, errors.Join(ErrInvalidPackage, err)
}
return buffer.Bytes(), nil
}
func (s *GamePackageService) extractMedia(ctx context.Context, dir string, entry *zip.File, info FileInfo) (string, error) {
r, err := entry.Open()
if err != nil {
return "", errors.Join(ErrInvalidPackage, err)
}
defer func() { _ = r.Close() }()
// Generated private filenames never depend on archive paths or existing parent files.
file, err := os.CreateTemp(dir, "media-*")
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hash := sha256.New()
n, err := copyLimited(ctx, io.MultiWriter(file, hash), r, min(s.limits.file, info.Size))
if err != nil {
return "", errors.Join(ErrInvalidPackage, err)
}
if n == 0 || n != info.Size || hex.EncodeToString(hash.Sum(nil)) != info.SHA256 {
return "", invalid("неверный размер или контрольная сумма медиафайла")
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
header := make([]byte, 512)
nread, err := file.Read(header)
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
mime, ext := mediaType(header[:nread])
if ext == "" || mime != info.MIME || filepath.Ext(info.Path) != ext || info.Path != "files/"+info.SHA256+ext {
return "", invalid("содержимое файла не соответствует манифесту")
}
if err := file.Close(); err != nil {
return "", err
}
filename := file.Name() + ext
if err := os.Rename(file.Name(), filename); err != nil {
return "", err
}
return filename, nil
}
// Bound the central directory before archive/zip allocates one object per entry.
// Version 1 cannot need ZIP64: all counts and sizes fit in ordinary ZIP fields.
func (s *GamePackageService) checkDirectory(input io.ReaderAt, size int64) error {
tail := make([]byte, min(size, int64(65535+22)))
if _, err := input.ReadAt(tail, size-int64(len(tail))); err != nil {
return errors.Join(ErrInvalidPackage, err)
}
for i := len(tail) - 22; i >= 0; i-- {
if !bytes.Equal(tail[i:i+4], []byte{'P', 'K', 5, 6}) ||
i+22+int(binary.LittleEndian.Uint16(tail[i+20:i+22])) != len(tail) {
continue
}
disk := binary.LittleEndian.Uint16(tail[i+4 : i+6])
centralDisk := binary.LittleEndian.Uint16(tail[i+6 : i+8])
diskCount := binary.LittleEndian.Uint16(tail[i+8 : i+10])
count := binary.LittleEndian.Uint16(tail[i+10 : i+12])
length := binary.LittleEndian.Uint32(tail[i+12 : i+16])
offset := binary.LittleEndian.Uint32(tail[i+16 : i+20])
if disk != 0 || centralDisk != 0 || diskCount != count || count == 65535 || offset == 0xffffffff || length == 0xffffffff {
return invalid("ZIP64 и многотомные архивы не поддерживаются")
}
if int(count) > s.limits.entries || int64(length) > s.limits.json {
return ErrLimitExceeded
}
if int64(offset)+int64(length) != size-int64(len(tail))+int64(i) {
return invalid("некорректный каталог ZIP")
}
// Every central header is at least 46 bytes; checkDirectory cannot trust count alone.
central := make([]byte, int(length))
if _, err := input.ReadAt(central, int64(offset)); err != nil {
return errors.Join(ErrInvalidPackage, err)
}
actual := 0
for len(central) > 0 {
if len(central) < 46 || !bytes.Equal(central[:4], []byte{'P', 'K', 1, 2}) {
return invalid("некорректная запись каталога ZIP")
}
n := 46 + int(binary.LittleEndian.Uint16(central[28:30])) + int(binary.LittleEndian.Uint16(central[30:32])) + int(binary.LittleEndian.Uint16(central[32:34]))
if n > len(central) {
return invalid("обрезанная запись каталога ZIP")
}
central = central[n:]
actual++
if actual > s.limits.entries {
return ErrLimitExceeded
}
}
if actual != int(count) {
return invalid("неверное число записей ZIP")
}
return nil
}
return invalid("отсутствует каталог ZIP")
}
@@ -0,0 +1,197 @@
package game_package_service
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"path"
"strings"
"unicode"
)
func invalid(reason string) error {
return fmt.Errorf("%w: %s", ErrInvalidPackage, reason)
}
func validateData(data *Data) error {
g := data.Game
if g.ID <= 0 || strings.TrimSpace(g.Name) == "" || g.StartAt.IsZero() ||
g.Status != "draft" || g.StartedAt != nil || g.EndedAt != nil ||
(g.Format != "online" && g.Format != "offline") {
return invalid("игра должна быть подготовлена и не начата")
}
s := data.Scenario
if s.ID <= 0 || s.ID != g.ScenarioID || strings.TrimSpace(s.Name) == "" || s.Story == nil || !validUserID(s.AuthorID) {
return invalid("некорректный сценарий")
}
places := map[string]bool{}
for _, place := range s.Story.Places {
if place == nil {
return invalid("пустое место сценария")
}
if place.Code == "" || places[place.Code] {
return invalid("пустой или повторный код места")
}
places[place.Code] = true
for _, application := range place.Applications {
if application == nil {
return invalid("пустая улика сценария")
}
}
for _, key := range place.Keys {
if key == nil {
return invalid("пустой ключ сценария")
}
}
}
for _, place := range s.Story.Places {
for _, door := range place.Doors {
if door == nil || !places[door.Code] {
return invalid("дверь ведёт в отсутствующее место")
}
for _, key := range door.Keys {
if key == nil {
return invalid("пустой ключ двери")
}
}
}
}
questions := map[string]bool{}
for _, question := range s.Story.Questions {
if question == nil || question.Code == "" || questions[question.Code] {
return invalid("пустой или повторный вопрос")
}
questions[question.Code] = true
}
teams := map[int]bool{}
deliveries := map[string]bool{}
for _, team := range data.Teams {
if team.ID <= 0 || teams[team.ID] || team.GameID != g.ID || strings.TrimSpace(team.Name) == "" ||
team.Password == "" || team.Status != "register" ||
team.ParticipantsCount < 0 || !validUserID(team.CreatorID) ||
(team.ParticipationStatus != "pending" && team.ParticipationStatus != "confirmed" && team.ParticipationStatus != "cancelled") {
return invalid("некорректная команда")
}
if team.RegistrationDeliveryID != nil {
if deliveries[*team.RegistrationDeliveryID] {
return invalid("повторная регистрация команды")
}
deliveries[*team.RegistrationDeliveryID] = true
}
teams[team.ID] = true
}
tickets := map[int]bool{}
hashes := map[string]bool{}
for _, ticket := range data.Tickets {
if ticket.ID <= 0 || tickets[ticket.ID] || ticket.GameID != g.ID || len(ticket.TokenHash) != sha256.Size ||
hashes[string(ticket.TokenHash)] || !validUserID(ticket.UsedBy) ||
(ticket.Status != "active" && ticket.Status != "used") ||
(ticket.Status == "active" && (ticket.UsedAt != nil || ticket.UsedBy != nil)) ||
(ticket.Status == "used" && ticket.UsedAt == nil) {
return invalid("некорректный билет")
}
tickets[ticket.ID] = true
hashes[string(ticket.TokenHash)] = true
}
playlists := map[int]bool{}
tracks := map[int]bool{}
for _, playlist := range data.Playlists {
if playlist.ID <= 0 || playlists[playlist.ID] || strings.TrimSpace(playlist.Name) == "" {
return invalid("некорректный плейлист")
}
playlists[playlist.ID] = true
for _, track := range playlist.Tracks {
if track.ID <= 0 || tracks[track.ID] || strings.TrimSpace(track.Name) == "" || track.Filename == "" || track.Position < 0 {
return invalid("некорректный трек")
}
tracks[track.ID] = true
}
}
for _, id := range []*int{g.PlaylistID, s.PlaylistID} {
if id != nil && !playlists[*id] {
return invalid("отсутствует назначенный плейлист")
}
}
for id := range playlists {
if (g.PlaylistID == nil || *g.PlaylistID != id) && (s.PlaylistID == nil || *s.PlaylistID != id) {
return invalid("посторонний плейлист")
}
}
if plan := data.ScreenPlan; plan != nil {
if plan.GameID != g.ID || plan.CurrentStep < 0 ||
(len(plan.Steps) == 0 && plan.CurrentStep != 0) ||
(len(plan.Steps) > 0 && plan.CurrentStep >= len(plan.Steps)) {
return invalid("некорректный план экранов")
}
for _, step := range plan.Steps {
if step.Mode < 1 || step.Mode > 8 ||
(step.Mode == 7 && (step.TimerDurationSeconds <= 0 || step.TimerDurationSeconds > 86400 || len(step.TimerLabel) > 200)) ||
(step.Mode == 8 && (strings.TrimSpace(step.Text) == "" || len(step.Text) > 10000)) {
return invalid("некорректный шаг плана экранов")
}
}
}
items := map[string]bool{}
for _, item := range data.Map {
key := fmt.Sprintf("%s/%d", item.Kind, item.ItemID)
if item.GameID != g.ID || items[key] ||
(item.Kind != "team" && item.Kind != "organizer_desk") ||
(item.Kind == "team" && !teams[item.ItemID]) || item.ItemID <= 0 ||
(item.Kind == "organizer_desk" && item.ItemID != 1) {
return invalid("некорректный объект карты")
}
items[key] = true
}
return nil
}
func validUserID(id *int) bool {
return id == nil || *id > 0
}
func validServer(server string) bool {
u, err := url.Parse(server)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
u.User == nil && u.RawQuery == "" && u.Fragment == ""
}
func validPath(name string) bool {
return name != "" && name != "." && !strings.HasPrefix(name, "/") &&
!strings.ContainsAny(name, "\\:") && path.Clean(name) == name &&
!strings.Contains(name, "../") && name != ".." &&
strings.IndexFunc(name, unicode.IsControl) < 0
}
func mediaPath(name string) bool {
return validPath(name) && strings.HasPrefix(name, "files/") && strings.Count(name, "/") == 1
}
func validHash(hash string) bool {
b, err := hex.DecodeString(hash)
return err == nil && len(b) == sha256.Size && hash == strings.ToLower(hash)
}
// mediaRefs returns writable references only after validateData has checked pointers.
func mediaRefs(data *Data) []*string {
story := data.Scenario.Story
refs := []*string{&data.Scenario.Image, &story.Background}
if story.Introduction != nil {
refs = append(refs, &story.Introduction.Audio)
}
if story.Conclusion != nil {
refs = append(refs, &story.Conclusion.Video)
}
for _, place := range story.Places {
refs = append(refs, &place.Image)
for _, application := range place.Applications {
refs = append(refs, &application.Image)
}
}
for i := range data.Playlists {
for j := range data.Playlists[i].Tracks {
refs = append(refs, &data.Playlists[i].Tracks[j].Filename)
}
}
return refs
}