Предыдущая часть

Предисловие. Спасибо за тёплые отзывы о предыдущих статьях цикла. Ваша поддержка – настоящий стимул продолжать дальнейшую работу! Оставляйте мнение и вопросы по пройденному материалу в комментариях – я буду рад любой обратной связи.


Если вы читали предыдущую часть – вы помните, какой серьезный фундамент мы заложили для нашего сервиса. DeadDrop уже научлся:

  • Принимать и обрабатывать POST-запросы

  • Парсить файлы

  • Защищаться от DoS-атак

  • Хранить данные в потоко-безопасном in-memory хранилище

Мы даже успели написать наши первые утилиты для генерации ID и паролей. Если вы воспользовались подсказками к обновлению Create Handler, которые я привел в итогах предыдущей статьи, то, вероятно, у вас уже удалось протестировать его работу.

Но, не всё так просто… Если вы посмотрите на код нашего CreateHandler, то он вызовет, мягко говоря, смешанные чувства. Сейчас 100+ строк кода обработчика делают всё сразу:

  • Проверяют метод запроса

  • Читают файл

  • Генерируют ID и пароли

  • Вычисляют время

  • Логируют результаты

Я перечислил даже не весь перечень его функция, но даже неопытному веб-разработчику это уже покажется грузным. Сейчас наш хэндлер напоминает швейцарский нож, которым пытаются забивать гвозди – вроде и получается, но как-то выглядит странно, да и масштабировать такое будет сложновато.

И это – начало проблем.

Когда наш сервис начнёт разрастаться логичным шагом станет переход к более серьезным технологиям (мы осознаем, что хранение всего подряд в памяти – настоящая роскошь) и что теперь будем делать? Вырывать куски обработчика и переписывать их сначала, обновляя логику?

Ещё один важный момент – безопасность, мы всё ещё храним пароли в открытом виде. Злоумышленник, получивший доступ к нашей памяти (или базе данных), увидит и все «секреты» - все пароли в открытом виде, данные, хранящиеся в ячейках. Это катастрофа для любого сервиса и исправлять это нужно срочно.

Чем будем заниматься?

Сегодня мы превратим эту «поделку» в структурированное и масштабируемое приложение. Учиться будем, как и всегда на практике – Go и net/http:

  1. Будем рефакторить «толстый» обработчик. Пора разделить ответственность: Слой доставки будет только принимать запросы, а бизнес-логика будет вынесена в use case слой

  2. Научимся внедрять зависимости (Dependency Injection), подключим наше хранилище к логике

  3. Будем хэшировать пароли с помощью bcrypt и насовсем забудем о хранении паролей в открытом виде

Это лишь малая часть работы, которую нам предстоит выполнить. К концу статьи мы сделаем код масштабируемым и правильным.

Начнём вместе!

Весь код, как всегда, доступен в GitHub-репозитории проекта (директория для этой статьи — v2 ) https://github.com/Meedoeed/DeadDrop.

Реализуем сценарии использования

Теперь, когда у нас есть хранилище, утилиты и модели настало время объединить всё вместе в нашем первом сценарии использования (use case) – создании секрета.

Но возникает вопрос: где должна располагаться бизнес-логика? До сих пор мы писали весь код прямо в обработчике (CreateHandler), но это не совсем правильно. Обратите внимание, где лежит наш обработчик: в слое internal/delivery – это слой доставки, его задача – принять запрос, вызвать нужные методы и вернуть ответ. Бизнес-логика – генерация идентификатора, пароля и так далее должна жить в отдельном месте.

Вспомним структуру проекта:

internal/
├── delivery/
│   └── http/
│       └── handler/
├── lib/
│   └── generator/
├── models/
├── storage/
│   └── in-memory/
└── usecase/

Созданная ранее директория internal/usecase – это как раз то место, где должен находиться полезный функционал нашего приложения. Давайте создадим внутри неё файл secret.go.

Use case: CreateSecret

В этом файле нам нужно описать логику создания секрета. Как она должна выглядеть?

  1. Принимаем «сырые» данные от клиента: сообщение, время жизни, файл (при наличии)

  2. Генерируем ID и надежный пароль с помощью утилит, реализованных выше

  3. Вычисляем точное время истечения срока действия ячейки на основе текущего времени и ttl

  4. Создаем экземпляр структуры models.Secret, заполняя все поля

  5. Сохраняем секрет в хранилище, пользуясь интерфейсом. Обратите внимание! Мы не будем завязываться на конкретной реализации (in-memory или база данных), мы работаем через интерфейс, который всё это в себе объединяет методами.

  6. Возвращаем созданный секрет (или только ID с паролем) обратно в слой доставки, чтобы обработчик мог отдать пользователю результат

Как нам использовать наш интерфейс и не привязываться к реализации?

Когда нашему сценарию использования нужно хранилище (storage) есть два пути

ПЛОХО

Проблема

type SecretUseCase struct {
    storage *
inmemory.Storage // Жёсткая привязка!
}

func NewSecretUseCase() *SecretUseCase {
    // UseCase САМ создаёт себе хранилище
    storage := inmemory.NewStorage()
    return &SecretUseCase{storage: storage}
}

UseCase теперь намертво привязан к in-memory хранилищу. Если захотим перейти на PostgreSQL — придётся переписывать UseCase.

ХОРОШО

type SecretUseCase struct {
    storage
storage.Storage // Интерфейс, не важно, что внутри!
}

func NewSecretUseCase(s storage.Storage) *SecretUseCase {
    // UseCase получает готовое хранилище извне
    return &SecretUseCase{storage: s}
}

Файл main.go

func main() {
    // Сначала создаём конкретное хранилище
    storage := inmemory.NewStorage()
    // И "внедряем" его в UseCase
    secretUseCase := NewSecretUseCase(storage) // DI происходит здесь!
}

Здесь главная идея в следующем:

Внедрение зависимостей — это когда объект говорит: "Дайте мне то, с чем я должен работать", а не "Я сам создам то, с чем буду работать". Как повар в ресторане не выращивает овощи сам, а получает их от поставщиков — так и наш код получает готовые зависимости извне. Это делает код гибким, тестируемым и понятным.

Давайте обозначим, какая логика внутри CreateHandler лишняя и перенесем ее в use case. Посмотрите на код Create Handler и соответствующие комментарии:

func CreateHandler(w http.ResponseWriter, r *http.Request) {
    // --- СЛОЙ ДОСТАВКИ (HTTP-специфичная логика) ---
    // Проверка метода — чисто HTTP-овая задача
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    
    // Ограничение размера тела — защита на уровне HTTP
    r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
    
    // Парсинг multipart формы — работа с HTTP-запросом
    if err := r.ParseMultipartForm(20 << 20); err != nil {
        http.Error(w, "File is too big or invalid form", http.StatusBadRequest)
        return
    }
    
    // Извлечение данных из формы — чисто HTTP
    message := r.FormValue("message")
    ttl := r.FormValue("ttl")

    var fileData []byte
    var fileName string
    var fileExt string

    // --- ЗДЕСЬ СМЕШАНО: И HTTP, И БИЗНЕС-ЛОГИКА ---
    if file, fileHeader, err := r.FormFile("file"); err == nil {
        defer file.Close()

        // Проверка размера — защита, но уже ближе к бизнесу
        if fileHeader.Size > 20<<20 {
            http.Error(w, "file is too big", http.StatusBadRequest)
            return
        }

        // Чтение файла — техническая операция
        fileData, err = io.ReadAll(file)
        if err != nil {
            http.Error(w, "Cannot read file", http.StatusInternalServerError)
            return
        }

        // Санитаризация имени — БИЗНЕС-ЛОГИКА!
        fileName = filepath.Base(fileHeader.Filename)
        fileExt = filepath.Ext(fileHeader.Filename)
        
        // Проверка MIME-типа — БИЗНЕС-ЛОГИКА (какие файлы разрешены)
        mime := http.DetectContentType(fileData)
        allowedExts := map[string]bool{
            "image/jpeg":      true,
            "image/png":       true,
            "image/gif":       true,
            "application/pdf": true,
            "text/plain":      true,
        }

        if !allowedExts[mime] {
            http.Error(w, "Invalid file type", http.StatusBadRequest)
            return
        }
    }
    
    // --- БИЗНЕС-ЛОГИКА (должна быть в use case) ---
    id, err := generator.GenerateID(10)
    if err != nil {
        http.Error(w, "Cannot generate ID", http.StatusInternalServerError)
        return
    }
    
    // --- СНОВА СЛОЙ ДОСТАВКИ ---
    log.Printf("[INFO] POST /create | id=%s file=%s ttl=%s message=%s fileext=%s",
        id, fileName, ttl, message, fileExt)
    
    http.Redirect(w, r, "/", 303)
}

Давайте подытожим:

Что должно остаться в обработчике?

Что должно уйти в use case?

1)    Проверка HTTP-метода

2)    Ограничение размера тела

3)    Парсинг формы

4)    Чтение файла

5)    Вызов use case’а – должен появиться

6)    Формирование HTTP-ответа

7)    Логирование на уровне запроса

1)    Генерация ID

2)    Генерация пароля

3)    Проверка mime-типа

4)    Вычисление ExpiresAt по TTL

5)    Валидация данных

6)    Сохранение хранилища – через интерфейс

Итак, давайте создадим файл internal/usecase/secret.go

И начнем с описания структуры и функции-конструктора:

package usecase

package usecase

import (
    "deaddrop/internal/storage"
)

type SecretUseCase struct {
    storage storage.Storage // Зависимость от интерфейса, а не конкретной реализации
}

//функция-конструктор возвращает объект SecretUseCase
func NewSecretUseCase(s storage.Storage) *SecretUseCase {
    return &SecretUseCase{
        storage: s,
    }
}

Теперь давайте решим, как мы будем меняться данными между слоем доставки (где лежит наш хэндлер) со слоем сценариев (secret.go). Обратимся к CreateHandler:

log.Printf("[INFO] POST /create | id=%s file=%s ttl=%s message=%s fileext=%s",
        id, fileName, ttl, message, fileExt)

В логе, по сути, находятся все поля, которые нам нужно отправлять на сохранение: id, file, ttl, message, fileext. Давайте определим структуру запроса для передачи данных:

type CreateSecretRequest struct {
    Message  string
    TTL      string 
    FileData []byte
    FileName string
    FileExt  string
}

В свою очередь, слой сценариев использования должен отдавать обратно какой-то ответ (пользователь в ответ на запрос создание ячейки хочет узнать от неё пароль и id), назовем его CreateSecretResponse и определим соответствующую структуру:

type CreateSecretResponse struct {
    ID       string
    Password string
    ExpiresAt time.Time
}

Таким образом, взаимодействие слоев между собой в нашей реализации будет иметь следующий вид:

Схема общения между архитектурными слоями сервиса
Схема общения между архитектурными слоями сервиса

Теперь мы готовы к реализации сценария создания ячейки. Ознакомьтесь с моей реализацией, я вынес сюда те части CreateHandler, которые мы обозначили бизнес-логикой выше, в последствии у вас ещё будет возможность попробовать свои силы в написании собственных use case’ов:

func (uc *SecretUseCase) Create(req *CreateSecretRequest) (*CreateSecretResponse, error) {
    id, err := generator.GenerateID(10)
    if err != nil {
        return nil, errors.New("failed to generate ID: " + err.Error())
    }

    password, err := generator.GeneratePassword(12)
    if err != nil {
        return nil, errors.New("failed to generate password: " + err.Error())
    }

    ttlSeconds := 3600
    if req.TTL != "" {
        var parsedTTL int
        _, err := fmt.Sscanf(req.TTL, "%d", &parsedTTL)
        if err == nil && parsedTTL > 0 {
            ttlSeconds = parsedTTL
        }
    }

    expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Hour)

    if len(req.FileData) > 0 {
        mime := http.DetectContentType(req.FileData)
        allowedExts := map[string]bool{
            "image/jpeg":      true,
            "image/png":       true,
            "image/gif":       true,
            "application/pdf": true,
            "text/plain":      true,
        }

        if !allowedExts[mime] {
            return nil, errors.New("failed to recognize mime-type")
        }
    }

    secret := &models.Secret{
        ID:        id,
        Message:   req.Message,
        FileData:  req.FileData,
        FileName:  req.FileName,
        FileExt:   req.FileExt,
        Password:  password, // ВАЖНО! Мы пока сохраняем пароль в открытом виде. Это небезопасно!
        ExpiresAt: expiresAt,
    }
    if err := uc.storage.Save(secret); err != nil {
        return nil, errors.New("failed to save secret: " + err.Error())
    }
    return &CreateSecretResponse{
        ID:        id,
        Password:  password,
        ExpiresAt: expiresAt,
    }, nil
}

Остановка и рефакторинг

Посмотрите на реализацию сценария использования, приведенную выше, я специально допустил в ней несколько ошибок, чтобы мы смогли вместе обсудить и исправить их. Это важно и в реальной разработке править код приходится постоянно.

Проблема 1: Нарушение архитектуры

Мы распознаем mime-тип прямо в нашем сценарии использования, но делать так – неправильно. Мы не можем пользоваться методом DetectContentType пакета “net/http” – это создает ненужную зависимость от http-пакета, о котором слой use case знать и вовсе не должен. Что будет, если мы захотим из формата web-приложения перейти, к примеру, к формату телеграм-бота или консольной утилиты? Тогда нам придется тащить за собой весь пакет net/http.

Решение: создать отдельную утилиту для определения mime-типа

Создаём файл internal/lib/mime/mime.go

internal/lib/mime/mime.go — утилита для работы с MIME-типами
package mime

import (
    "net/http" // Здесь зависимость от http допустима, потому что это утилитный пакет
)

var allowedMimeTypes = map[string]bool{
    "image/jpeg":      true,
    "image/png":       true,
    "image/gif":       true,
    "application/pdf": true,
    "text/plain":      true,
}

// IsMimeTypeAllowed проверяет, разрешён ли данный MIME-тип
func IsMimeTypeAllowed(mimeType string) bool {
    return allowedMimeTypes[mimeType]
}

// DetectFromData определяет MIME-тип по содержимому файла
func DetectFromData(data []byte) string {
    if len(data) == 0 {
        return "application/octet-stream"
    }
    return http.DetectContentType(data)
}

Проблема 2: Мы храним пароли в открытом виде

Это критическая уязвимость! Если мы ничего не изменим, то в случае получения злоумышленником доступа к нашей памяти или же базе данных, он увидит все пароли пользователей. Пароли должны храниться только в захэшированном виде.

Решение: хэширование паролей.

Хэширование – процесс преобразования пароля в строку фиксированной длины, которую практически невозможно обернуть вспять. Когда пользователь снова введет пароль мы обратно превратим его в хэш и сверим между собой не пароли, а эти хэши.

Результат хэширования строки "QWERTY123" SHA-256
Результат хэширования строки "QWERTY123" SHA-256

Взгляните, к какому виду преобразует хэш-алгоритм SHA-256 базовый пароль «QWERTY123», давайте теперь посмотрим, каким будет хэш для очень похожего пароля: «QWERTY122»

Результат хэширования строки "QWERTY122" SHA-256
Результат хэширования строки "QWERTY122" SHA-256

Как вы можете заметить, они друг на друга совсем не похожи – это одно из ключевых свойств хэшей. Хэши схожих оригинальных последовательностей очень далеки друг от друга.

Как вы могли догадаться, под капотом это устроено сложно и реализация хорошего, правильного хэш-алгоритма – тема для отдельной статьи и к тому же, его реализация в рамках веб-сервиса будет нерациональной. В Go есть общепринятый пакет, который предоставляет возможности хэширования и делает это максимально безопасно и удобно. Речь идет о пакете bcrypt (возможно, вы с ним уже встречались при разработке других языков: C++, C# или Python).

bcrypt – сторонний пакет и нам следует научиться импортировать такие пакеты в наши проекты, они зачастую очень полезны!
Добавить пакет можно с помощью консольной утилиты:

go get <адрес_пакета>

Адресом может служить как github-репозиторий отдельного пакета, так и адрес пакета на официальной странице go, как в случае нашего пакета для хэширования. Его адрес: golang.org/x/crypto/bcrypt
Установите пакет в свой проект DeadDrop.

Результат скачивания bcrypt
Результат скачивания bcrypt

Пакет успешно добавлен и теперь мы можем им пользоваться.

Мы уже знаем, что завязываться на конкретных зависимостях не стоит, что это для нас значит? Именно, хэширование паролей мы тоже вынесем в отдельную утилиту, чтобы не тянуть в use case зависимость от bcrypt пакета.

Давайте перед реализацией я расскажу вам о методах bcrypt, которые понадобятся нам при реализации новой утилиты.
Для работы с bcrypt нам будет достаточно всего две основные функции:

  1. GenerateFromPassword – принимает на входе слайс байт (наш пароль) и стоимость (cost) хэширования. Возвращает захэшированный пароль (тоже слайс байт) или ошибку. Логичный вопрос: «Что за стоимость?». Стоимость – это просто число, которое определяет насколько «тяжелым» будет процесс вычисления хэша. Чем тяжелее – тем дольше злоумышленник будет его подбирать, но вместе с этим и ресурсов ваш сервер на генерацию хэша будет тратить больше, а тратить он будет их при каждой аутентификации (чтобы сверить хэш в хранилище с хэшем того, что пользователь дал ему в форме на сайте). Здесь важно уметь найти баланс. Обратите внимание на константу bcrypt.DefaultCost предоставляемую пакетом, она отсылает нас к числу 10 – стандартной стоимости хэша – это хороший компромисс для начала!

  2. CompareHashAndPassword – принимает хэш и пароль в открытом виде (слайс байт). Функция сама хэширует пароль и сравнивает его с хэшем. Возвращает nil при совпадении пароля с хэшем и ошибку, если они не совпали.

Теперь ваших знаний более чем достаточно для реализации утилиты для хэширования, напишите готовую реализацию (функции создания хэша из пароля и сверки пароля с хэшем) в специально отведенном файле, который создайте по адресу internal/lib/hash/hash.go.

Предлагаю ознакомиться с моим вариантом ниже:

internal/lib/hash/hash.go — утилита для хэширования паролей с bcrypt
package hash
import (    "fmt"    "golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {    bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)    if err != nil {        return "", fmt.Errorf("failed to hash password: %w", err)    }    return string(bytes), nil
}
func CheckPasswordHash(password, hash string) bool {    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))    return err == nil
}

Теперь настал момент внедрить наши новые утилиты в use case и на этом завершить его рефакторинг. Вернемся к internal/usecase/secret.go и обновим функцию secret.go:

internal/usecase/secret.go — финальная версия Use Case с хэшированием и MIME-проверкой
package usecase

import (
    "errors"
    "fmt"
    "time"

    "deaddrop/internal/lib/generator"
    "deaddrop/internal/lib/hash"
    "deaddrop/internal/lib/mime"
    "deaddrop/internal/models"
    "deaddrop/internal/storage"
)

type SecretUseCase struct {
    storage storage.Storage
}

type CreateSecretRequest struct {
    Message  string
    TTL      string
    FileData []byte
    FileName string
    FileExt  string
}

type CreateSecretResponse struct {
    ID        string
    Password  string
    ExpiresAt time.Time
}

func NewSecretUseCase(s storage.Storage) *SecretUseCase {
    return &SecretUseCase{
        storage: s,
    }
}

func (uc *SecretUseCase) Create(req *CreateSecretRequest) (*CreateSecretResponse, error) {
    id, err := generator.GenerateID(10)
    if err != nil {
        return nil, fmt.Errorf("failed to generate ID: %w", err)
    }

    rawPassword, err := generator.GeneratePassword(12)
    if err != nil {
        return nil, fmt.Errorf("failed to generate password: %w", err)
    }

    hashedPassword, err := hash.HashPassword(rawPassword)
    if err != nil {
        return nil, fmt.Errorf("failed to hash password: %w", err)
    }

    ttlSeconds := 3600 // значение по умолчанию - 1 час
    if req.TTL != "" {
        var parsedTTL int
        _, err := fmt.Sscanf(req.TTL, "%d", &parsedTTL)
        if err == nil && parsedTTL > 0 {
            ttlSeconds = parsedTTL
        }
    }

    expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Hour)

    if len(req.FileData) > 0 {
        detectedMime := mime.DetectFromData(req.FileData)
        if !mime.IsMimeTypeAllowed(detectedMime) {
            return nil, errors.New("file type not allowed")
        }
    }

    secret := &models.Secret{
        ID:        id,
        Message:   req.Message,
        FileData:  req.FileData,
        FileName:  req.FileName,
        FileExt:   req.FileExt,
        Password:  hashedPassword, // Сохраняем ТОЛЬКО хэш!
        ExpiresAt: expiresAt,
    }

    if err := uc.storage.Save(secret); err != nil {
        return nil, fmt.Errorf("failed to save secret: %w", err)
    }

    // возвращаем ответ с raw-паролем (он нужен пользователю для доступа)
    return &CreateSecretResponse{
        ID:        id,
        Password:  rawPassword, // Отдаём пользователю оригинальный пароль
        ExpiresAt: expiresAt,
    }, nil
}

Ключевые изменения:

  1. Хэширование пароля: пароль пользователя хранится только в захэшированном виде. Возвращаем клиенту в ответе от сервера оригинальный пароль, так как он нужен для доступа. В хранилище только хэши.

  2. Вынос Mime-логики: используем утилитный пакет mime вместо прямого вызова http.DetectContentType

Мы успешно завершили рефакторинг: теперь наш код архитектурно верен и безопасен. Логика работы в нашем сценарии использования четко разделена. Как уже говорилось выше, в разработке постоянно приходится что-то править и нужно не бояться потратить на это время, в последствии это позволяет намного проще отлаживать код или адаптировать его. Наконец, давайте обновим наш CreateHandler.

Правим CreateHandler

Теперь, когда у нас есть полноценный use case, мы можем значительно упростить наш обработчик. Важно помнить то, для чего мы всё это делали – всё это время мы разделяли логику и, наконец, задачи обработчика упираются в:

  1. Получить и подготовить данные из формы

  2. Вызвать соответствующий сценарий использования с этими данными

  3. Обработать результат и вернуть клиенту

Давайте обдумаем, что нужно будет поменять в CreateHandler, чтобы учесть все обновления и изменения:

Шаг 1: Что нужно делать в CreateHandler?

  1. Нам нужно читать и обрабатывать данные из формы. Мы уже умеем это делать, просто оставим уже существующие участки кода где мы парсим текстовые поля и читаем файл.

  2. Вызвать соответствующий сценарий использования:

    Обратите внимание! Для этого дать обработчику доступ к конкретному экземпляру SecretUseCase. Верно говоря, вы должны «внедрить» зависимость в наш хэндлер. Для этого предлагаю вспомнить материал первой статьи (https://habr.com/ru/articles/981356/), где мы обсуждали, что хэндлером может являться структура, удовлетворяющая интерфейсу Handler. Он удовлетворяется в случае наличия у функции метода: ServeHTTP(http.ResponseWriter, *http.Request). Думаю, вы поняли, к чему я веду. Верным решением с внедрением конкретного экземпляра SecretUseCase в наш CreateHandler будет создание структуры следующего вида:

    type Handler struct {
        secretUseCase *usecase.SecretUseCase
    } // Хэндлер для работы должен иметь метод ServeHTTP, перенесем всю текущую логику из нашего CreateHandler в этот метод

    «Общаться» с use case’ом наш хэндлер должен путем отправки ему структур CreateSecretRequest, созданных нами выше.

  3. Обработать результат и вернуть ответ пользователю:

    Когда сценарий использования успешно отработал и секрет создан он возвращает CreateSecretResponse(напомню, он содержит ID, сырой пароль и время истечения срока ячейки), в случае отсутствия ошибок необходимо каким-то образом эти данные сообщить создателю ячейки и здесь самым правильным и безопасным решением будет перенаправление клиента на одноразовую страницу, которая покажет всю эту информацию

Схема общения между разными слоями проекта
Схема общения между разными слоями проекта

Третий пункт в последствии мы реализуем в виде отдельного шаблона, на подобие того, как мы делали в первой статье цикла, пока ограничимся методом Write, применимым к http.ResponseWriter нашего хэндлера.

Рефакторим код CreateHandler

Вы можете попробовать самостоятельно обновить код для CreateHandler и в случае трудностей обратиться к моей реализации, приведенной ниже, в ней приведены комментарии к наиболее сложным этапам.

internal/delivery/http/handler/create.go — обновлённый хэндлер создания секрета
package handler

import (
    "deaddrop/internal/usecase"
    "fmt"
    "io"
    "log"
    "net/http"
    "path/filepath"
    "time"
)

type CreateHandler struct {
    secretUseCase *usecase.SecretUseCase
}

// NewCreateHandler - конструктор для нашего хэндлера. Мы внедрили зависимость от uc
func NewCreateHandler(uc *usecase.SecretUseCase) *CreateHandler {
    return &CreateHandler{
        secretUseCase: uc,
    }
}

func (h *CreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Здесь проверяем метод запроса - это слой доставки
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    //Ограничение размера тела запроса для защиты от DoS-атак. Мы обсуждали это выше
    r.Body = http.MaxBytesReader(w, r.Body, 10<<20)

    // Парсим multipart форму и ограничиваем размер
    if err := r.ParseMultipartForm(20 << 20); err != nil {
        http.Error(w, "File is too big or invalid form", http.StatusBadRequest)
        return
    }

    // Достаем данные из формы, готовимся к передаче в uc
    message := r.FormValue("message")
    ttl := r.FormValue("ttl")

    var fileData []byte
    var fileName string
    var fileExt string

    // Если файл в наличии пытаемся его обработать
    if file, fileHeader, err := r.FormFile("file"); err == nil {
        defer file.Close()

        // Прооверяем размер файла через fileHeader.Size
        if fileHeader.Size > 20<<20 {
            http.Error(w, "File is too big", http.StatusBadRequest)
            return
        }

        // Читаем содержимое файла
        fileData, err = io.ReadAll(file)
        if err != nil {
            http.Error(w, "Cannot read file", http.StatusInternalServerError)
            return
        }

        // Проверка чтобы имя файла не включало в себя пути, оно не должно иметь вид "/home/.." или "./..." - это опасно
        fileName = filepath.Base(fileHeader.Filename)
        fileExt = filepath.Ext(fileHeader.Filename)
    }

    // Готовим структуру для отправки в uc
    createReq := &usecase.CreateSecretRequest{
        Message:  message,
        TTL:      ttl,
        FileData: fileData,
        FileName: fileName,
        FileExt:  fileExt,
    }

    // Отправляем данные в uc-слой
    resp, err := h.secretUseCase.Create(createReq)
    if err != nil {
        log.Printf("[ERROR] Failed to create secret: %v", err)
        http.Error(w, "Failed to create secret: "+err.Error(), http.StatusInternalServerError)
        return
    }

    // Шаг 7: Логирование успешного создания
    log.Printf("[INFO] POST /create | id=%s ttl=%s message='%s' file=%s",
        resp.ID, ttl, message, fileName)

    // Перенаправляем пользователя на страницу с результатом
    // Позже здесь будет редирект на /secret/{id}, а пароль нужно будет показать на отдельной странице.
    // Пока же мы просто вернём ID и пароль в теле ответа для наглядности.
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.WriteHeader(http.StatusCreated)
    responseHTML := fmt.Sprintf(`
        <h1>Ячейка создана!</h1>
        <p><strong>ID:</strong> %s</p>
        <p><strong>Пароль:</strong> <code>%s</code></p>
        <p><strong>Исчезнет:</strong> %s</p>
        <p>Сохраните пароль. Он исчезнет при перезагрузке</p>
        <a href="/">На главную</a>
    `, resp.ID, resp.Password, resp.ExpiresAt.Format(time.RFC1123))
    w.Write([]byte(responseHTML))
}

Посмотрите, не вызвало ли обновление CreateHandler никаких ошибок внутри проекта?

Файл router.go теперь сообщает нам о наличии такой ошибки внутри себя:

handler.CreateHandler (type) is not an expression

на строке

mux.HandleFunc("/create", handler.CreateHandler)

Это происходит потому что теперь CreateHandler – не функция. Мы сделали хэндлер структурой с зависимостями, а метод HandleFunc ждёт аргументом функцию-обработчик. Как мы говорили выше, это не проблема, если обработчик – структура покрывающая интерфейс Handler (структура, имеющая метод ServeHTTP), то она может быть зарегистрирована в мультиплексор методом mux.Handle(“<маршрут>”, <экземпляр структуры>)

Обновленный код для router.go имеет вид:

func RegisterRoutes(mux *http.ServeMux, secretUC *usecase.SecretUseCase) {
    mux.Handle("/static/", http.StripPrefix("/static/", handler.StaticHandler))
    mux.HandleFunc("/", handler.HomeHandler)
    createHandler := handler.NewCreateHandler(secretUC)
    mux.Handle("/create", createHandler)
}

Можете заметить, что теперь мы передаем в функцию RegisterRoutes указатель на экземпляр SecretUseCase – это нужно для создания конструктором (NewCreateHandler) экземпляра хэндлера. Для нас важно, что теперь в RegisterRoutes нужно передавать usecase.SecretUseCase, создавать и передавать его будем там, где регистрируем машруты – прямо в app.go. Обратите внимание, он сам сообщает нам о недостающем аргументе для вызова регистратора маршрутов.

Ошибка в app.go
Ошибка в app.go

Давайте сделаем всё, что указано выше. Это буквально последний шаг перед тем, как мы увидим результаты нашей долгой работы! Создайте конструкторами экземпляр storage и secretUseCase и передайте последний в RegisterRoutes вторым аргументом

Обновленный deaddrop/internal/app/app.go

func Run() {
    mux := http.NewServeMux()

    storage := inmemory.NewStorage()
    secretUseCase := usecase.NewSecretUseCase(storage)

    deliveryHttp.RegisterRoutes(mux, secretUseCase)

    handler := middleware.Chain(
        mux,
        middleware.RecoveryMiddleware,
        middleware.LoggingMiddleware,
    )

    http.ListenAndServe(":8080", handler)
}

Первые результаты работы

Собираем и запускаем наш сервис, переходим на localhost:8080, заполняем форму, отправляем и, наконец, смотрим на результат!

Результат создания ячейки
Результат создания ячейки

Первые результаты достигнуты, мы успешно создаём ячейки, сохраняя их в in-memory хранилище, логика приложения разделена. Теперь мы сможем легко масштабировать DeadDrop, ведь каждый слой берёт на себя только то, за что он должен отвечать:

  • Слой доставки – принимает HTTP-запросы, парсит форму, валидирует базовые параметры (метод, размер тела), вызывает соответствующий use case и формирует HTTP-ответ (шаблон, редирект или ошибку).

  • Слой use case - содержит бизнес-логику: генерирует ID и пароль, хэширует пароль, вычисляет время жизни, проверяет MIME-типы, создает модель секрета и сохраняет её через интерфейс хранилища, не заботясь о деталях реализации самого хранилища.

Шаблон для ответа от обработчика создания ячейки

В дополнение, реализуем шаблон для ответа сервера клиенту при создании ячейки. В первой статье (https://habr.com/ru/articles/981356/) мы подробно обсудили, как делать шаблоны и интегрировать их в handler, повторив все те же шаги – заменим w.Write в конце кода обработчика на шаблон result.html

Вы можете вспомнить как писать шаблоны и интегрировать их в код ознакомившись с коротким планом-заметкой, который я составил для вашего удобства (Обратите внимание! Есть важное уточнение относительно создания шаблонов на шаге 3, ознакомьтесь с ним!):

Шаг 1: Создаем файл шаблона
По пути internal/assets/templates создаем result.html и в нём создаем шаблон. Не забываем про блоки {{ define "content" }} и {{ end }}

Шаг 2: Убеждаемся что шаблоны подключены к embedFS
Проверьте наличие
//go:embed templates/*.html
var templateFiles embed.FS

по пути internal/assets/templates.go

Важно!

Шаг 3: Создаем для шаблона глобальную переменную по образцу HomeTemplate.
Важно! Ранее в переменной HomeTemplate мы парсили все шаблоны:
template.ParseFS(templateFiles, "templates/*.html")
Это будет проблемой и помешает нам встраивать новые. Впредь, будем указывать для парсинга конкретные шаблоны, чтобы не между ними не случалось конфликтов, это делается так:
template.ParseFS(templateFiles, "templates/layout.html", "templates/home.html"),
для HomeTemplate и так:
template.ParseFS(templateFiles, "templates/layout.html", "templates/result.html"),
для шаблона результата

Шаг 4: Определяем структуру данных для передачи в шаблон
Внутри CreateHandler или в отдельном файле models нужна структура с полями к передаче в result.html. Например:

data := struct {
    ID        string
    Password  string
    ExpiresAt string
    ID:        resp.ID,
    Password:  resp.Password,      // сырой пароль, не хэш!
    ExpiresAt: resp.ExpiresAt.Format(time.RFC1123),
}

Шаг 5: Устанавливаем заголовок Content-Type для http.ResponseWriter
Браузер должен чётко знать, что мы отдаём не plain/text, а именно html разметку

Шаг 6: Выполняем шаблон
Используем метод ExecuteTemplate по отношению к assets.ResultTemplate по образцу с домашней страницей

Шаг 7: Убираем старый код

В результате обновлений и легкого рефакторинга на странице /create успешно прогружается такой красивый шаблон в стилистике нашего сервиса:

Шаблон для ответа от CreateHandler
Шаблон для ответа от CreateHandler

Если вы хотите сделать такую же страницу или если у вас возникли проблемы с интеграцией шаблона – вы всегда можете обратиться к GitHub репозиторию проекта: https://github.com/Meedoeed/DeadDrop - можете достать отсюда новый style.css и result.html или любые другие необходимые файлы или ознакомиться с особенностями реализации непонятных моментов!

Промежуточный итог по статье «Разбираем net/http на практике. Часть 2.2»

Мы провели фундаментальную работу по превращению нашего рабочего прототипа в архитектурно-целостное приложение. В отличие от первой части, где мы заложили технический фундамент, здесь мы занялись серьезной качественной перестройкой кода.

Что мы сделали в этой части?

  • Провели архитектурный рефакторинг

  • Научились внедрять зависимости

  • Обеспечили безопасность паролей

  • Привели в порядок маршрутизацию и сборку проекта в соответствии с применением нового слоем use case

Что будет дальше?

Работа над нашим проектом вступает в новую фазу! Теперь, когда у нас появилось безопасное хранилище и проект, благодаря архитектурным обновлениям, готов к масштабированию мы готовы двигаться дальше:

  • Реализуем получение секретов – напишем новый handler и use case для чтения ячеек

  • Защитим файлы – научимся отдавать файлы только после аутентификации, с использованием cookie

  • Реализуем фоновую очистку нашего хранилища в отдельной горутине

Подписывайтесь и следите за обновлениями в блоге! Буду рад обратной связи в комментариях!

Полезные ссылки:

Репозиторий проекта: https://github.com/Meedoeed/DeadDrop

Предыдущая часть цикла (Основы работы с формами и хранилищем)