Загрузка резервной копии не может быть возобновлена после прерывания, так как токен электронной почты уже использован

Я помню о подобной проблеме, которая, по всей видимости, была исправлена в версии 2026.01.0-latest, поэтому я считаю, что это отдельная актуальная проблема, связанная с восстановлением после прерванной загрузки резервной копии, а не со старой ошибкой при первоначальной загрузке.

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

Мне удалось воспроизвести именно это поведение в текущей версии Discourse.

Воспроизведение

Я загружал локальную резервную копию Discourse размером:

4 231 639 143 байта

с помощью Safari на iOS.

Загрузка шла нормально. У меня есть запись экрана, показывающая увеличение с примерно:

https://youtube.com/shorts/_Pjc-kxJ1OE?feature=shared

313,3 МБ до 317,8 МБ

Затем я переключился с Safari примерно на 15 секунд.

Когда я вернулся в Safari, загрузка остановилась на отметке примерно:

319,8 МБ

и Safari отобразил элемент управления для повторной попытки.


В журнале доступа Caddy для исходного запроса видно:

GET /admin/backups/...

HTTP/3

Range: none

status: 200

Content-Length: 4231639143

size: 319864024

Затем я использовал собственный элемент управления повторной попытки в Safari.

Safari отправил реальный запрос HTTP на диапазон байтов:

GET /admin/backups/...

HTTP/3

Range: bytes=319799904-

status: 422

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

Discourse отклонил этот запрос Range с кодом 422.

Текущий код Discourse

Если посмотреть на текущий Admin::BackupsController#show, последовательность действий выглядит следующим образом:

EmailBackupToken.compare(current_user.id, token)

        ↓

find backup

        ↓

EmailBackupToken.del(current_user.id)

        ↓

send_file

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

Если эта передача впоследствии прерывается, Safari отправляет другой запрос, например:

Range: bytes=319799904-

но этот запрос снова проходит через /admin/backups/…, а токен электронной почты уже был удален.

Похоже, это объясняет код 422, который я зафиксировал.

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

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

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

Почему это стало легче воспроизвести на iOS

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

В сообществе Apple размещено как минимум три отчета:

более старые отчеты

«Неудачные фоновые загрузки в Safari после обновления IOS 26» — октябрь 2025 г.

Safari Background Downloads Failing After… - Apple Community

Пользователь сообщает о неудачных крупных загрузках в Safari при работе в фоновом режиме после обновления до iOS 26. Он также обновил iPhone 15 Pro до iOS 26 и сообщил о том же поведении.

«Проблема фоновой загрузки в Safari iOS — отмена при сворачивании» — декабрь 2025 г.

Safari iOS Background Download Issue - Ca… - Apple Community

Сообщено на iPhone 15 с iOS 26.1. Загрузки начинаются нормально, но отменяются после переключения на другое приложение или сворачивания Safari.

«Safari не может завершить большую загрузку» — апрель 2026 г.

Сообщено на iPhone 17 Pro Max с iOS 26.4. По сообщениям, большие загрузки приостанавливаются вскоре после блокировки устройства, переключения между Safari или перехода с сотовой сети на Wi-Fi. Автор специально указывает на хороший сигнал и говорит, что те же загрузки завершаются на ПК, Mac и Android.

Я не думаю, что этих отчетов сообщества достаточно, чтобы утверждать, что Apple подтвердила регрессию в iOS.

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

Важный момент со стороны Discourse не зависит от причины прерывания:

передача большой резервной копии прервана

        ↓

браузер пытается возобновить загрузку с использованием стандарта Range

        ↓

токен резервной копии уже был использован

        ↓

возобновление получает код 422

Наблюдения на стороне сервера

Похоже, это не является общей проблемой с текущим путем nginx/sendfile.

Тот же путь загрузки резервной копии в несколько ГБ успешно завершался:

  • из Firefox/Linux по HTTP/3; и
  • с того же iPhone по Wi-Fi с использованием HTTP/3.

Текущий nginx также заявляет о поддержке диапазонов байтов.

Поэтому я не утверждаю, что HTTP/3, Caddy или nginx внутренне неспособны доставить резервную копию.

В этом конкретном случае воспроизведения исходная передача была прервана после примерно 320 МБ, Safari впоследствии отправил корректный запрос Range, и этот второй запрос был отклонен Discourse.

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

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

Поведение «одноразового» использования восходит к изменению, направленному на повышение безопасности, от марта 2017 года:

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

Та же последовательность действий сохраняется и в текущей реализации Admin::BackupsController#show:

Базовая реализация токенов также остается очень простой:

EmailBackupToken хранит один токен в Redis на каждого пользователя с сроком действия один день. Он привязан к пользователю, а не к конкретной резервной копии.

Изучив историю этого файла, я обнаружил, что изменения с 2017 года носят механический или стилистический характер, а не затрагивают модель авторизации.

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

Текущие тесты запросов находятся здесь:

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

Спецификация запросов API также документирует обычный путь загрузки с использованием имени файла и токена:

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

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

Поведения, которые я намерен сохранить/добавить:

  • начальная GET-запрос с действительным токеном по электронной почте по-прежнему разрешен;
  • другой обычный полный GET-запрос с использованием уже израсходованного токена по электронной почте по-прежнему отклоняется;
  • повторная попытка запроса диапазона байтов для той же резервной копии тем же аутентифицированным администратором может быть возобновлена в течение короткого ограниченного периода;
  • попытка возобновления для другой резервной копии по-прежнему отклоняется;
  • просроченное разрешение на возобновление по-прежнему отклоняется.

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

Я готов подготовить черновой патч и регрессионные тесты для воспроизведения проблемы с заголовком Range: в Safari, если эта общая модель безопасности кажется разумной.

Я подготовил PR по этому вопросу:

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

  • тому же аутентифицированному администратору;

  • той же резервной копии;

  • тому же исходному значению токена; и

  • последующему запросу с заголовком Range.

Обычное повторное полное GET с использованным токеном по-прежнему отклоняется, как и запрос Range для другой резервной копии. Резервные копии в Remote/S3 сохраняют существующее поведение.

Я сделал период возобновления перечислением (enum), а не неограниченной длительностью:

  • Disabled (Отключено)

  • 1 hour (1 час)

  • 6 hours (recommended) (6 часов (рекомендуется))

  • 12 hours (12 часов)

  • Until the original email token expires (До истечения срока действия исходного токена из письма)

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

Текущее поведение Discourse остаётся значением по умолчанию (Disabled) для обратной совместимости, тогда как 6 hours (6 часов) предлагается как рекомендуемый вариант при включении возобновляемых загрузок.

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

Я также добавил регрессионное покрытие для успешного пути возобновления и основных границ авторизации, включая:

  • повторное использование обычного токена по-прежнему отклоняется;

  • возобновление для другой резервной копии по-прежнему отклоняется;

  • отключение настройки немедленно предотвращает дальнейшее возобновление;

  • S3 не получает локальный токен возобновления; и

  • начальная загрузка и её возобновление создают только одну запись в журнале аудита загрузок резервных копий.

Полный набор тестов CI проходит успешно, и PR готов к ревью.

Буду рад получить обратную связь по модели безопасности, а в частности по рекомендации в 6 часов и значению по умолчанию.