generated from VLADIMIR/template
up
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user