Разбираем net/http на практике. Часть 2.1: POST, файлы и in-memory хранилище
- среда, 18 марта 2026 г. в 00:00:08
В предыдущей статье цикла мы успели разобраться с темами создания и запуска сервера, обработчиками и промежутками обработчиками http-запросов, обработкой статических файлов, embed FS и другими базовыми механики net/http.
Вторая часть цикла получилась очень объемной и поэтому, в целях повышения читаемости я разделил её на 3 связанные части:
Фундамент. Принимаем POST-запросы, загружаем файлы и создаем In-memory хранилище.
Архитектура и безопасность. Внедряем Clean Architecture, хэшируем пароли и защищаем файлы
Взаимодействие. Динамические маршруты, аутентификация и управление доступом Что думаете? Правильное ли деление?
Здесь мы рассмотрим:
Обработка форм POST и загрузка файлов
In-memory хранилище паролей и материалов
Сегодня наша задача – заложить крепкую базу нашего сервиса – научить его обрабатывать данные, отправленные пользователями и реализовать удобный способ хранения информации в нашем сервисе.
https://github.com/Meedoeed/DeadDrop - здесь для каждой статьи удобно создана своя поддиректория – для текущей статьи актуальная версия «v2»:

Приступим к изучению!
Мы уже успели создать страницу создания почтовой ячейки DeadDrop:

… <form action="/create" method="POST" enctype="multipart/form-data"> <div class="form-group"> <label for="message">Сообщение:</label><br> <textarea id="message" name="message" rows="5" required></textarea> </div> …
Здесь – давайте подробно поговорим о атрибутах action, method и enctype:
action – то, куда отправится форма. Здесь мы отправляем форму по относительному адресу «/create» - но вы можете использовать и абсолютные пути других веб-ресурсов
method – HTTP-метод отправки данных. Мы подробнее рассмотрим их уже скоро
enctype – тип кодировки формы. Есть два основных используемых типа кодировки:
- multipart-form – используется когда наша форма содержит файлы для загрузки, здесь данные отправляются в сыром виде, этот вариант производителен для бинарных данных и файлов
- application/x-www-form-urlencoded – используется когда есть только текстовые поля, он производительнее для небольших данных однако накладных расходов выйдет больше так как данные будут кодированы
Настало время поговорить о методах запросов к веб-ресурсам. Мной будут рассмотрены основные HTTP-методы, если вы уже знакомы с ними – просто пропустите эту часть и следуйте дальше либо закрепите свои знания – попробуйте рассказать для чего нужен и где может быть применен каждый из них:
Метод | Назначение | Данные хранятся в | Пример в DeadDrop | Статусы ответа |
GET | Получение данных с сервера | URL (параметры запросы) | GET /secret/{id} - просмотр ячейки | 200 (ОК) 404 (Not Found) |
POST | Создание нового ресурса | Теле запроса | POST /create - создать секрет | 201 (Created) 303 (See other) |
PUT | Полное обновление ресурса | Теле запроса | PUT /secret/{id} - обновить секрет | 200 (ОК) 204 (No Content) |
PATCH | Частичное обновление ресурса | Теле запроса | PATCH /secret/{id} - изменить пароль | 200 (ОК) 204 (No Content) |
DELETE | Удаление ресурса | - | DELETE /secret/{id} - удалить секрет | 200 (ОК) 204 (No content) |
Теперь, когда мы понимаем, как устроена форма пользователя, давайте разберёмся с ее обработкой на стороне сервера. Приступим
Как говорилось ранее, атрибут action формы переносит нас на указанный внутри относительный адрес или абсолютный адрес. Давайте запустим сервер и проверим, так ли это:

Как видите, нажатие на кнопки действительно переносит нас на «localhost:8080/create» и как вы уже, должно быть, догадались – нам необходимо создать соответствующий обработчик под данный маршрут.
Задумаемся: какой метод запроса подходит для нашего «/create»? Ответ быстро приходит на ум. Для создания нового ресурса подходит метод POST (так как мы передаём серверу данные), соответственно и обрабатывать нам нужно этот метод. Хорошим тоном считается явно отвечать клиенту если он обращается к ресурсу с помощью неверного метода – пакет net/http позволяет это сделать довольно просто. Достаточно воспользоваться методом http.Error() в который передать http.ResponseWriter, саму ошибку и её код. Обращаю ваше внимание! После любого http.Error() должен обязательно стоять return из обработчика. Если вы его забудете – обработчик продолжит своё выполнение. Такие ошибки тяжело исправлять и заметить, когда ваш код стал достаточно большим. Не забывайте return.
Вы уже знаете, проверить метод запроса можно с помощью r.Method
Также я должен сказать, что пакет net/http предоставляет вам целый ряд констант, использование которых повысит читаемость вашего кода, среди них:
http.MethodPost = «POST»
http.MethodGet = «GET»
http.MethodDelete = «DELETE»
Аналогичные константы имеются и для других методов запросов. С текущими знаниями попробуйте реализовать обработчик маршрута «/create», который будет проверять метод обращения по указанному маршрута. В том случае, если метод отличен от «POST» - на клиент должна возвращаться ошибка –Status Method Not Allowed. В данном случае реализация будет следующей:
//internal/delivery/http/handler/create.go … func CreateHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } …
Обратите внимание – констант действительно много. Здесь мы пользуемся http.StatusMethodNotAllowed, которая отсылает нас к коду ошибки. Константы в пакете net/http есть для любого кода ошибки, который может вам пригодится. Удобно и читаемо, не правда ли?
Теперь, убедившись, что метод получения запроса – POST, мы можем приступить к чтению данных, отправленных пользователем.
Наша форма внутри шаблона использует enctype="multipart/form-data", значит данные приходят на сервер в формате multipart о котором мы говорили выше.
Для того чтобы обрабатывать такие данные пакет net/http подходит отлично.
Для начала – нам нужно как бы разобрать всё по полочкам и привести всё к понятному виду – спарсить форму. Определить: поле «message» - значение «Hello Habr». Поле «ttl» - значение «1200»
Для этого подходит метод, применимый к http.Request - ParseMultipartForm(maxMemory)
err := r.ParseMultipartForm(32 << 20) // Разбираем форму пользователя ограничивая её размер 32 Мб
Важно! Не забывайте ограничивать размер формы, это важно, чтобы предотвратить DoS-атаки на сервер: злоумышленники могли бы отправить на наш сервис просто огромный файл и сервер на его чтении бы просто повис, не отвечая легитимным пользователям. Это очень распространённый вид атак.
Когда мы вызвали ParseMultipartForm - все данные стали доступны для чтения.
Пакет net/http предоставляет удобные методы чтобы забирать значения по имени поля (name из структуры HTML-формы).
Текстовые поля (message, ttl и другие)
Наиболее используемый метод: r.FormValue(“name”)
message := r.FormValue("message") // достаем значение из поля message и помещаем его в переменную с соответствующим названием ttl := r.FormValue("ttl") // аналогично для HTML-поля ttl
Нюансы:
FormValue вернет первое значение для указанного ключа (если у вас 2+ поля с одинаковым name в HTML-форме – в переменную вернется значение первого поля)
Если поле пусто – вернет empty value для string (“”)
Работает как с multipart, так и с application/x-www-form-urlencoded.
Есть альтернатива - r.PostFormValue("name") практически с тем же функционалом за исключением того, что такой метод ищет значение только в полях POST-запроса игнорируя query-параметры

На предыдущем шаге мы научились доставать значения из mulipart-формы и доставать значения пользователя, ограничивая размер формы, однако этого недостаточно для стабильной и безопасной работы любого сервиса:
- «Почему это важно?»
- Ответ прост: фактически, HTTP-запрос является неограниченным по размеру потоком данных, а значит если не установить соответствующие ограничения, злоумышленник сможет:
Отправить запрос на сотни мегабайт, тем самым он займет оперативную память нашего сервера. Это DoS-атака.
Именно поэтому нам так важно сделать ряд ограничений для пользователя нашего сервиса.
Ограничение размера тела запроса(http.MaxBytesReader)
Первое, что мы сделаем – ограничим размер тело запроса целиком. Для этого изучаемый пакет net/http предоставляет метод http.MaxBytesReader.
r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10 MB
Здесь:
r.Body – поток, который оборачивается MaxBytesReader’ом. Эта обертка прерывает чтение в случае превышения установленного лимита, а также отдает клиенту ошибку http: request body too large
Вызывать метод для оборачивания тела запроса следует, очевидно, до парсинга формы иначе он просто не сработает
Необходимо явно указать какие типы файлов пользователь сможет загружать в нашу «почтовую ячейку» в этом снова помогает пакет net/http – в нём есть метод применимый к слайсу байт считываемого файла и возвращающий его MIME-тип – стандартную «этикетку», которая сообщает какой у него тип и формат.
Вот список MIME-типов, которые человек может загружать в DeadDrop:
- image/jpeg – изображение jpeg
- image/png – изображение png
- image/gif – изображение gif
- application/pdf – pdf-документы
- text/plain – .txt .csv и т.д.
Вы можете подробнее ознакомиться с mime-типами здесь:
О том как достать MIME-тип файла из данных формы поговорим в следующей главе
Мы уже научились забирать значения полей формы, но как насчёт файлов? Они требуют немного другого подхода. Для обработки файлов формы пакет net/http предоставляет удобный метод
File, FileHeader, err := r.FormFile([name])
Как видите, он возвращает файл, его заголовок и ошибку. Если с файлом и ошибкой всё вполне понятно, то заголовок файла может оставить вопросы: что такое заголовок файла, зачем он нужен и что в нём лежит?
Для понимания приведу доступную аналогию: FileHeader это как паспорт файла:

Как видно, в мета-данных файла – его fileHeader лежит
- Имя файла
- Заявленный размер
- MIME-тип
- Информация о форме (Content-Disposition, она нам не пригодится в контексте данной статьи)
Каждый из этих пунктов, в которых всё, на первый взгляд, полностью ясно имеет подводные камни, о которых нам следует поговорить:
Имя файла Filename (string)
fileName := fileHeader.Filename // “User_photo.jpg”
Пакет filepath предлагает хорошее решение – метод Base(). Он извлекает конечное имя файла, отбрасывая путь safeFileName := filepath.Base(fileHeader.Filename) // если Filename = “../../pass”, то получим в safeFileName просто pass
Этот подход также называют санитаризацией.
Также имеет место подход замены всех не ASCII символов и замены последовательностей, имеется много удобных решений в пакете strings
Одной из наиболее весомых практик является полное игнорирование имени файла, отправленное клиентом и присвоение ему своего уникального сгенерированного на сервере имени.
Размер файла Size (int64)
fileSize := fileHeader.Size // например, 1024000 (1 Мб)
Свои проблемы есть и здесь: заголовок размера заявляется пользователем и по сути отправленный файл может ему не соответствовать и это не обязательно значит, что на ваш сервис положил свой глаз злоумышленник, такое вполне может произойти с пользователем случайно.
Для понимания, приведу пример: ZIP-архивы при обработке на сервере могут раскрываться в огромные объемы данных провоцируя что-то вроде DoS-атаки – это называется ZIP-бомба.
В связи с этим для надёжности размер представляемого файла нужно фактически проверять трижды. Разделим мысленно проверку на 3 уровня:
Проверка в через заголовок (быстрая, но ненадежная)
Ограничение в ParseMulipartForm
Фактическая проверка при сохранении файла на диск
Такая система, хоть и может на первый взгляд показаться избыточной, гарантирует как защиту от большей части DoS-атак, так и предохраняет ваших клиентов от ошибок использования сервиса.
Header

headers := fileHeader.Header
Как видно из представленного изображения Header делится на целый ряд заголовков наиболее важным из которых и самым весомым для нас в контексте реализации сервиса Deaddrop MIME-тип, о котором мы уже говорили выше:
headers := fileHeader.Header contentType := headers.Get("Content-Type")
Почему нужно доставать тип файла именно в формате MIME? В голову может прийти и другой способ найти его расширение – вытянуть из полного названия входящего файла: «filename.txt» // достать отсюда txt
Это не всегда сработает: к примеру, человек может изменить расширение в названии файла назвав исполняемый или какой-либо другой файл другим расширением.
Более того, человек может подменить MIME-тип в запросе изменив Content-Type, но есть решение. Пакет net/http предоставляет удобный метод, который анализирует первые 512 байт файла для определения реального типа:
// Определяем реальный MIME-тип detectedType := http.DetectContentType([слайс байт файла «file»])
В процессе работы мы определим список файлов, допущенных к загрузке на наш сервис.
Учтите, что в процессе реализации всякого сервиса лучше делать белые списки, чем чёрные – разрешайте к входу только известные вам, безопасные типы файлов.
Чтение файла
Мы уже условились о том, что для проверки типа файла нам нужен слайс байт файла, но взглянув на тип файла, получаемый методом FormFile имеет тип multipart.File, это интерфейс вида
type File interface { io.Reader // Последовательное чтение io.ReaderAt // Чтение с произвольной позиции io.Seeker // Перемещение по файлу io.Closer // Закрытие файла }
Для нашего случая получение байт можно организовать простым и понятным способом – использовать функцию встроенного пакета io – io.ReadAll()
При работе с этим методом не забывайте обрабатывать возвращаемую им ошибку и использовать многоуровневый контроль размера файлов:
// Уровень 1: Ограничение всего запроса r.Body = http.MaxBytesReader(w, r.Body, 30<<20) // 30 МБ // Уровень 2: Ограничение multipart-формы err := r.ParseMultipartForm(25 << 20) // 25 МБ // Уровень 3: Проверка размера по заголовку if fileHeader.Size > 20<<20 { ... }
Теперь, когда мы учли нюансы безопасности – настало время расширить возможности нашего Create хэндлера и чтобы полностью определиться с его функционалом, я предлагаю вам составить блок-схему этого обработчика. Такой подход помогает не упустить из виду никаких функций.
Взгляните:

Давайте реализуем соответствующие функции create handler, применив полученные знания. Нажмите, чтобы посмотреть, какая реализация этого хэндлера была написана мной:
package handler import ( "deaddrop/internal/lib/generator" "io" "log" "net/http" "path/filepath" ) func CreateHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 10<<20) if err := r.ParseMultipartForm(20 << 20); err != nil { http.Error(w, "File is too big or invalid form", http.StatusBadRequest) return } 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() 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 := 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 } } log.Printf( "[INFO] POST /create | file=%s ttl=%s message=%s fileext=%s", fileName, ttl, message, fileExt, ) http.Redirect(w, r, "/", 303) }
Обратите внимание на конечный редирект пользователя, а именно на его код http.Redirect(w, r, "/", 303) — он перенаправляет пользователя на главную страницу, хотя должен вести на страницу созданного секрета /secret/{id}. Это происходит потому, что мы пока не реализовали сохранение данных и генерацию уникального ID. Давайте же приступим к реализации этой логики!
Всё, что мы делали ранее – каша, в одном слое доставки – CreateHandler, были объединены и чтение файла, и логирование, и обработка запроса с проверкой метода.
И далее, с точки зрения бизнес-логики сервиса всё, что мы делали абсолютно бесполезно – секрет не сохраняется, ячейка не создается, уникальный идентификатор не генерируется.
Настало время добавить в наш проект слой утилит и use case’ов (сценариев использования) – именно здесь будет реализована основная бизнес-логика создания и хранения секретных ячеек DeadDrop.
Как вы думаете, какие нужны поля для структуры хранения данных? Попробуйте самостоятельно ответить на этот вопрос и свериться с моим вариантом:
ID – уникальный идентификатор ячейки
Message – сообщение из формы клиента
FileData –содержимое файла
FileName – имя файла
FileExt – расширение файла
Password – пароль от почтовой ячейки
ExpiresAt – время исчезновения ячейки
Вы, должно быть, заметили: появилось несколько новых для нас пунктов, а именно: уникальный идентификатор ячейки, пароль и время исчезновения ячейки, давайте сначала рассмотрим их подробнее, а потом реализуем в формате утилит.
Обращу внимание: Утилиты — то, что не является бизнес-логикой, но лежит в основе технической реализации и не должно зависеть от остального кода. Также их важной чертой за частую является переиспользуемость.
ID - уникальный идентификатор ячейки:
Постарайтесь ответить на вопрос, как можно удобно обращаться к конкретной ячейке?
- по полю Message? – нет, пользователи могут спокойно создавать множество ячеек с одинаковыми сообщениями
- может по полю FileName? – тоже нет, по той же причине.
Ни одно поле из ранее рассматриваемых нами не может быть гарантированно уникальным.
Значит необходимо сделать какие-то идентификаторы, которые не могут быть одинаковыми и будут единственным образом характеризовать каждую ячейку – ID, по сокращению от identifier.
Логично будет реализовать функцию-генератор таких значений.
Password – пароль
В первой статье мы уже говорили о том, насколько важная роль у паролей в сервисах мертвой почты.
К паролю предъявим ряд требований:
Он должен будет содержать как большие, так и маленькие буквы.
Пароль должен будет обязательным образом содержать цифры.
Пароль должен содержать специальные символы («@», «!», «$», «%», «&», «#»).
Фактически генератор паролей строить будем почти тем же образом, что и генератор ID.
Вопросы хэширования паролей в нашем сервисе рассмотрим в статье «Изучаем net/http. Часть 2.2», я выделю под это отдельный подраздел в ней.
ExpiresAt – время исчезновения ячейки
Из всех новых пунктов этот, на мой взгляд, самый понятный. Он вытекает из поля ttl (time to live) – которое мы парсим из формы.
Подумайте, как получить expires at, зная ttl:
ExpiresAt := time.Now().Add(time.Duration(ttl) * time.Hour)
Вы можете задуматься: «А почему не хранить просто ttl как поле создания формы и время её создания, например CreatedAt := time.Now()?»
- Всё просто, когда вы сохраняете готовую дату истечения (ExpiresAt), время создания нам больше не нужно для проверки срока годности. Вы уже один раз вычислили момент, когда ячейка должна исчезнуть, и сохранили его.
Это как купить йогурт и посмотреть на дату истечения срока годности, напечатанную на упаковке. Вам не нужно знать, когда именно его произвели, чтобы понять, можно ли его есть сегодня.
Перед реализацией утилит с use case’ам нам нужно определиться, где вообще их писать? В 1 статье цикла (https://habr.com/ru/articles/981356/) мы определили предназначение существующих директорий проекта и бизнес-логики там не рассматривалась, но мы говорили о том, что в последствии определим под неё отдельный слой. Правильным решением будет отвести под сценарии использования и утилиты отдельные директории в internal и теперь структура нашего проекта выглядит следующим образом:

В директории lib, для удобства, создадим отдельный пакет generator в котором будут происходить дальнейшие работы.
В следующем разделе рассмотрим реализацию отдельных утилит:
Итак, пока что нам следует реализовать 2 утилиты: генератор id и генератор паролей. Как вы думаете, какая из этих функций является более общей?
Правильный ответ, генератор уникальных идентификаторов: пароли мы будем генерировать по той же схеме и лишь введем несколько особенностей и ограничений чтобы удовлетворить предъявленным к ним требованиям (наличие специальных символов, разных регистров символов и цифр). Так, чтобы оптимизировать процесс разработки, в последствии мы позаимствуем некоторые части кода из уже имеющегося генератора id в генераторе паролей.
Разработка устойчивого и безопасного алгоритма генерации id – сложная тема и я предлагаю научиться ей на примере моей реализации:
import ( "crypto/rand" "encoding/base64" "fmt" ) func GenerateID(lenght int) (string, error) { bytes := make([]byte, lenght) _, err := rand.Read(bytes) if err != nil { return "", fmt.Errorf("error in ID generation: %s", err) } id := base64.RawURLEncoding.EncodeToString(bytes) return id, nil }
Давайте построчно пройдемся по реализации, и я расскажу, какой участок кода за что отвечает.
Строки 1-5: Импорт необходимых пакетов, здесь появляются новые для нас crypto/rand и encoding/base64, вы подробнее узнаете о них ниже.
Строка 7: Сигнатура функции:
Имя функции | GenerateID |
Атрибуты функции | На входе в функции мы запрашиваем длину ID, необходимого к генерации (length int) |
Возвращаемые значения | На выходе имеем ID указанной длины (string) и возвращаемую ошибку (error) |
Строка 8: Для понимания того, что мы будем делать дальше, давайте обдумаем, каким образом мы будем генерировать идентификаторы:
Нам нужна строчка определенной длины, строка ASCII-символов — это фактически слайс однобайтных символов, но типа char, который присутствует во многих Си-производных языках, в Go нет. Однако есть его аналог – byte – фактически он и является тем же самым, что и char, неся в себе ровно 1 байт информации. Исходя из этого, строку в языке Go мы можем представить так: []byte.
Здесь, мы как раз создаем такой слайс.
Строка 9: Используя библиотеку crypto/rand заполняем созданный выше срез случайными байтами, например, мы запросили генерацию 2 символьного ID, тогда у нас создался слайс из двух байт который, к примеру, случайно заполнился так:
Байт 1 | Байт 2 | ||||||||||||||
0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 1 | 1 | 1 |
Строка 10-12: Обработка ошибки, которая может возникнуть в строке 9. Обратите внимание, для ясности и отладки ошибка оборачивается перед тем как отправиться в return’е обратно к тому, кто вызвал функцию – это хороший тон и сильно упрощает процесс разработки и отладки.
Строка 13: Здесь мы работаем с библиотекой base64. Не уходя в дебри того, как работает преобразование в ASCII случайных байт, содержащихся в данный момент в нашем слайсе «bytes» функцией EncodeToString (под капотом она имеет за собой очень элегантное решение для преобразования двоичных чисел в текстовый формат, предлагаю вам самостоятельно с ним ознакомиться)
Почему мы применяем именно RawURLEncoding? Как вы знаете, кодировка ASCII включает в себя почти все стандартные символы и специальные символы

Как вы думаете, все ли из них «безопасны» для использования в URL адресах страниц (в последствии мы используем id в качестве одного из атрибутов адреса страницы)? Посмотрите на них и задумайтесь, какие могут вызвать потенциальные проблемы?
Всё верно, символы «+» и «/» недопустимы к использованию в качестве элементов id.
Почему? – Символ «+» в браузерной строке интерпретируется как «пробел», для наглядности вы можете в любом популярном поисковике сделать запрос содержащий пробел и взглянуть на адресную строку после выполнения запроса:

Символ «/» в комментариях не нуждается, его наличие сделает предшествующую сгенерированную символьную часть префиксом дальнейшего адреса.
Это ещё не всё. Символ «=» также может вызвать проблемы. В URL он используется для передачи параметров в query-строке (например: ?id=123 - такой адрес логически должен был бы сослать нас на пользователя с уникальным идентификатором №123 и хотя технически он разрешен в пути, это может запутать как пользователей, так и вас - разработчиков). Помимо этого, недопустимость использования символа «=» обусловлена особенностями реализации пакета base64, которые не имеют необходимости в рассмотрении в рамках статьи. Достаточно знать, что для генерации каких-либо уникальных комбинаций рационально исключать этот символ.
Именно поэтому мы и используем RawURLEncoding:
URLEncoding заменяет «+» и «/» на «-» и «_» соответственно
Raw исключает символы «=», что на выходе даёт компактный и чистый идентификатор
Строка 14: Возвращение функцией уникального идентификатора заданной длины.
Чтобы видеть результаты нашей работы – давайте поскорее вставим наш генератор в код CreateHandler’a и добавим в логирование id созданной ячейки
Импортируйте созданную функцию из пакета lib/generator в delivery/http/handler, импортировать пакет можно следующим образом:
import ( "deaddrop/internal/lib/generator" )
Теперь вы можете пользоваться функциями этого пакета, если у вас всё же возникают проблемы с доступом к созданной функции, проверьте её название: дело в том, что go позволяет пользоваться функциями сторонних пакетов только в том случае, если они называются с большой буквы. Проверьте, чтобы ваша функция GenerateID начиналась с большой буквы.
Поэкспериментируйте, попробуйте назвать её буквой нижнего регистра и проверьте её доступность в стороннем пакете.
Попробуйте самостоятельно добавить новую функцию в хэндлер и залогировать данные следующим образом:

С соответствующей реализацией вы можете ознакомиться ниже:
package handler import ( "deaddrop/internal/lib/generator" "io" "log" "net/http" "path/filepath" ) func CreateHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 10<<20) if err := r.ParseMultipartForm(20 << 20); err != nil { http.Error(w, "File is too big or invalid form", http.StatusBadRequest) return } 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() 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 := 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 } } 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) }
Давайте перейдём к реализации генератора паролей.
Вооружившись знаниями, полученными в разделе о генераторе id попробуйте самостоятельно написать генератор паролей с учётом всех требований, которые мы к нему представили выше, напомню:
Он должен будет содержать как большие, так и маленькие буквы.
Пароль должен будет обязательным образом содержать цифры.
Пароль должен содержать специальные символы («@», «!», «$», «%», «&», «#»).
Хоть я и приведу свою реализацию данной утилиты ниже, я крайне рекомендую попробовать сделать его своими руками, а не брать готовый вариант. Каждый раз, когда вы пишите что-то своими руками – вы совершенствуете свои знания и умения в программировании – не пренебрегайте этим.
Моя реализация функции GeneratePassword имеет следующую сигнатуру:
func GeneratePassword(length int) (string, error)
и представлена ниже:
import ( "crypto/rand" "fmt" "math/big" ) const ( lower = "abcdefghijklmnopqrstuvwxyz" upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" digits = "0123456789" spec = "@!$%&#" total = lower + upper + digits + spec ) func GeneratePassword(length int) (string, error) { if length < 5 { return "", fmt.Errorf("length of password is too low") } password := make([]byte, length) randChar := func(set string) (byte, error) { max := big.NewInt(int64(len(set))) n, err := rand.Int(rand.Reader, max) if err != nil { return 0, err } return set[n.Int64()], nil } chars := []string{lower, upper, spec, digits} for i := 0; i < 4; i++ { char, err := randChar(chars[i]) if err != nil { return "", err } password[i] = char } for i := 4; i < length; i++ { char, err := randChar(total) if err != nil { return "", err } password[i] = char } for i := length - 1; i > 0; i-- { max := big.NewInt(int64(i + 1)) j, err := rand.Int(rand.Reader, max) if err != nil { return "", err } password[i], password[j.Int64()] = password[j.Int64()], password[i] } return string(password), nil }
Можете ознакомиться с тем, какие результаты получаются на выходе представленной функции:

Обращаю внимание, постарайтесь сделать перемешивание символов в пароле после его генерации, это придаст ему более высокий уровень криптостойкости, в своей реализации вы можете прибегнуть к следующей последовательности действий:
Заполнение указанной длины символами из каждой категории (буквы разных регистров, специальные символы, цифры) – перемешивание получившегося массива – преобразование массива к строке
После того как мы написали генератор паролей аналогичным с генератором идентификаторов добавим его в наш CreateHandler и проверим работоспособность, ВРЕМЕННО логируя пароль в консоли сервера (если у вас возникли сложности с добавлением или реализацией вы можете ознакомиться с ними в GitHub репозитории проекта: https://github.com/Meedoeed/DeadDrop)
Результат добавления генератора паролей при логировании:

На данный момент, все необходимые нам утилиты успешно созданы, постепенно наш обработчик обрастает полезным функционалом, но пока что он всё еще «говорящий попугай» - он получает данные, что-то логирует, крутит их внутри себя, но «почтовые ячейки» не создаются и не сохраняются, с этим пора что-то делать!
Вы, скорее всего, знаете, что по-правильному, организовать хранение ячеек стоит в базе данных, но почему бы нам не начать с чего-то простого, а уже потом мигрировать в сторону серьезных решений. Это позволит нам осознать изнанку работы сервиса, увидеть какой он внутри, а современные технологии мы использовать мы ещё успеем.
Go представляет нам простое и элегантное решение для начала работы – In-memory хранилище.
Представьте наше хранилище как большую библиотеку. Каждая книга в ней имеет свой уникальный номер (ID). Библиотекарь (наш сервис) принимает новые книги и ставит их на полку, а посетители, зная каталожный номер и имея ключ доступа (пароль), могут прийти и прочитать книгу.
В Go такой «библиотекой» является простая структура данных – map. Map – встроенный тип, который представляет собой хранит в себе пары вида «ключ-значение».
Давайте посмотрим на несколько примеров использования этого типа данных:
prices := map[string]float64{ "хлеб": 50.5, "молоко": 80.0, "яйца": 120.0, "сыр": 350.0, "колбаса": 450.0, } // Добавляем новый продукт prices["йогурт"] = 65.0
Здесь в удобном виде хранятся данные о продуктах в магазине и их ценах, запросить цену продукта можно просто по ключу, обращение вида:
prices[“йогурт”] вернет нам значение 65 – его цену.
А какого вида должна быть мапа для нашего хранилища? Что должно быть ключом, что должно быть значением? Какого типа должно быть значение?
Давайте попробуем, ключом сделать ID, который мы уже научились генерировать в утилите – он уникален, что важно для нас, значением сделаем абстрактную структуру ячейки.
Давайте, для ясности, попробуем на практике реализовать структуру данных, которая будет характеризовать наш мертвый почтовый ящик.
В реализации обратимся к тому, что мы уже описывали выше относительно структуры секретной ячейки, напомню:
ID – уникальный идентификатор ячейки
Message – сообщение из формы клиента
FileData –содержимое файла
FileName – имя файла
FileExt – расширение файла
Password – пароль от почтовой ячейки
ExpiresAt – время исчезновения ячейки
Начнем с простого, давайте ответим на вопрос: где реализовывать эту структуру данных? – Должно быть вы уже догадались, для этого следует реализовать новую директорию в нашем проекте, лежать она будет в internal/ и называться models. Здесь мы будем хранить все необходимые модели и структуры данных.

Структуры в Go пишутся крайне просто, давайте я приведу абстрактный пример-образец, чтобы помочь вам в написании собственной реализации:
Образец | Пример |
|
|
Если вы знакомы с другими языками программирования, по типу C++/Java вы должно быть знаете о уровнях доступа к переменным, а именно public, private, protected. В Go нет таких ключевых слов. Здесь структуры и поля структур могут подразделяться на 2 типа: экспортируемые и неэкспортируемые. Экспортируемые будут доступны везде, где вы импортируете пакет, где написали реализацию своей структуры. Неэкспортируемые доступны только в рамках пакета, где они реализованы.
Но как же сделать что-то экспортируемым или нет? – Всё просто, здесь работает простое правило, основанное на регистре первой буквы названия структуры или её поля. Все поля, названные с большой буквы – экспортируемы, все структуры, названные с большой буквы также экспортируемы. Если структура или поле структуры называется с маленькой буквы: они будут доступны только в рамках пакета, где они реализованы.
Давайте для ясности рассмотрим пример со структурой, которую мы хотим реализовать:
Представьте, мы написали её так:
Код | Пояснение |
| Такой реализацией мы сможем пользоваться только в файлах, которые лежат в нашем случае в internal/models. Это неудобно и неправильно! Мы разделяем логику нашего приложения и хотим, чтобы структурой можно было пользоваться вне пакета её реализации |
| Теперь можно импортировать пакет models в другие пакеты и пользоваться нашей новой структурой данных |
| Структура импортировалась, но мы не можем обратиться к полю: |
| Теперь можно менять поле Id и обращаться к нему в других пакетах |
В реализациях своих проектов старайтесь уделять внимание тому, какие поля и структуры правильно будет инкапсулировать внутри пакета их реализации, а какие вынести «наружу», к другим пакетам.
Создайте в internal/models файл, где попробуйте реализовать структуру «Secret», постарайтесь представить к каждому полю тип данных, который правильно его описывает.
Предлагаю ознакомиться с моей реализацией:
package models import ( "time" ) type Secret struct { ID string Message string FileData []byte FileName string FileExt string Password string ExpiresAt time.Time }
Я думаю, вопросов к причинам выбора соответствующих типов данных полей возникнуть не должно, рассмотрим лишь FileData:
FileData []byte:
Вы спросите: «Почему мы храним файл, как срез байт, а не, скажем, строку?»
Строка: в Go строка – неизменяемый срез данных, предназначенный для хранения текста. Наш файл может быть бинарным и хранение его как текста не только неправильно логически, так ещё и неэффективно. Срез байт – «сырые» данные, они идеально подходят для любого рода информации.
Почему бы нам не хранить файл в памяти, сохраняя лишь путь к нему? Для in-memory хранилища, где все данные пропадают при перезагрузке сервиса это не совсем рационально: пути пропали – файлы остались. Этот подход можно реализовать, создавая резервные копии данных, позднее это может быть рассмотрено в следующих статьях цикла, в контексте graceful shutdown.
Всё касаемо хранения данных в рамках структурной организации проекта следует хранить в отведенном специально под это слое: «storage». Давайте создадим для него соответствующую директорию: internal/storage
Здесь нам необходимо создать файл interface.go и поддиректорию in-memory, давайте обсудим подробнее, для чего нам такие «усложнения»:
Предположим: наш DeadDrop-сервис стал популярным. Нагрузка начала расти и простое in-memory хранилище не справляется: памяти для хранения файлов начинает не хватать, а на покупку сервера с большим её объемом у нас просто нет средств. К тому же, пользователи поняли, что из-за in-memory хранилища при перезагрузке сервиса все их секреты теряются – они расстроены. Что вы предпримите? Наверное, как грамотные разработчики вы решите перейти на базу данных, к примеру, PostgreSQL.
И что теперь, переписывать весь код с нуля? – Если бы мы жестко привязывались к способу хранения данных, то да – пришлось бы. Мы бы долго и муторно перебирали все утилиты, все use case’ы, в которых каким-то образом обращались к хранилищу. Это нерационально. На помощь нам приходят интерфейсы.
Интерфейс – это контракт. Он говорит: «Неважно, как ты хранишь данные: в памяти или в базе данных – главное, чтобы ты умел выполнять такие операции»
Благодаря интерфейсу мы можем:
Заменять реализации не трогая остальной код. Сегодня in-memory, завтра PostgreSQL, послезавра MongoDB
Чётко определить, что нужно от хранилища на уровне бизнес-логики
Давайте обдумаем, что должен требовать наш интерфейс хранилища. Какой минимальный набор операций необходим для корректной работы сервиса?
Создание секрета – когда пользователь создаёт ячейку, мы должны сохранить данные, которые пришли от него в форме.
Получение данных из секрета по ID - когда пользователь переходит по ссылке /secret/{id} и вводит корректный пароль, нам нужно загрузить данные конкретной ячейки
Удаление секрета — При истечении срока действия – секрет должен удаляться.
Закрытие подключения – будет полезно для закрытия подключения к базе данных в случае ошибки. Если это не имеет необходимости в конкретном способе хранения – покрывать этот метод будем функцией-заглушкой.
Документация Go отлично описывает то, как писать интерфейсы, и чтобы не раздувать объем статьи я дам ссылку на метанит, где вы можете ознакомиться с тем, как правильно их реализовывать: https://metanit.com/go/tutorial/6.1.php - настоятельно рекомендую прочитать, интерфейсы не раз вам пригодятся в рамках работы с этим языком, они представляют удобное средство полиморфизма и понимание их реализаций повышает ваш уровень, как специалиста.
Давайте ознакомимся с реализацией storage/interface.go:
type Storage interface { Save(secret *models.Secret) error Get(id string) (*models.Secret, error) Delete(id string) error Close() error }
Обращу внимание, в реальных проектах в методы следовало бы также передавать context.Context для контроля таймаутов и отмены операций. Мы вернёмся к этому, когда будем совершенствовать наше хранилище в следующих статьях этого цикла.
Теперь, когда контракт (интерфейс) создан, мы можем создать его конкретную реализацию и как мы и договаривались, начнем с простого – хранить данные мы пока что будем в оперативной памяти.
Давайте создадим файл internal/storage/in-memory/storage.go
В нем должна быть структура, которая своими методами имплементирует описанный выше интерфейс. Это значит – реализует сигнатуры всех методов контракта Storage (Save, Get, Delete, Close).
Перед тем, как я предложу вам реализовать структуру Storage, давайте обсудим, как реализовать ее правильно.
Что должна включать структура хранилища? – Очевидно, мапу, про которую мы общались выше. Мапу, которая ключом к себе будет иметь ID секрета, а значением по этому ключу будет ячейка – экземпляр структуры Secret, которую мы только что реализовали.
Но одной лишь мапы будет недостаточно и вот почему.
Снова представим, что сервис DeadDrop обрёл популярность, сотни пользователей одновременно создают ячейки и читают их содержимое. Все эти действия – отдельные независимые потоки (горутины), которые пытаются работать с одной нашей map. Вот тут-то нас поджидает опасность.
Давайте подумаем: «Что произойдет если одновременно 2 горутины попытаются записать что-то в map?»
Мысленно смоделируем следующее:
Наша мапа – записная книжка, а потоки – люди, которые пытаются писать туда что-то и читать одновременно.
Ситуация 1: Чтение и запись одновременно:
Петя читает написанное на странице 5, например «ID1: привет, Хабр!».
Вася в это же время пытается вписать туда что-то своё: «ID2: пока, Хабр!».
Вопрос: «Что прочтет Петя?». То, что ожидал? То, что написал Вася? А может вообще ничего не прочтет? Ответ: результат действительно непредсказуем и произойти может любой из вариантов.
Ситуация 2: Две одновременные записи:
Петя пишет на странице 10 «ID1: привет, Хабр!»
Вася выдергивает у него из рук книгу и пытается записать «ID2: пока, Хабр!».
Аналогичный вопрос, что в конечном итоге будет написано на странице? Ответ аналогично простой: неопределенность.
Именно это происходит с нашей map в Go. На самом деле под капотом у этой структуры данных кроются серьезные и сложные механизмы: хеш-таблицы, корзины, указатели и так далее. Если 2 горутины попробуют одновременно что-то в ней поменять, они вполне возможно нарушат её внутреннее состояние. Программа в таком случае может:
Упасть сфатальной ошибкой (чаще всего fatal error: concurrent map writes)
Выдать неверные данные
Войти в состояниегонки данных (data race)
- «Что же делать, чтобы такого не случалось?»
Правильным решением будет синхронизировать доступ к map с помощью мьютекса. Мьютекс это как «замок» в уборной в общественном месте: кто зашел – закрыл замок, а остальные пускай ждут, пока он закончит свои дела. В Go есть 2 основных вида мьютексов:
1) sync.Mutex – простой мьютекс. Он гарантирует, что только одна горутина может выполнять критическую секцию кода (участок, где происходит доступ к данным).
2) sync.RWMutex — мьютекс с разделением на чтение и запись (Read-Write Mutex). Он позволяет:
Неограниченному количеству горутин одновременно читать данные (вызывать RLock())
Только одной горутине писать данные (вызывать Lock())
Пока писатель работает, никто из читателей не может получить доступ к данным
В ситуациях, когда операций чтения значительно больше, чем операций записи оптимальным решением является использование именно RWMutex. Это как раз наш случай.
Теперь вы готовы к реализации структуры Storage, пока что опустите реализацию конкретных методов и попробуйте написать просто структуру данных в файле internal/storage/in-memory/storage.go, содержащую map и RWMutex. Напишите её самостоятельно и сверьтесь с правильной реализацией ниже, там есть интересная оптимизация:
package inmemory import ( "deaddrop/internal/models" "sync" ) type Storage struct { mu sync.RWMutex secrets map[string]*models.Secret }
Рассмотрим структуру подробнее:
mu – наш «замок», который защищает карту от одновременного доступа.
secrets – карта, где ключом типа string будет ID, а значением указатель на созданную выше структуру данных Secret. Оптимизация заключается именно в этом указателе – он позволяет не постоянно копировать конкретный секрет при каждом чтении и записи, передача его по значению была бы крайне неэффективна.
Для удобства создания нашего хранилища реализуем функцию-конструктор. Это общепринятая практика в Go, которая позволяет гарантировать, что наше хранилище инициализировано и готово к использованию. Функция-конструктор в нашем случае будет иметь следующий вид:
func NewStorage() *Storage { return &Storage{ secrets: make(map[string]*models.Secret), } }
Лежать она должна в том же файле internal/storage/in-memory/storage.go
Итак, теперь у нас есть структура Storage, но как вы понимаете, она не покрывает методами одноименный интерфейс. Давайте же реализуем эти методы. При реализации необходимо помнить о том, что с мапой надо работать максимально аккуратно, применяя вложенный в структуру мьютекс по предназначению. Давайте я вкратце опишу что нужно реализовать в методах и обозначу при этом правила работы с мьютексом для каждого из них.
Метод Save – Сохраняем секрет, полученный из формы на сайте в in-memory хранилище. При записи нам необходимо полностью блокировать мьютекс как на чтение, так и на запись сторонними горутинами, потому что мы обновляем структуру данных, если кто-то попытается прочитать что-то из неё в этот момент или же записать что-то, то будет либо паника, либо гонка данных.
Метод Get – Этот метод используется для получения секрета по ID. Здесь мы только читаем данные, поэтому логично воспользоваться методом RLock для нашего мьютекса, это – разделяемая блокировка на чтение. Она позволяет многим горутинам одновременно вызывать Get, не дожидаясь друг друга.
Метод Delete - Удаление секрета по соответствующему ID. По аналогии с Save структура данных обновляется, а значит используем полную блокировку.
Метод Close – В in-memory хранилище не используется, здесь нам следовало бы закрывать файлы или сетевые соединения, а ничего из этого в нашем случае не используется. Однако, чтобы удовлетворить интерфейсу нам следует всё же реализовать этот метод, оставьте в нём заглушку.
Важно! Не забывайте, что после того как вы заблокировали мьютекс – его необходимо разблокировать! Хорошей практикой считается разблокировка мьютекса в defer – это гарантирует его разблокировку по окончании функции, которая его вызвала. Разблокировка осуществляется с помощью методов mu.RUnlock() (для чтения) и mu.Unlock(для полной разблокировки после mu.Lock())
Теперь вы знаете всё, что необходимо чтобы написать методы для структуры in-memory хранилища Storage. Реализуйте их так, чтобы структура удовлетворяла интерфейсу хранилища. Реализовать методы можно прямо в файле, где хранится сама структура: internal/storage/in-memory/storage.go
После того, как написали методы сами, сверьтесь с верной реализацией, в ней приведены построчные комментарии, которые позволят вам понять материал лучше:
// Save сохраняет секрет в хранилище. func (s *Storage) Save(secret *models.Secret) error { // Блокируем мьютекс на запись. Это означает, что: // 1. Ни одна другая горутина не сможет выполнить этот же участок кода одновременно // 2. Ни одна горутина не сможет выполнить метод Get (так как он использует RLock) // 3. Текущая горутина получает эксклюзивный доступ к мапе secrets s.mu.Lock() // Гарантируем, что при выходе из функции мьютекс будет разблокирован. // defer выполняется даже в случае паники, поэтому это надёжный способ избежать deadlock'а defer s.mu.Unlock() // Сохраняем секрет в мапу. Ключом выступает ID секрета, // значением - указатель на структуру Secret. // Использование указателя экономит память, так как не копирует структуру при каждом обращении s.secrets[secret.ID] = secret // Возвращаем nil - ошибок не произошло return nil } // Get возвращает секрет по его ID. func (s *Storage) Get(id string) (*models.Secret, error) { // Блокируем мьютекс на чтение. Это означает, что: // 1. Другие горутины также могут одновременно выполнять методы с RLock (например, другие вызовы Get) // 2. Ни одна горутина не сможет выполнить методы с Lock (Save, Delete), пока не завершится наше чтение // 3. Это оптимально, так как операций чтения обычно больше, чем записи s.mu.RLock() // Гарантированно снимаем блокировку чтения при выходе из функции defer s.mu.RUnlock() // Пытаемся найти секрет в мапе по ID. // Второе возвращаемое значение (ok) показывает, найден ли ключ в мапе secret, ok := s.secrets[id] // Если секрет не найден (ok == false) if !ok { // Возвращаем nil вместо указателя на Secret и nil вместо ошибки. // Вызывающий код должен проверить, что secret == nil // В будущем здесь лучше возвращать кастомную ошибку ErrSecretNotFound return nil, nil } // Секрет найден - возвращаем указатель на него и nil вместо ошибки return secret, nil } // Delete удаляет секрет по ID. func (s *Storage) Delete(id string) error { // Блокируем мьютекс на запись, так как будем изменять мапу. // Во время выполнения этого метода никакие другие горутины не смогут // ни читать (Get), ни писать (Save, Delete) данные s.mu.Lock() // Гарантированно разблокируем мьютекс при выходе defer s.mu.Unlock() // Встроенная функция delete удаляет элемент из мапы по ключу. // Если ключа не существует - ничего не происходит, ошибки не будет delete(s.secrets, id) // Возвращаем nil - ошибок не произошло return nil } // Close закрывает хранилище. Для in-memory версии ничего не делает. // Этот метод требуется для реализации интерфейса storage.Storage. // В случае с базой данных здесь бы закрывалось соединение, // но для хранения в памяти никаких ресурсов освобождать не нужно func (s *Storage) Close() error { return nil }
Для понимания:
Почему в Get возвращается nil, nil, а не ошибка? – В последствии мы сделаем кастомную ошибку, которая будет говорить, случился сбой на стороне сервиса или секрет по указанному ключу не найден.
Почему в Get – Rlock, а в Save и Delete – Lock? – Lock предоставляет эксклюзивный доступ: ни читать, ни писать не может никто, а Rlock – разделяемый доступ: многие горутины могут читать структуру данных, но писать никто не может. Так как обращаются за чтением чаще, чем за записью это является хорошей оптимизацией.
Что если не вызвать Unclock после блокировки? - Произойдёт взаимная блокировка (deadlock). Все горутины, пытающиеся получить доступ к хранилищу, остановятся навсегда. Именно поэтому мы используем defer — он гарантирует вызов даже при панике или раннем возврате.
Мы провели огромную работу. Фундамент сервиса заложен, давайте подведём промежуточный итог в целях закрепления материала:
Что мы сделали в этой подстатье?
Научились работать с POST-запросами и формами
Обеспечили базовую безопасность при загрузке файла
Создали необходимые утилиты
Спроектировали и реализовали in-memory хранилище для нашего сервиса
Теперь наш CreateHandler готов к созданию секретов! Он не просто читает данные, а уже готов к их сохранению в новое хранилище. Однако, то как им чисто и правильно пользоваться – длинный разговор, мы будем заниматься этим в продолжении этой статьи. Вы можете самостоятельно попробовать временно обновить CreateHandler чтобы проверить, как работает хранилище, для этой реализации дам несколько подсказок:
1. Импортируйте созданные пакеты:
import ( "deaddrop/internal/models" "deaddrop/internal/lib/generator" "deaddrop/internal/storage" // или конкретную реализацию )
2. Сгенерируйте ID:
id, err := generator.GenerateID(10) if err != nil { http.Error(w, "Cannot generate ID", http.StatusInternalServerError) return }
Не забудьте обработать ошибку!
3. Сгенерируйте пароль
password, err := generator.GeneratePassword(12) if err != nil { http.Error(w, "Cannot generate password", http.StatusInternalServerError) return }
4. Создайте структуру Secret из пакета models:
secret := &models.Secret{ ID: id, Message: message, Password: password, ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Hour), }
Вычислите ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Hour)
5. Сохраните секрет в хранилище:
// Понадобится доступ к хранилищу (например, через глобальную переменную) var secretStorage storage.Storage // инициализируйте где-то err = secretStorage.Save(secret) if err != nil { http.Error(w, "Cannot save secret", http.StatusInternalServerError) return }
6. Временно покажите ID и пароль пользователю:
w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, ` <h1>Secret created!</h1> <p><strong>ID:</strong> %s</p> <p><strong>Password:</strong> %s</p> <p><a href="/">Back to home</a></p> `, id, password)
Прямо в HTML-ответе, чтобы проверить работу
7. Залогируйте успешное создание секрета:
log.Printf("[INFO] Secret created: id=%s ttl=%s", id, ttl)
Если с реализацией возникнут сложности - я с удовольствием объясню особенности этой реализации в комментариях или в личных сообщениях
На этом работа не закончена. Мы создали лишь каркас и в следующей статье: «Разбираем net/http на практике. Часть 2.2: Архитектура и безопасность. Внедряем Clean Architecture, хэшируем пароли и защищаем файлы» нас ждёт не менее увлекательная работа:
Мы проведём полный рефакторинг CreateHandler – начнём пользоваться хранилищем, внедрим элементы чистой архитектуры. Чётко разделим слои достаквки, логики (use case) и хранения (storage). Это подготовит сервис к росту и изменениям.
Мы научимся хэшированию паролей. Их хранение в открытом виде – моветон. Мы исправим это с помощью методов хэширования пакета bcrypt
Мы сделаем так, чтобы просмотреть содержимое ячейки мог только тот, кто знает от неё пароль
Я рад любым вопросам и предложениям. Спасибо за комментарии и добавления в закладки первой статьи цикла. Это действительно даёт большой стимул к продолжению работы.
Делитесь своими мыслями:
Как вам формат разделения статьи на части? Достаточно ли подробно рассмотрены вопросы работы с файлами? Я буду рад пояснить что-то или внести правки в материал.
Пишите свои вопросы, замечания или предложения в комментариях! Код этой статьи, как всегда, доступен на GitHub проекта.
https://github.com/Meedoeed/DeadDrop