Files
2026-08-21 00:09:32 +07:00

326 lines
11 KiB
Go

// Пакет 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"
)
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
var (
maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива
maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
maxEntries = 500 // число записей
maxEntrySize = 32 << 20 // 32 МБ — размер одной записи
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
)
type scenarioArchive struct{}
// NewScenarioArchive создаёт реализацию IScenarioArchive.
func NewScenarioArchive() IScenarioArchive {
return &scenarioArchive{}
}
// MaxArchiveSize — максимальный размер входящего архива (для проверок
// в хендлере и на HTTP-слое).
func MaxArchiveSize() int {
return maxArchiveSize
}
// 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 (a *scenarioArchive) 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 (a *scenarioArchive) 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
}