// Пакет scenario_archive — сборка и разбор ZIP-архива сценария. // Архив: scenario.json (описание и история) + images/<имя> (файлы изображений). // Все пути внутри архива относительные. package scenario_archive import ( "archive/zip" "bytes" "context" "encoding/json" "errors" "fmt" "io" "path" "sort" "strings" "time" "evening_detective_server/internal/modules/storytelling" "evening_detective_server/internal/repos" ) // Имя файла с описанием сценария внутри архива. const FileName = "scenario.json" // Текущая версия формата scenario.json. const Version = 1 // Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается // по фактически распакованным байтам — размеры в заголовках ZIP подделываются. var ( maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного maxEntries = 500 // число записей maxEntrySize = 32 << 20 // 32 МБ — размер одной записи maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке ) // MaxArchiveSize — максимальный размер входящего архива (для проверок // в хендлере и на HTTP-слое). func MaxArchiveSize() int { return maxArchiveSize } // ScenarioJSON — описание сценария в архиве; Story — в формате колонки // scenarios.scenario в БД. type ScenarioJSON struct { Version int `json:"version"` Name string `json:"name"` Description string `json:"description,omitempty"` Image string `json:"image,omitempty"` Story *storytelling.Story `json:"story"` } // Bundle — результат разбора входящего архива. type Bundle struct { Scenario *ScenarioJSON Files map[string][]byte } // ImagePath возвращает путь изображения внутри архива (images/<имя>) и true // для ссылок нашего хранилища: относительное имя или URL с доменом (legacy). // Внешние URL и пустые ссылки — ("", false): в архив не кладутся. func ImagePath(ref, domain string) (string, bool) { ref = strings.TrimPrefix(ref, domain) if ref == "" { return "", false } low := strings.ToLower(ref) if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") { return "", false } return "images/" + path.Base(ref), true } // Pack собирает ZIP-архив сценария: ссылки на изображения хранилища // переписываются в images/<имя>, файлы запрашиваются через getFile; внешние // URL остаются без изменений. Архив детерминирован (порядок записей и метки). func Pack( ctx context.Context, scenario *repos.Scenario, domain string, getFile func(ctx context.Context, name string) ([]byte, error), ) ([]byte, error) { if scenario == nil { return nil, errors.New("сценарий не задан") } story := &storytelling.Story{} if scenario.Scenario != "" { if err := json.Unmarshal([]byte(scenario.Scenario), story); err != nil { return nil, fmt.Errorf("не удалось разобрать историю сценария: %w", err) } } // Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка. archivePathOf := map[string]string{} addRef := func(ref string) error { if ref == "" { return nil } archivePath, ok := ImagePath(ref, domain) if !ok { return nil } if _, exists := archivePathOf[ref]; exists { return nil } for storageName, existingPath := range archivePathOf { if existingPath == archivePath { return fmt.Errorf("конфликт имён изображений в архиве: %q и %q", storageName, ref) } } archivePathOf[ref] = archivePath return nil } if scenario.Image != nil { if err := addRef(*scenario.Image); err != nil { return nil, err } } for _, place := range story.Places { if err := addRef(place.Image); err != nil { return nil, err } for _, application := range place.Applications { if err := addRef(application.Image); err != nil { return nil, err } } } // Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом. images := make(map[string][]byte, len(archivePathOf)) var totalPacked int64 for ref, archivePath := range archivePathOf { storageName := strings.TrimPrefix(ref, domain) data, err := getFile(ctx, storageName) if err != nil { return nil, fmt.Errorf("не удалось получить изображение %q: %w", storageName, err) } totalPacked += int64(len(data)) if totalPacked > int64(maxPackSize) { return nil, fmt.Errorf("суммарный объём изображений больше %d байт", maxPackSize) } images[archivePath] = data } for _, place := range story.Places { place.Image = rewriteRef(place.Image, archivePathOf) for _, application := range place.Applications { application.Image = rewriteRef(application.Image, archivePathOf) } } doc := ScenarioJSON{ Version: Version, Name: scenario.Name, Description: strPtrValue(scenario.Description), Story: story, } if scenario.Image != nil { doc.Image = rewriteRef(*scenario.Image, archivePathOf) } docJSON, err := json.Marshal(doc) if err != nil { return nil, fmt.Errorf("не удалось собрать %s: %w", FileName, err) } var buf bytes.Buffer zw := zip.NewWriter(&buf) if err := writeZipEntry(zw, FileName, docJSON); err != nil { return nil, err } names := make([]string, 0, len(images)) for name := range images { names = append(names, name) } sort.Strings(names) for _, name := range names { if err := writeZipEntry(zw, name, images[name]); err != nil { return nil, err } } if err := zw.Close(); err != nil { return nil, fmt.Errorf("не удалось закрыть архив: %w", err) } return buf.Bytes(), nil } func writeZipEntry(zw *zip.Writer, name string, data []byte) error { header := &zip.FileHeader{ Name: name, Method: zip.Deflate, } // Нулевое время — детерминизм архива между запусками. header.Modified = time.Time{} w, err := zw.CreateHeader(header) if err != nil { return fmt.Errorf("не удалось создать запись %q: %w", name, err) } if _, err := w.Write(data); err != nil { return fmt.Errorf("не удалось записать %q: %w", name, err) } return nil } func rewriteRef(ref string, archivePathOf map[string]string) string { if ref == "" { return "" } if archivePath, ok := archivePathOf[ref]; ok { return archivePath } return ref } func strPtrValue(p *string) string { if p == nil { return "" } return *p } // Unpack разбирает входящий архив с проверкой лимитов и путей записей. func Unpack(data []byte) (*Bundle, error) { if len(data) == 0 { return nil, errors.New("архив пуст") } if len(data) > maxArchiveSize { return nil, fmt.Errorf("архив больше максимального размера (%d байт)", maxArchiveSize) } zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, fmt.Errorf("не удалось открыть архив: %w", err) } if len(zr.File) > maxEntries { return nil, fmt.Errorf("в архиве больше %d записей", maxEntries) } bundle := &Bundle{Files: make(map[string][]byte, len(zr.File))} var totalUnpacked int64 var scenarioRaw []byte for _, f := range zr.File { name := f.Name if f.FileInfo().IsDir() { continue } if err := validateEntryPath(name); err != nil { return nil, err } rc, err := f.Open() if err != nil { return nil, fmt.Errorf("не удалось открыть запись %q: %w", name, err) } content, err := readEntry(rc) rc.Close() if err != nil { return nil, fmt.Errorf("запись %q: %w", name, err) } totalUnpacked += int64(len(content)) if totalUnpacked > int64(maxUnpackedSize) { return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize) } // Дубли записей отвергаем — «последний победил» портит данные. if name == FileName { if scenarioRaw != nil { return nil, fmt.Errorf("в архиве несколько записей %s", FileName) } scenarioRaw = content continue } if _, exists := bundle.Files[name]; exists { return nil, fmt.Errorf("в архиве несколько записей %q", name) } bundle.Files[name] = content } if scenarioRaw == nil { return nil, fmt.Errorf("в архиве нет файла %s", FileName) } scenario := &ScenarioJSON{} if err := json.Unmarshal(scenarioRaw, scenario); err != nil { return nil, fmt.Errorf("не удалось разобрать %s: %w", FileName, err) } if scenario.Version != Version { return nil, fmt.Errorf("неподдерживаемая версия архива: %d", scenario.Version) } if scenario.Name == "" { return nil, errors.New("в сценарии не указано название") } if scenario.Story == nil { return nil, errors.New("в сценарии не указана история") } bundle.Scenario = scenario return bundle, nil } // readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP // подделываются тривиально). func readEntry(r io.Reader) ([]byte, error) { lr := io.LimitReader(r, int64(maxEntrySize)+1) content, err := io.ReadAll(lr) if err != nil { return nil, fmt.Errorf("не удалось прочитать: %w", err) } if int64(len(content)) > int64(maxEntrySize) { return nil, fmt.Errorf("размер больше максимального (%d байт)", maxEntrySize) } return content, nil } // validateEntryPath отклоняет небезопасные пути записей. func validateEntryPath(name string) error { if name == "" { return errors.New("пустое имя записи в архиве") } if strings.Contains(name, "\\") { return fmt.Errorf("недопустимое имя записи в архиве: %q", name) } if strings.HasPrefix(name, "/") { return fmt.Errorf("недопустимое имя записи в архиве: %q", name) } for _, part := range strings.Split(name, "/") { if part == ".." { return fmt.Errorf("недопустимое имя записи в архиве: %q", name) } } if clean := path.Clean(name); clean != name { return fmt.Errorf("недопустимое имя записи в архиве: %q", name) } return nil }