generated from VLADIMIR/template
add zip
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
// Пакет 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
|
||||
}
|
||||
Reference in New Issue
Block a user