generated from VLADIMIR/template
fix
This commit is contained in:
@@ -36,7 +36,6 @@ func NewSender(
|
||||
replyTo string,
|
||||
timeout time.Duration,
|
||||
) IEmailSender {
|
||||
// From заголовка письма по умолчанию совпадает с учётной записью SMTP.
|
||||
from := smtpUser
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
@@ -50,18 +49,11 @@ func NewSender(
|
||||
fromName: sanitizeHeader(fromName),
|
||||
replyTo: sanitizeHeader(replyTo),
|
||||
timeout: timeout,
|
||||
// Проверка имени сервера включена всегда; поле переопределяется
|
||||
// только в тестах (свой RootCAs для самоподписанного сертификата).
|
||||
tlsConfig: &tls.Config{ServerName: smtpHost},
|
||||
tlsConfig: &tls.Config{ServerName: smtpHost},
|
||||
}
|
||||
}
|
||||
|
||||
// Send доставляет письмо через SMTP (implicit TLS) с учётом контекста и
|
||||
// таймаута: соединение устанавливается через DialContext, а все фазы SMTP-
|
||||
// диалога ограничены общим deadline, производным от ctx.
|
||||
func (s *sender) Send(ctx context.Context, message Message) error {
|
||||
// Санитизация пользовательского ввода: CR/LF/NUL в заголовках ломают
|
||||
// формат письма и позволяют инъекцию произвольных заголовков (Bcc и т.п.).
|
||||
to := sanitizeHeader(message.To)
|
||||
subject := sanitizeHeader(message.Subject)
|
||||
if err := validateMessage(to, subject); err != nil {
|
||||
@@ -81,8 +73,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
|
||||
|
||||
conn := tls.Client(rawConn, s.tlsConfig)
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
// Единый бюджет времени на весь SMTP-диалог: зависший сервер не
|
||||
// должен держать запрос дольше таймаута.
|
||||
if err = conn.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("email: set deadline: %w", err)
|
||||
}
|
||||
@@ -92,8 +82,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("email: smtp greeting: %w", err)
|
||||
}
|
||||
// Close гарантированно закрывает соединение при раннем выходе; в конце
|
||||
// штатного пути вместо него вызывается Quit.
|
||||
defer client.Close() //nolint:errcheck
|
||||
|
||||
auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
||||
@@ -123,12 +111,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMessage собирает тело письма. Заголовки обязаны быть ASCII, поэтому
|
||||
// не-ASCII значения (имя отправителя, тема) кодируются по RFC 2047, иначе
|
||||
// кириллица может быть испорчена промежуточными серверами. Заголовки
|
||||
// Date и Message-ID добавляются явно: их отсутствие — типичный признак
|
||||
// спама для почтовых фильтров (включая Gmail). Если задана HTML-версия,
|
||||
// письмо собирается как multipart/alternative (text/plain + text/html).
|
||||
func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byte {
|
||||
var b strings.Builder
|
||||
b.Grow(len(from) + len(fromName) + len(to) + len(subject) + len(body) + len(html) + 512)
|
||||
@@ -190,7 +172,6 @@ func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byt
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// messageID генерирует уникальный Message-ID в домене отправителя.
|
||||
func messageID(from string) string {
|
||||
domain := ""
|
||||
if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 {
|
||||
@@ -202,8 +183,6 @@ func messageID(from string) string {
|
||||
return "<" + randomHex(12) + "@" + domain + ">"
|
||||
}
|
||||
|
||||
// randomHex возвращает случайную hex-строку (криптостойкий генератор;
|
||||
// при сбое — значение из таймера, чтобы отправка не падала).
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -212,15 +191,11 @@ func randomHex(n int) string {
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// sanitizeHeader удаляет символы, ломающие структуру заголовков письма
|
||||
// (CRLF-инъекция заголовков, NUL).
|
||||
func sanitizeHeader(s string) string {
|
||||
r := strings.NewReplacer("\r", "", "\n", "", "\x00", "")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// validateMessage проверяет адрес получателя и наличие темы до обращения к
|
||||
// SMTP-серверу.
|
||||
func validateMessage(to, subject string) error {
|
||||
if _, err := mail.ParseAddress(to); err != nil {
|
||||
return fmt.Errorf("email: некорректный адрес получателя: %w", err)
|
||||
|
||||
@@ -16,11 +16,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// startSMTPTestServer поднимает минимальный SMTP-сервер (implicit TLS) на
|
||||
// случайном порту 127.0.0.1 и возвращает его адрес и канал с принятыми
|
||||
// телами писем. При hang=true сервер отправляет приветствие и молчит —
|
||||
// для проверки таймаутов. Сертификат генерируется один на тест и
|
||||
// передаётся и серверу, и клиенту (testSender).
|
||||
func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr string, messages chan []byte) {
|
||||
t.Helper()
|
||||
|
||||
@@ -41,7 +36,6 @@ func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr st
|
||||
}
|
||||
if hang {
|
||||
go func() {
|
||||
// Приветствие отправляем, дальше молчим.
|
||||
_, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n"))
|
||||
}()
|
||||
continue
|
||||
@@ -53,7 +47,6 @@ func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr st
|
||||
return ln.Addr().String(), messages
|
||||
}
|
||||
|
||||
// handleSMTPConn обслуживает один SMTP-диалог, достаточный для net/smtp:
|
||||
// EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT.
|
||||
func handleSMTPConn(conn net.Conn, messages chan<- []byte) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
@@ -102,9 +95,6 @@ func handleSMTPConn(conn net.Conn, messages chan<- []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и
|
||||
// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы
|
||||
// тест мог добавить его в корни доверия клиента.
|
||||
func testTLSCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
@@ -133,8 +123,6 @@ func testTLSCert(t *testing.T) tls.Certificate {
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}
|
||||
}
|
||||
|
||||
// testSender собирает sender, доверяющий самоподписанному сертификату
|
||||
// тестового SMTP-сервера (в проде NewSender использует системные корни).
|
||||
func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certificate) *sender {
|
||||
t.Helper()
|
||||
host, port := splitHostPort(t, addr)
|
||||
@@ -183,7 +171,6 @@ func TestSendSuccess(t *testing.T) {
|
||||
case data := <-messages:
|
||||
raw := string(data)
|
||||
for _, want := range []string{
|
||||
// From с отображаемым именем (RFC 2047).
|
||||
"From: =?utf-8?b?",
|
||||
"<sender@example.com>",
|
||||
"To: user@example.com",
|
||||
@@ -213,8 +200,6 @@ func TestSendRejectsCRLFInjection(t *testing.T) {
|
||||
addr, messages := startSMTPTestServer(t, false, cert)
|
||||
s := testSender(t, addr, 5*time.Second, cert)
|
||||
|
||||
// CRLF в адресе — попытка инъекции заголовков (Bcc и т.п.): письмо
|
||||
// должно быть отклонено до обращения к SMTP-серверу.
|
||||
err := s.Send(context.Background(), Message{
|
||||
To: "user@example.com\r\nBcc: victim@example.com",
|
||||
Subject: "Test",
|
||||
@@ -250,7 +235,7 @@ func TestSendContextCancelled(t *testing.T) {
|
||||
|
||||
func TestSendTimeout(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия
|
||||
addr, _ := startSMTPTestServer(t, true, cert)
|
||||
s := testSender(t, addr, 500*time.Millisecond, cert)
|
||||
|
||||
start := time.Now()
|
||||
@@ -264,7 +249,6 @@ func TestSendTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildMessage(t *testing.T) {
|
||||
// С HTML-версией — multipart/alternative.
|
||||
raw := string(buildMessage(
|
||||
"sender@example.com", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "support@example.com",
|
||||
@@ -290,7 +274,6 @@ func TestBuildMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Без HTML-версии — только text/plain (обратная совместимость).
|
||||
raw = string(buildMessage(
|
||||
"sender@example.com", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "",
|
||||
|
||||
@@ -10,7 +10,6 @@ func ButtonHTML(link, label string) string {
|
||||
</td></tr></table>`
|
||||
}
|
||||
|
||||
// WrapHTML возвращает общую светлую/тёмную HTML-обёртку для писем сервиса.
|
||||
func WrapHTML(content, baseURL string) string {
|
||||
fontURL := ""
|
||||
if baseURL != "" {
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrFileNotFound — файл не найден в хранилище. Возвращается Get для
|
||||
// отсутствующих объектов (маппинг smithy-ошибок NoSuchKey/NotFound).
|
||||
var ErrFileNotFound = errors.New("файл не найден")
|
||||
|
||||
// File describes a stored file.
|
||||
@@ -28,10 +26,6 @@ type IFileStorage interface {
|
||||
MimeType(filename string) string
|
||||
}
|
||||
|
||||
// FileType возвращает категорию файла по расширению имени: pdf → "pdf",
|
||||
// jpg/jpeg/png/gif/webp → "image", mp3/ogg/wav → "audio", mp4/webm → "video", иначе "".
|
||||
// Регистр расширения и query-параметры игнорируются.
|
||||
// Используется для деривации file_type улик (см. storytelling.Application).
|
||||
func FileType(filename string) string {
|
||||
ext := strings.ToLower(extension(filename))
|
||||
switch ext {
|
||||
@@ -48,9 +42,6 @@ func FileType(filename string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// NewName возвращает случайное имя файла в хранилище (hex-токен 16 байт
|
||||
// без расширения): исключает коллизии в общем бакете. Расширение добавляет
|
||||
// вызывающий код.
|
||||
func NewName() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
|
||||
@@ -59,8 +59,6 @@ func (s *storage) Put(ctx context.Context, file *File) error {
|
||||
func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
|
||||
data, err := s.client.GetObjectBytes(ctx, s.bucket, filename)
|
||||
if err != nil {
|
||||
// s3kit отдаёт сырые smithy-ошибки: маппим отсутствие объекта
|
||||
// (NoSuchKey/NotFound) в sentinel для прикладного слоя.
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
@@ -88,8 +86,6 @@ func (s *storage) MimeType(filename string) string {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// mimeFor — Content-Type для Put: явный Mime файла приоритетнее вывода из
|
||||
// расширения, чтобы объект в S3 соответствовал фактическому содержимому.
|
||||
func mimeFor(f *File) string {
|
||||
if f.Mime != "" {
|
||||
return f.Mime
|
||||
@@ -97,10 +93,6 @@ func mimeFor(f *File) string {
|
||||
return mimeFromData(f.Data, f.Name)
|
||||
}
|
||||
|
||||
// mimeFromData — Content-Type по содержимому (первые 512 байт) с запасным вариантом
|
||||
// на расширение. text/* (например, HTML) трактуется как octet-stream и
|
||||
// уходит в запасной вариант: при публичном скачивании не должно отдаваться как
|
||||
// html с доверенного домена.
|
||||
func mimeFromData(data []byte, filename string) string {
|
||||
detected := http.DetectContentType(data)
|
||||
if strings.HasPrefix(detected, "text/") || detected == "application/octet-stream" {
|
||||
@@ -112,8 +104,6 @@ func mimeFromData(data []byte, filename string) string {
|
||||
return detected
|
||||
}
|
||||
|
||||
// extension — расширение имени файла в нижнем регистре, без query/fragment
|
||||
// (например, "photo.PNG?v=2" → ".png").
|
||||
func extension(filename string) string {
|
||||
if i := strings.IndexAny(filename, "?#"); i >= 0 {
|
||||
filename = filename[:i]
|
||||
|
||||
@@ -48,7 +48,6 @@ func TestNewName(t *testing.T) {
|
||||
if a == b {
|
||||
t.Errorf("NewName вернул одинаковые имена %q и %q", a, b)
|
||||
}
|
||||
// hex-токен 16 байт = 32 символа
|
||||
if len(a) != 32 {
|
||||
t.Errorf("длина имени = %d, want 32", len(a))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package network_utils
|
||||
|
||||
import "net"
|
||||
|
||||
// GetLocalIPv4Addresses возвращает IPv4-адреса хоста, кроме loopback-адресов.
|
||||
func GetLocalIPv4Addresses() ([]string, error) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
|
||||
@@ -10,7 +10,6 @@ func ContextWithClaims(ctx context.Context, claims *JWTClaims) context.Context {
|
||||
return context.WithValue(ctx, claimsContextKey{}, claims)
|
||||
}
|
||||
|
||||
// ClaimsFromContext возвращает данные аутентификации, сохранённые в ctx.
|
||||
func ClaimsFromContext(ctx context.Context) (*JWTClaims, bool) {
|
||||
claims, ok := ctx.Value(claimsContextKey{}).(*JWTClaims)
|
||||
return claims, ok && claims != nil
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Package roles defines application roles and role checks.
|
||||
package roles
|
||||
|
||||
// Роли пользователей системы.
|
||||
const (
|
||||
Admin = "admin"
|
||||
Author = "author"
|
||||
@@ -9,7 +8,6 @@ const (
|
||||
User = "user"
|
||||
)
|
||||
|
||||
// AllRoles — список всех ролей.
|
||||
var AllRoles = []string{
|
||||
Admin,
|
||||
Author,
|
||||
@@ -17,7 +15,6 @@ var AllRoles = []string{
|
||||
User,
|
||||
}
|
||||
|
||||
// IHasRole — контракт субъекта, у которого можно проверить наличие роли.
|
||||
type IHasRole interface {
|
||||
HasRole(role string) bool
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package roles
|
||||
|
||||
// HasRole возвращает true, если у субъекта есть роль; администратор
|
||||
// проходит проверку любой роли.
|
||||
func HasRole(claims IHasRole, role string) bool {
|
||||
if claims.HasRole(Admin) {
|
||||
return true
|
||||
|
||||
@@ -2,7 +2,6 @@ package roles
|
||||
|
||||
import "slices"
|
||||
|
||||
// subject — реализация IHasRole: субъект с фиксированным набором ролей.
|
||||
type subject struct {
|
||||
roles []string
|
||||
}
|
||||
@@ -12,7 +11,6 @@ func NewSubject(roles ...string) IHasRole {
|
||||
return &subject{roles: roles}
|
||||
}
|
||||
|
||||
// HasRole возвращает true, если роль есть в наборе ролей субъекта.
|
||||
func (s *subject) HasRole(role string) bool {
|
||||
return slices.Contains(s.roles, role)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Package scenario_archive собирает и разбирает ZIP-архивы сценариев.
|
||||
// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений).
|
||||
// Все пути внутри архива относительные.
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
@@ -19,32 +16,24 @@ import (
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
|
||||
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
|
||||
var (
|
||||
maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива
|
||||
maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
|
||||
maxEntries = 500 // число записей
|
||||
maxEntrySize = 32 << 20 // 32 МБ — размер одной записи
|
||||
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
|
||||
maxArchiveSize = 32 << 20
|
||||
maxUnpackedSize = 128 << 20
|
||||
maxEntries = 500
|
||||
maxEntrySize = 32 << 20
|
||||
maxPackSize = 256 << 20
|
||||
)
|
||||
|
||||
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 == "" {
|
||||
@@ -57,9 +46,6 @@ func ImagePath(ref, domain string) (string, bool) {
|
||||
return "images/" + path.Base(ref), true
|
||||
}
|
||||
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
|
||||
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
|
||||
func (a *scenarioArchive) Pack(
|
||||
ctx context.Context,
|
||||
scenario *Scenario,
|
||||
@@ -77,7 +63,6 @@ func (a *scenarioArchive) Pack(
|
||||
}
|
||||
}
|
||||
|
||||
// Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка.
|
||||
archivePathOf := map[string]string{}
|
||||
addRef := func(ref string) error {
|
||||
if ref == "" {
|
||||
@@ -127,7 +112,6 @@ func (a *scenarioArchive) Pack(
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом.
|
||||
images := make(map[string][]byte, len(archivePathOf))
|
||||
var totalPacked int64
|
||||
for ref, archivePath := range archivePathOf {
|
||||
@@ -197,7 +181,6 @@ func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
// Нулевое время — детерминизм архива между запусками.
|
||||
header.Modified = time.Time{}
|
||||
w, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
@@ -226,7 +209,6 @@ func strPtrValue(p *string) string {
|
||||
return *p
|
||||
}
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("архив пуст")
|
||||
@@ -273,7 +255,6 @@ func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||
return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize)
|
||||
}
|
||||
|
||||
// Дубли записей отвергаем — «последний победил» портит данные.
|
||||
if name == FileName {
|
||||
if scenarioRaw != nil {
|
||||
return nil, fmt.Errorf("в архиве несколько записей %s", FileName)
|
||||
@@ -309,8 +290,6 @@ func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP
|
||||
// подделываются тривиально).
|
||||
func readEntry(r io.Reader) ([]byte, error) {
|
||||
lr := io.LimitReader(r, int64(maxEntrySize)+1)
|
||||
content, err := io.ReadAll(lr)
|
||||
@@ -323,7 +302,6 @@ func readEntry(r io.Reader) ([]byte, error) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// validateEntryPath отклоняет небезопасные пути записей.
|
||||
func validateEntryPath(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("пустое имя записи в архиве")
|
||||
|
||||
@@ -90,7 +90,6 @@ func TestPackUnpackRoundTrip(t *testing.T) {
|
||||
t.Errorf("в архиве нет файла %q", want)
|
||||
}
|
||||
}
|
||||
// Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз.
|
||||
if len(bundle.Files) != 3 {
|
||||
t.Errorf("Files = %v, want 3 files", bundle.Files)
|
||||
}
|
||||
@@ -176,7 +175,6 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPackLegacyFullURLImage(t *testing.T) {
|
||||
// Устаревшие данные: полный URL хранилища срезается до базового имени файла.
|
||||
description := "desc"
|
||||
scenario := &Scenario{
|
||||
Name: "Legacy",
|
||||
@@ -438,7 +436,6 @@ func TestUnpackRejectsDuplicateFiles(t *testing.T) {
|
||||
name = "images/x.png"
|
||||
content = []byte("x")
|
||||
case 2:
|
||||
// Дубль записи images/x.png.
|
||||
name = "images/x.png"
|
||||
content = []byte("y")
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ const FileName = "scenario.json"
|
||||
// Version is the current scenario.json format version.
|
||||
const Version = 1
|
||||
|
||||
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
|
||||
// scenarios.scenario в БД.
|
||||
type ScenarioJSON struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
@@ -22,7 +20,6 @@ type ScenarioJSON struct {
|
||||
Story *storytelling.Story `json:"story"`
|
||||
}
|
||||
|
||||
// Scenario — данные сценария, необходимые для сборки архива.
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Description *string
|
||||
@@ -36,12 +33,7 @@ type Bundle struct {
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// IScenarioArchive — контракт модуля scenario_archive: сборка и разбор
|
||||
// ZIP-архива сценария.
|
||||
type IScenarioArchive interface {
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile;
|
||||
// внешние URL остаются без изменений.
|
||||
Pack(
|
||||
ctx context.Context,
|
||||
scenario *Scenario,
|
||||
@@ -49,6 +41,5 @@ type IScenarioArchive interface {
|
||||
getFile func(ctx context.Context, name string) ([]byte, error),
|
||||
) ([]byte, error)
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
Unpack(data []byte) (*Bundle, error)
|
||||
}
|
||||
|
||||
@@ -22,17 +22,12 @@ func (s *story) GetStory(
|
||||
codesWithUseActions := map[string]struct{}{}
|
||||
codesHidden := map[string]struct{}{}
|
||||
givenApplications := map[string]struct{}{}
|
||||
// Введение передаётся игроку всегда — и до первого хода (начало игры),
|
||||
// и в дальнейшем (как заголовок истории).
|
||||
if scenario.Introduction != nil {
|
||||
story.Introduction = &Introduction{
|
||||
Text: s.cleaner.ClearText(scenario.Introduction.Text),
|
||||
Audio: scenario.Introduction.Audio,
|
||||
}
|
||||
}
|
||||
// Вопросы дела передаются команде всегда — с начала игры; правильные
|
||||
// ответы (Answer) команде не передаются: они нужны только автору и
|
||||
// организаторам при оценке ответов.
|
||||
for _, question := range scenario.Questions {
|
||||
story.Questions = append(story.Questions, &Question{
|
||||
Code: question.Code,
|
||||
@@ -49,7 +44,6 @@ func (s *story) GetStory(
|
||||
if i < len(codes)-1 {
|
||||
nextPlace, _ = s.findPlace(scenario, codes[i+1])
|
||||
}
|
||||
// Если следующая точка это открытие двери в текущей точке, то текущая точка считается использованной для действия
|
||||
if nextPlace != nil && currentPlace != nil {
|
||||
openDoor := false
|
||||
for _, door := range currentPlace.Doors {
|
||||
@@ -60,7 +54,6 @@ func (s *story) GetStory(
|
||||
}
|
||||
}
|
||||
if openDoor {
|
||||
// Запрещаем смотреть не выбранные действия
|
||||
for _, door := range currentPlace.Doors {
|
||||
if door.Code != nextPlace.Code {
|
||||
codesHidden[door.Code] = struct{}{}
|
||||
|
||||
@@ -45,8 +45,6 @@ var (
|
||||
)
|
||||
)
|
||||
|
||||
// Transliterate выполняет транслитерацию русского текста в латиницу,
|
||||
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
|
||||
func Transliterate(text string) string {
|
||||
|
||||
result := replacer.Replace(strings.ToLower(text))
|
||||
|
||||
@@ -61,8 +61,6 @@ func Generate(cards []Card) ([]byte, error) {
|
||||
}
|
||||
x := margin + float64(i%3)*width
|
||||
y := margin + float64(i%12/3)*height
|
||||
// Растровый режим включает свободное поле QR; векторные квадраты сохраняют
|
||||
// чёткость при печати без растрового масштабирования.
|
||||
bitmap := qr.Bitmap()
|
||||
pixel := qrSize / float64(len(bitmap))
|
||||
for row, cells := range bitmap {
|
||||
@@ -73,8 +71,6 @@ func Generate(cards []Card) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
name := strings.Join(strings.Fields(card.Name), " ")
|
||||
// ponytail: fpdf поддерживает BMP; для символов вне BMP (например,
|
||||
// emoji) нужен другой движок шрифтов. Возвращаем ошибку вместо паники.
|
||||
for _, r := range name {
|
||||
if r > 0xffff {
|
||||
return nil, fmt.Errorf("название команды %d содержит неподдерживаемый символ %q", i+1, r)
|
||||
|
||||
Reference in New Issue
Block a user