generated from VLADIMIR/template
326 lines
11 KiB
Go
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
|
|
}
|