В статье про ядро своего генератора QR‑кодов листами я написал в первом абзаце, что не хотел прогонять ссылки через чужой редирект, который умрет вместе с триалом. Та статья вышла в августе, а в октябре у моего сервиса qqkod.ru появился свой редирект.
В банках «динамическим QR» называют платежный код СБП с суммой под каждую покупку. Здесь речь о другом: в код записана короткая ссылка вида https://qqkod.link/abc1234, и страницу, куда она ведет, можно сменить в кабинете уже после печати. Одно решение из статьи про публичный API генератора здесь пришлось вывернуть наизнанку. Динамические ссылки появились в репозитории 29.09, открылись всем пользователям 06.10, а проверку через Google получили 15.10.
Что я пообещал
Любой динамический код зависит от сервера. Каждый переход идет через мой редирект, и если сервис закроется, напечатанные коды с короткой ссылкой перестанут работать. Слов «без lock‑in» на лендинге нет, там написано, что так устроен любой динамический код.
Что я могу обещать, записано в оферте и на лендинге. 3 динамические ссылки в каждой учетной записи бесплатно и без срока. Лимита переходов нет, рекламы и промежуточной страницы тоже: редирект отвечает 302 сразу на цель. Статистика переходов бесплатна всем. Больше ссылок дают подписки Pro и Business, 100 и 1000. Список ссылок кабинета с целями, статусами и числом переходов в любой момент скачивается в CSV.
Ссылки сверх бесплатных 3 я хотел после конца подписки оставить работать и только закрыть создание новых. Потом подумал: каждую живую ссылку я перепроверяю по спискам Google, и держать так 1 000 ссылок клиента, который ушел, значит без конца отвечать за чужие цели. Вышло так: как только подписка кончилась, менять страницу у ссылок сверх 3 нельзя, еще 10 дней они открываются, потом ждут подписки и перестают открываться. Ничего не удаляется, статистика видна, а после оплаты все ссылки возвращаются сами. Остаются 3 самые старые по дате создания.
Код никогда не выдается повторно. При удалении ссылка получает статус deleted, строка остается в таблице и держит уникальный ключ, иначе человек со старой наклейкой мог бы попасть на чужую новую страницу. Приостановленная, заблокированная, удаленная и ждущая подписки ссылки отвечают одинаково: 410 и страница «Ссылка остановлена». По ответу человек с бумажным QR не узнает, за фишинг ее заблокировали, владелец сам удалил или перестал платить.
Короткий код
Код из 7 знаков, алфавит 32 символа: цифры от 2 до 9 и строчные латинские буквы без l и o. Ноль, единицу, l и o я выбросил, потому что на бумаге их легко перепутать между собой. 32 в 7 степени это 34 359 738 368 кодов, коллизию отбивает уникальный индекс в базе, попыток 5. Пример abc1234 в начале статьи я нарочно взял с единицей: такой код сервис не выдаст, и по этой ссылке не откроется ничья страница.
В короткой ссылке 26 знаков, и при уровне коррекции L, который ставит форма по умолчанию, такой код укладывается в 25 модулей по стороне. Ссылка на меню кафе с 3 метками utm, 76 знаков, дает 33 модуля. При коде 2 см модуль получается 0,80 мм у короткой ссылки и 0,61 мм у длинной. В коде всегда 26 знаков, и плотность печати не зависит от того, какую ссылку в него потом подставят.
Редирект без CMS
Короткий домен qqkod.link смотрит в отдельный каталог с одним r.php. Сайт живет на Битриксе, но редирект его не подключает: ни пролога, ни composer, ни сессий с куками. Свой spl_autoload_register на один неймспейс, mysqli по реквизитам из.settings.php ядра, на каждый скан один SELECT по коду. Ридер геобазы maxmind‑db/reader подключается своим autoload.php пакета, и если файла базы нет, страна остается пустой, а переход работает.
Редирект отвечает:
302 с Cache‑Control: no‑store на активную ссылку, прошедшую проверку;
503 с Retry‑After: 60 и страницей «Ссылка проверяется» на ссылку, которая проверку еще не прошла;
410 на приостановленную, заблокированную, удаленную и ждущую подписки;
404 на несуществующий код, код не из алфавита или не той длины, favicon.ico и лишний слэш;
503 с Retry‑After: 30, если база не ответила.
Методы GET и HEAD, остальное 405. Редирект не читает настройки кабинета: напечатанные коды обязаны работать, даже если создание новых ссылок закрыто.

Две заглушки редиректа: 503, пока ссылка не прошла проверку, и 410 на приостановленную, заблокированную, удаленную и ждущую подписки.
Заглушки рисует PHP, а не nginx. На BitrixVM return 404 из nginx перехватывает error_page, и клиент получает 200 с HTML страницы ошибки сайта. Для редиректа это значило бы, что остановленная ссылка отвечает «200 OK».
htaccess в каталоге из двух правил: любой путь, кроме существующего файла, уходит в r.php?code=$1. Флаг QSA я убрал после приемки, с ним query запроса подменял код из пути. realpath на отсутствующем файле дает false, так что в r.php его нет, пути собираются через dirname().
Проверка конечной ссылки
В статье про API лимитер был fail‑open: сломанный счетчик не должен класть генерацию. Тут наоборот. Одна фишинговая цель на qqkod.link уведет в списки Google и Яндекса весь домен вместе с напечатанными кодами всех пользователей, и непроверенная ссылка не работает, пока проверка не пройдет.
Сначала правила без сети: только http и https, адрес без логина и нестандартного порта, имя вместо IP, не служебная зона вроде.local и.onion, не мой же короткий домен и не сокращатель вроде clck.ru и vk.cc (сокращатель прячет настоящую цель, проверять было бы нечего). Потом списки Google Web Risk для самой цели: MALWARE, SOCIAL_ENGINEERING, UNWANTED_SOFTWARE. Потом один HEAD на цель. Если цель перенаправляет, страница, куда она перенаправляет, проходит те же правила и те же списки.
Опаснее всего HEAD: пользователь дает адрес, мой сервер по нему ходит, то есть SSRF в чистом виде. Имя разрешается заранее, из ответа берется публичный IPv4, и соединение прибивается к нему через CURLOPT_RESOLVE, чтобы второе разрешение имени внутри curl не подсунуло другой адрес. CURLOPT_FOLLOWLOCATION выключен, протоколы http и https, прокси из окружения отключен явно. FILTER_FLAG_GLOBAL_RANGE в PHP 8.2 отвергает частные и служебные сети, но пропускает многоадресные 224.0.0.0/4, у IPv6 еще NAT64 64:ff9b:: и 6 to4 2002:: с частным IPv4 внутри. Первый октет я проверяю отдельно, а IPv6 для HEAD не поддерживаю вовсе.
Если Web Risk не ответил (сеть, квота, код ответа), ссылка все равно создается, но в состоянии pending: по короткой ссылке открывается страница «Ссылка проверяется», печатать код можно и до конца проверки. Крон раз в минуту перепроверяет: pending каждые 10 минут, прошедшие проверку реже, по расписанию. Опасную цель, которая попала в список после создания, крон блокирует. Бюджет вызовов на сутки лежит в файле, часть его отложена под крон, и запросы с сайта этот резерв не трогают. Одна проверка это 1–2 вызова.
Если цель в списке, кабинет отказывает в создании ссылки, и у текста отказа есть обязательные части по правилам Google: осторожная формулировка («страница может быть опасна»), строка «Advisory provided by Google» со ссылкой и оговорка, что список может пропустить опасную страницу и отклонить безопасную. Ключ API уходит заголовком X‑goog‑api‑key, в адресе запроса его нет никогда, чтобы он не оседал в логах.
9 дней без Google
Отправляя конечную ссылку в Google, я передаю данные за границу. В ссылке бывает телефон (wa.me/79991234567) или имя в мессенджере (t.me/ivanov), и это персональные данные: перед их трансграничной передачей по ст. 12 152-ФЗ надо уведомить Роскомнадзор и подождать 10 рабочих дней. Уведомление я подал 30.09, ответ «без ограничений» пришел 15.10.
Все было готово, и 06.10 я включил ссылки всем, а запросы к Web Risk до ответа Роскомнадзора пустил через прокси на мертвый порт. Проверка падала, ссылка оставалась в pending, по коду открывалась страница «Ссылка проверяется». Эти 9 дней ссылки получали passed только после ручной проверки: цель глазами, домен в Яндексе. 15.10 прокси убран, проверка идет через Google.
Статистика без IP
В статистике ссылки видно переходы по дням, странам, ОС и типу устройства, а IP‑адрес нигде не хранится. Переход пишется строкой в дневной ndjson‑файл, вместо адреса 16 hex от sha256 строки «соль|день|адрес». Соль закрытая, день входит в хеш, так что один и тот же адрес завтра даст другой хеш, и склеить дни между собой нельзя. Страна считается локально по базе DB‑IP IP to Country Lite (CC BY 4.0), внешних запросов по IP нет. Базу раз в неделю обновляет крон: скачивает во временный файл, открывает ридером, проверяет, что тип базы Country, что 77.88.8.8 это RU и 8.8.8.8 это US, что сборка новее файла на диске, и после этого делает chmod и rename на место. Если хоть одна проверка не прошла, старый файл остается.
Бот определяется первым, до ОС и устройства, потому что у Googlebot для смартфона в строке User‑Agent есть и Android, и Mobile. Пустой User‑Agent тоже считается ботом.
Сводки по суткам хранятся в таблице: первичный ключ из ссылки, дня, страны, ОС и устройства, плюс счетчик переходов. Прибавляет к ней только редирект в момент перехода, одним запросом: INSERT... SELECT... FROM link WHERE ID =? AND STATUS = 'active' LOCK IN SHARE MODE ON DUPLICATE KEY UPDATE SCANS = SCANS + 1. На проде MySQL 8.0 с READ‑COMMITTED, и без блокирующего чтения скан, начатый во время удаления ссылки, вставлял сводку уже удаленной ссылке. Удаление сделано в обратном порядке: сначала ссылке ставится deleted, потом стираются ее сводки. Когда сводки стирались первыми, скан и удаление ловили взаимную блокировку.
Журнал переходов хранится 90 дней, сводки хранятся 13 месяцев, старое убирает крон раз в сутки. В кабинете статистику можно смотреть за 7, 30, 90 и 365 дней.

Панель статистики у ссылки в кабинете, снимок со стенда: столбики по дням, страны, тип устройства, ОС и история замен страницы.
Пачка из списка
Когда на вкладке «Ссылки» несколько строк, например меню кафе для каждого стола, под списком появляется кнопка «сделать ссылки динамическими»: у каждого стола своя короткая ссылка и своя статистика. Форма сначала узнает у сервера, сколько ссылок еще можно создать, и если их меньше, чем строк, не создает ни одной. Потом форма по одной строке шлет тот же запрос, которым создается одиночная ссылка, каждая ссылка проходит ту же проверку, отказ по одной строке не останавливает остальные.
Что в итоге
На динамические ссылки ушло 594 теста из 4 183 в проекте, и все они без базы и сети: проверка целей, редирект и статистика работают через интерфейсы, и в тестах за интерфейсами стоят фейки. Снаружи, с условиями и лимитами, это показано на странице qqkod.ru/dynamic/.
Спорным осталось одно решение. Приостановленная ссылка и ссылка, ждущая подписки, отвечают тем же 410, что заблокированная и удаленная, хотя обе обратимы: первую владелец включает сам, вторая возвращается после оплаты, и по смыслу статусов HTTP им больше подходит 503 или 404. Я выбрал один ответ на все четыре состояния, чтобы по ответу короткой ссылки нельзя было отличить паузу или неоплату от блокировки. А как бы решили вы?