diff --git a/docs/GAME_PACKAGE.md b/docs/GAME_PACKAGE.md new file mode 100644 index 0000000..461d98f --- /dev/null +++ b/docs/GAME_PACKAGE.md @@ -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/.` | Медиа с именем по 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/?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 ./...`. Тесты покрывают сохранение данных, автономное чтение, загрузку +медиа, дедупликацию, потоковую обработку, отмену, очистку, лимиты и повреждённые пакеты. diff --git a/internal/services/game_package_service/download.go b/internal/services/game_package_service/download.go new file mode 100644 index 0000000..949126e --- /dev/null +++ b/internal/services/game_package_service/download.go @@ -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 +} diff --git a/internal/services/game_package_service/model.go b/internal/services/game_package_service/model.go new file mode 100644 index 0000000..e4c0841 --- /dev/null +++ b/internal/services/game_package_service/model.go @@ -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 +} diff --git a/internal/services/game_package_service/service.go b/internal/services/game_package_service/service.go new file mode 100644 index 0000000..1e95696 --- /dev/null +++ b/internal/services/game_package_service/service.go @@ -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] +} diff --git a/internal/services/game_package_service/service_test.go b/internal/services/game_package_service/service_test.go new file mode 100644 index 0000000..3ec74c0 --- /dev/null +++ b/internal/services/game_package_service/service_test.go @@ -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("not music") + }, 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) + } + }) + } +} diff --git a/internal/services/game_package_service/unpack.go b/internal/services/game_package_service/unpack.go new file mode 100644 index 0000000..0c79022 --- /dev/null +++ b/internal/services/game_package_service/unpack.go @@ -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") +} diff --git a/internal/services/game_package_service/validation.go b/internal/services/game_package_service/validation.go new file mode 100644 index 0000000..4873033 --- /dev/null +++ b/internal/services/game_package_service/validation.go @@ -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 +}