Список желаний на Go: как он устроен

· News · Read in English

У меня появился список желаний — страница, где можно выбрать мне подарок. Задача была простая: я кладу туда ссылки на вещи из любых интернет-магазинов, а гость выбирает подарок, отмечает «беру на себя» — и карточка сереет, чтобы никто другой не купил то же самое. Без регистрации, без имён, без переписки «а ты что даришь?».

Устроен он так же, как этот блог: маленькая программа на Go в Docker-контейнере на Raspberry Pi дома. Ниже — как оно работает, что вошло, что сознательно осталось за бортом и почему.

Что было нужно

Требования поместились в один абзац: админка, куда я вставляю ссылки на Amazon, eBay и вообще любой магазин; у гостей — список с картинками, ценами и магазинами; анонимная бронь, после которой подарок становится недоступен для остальных. И подсказка, куда отправлять подарок: из США — на мой американский адрес, из любой другой страны — на канадский.

Цифры

Замер на Raspberry Pi 2 ноября 2026 года:

Память процесса 24 МБ
Программа 9,3 МБ
Docker-образ целиком 18,5 МБ
Все данные (wishlist.json) 15 КБ
Главная страница около 4 мс

Один бинарник, ноль зависимостей

Весь список желаний — одна программа на Go. Шаблоны, стили, скрипт и иконки вшиты внутрь, так что рядом с ней на сервере не лежит ни одного файла. Внешних зависимостей нет совсем: в go.mod только имя модуля и версия Go. HTTP-сервер, шаблоны, разбор HTML-страниц магазинов, SMTP-клиент, хеширование пароля — всё из стандартной библиотеки.

Отброшено: веб-фреймворки, ORM, сборщики фронтенда и сторонние библиотеки вообще. Каждая лишняя зависимость — это ещё одна вещь, которая однажды сломается при обновлении.

Никакой базы данных

Подарки, брони и настройки хранятся в одном файле wishlist.json. Он целиком лежит в памяти, а при каждом изменении записывается атомарно: сначала во временный файл, затем переименование. Если питание пропадёт посреди записи, на диске останется либо старая версия, либо новая.

Бронь делается под одной блокировкой: проверка «свободен ли ещё подарок» и запись брони — это одно неделимое действие. Поэтому два гостя, нажавшие кнопку одновременно, не смогут взять одну и ту же последнюю штуку.

Отброшено: любая СУБД. Весь мой список желаний — 15 килобайт.

Бронь без регистрации

Гость нажимает на карточку и попадает на следующий шаг: там правила доставки и поле для почты. После брони он получает письмо со ссылкой на свою бронь — по ней можно увидеть адрес доставки, ссылку на магазин, отметить «уже купил» или отменить. За три дня до окончания брони приходит одно напоминание. Бронь держится 14 дней, если подарок не отмечен как купленный, — потом подарок снова становится свободным.

Браузер запоминает свои брони, поэтому на карточке гость видит «Это дарите вы».

Почта при брони обязательна: кнопка не нажимается, пока адрес не введён правильно, и сервер без него бронь не примет. Ссылка на бронь — единственный способ ею управлять, и в письме она не потеряется. Адрес гостя хранится только до того момента, когда подарок отмечен как купленный.

Отброшено: вход для гостей через Apple или Google, имена и аккаунты. Чтобы подарить подарок, не нужно никому представляться.

Куда отправлять подарок

После брони гость видит адрес в виде отдельных полей — имя, улица, город, индекс, — каждое копируется одним касанием, есть и кнопка «Копировать всё». Какой адрес показать, решает магазин: американский магазин везёт на американский адрес, магазин любой другой страны — на канадский. Если подарок привязан к одной стране, показывается только её адрес. Для подарков без привязки адрес по умолчанию выбирается по стране посетителя, которую сообщает Cloudflare.

Ещё один вариант — заказать подарок на свой адрес и вручить лично. Об этом сказано до брони, вместе с правилами доставки.

Сами адреса хранятся в данных на сервере и редактируются в админке, в репозитории их нет.

Как читаются магазины

Я вставляю в админку ссылку, и программа сама достаёт название, картинку и цену. Оказалось, что каждый магазин приходится читать по-своему:

Магазин Как
amazon.com Из Канады amazon.com не показывает цену товаров, которые сюда не доставляются. Поэтому программа один раз за сессию ставит американский индекс доставки через собственный диалог Amazon «Deliver to» — и цена появляется.
amazon.ca Страница товара, цена в канадских долларах.
Discogs Сайт закрыт проверкой Cloudflare, зато есть открытый API без ключа. Для релиза показывается самое дешёвое предложение на маркетплейсе — «от $X», для конкретного лота — его цена.
Любой другой Теги OpenGraph и разметка schema.org Product на странице.
eBay, Casio, Best Buy, LEGO Отказывают любому клиенту, кроме настоящего браузера. Карточка создаётся с пометкой, а название, цену и картинку я заполняю руками.

Картинки скачиваются один раз и отдаются самой программой, так что карточки не зависят от того, что магазин сделает со своими ссылками.

Отброшено: обход защиты от ботов. Сайты, которые закрылись от программ, остаются с ручным заполнением.

Цены каждое утро

Каждый день в 06:00 программа заново читает цены всех подарков, а если сервер был выключен дольше суток — через минуту после запуска. Цена заменяется только ценой, реально прочитанной с сайта магазина: то, что я ввёл руками для «закрытых» магазинов, остаётся как есть.

Если у Amazon нет собственного предложения на товар (остались только сторонние продавцы, «See All Buying Options», или «Currently unavailable»), гость вместо цены видит «Временно недоступен в магазине». Как только утренняя проверка снова прочитает цену, она вернётся на карточку.

Отброшено: письма о снижении цены. Идея была в первую очередь для Discogs, но его API не умеет отдать самую низкую цену только среди пластинок в состоянии Mint или Near Mint — а покупать нужно именно такие (об этом ниже).

Пластинки с Discogs

На Discogs продают подержанные пластинки, кассеты и диски, и их состояние сильно различается. Поэтому для подарков с Discogs действует правило: только Mint или Near Mint, и для самого носителя, и для конверта. На карточке об этом одна строка, а на шаге брони и на странице брони есть свёрнутый блок с определениями этих оценок из справки Discogs — для того формата, в котором издан релиз: винил, CD или кассета. Русскоязычные гости видят перевод, остальные — оригинал.

Порядок списка

Подарки, которых мне хочется больше всего, помечены ★ «Очень хочу»: на сайте они стоят выше остальных и обведены красной рамкой. Забронированные уходят в конец списка.

В админке список разделён на две части: ★ сверху, всё остальное снизу. Строки можно перетаскивать мышью, пальцем или стрелками на клавиатуре, а перетаскивание строки из одной части в другую ставит или снимает ★. Кроме ручного порядка, список можно отсортировать по дате добавления, названию или цене. Цены в разных валютах сравниваются в канадских долларах по среднему дневному курсу Банка Канады — его программа берёт из открытого API банка.

Два языка

Сайт и админка — на русском и английском. Язык берётся из браузера, а переключатель запоминает выбор. Русские числа склоняются по правилам Unicode CLDR: «1 день, 2 дня, 5 дней», без сокращений вроде «дн.».

Карточка для ссылок в соцсетях и мессенджерах — только на английском: «Pick a gift to make my day».

Почта

Гостям приходят письмо со ссылкой на бронь и напоминание, мне — уведомления о бронях, покупках, отменах и истёкших бронях, с названием подарка. Почту отправляет собственный небольшой SMTP-клиент программы, только через зашифрованное соединение (STARTTLS), а доставляет её Resend — сервис для писем из приложений.

Отброшено:

  • Почта iCloud. Сначала письма уходили через мой ящик iCloud. Адресатам на Apple они доходили, а Gmail их отклонял с ошибкой 550 5.7.1 [CS101] Message rejected due to local policy.
  • Уведомления в Telegram. Их заменили письма на отдельный адрес.
  • Режим «без спойлеров», при котором я в админке не видел бы, что именно мне подарят. Он был, но я убрал его: на публичной странице пометка «Уже кто-то дарит» всё равно видна с любого телефона, так что прятать брони от самого себя было бессмысленно.

Безопасность и приватность

  • Поисковикам страницы закрыты (noindex). Роботам, которые строят превью ссылок для соцсетей и мессенджеров, публичные страницы открыты — иначе ссылка выглядела бы голым адресом. Админка и страницы броней закрыты и для них.
  • Не больше 5 броней в час и 5 попыток входа в админку за 15 минут с одного адреса.
  • Пароль админки хранится в виде хеша PBKDF2-SHA256 (600 000 итераций) из стандартной библиотеки Go, сессия — подписанная HMAC cookie, без хранилища сессий на сервере.
  • Скрипты и стили загружаются только с самого сайта (Content-Security-Policy), а формы принимаются только со своих же страниц.

Docker и туннель

Образ собирается в два этапа: на первом Go компилирует статический бинарник, на втором он копируется в чистый Alpine, где из лишнего — только сертификаты и часовые пояса. Контейнер работает от непривилегированного пользователя, все данные — в одном томе: wishlist.json, ключ сессий и картинки.

Образ собирается прямо из репозитория на GitHub, так что обновление на сервере — одна команда:

docker compose build --no-cache && docker compose up -d

Каждое изменение кода перед этим проходит проверку на GitHub: форматирование, go vet, тесты с детектором гонок и пробная сборка под ARM64 — архитектуру Raspberry Pi.

Наружу сайт выходит через Cloudflare Tunnel: отдельный контейнер cloudflared в той же Docker-сети, так что порты домашнего роутера открывать не пришлось.

Исходный код

Код списка желаний открыт: github.com/WhyElEss/wishlist