Ссылка на документ удобна ровно до того момента, пока её не переслали в чужой чат. Клиент кидает её бухгалтеру, бухгалтер — в рабочую группу, а оттуда она уезжает туда, где её уже никто не контролирует. Если ссылка ведёт на обычный открытый файл, у вас нет ни срока, ни счётчика, ни способа сказать «хватит». Поэтому прежде чем писать код бота, стоит решить, какие ограничения должны остаться с документом уже за пределами Telegram. Бот здесь только приёмная: он узнаёт клиента и выдаёт ему талон. А вот что происходит с талоном потом, определяет ваш сервер выдачи.
Ниже разберём, как устроена такая выдача, что на самом деле означает слово «одноразовая» и где в этой идее прячутся неприятные мелочи: два одновременных открытия, предпросмотр в мессенджере, оборванная загрузка.
Две разные ссылки, которые легко перепутать
В Bot API есть метод getFile. Он возвращает сведения, по которым файл можно скачать, и ссылка, построенная на этих сведениях, действует ограниченное время (документация Telegram: getFile). Соблазн понятен: зачем что-то строить, если Telegram уже выдаёт ссылку со сроком жизни? Но это инструмент другого назначения.
Ссылка из getFile нужна самому боту, чтобы забрать файл, который прислал пользователь. Она не знает, кто ваш клиент, не ведёт журнал и не умеет «гаснуть после первого скачивания». Кроме того, в такой ссылке фигурирует токен бота, поэтому показывать её клиенту нельзя в принципе: вы отдадите ключ от всего бота ради одного документа. К тому же исходные имя и MIME-тип файла после такого пути могут не сохраниться, а лимиты облачного и локального Bot API различаются (это тоже следует из описания getFile). Если документ — договор с красивым названием, имя придётся хранить у себя.
Поэтому схема получается из двух слоёв:
- Telegram — канал общения: клиент пишет боту, бот отвечает сообщением со ссылкой;
- ваш сервис выдачи — хозяин документа: он хранит файл, проверяет токен, отдаёт содержимое и пишет журнал.
Ссылка, которую увидит клиент, указывает на ваш домен, а не на инфраструктуру Telegram. Это и делает возможными все дальнейшие ограничения.
Сначала право, потом токен
Порядок действий важнее, чем кажется. Токен нельзя выпускать «на всякий случай» и проверять права при скачивании: тогда любой, кто увидел ссылку, уже стоит на пороге. Правильная последовательность обратная: бот сначала выясняет, имеет ли этот человек право на этот документ, и только после положительного ответа создаёт токен.
В боте у вас есть идентификатор пользователя Telegram. Он говорит, с какого аккаунта пришло сообщение, но сам по себе не доказывает, что за аккаунтом сидит именно сотрудник клиента. Поэтому нужна таблица соответствий: этот аккаунт привязан к этому клиенту (например, через заранее выданный код приглашения), а этому клиенту разрешены такие-то документы. Бот делает запрос к этой таблице и только после «да» идёт дальше. Одноразовая ссылка не доказывает личность: если аккаунт привязан неверно, документ честно уйдёт не тому человеку, просто один раз.
Сам токен — это случайная строка достаточной длины из криптографического генератора, а не номер документа и не хэш от имени файла. Предсказуемый токен обесценивает всё остальное. В базе удобно хранить не сам токен, а его хэш: если база когда-нибудь утечёт, по хэшам нельзя будет открыть документы. Сам секрет существует в открытом виде только в одном месте — в сообщении клиенту.
Рядом с хэшем в записи лежат поля, которые и превращают строку в талон:
| Поле | Зачем нужно |
|---|---|
| Хэш токена | Поиск записи без хранения секрета |
| Документ и клиент | Что именно и кому разрешено |
| Выпущена / истекает | Срок жизни, который не зависит от бота |
| Состояние | Свободна, занята, израсходована, отозвана |
| Идентификатор попытки | Связь с журналом при разборе жалоб |
Когда ссылка считается использованной
Фраза «одноразовая ссылка» звучит однозначно, пока не спросишь: одно — что? Открытие страницы? Начало передачи? Последний байт? Ответ на этот вопрос — проектное решение сервера, а не свойство протокола, и у каждого варианта своя цена для удобства.
Возьмём три момента расходования:
- При первом обращении к адресу. Просто и строго, но любое техническое обращение сжигает ссылку: сетевой сбой в начале, проверка адреса антивирусом, предпросмотр в мессенджере.
- При начале передачи файла. Чуть мягче, но оборванное на середине скачивание всё равно потеряно.
- После успешной отдачи всего содержимого. Самое дружелюбное к клиенту, но сложнее: пока идёт передача, ссылка должна быть чем-то промежуточным, а не свободной и не израсходованной.
Нет правильного ответа «вообще». Если документ малоценный и повторная выдача — одно нажатие в боте, строгий вариант допустим. Если документы тяжёлые, а клиенты ходят с мобильного интернета, строгость превратится в поток обращений «ссылка не работает». Дальше разберём вариант, который чаще всего оказывается разумным компромиссом, но сначала нужно понять, что ломается без аккуратности.
Два обращения к одному токену: временная линия
Представим, что клиент нажал на ссылку дважды подряд — или она одновременно попала в браузер и в программу, которая её проверяет. Сервер получает два запроса почти одновременно. Наивный код выглядит так: прочитать запись, убедиться, что она не израсходована, отдать файл, пометить израсходованной. Посмотрим, что происходит во времени.
| Момент | Запрос A | Запрос B |
|---|---|---|
| t1 | Читает запись: «свободна» | |
| t2 | Читает запись: «свободна» | |
| t3 | Начинает отдавать файл | |
| t4 | Тоже начинает отдавать файл | |
| t5 | Помечает «израсходована» | Помечает «израсходована» |
В журнале останется одна запись о расходовании и две фактические отдачи. «Одноразовая» ссылка сработала дважды, и никто этого не заметил. Проблема не в безалаберности: между проверкой и пометкой есть окно, и в него влезает второй запрос.
Лекарство в том, чтобы проверка и пометка стали одной неделимой операцией. Для этого база данных предлагает собственные механизмы управления конкурентным доступом: в PostgreSQL это блокировки, а сведения о текущих блокировках доступны через pg_locks (документация PostgreSQL: Explicit locking). Практически это выглядит так: вместо «прочитал, потом записал» вы делаете одну операцию условного обновления — переведи запись из «свободна» в «занята» и скажи, сколько строк изменилось. Если изменилась одна, вы выиграли и можете отдавать файл. Если ноль — кто-то уже взял ссылку.
| Момент | Запрос A | Запрос B |
|---|---|---|
| t1 | Обновление «свободна → занята»: изменена 1 строка | |
| t2 | Начинает отдачу | Обновление «свободна → занята»: изменено 0 строк |
| t3 | Отдача идёт | Ответ: ссылка уже используется |
Какой именно приём вы выберете — условное обновление, блокировку строки на время проверки или что-то ещё — зависит от вашей базы и нагрузки; главное, чтобы решение принимала база, а не код приложения по устаревшей копии записи. Приложений может быть несколько процессов, и в памяти одного из них гонку не остановить.
Есть и вторая тонкость. Проигравший запрос должен получить внятный ответ. Если просто написать «ссылка недействительна», клиент решит, что сломался сервис, хотя на самом деле его документ как раз скачивается в соседней вкладке. Статус «занята» отличается от «израсходована», и тексты ответов тоже стоит различать.
Прерванное скачивание и повторы браузера
Теперь вернёмся к вопросу момента расходования. Допустим, вы выбрали вариант с промежуточным состоянием: при захвате ссылка становится «занятой», а «израсходованной» делается только после того, как сервер дошёл до конца передачи. Что если соединение оборвалось на шестидесяти процентах?
Сервер видит обрыв (отправка прекратилась с ошибкой) и должен решить, что делать с занятой ссылкой. Разумных вариантов два:
- вернуть в «свободна», если отдача не дошла до конца, но не более определённого числа раз;
- оставить «занята» до таймаута и по его истечении вернуть в «свободна» или перевести в «истекла».
Первый вариант удобнее клиенту: оборвалось — нажал снова. Но он требует честного учёта попыток, иначе «неудачное скачивание» превращается в способ скачивать бесконечно, обрывая каждое соединение на последнем байте. Достаточно ограничить число возвратов в свободное состояние небольшой цифрой. Какой именно — вопрос политики, а не техники.
Второй вариант проще в коде, но хуже на практике: клиент ждёт таймаут, а перед этим пишет в поддержку. Если у вас есть возможность различать обрыв и успех, лучше использовать первый.
Отдельная история — докачка. Современные браузеры при сбое сами повторяют запрос и могут просить файл с середины. С точки зрения сервера это новое обращение к тому же токену. Если ссылка уже «израсходована», докачка не получится; если «занята» — вы получите гонку с самим собой. Простое правило: решите заранее, поддерживаете ли вы докачку в пределах одной попытки, и тогда повтор от того же клиента в рамках занятой ссылки обслуживайте, а не отклоняйте. Если докачка не нужна — запрещайте диапазонные запросы и не обещайте то, чего нет.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Предварительное открытие: ссылку сожгли до клиента
Самая коварная ситуация возникает без участия человека. Когда ссылка попадает в сообщение, многие мессенджеры и почтовые программы заходят по адресу, чтобы нарисовать превью или проверить его на безопасность. Для сервера это обычное обращение, и строгая модель «расходуется при первом открытии» отдаёт файл роботу. Клиент нажимает на ссылку и видит «уже использована».
Бороться с этим можно на нескольких уровнях, и все они — проектные решения:
- Промежуточная страница. По ссылке открывается не файл, а небольшая страница с кнопкой «Скачать». Робот предпросмотра обычно не нажимает кнопки, а человек нажмёт. Расходование происходит на втором запросе — от кнопки. Цена: лишний клик.
- Только метод GET или HEAD не расходует. Расходует лишь запрос, который сервер считает действием человека (например, POST с кнопки). Это снимает проблему технической проверки, но требует дисциплины в коде: никакого расходования «по умолчанию» на GET.
- Короткое окно и повторная выдача. Не бороться, а смягчать: если ссылка потрачена впустую, клиент получает новую одним нажатием в боте, а журнал показывает, что первое обращение пришло, скажем, без признаков браузера.
Я бы начинал с промежуточной страницы: она самая прозрачная для клиента, а лишний клик — небольшая плата за то, что ссылка не умирает в чужом превью. Это рассуждение о проекте, а не обещание, что любой робот себя так поведёт; поведение конкретных мессенджеров лучше проверить на своих сообщениях.
Пробный проход до выдачи клиентам
Всё перечисленное проверяется не рассуждением, а сценариями. Прежде чем отдавать систему клиентам, пройдите по ней сами, на тестовых документах и тестовом аккаунте:
- Выпустите ссылку и откройте её дважды одновременно из двух вкладок. Ожидание: один файл отдан, второй получил ответ «занята».
- Начните скачивание большого тестового файла и оборвите соединение. Ожидание: ссылка вернулась в «свободна», счётчик возвратов увеличился.
- Отправьте ссылку себе в чат и посмотрите, остаётся ли она рабочей после того, как мессенджер показал превью.
- Дождитесь истечения срока на тестовой ссылке с коротким сроком. Ожидание: понятный отказ и предложение запросить новую.
- Попросите документ с аккаунта, который не привязан к клиенту. Ожидание: токен не выпускается вообще.
Пятый пункт многие пропускают, хотя именно он проверяет порядок «сначала право, потом токен». Если там токен выпущен и отказ случился только при скачивании, значит секрет уже существует и может утечь.
Журнал без утечки секрета
Журнал нужен для двух вещей: отвечать на вопрос «кто и когда получил документ» и разбирать споры вроде «я не скачивал». Но сам журнал легко превращается в уязвимость: если в нём лежат полные адреса со ссылками, то любой, кто читает журнал, получает права клиента.
Поэтому в записях событий фигурирует не токен, а идентификатор попытки и, при необходимости, короткий префикс хэша для сверки. Секрет не пишется ни в журнал приложения, ни в журнал веб-сервера: обратите внимание на стандартные логи доступа, где адрес запроса сохраняется целиком. Если токен передаётся в пути адреса, он окажется в этих логах, и тогда нужно либо вырезать его при записи, либо передавать иначе (например, обменивать на сессию на промежуточной странице).
Какие события стоит фиксировать:
- выдача: кто (клиент и аккаунт), какой документ, когда, срок действия;
- захват: время, признак того, что ссылка стала «занятой»;
- завершение: успех передачи или обрыв, число отданных байт;
- отказ: причина — истекла, израсходована, занята, отозвана;
- повторная выдача: связь с предыдущей ссылкой.
Журнал должен честно называть то, что он знает. Запись «передача завершена» означает, что сервер отдал байты до конца, но не то, что клиент открыл файл или что он его не передаст дальше. Одноразовая ссылка не удаляет уже скачанное: после загрузки файл живёт у получателя и может быть сохранён и переслан, и никакой токен на сервере этого не отменит. Если документы настолько чувствительны, что пересылка недопустима, нужны другие меры — права на самом документе, водяные знаки, юридические условия, — но не обещание от ссылки.
Где всё это живёт и сколько ему нужно ресурсов
Сервис выдачи — это небольшой веб-сервер, база для записей о ссылках и хранилище документов. Бот и сервер можно держать на одной машине. Для такой нагрузки VPS подходит естественно: у вас есть root-доступ, чтобы настроить веб-сервер, закрыть лишние порты и вырезать токены из логов, а диск NVMe пригодится для хранения документов. У MAATRIX это тарифы от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ, 320 ГБ); ресурсы тарифа не равны обещанию ёмкости, поэтому сколько одновременных скачиваний вы выдержите, покажет только нагрузочная проверка на ваших файлах. Основной ресурс тут — канал и диск, а не процессор: тяжёлые документы дольше занимают соединения, и гонки из прошлых разделов как раз возникают на длинных передачах. В публичном предложении перечислены локации UK, US, RU и FR; расположение стоит выбирать исходя из того, где находятся ваши клиенты и какие у вас требования к хранению данных, а доступность конкретной конфигурации уточнять в форме заказа. Если вы хотите хранить документы в собственном файловом хранилище, в каталоге есть Nextcloud с установкой, но состояние наличия и ОС проверяется при заказе. Подробнее о соседнем, более простом подходе к выдаче закрытых файлов — в материале про выдачу документов по временным ссылкам. Подобрать VPS под такой сервис можно на странице аренды VPS MAATRIX.
Договорённость со срокам и путём к новой ссылке
Технику можно сделать безупречной и всё равно получить недовольных клиентов, если не договориться о правилах заранее. Их немного, но их лучше написать прямо в сообщении бота, а не оставлять на догадку.
Срок. Он должен соответствовать тому, как люди реально работают с документами. Если клиент получает договор и подписывает его в течение дня, ссылка на час — это боль; ссылка на неделю — это открытая дверь на неделю. Разумно выбирать срок в часах или в одних сутках и держать его в настройках, а не в коде. Конкретное число — ваше решение, исходя из того, как быстро ваши клиенты обычно открывают присланное.
Попытки. Расскажите клиенту простым языком: ссылка открывается один раз; если скачивание оборвалось, её можно повторить ограниченное число раз; если и это не получилось — запросите новую.
Новая ссылка. Самое важное место всей схемы. Кнопка «Получить новую ссылку» в боте должна быть всегда рядом с сообщением об ошибке. При нажатии бот снова проверяет право клиента на документ, выпускает свежий токен, а старый переводит в «отозвана», чтобы у одного документа не плодились живые копии. Новая ссылка не должна требовать письма в поддержку: если клиент уже прошёл проверку, нет причин заставлять его проходить её человеком. Но если запросов на один документ слишком много за короткое время, имеет смысл остановиться и попросить связаться с вами: так вы заметите и сбой, и злоупотребление.
Итоговая договорённость умещается в три фразы, которые бот может произнести клиенту: ссылка живёт столько-то часов; открывается один раз, а при обрыве допускает несколько повторов; если не получилось — нажмите «Новая ссылка», и документ придёт снова. Всё остальное — гонки, предпросмотры, журналы — остаётся задачей сервера, и клиент о ней не должен даже догадываться.