This commit is contained in:
2026-10-03 13:22:11 +07:00
parent 05090722a0
commit 2caa2c1a88
74 changed files with 44 additions and 676 deletions
+1 -26
View File
@@ -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)
+1 -18
View File
@@ -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 {
-10
View File
@@ -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
-3
View File
@@ -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
}
-2
View File
@@ -1,7 +1,5 @@
package roles
// HasRole возвращает true, если у субъекта есть роль; администратор
// проходит проверку любой роли.
func HasRole(claims IHasRole, role string) bool {
if claims.HasRole(Admin) {
return true
-2
View File
@@ -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)
}
+5 -27
View File
@@ -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)
}
-7
View File
@@ -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)