{ "index": 333, "slug": "editorial-2018-10-mechanism-image-workflow", "title": "Bitrix: почему preview не означает сохранённую картинку", "excerpt": "В форме уже виден новый preview, но карточка после сохранения показывает старое изображение. Разделяем браузерный файл, запись в b_file и ссылку PREVIEW_PICTURE, чтобы найти разрыв и проверить результат.", "contentHtml": "
В форме товара появляется новая картинка. Пользователь нажимает «Сохранить», открывает карточку и видит старую обложку. Иногда интерфейс сообщает об успехе, хотя сервер сохранил файл отдельно и не связал его с элементом инфоблока.
\nЦена ошибки выше, чем один неудачный POST. Оператор повторяет загрузку, каталог показывает устаревшие данные, а в хранилище остаются лишние файлы. При разборе инцидента команда видит preview и решает, что загрузка прошла. Но preview доказывает только состояние браузера.
\nТезис статьи простой: изображение проходит несколько границ. Браузер показывает выбранный File. PHP получает multipart-данные. Bitrix регистрирует файл и выдаёт числовой ID. Элемент инфоблока хранит ссылку на этот ID. Успех нужно проверять на каждой границе. Последний обязательный факт — после обновления элемент возвращает ожидаемый PREVIEW_PICTURE.
Первое состояние живёт в браузере. Диалог выбора создал объект File, а JavaScript показал его имя, размер или локальный preview. В этот момент сервер ещё ничего не знает. Перезагрузка страницы удалит это состояние.
Второе состояние возникает в HTTP-запросе. Сервер получает поле из multipart/form-data. Имя поля может отличаться от имени, которое видит разработчик в шаблоне: Ajax, вложенная структура и повторная отрисовка часто меняют фактический POST. Поэтому проверяют не DOM, а сетевой запрос и PHP-массив.
Третье состояние создаёт Bitrix. CFile::SaveFile() принимает файловый массив, сохраняет файл и регистрирует его в b_file. Положительный ID означает, что у приложения появился зарегистрированный файл. Он ещё не означает, что файл стал изображением товара.
Четвёртое состояние хранит сам элемент. В поле PREVIEW_PICTURE лежит ссылка на зарегистрированный файл. Её меняет операция обновления элемента. Если обработчик только вызвал SaveFile(), карточка останется со старым ID.
| Состояние | Владелец | Доказательство | Следующий шаг |
|---|---|---|---|
| Файл выбран | Браузер и DOM | Есть имя, размер или preview | Не считать сохранением |
| Данные пришли | PHP-обработчик | Ожидаемое поле и UPLOAD_ERR_OK | Проверить размер, тип, права и передать дальше |
| Файл зарегистрирован | b_file | CFile::SaveFile() вернул ID, GetFileArray() его находит | Собрать файловый массив для элемента |
| Карточка изменена | Элемент инфоблока | CIBlockElement::Update() вернул true, свежее чтение вернуло новый ID | Показать результат новым запросом |
В старой установке Bitrix форма может использовать \\Bitrix\\Main\\UI\\FileInput. Контрол рисует поля, кнопки, preview и JavaScript. Метод show() выводит интерфейс для текущего ID, но не обновляет элемент инфоблока сам по себе. На границе обработчика всё равно нужны имя поля, файловый массив и явное действие: заменить, оставить или удалить.
Ниже учебный фрагмент для одной картинки. Он показывает настройки интерфейса и не заменяет серверную проверку. Название поля должно совпасть с тем, что реально приходит в POST. Если проект использует другую версию Bitrix или собственный Ajax-адаптер, сначала смотрят фактический запрос.
\n<?php\nuse Bitrix\\Main\\UI\\FileInput;\n\n$currentFileId = (int) $arResult['PREVIEW_PICTURE'];\n\necho FileInput::createInstance([\n 'id' => 'catalog_preview',\n 'name' => 'CATALOG[PREVIEW_PICTURE]',\n 'upload' => true,\n 'allowUpload' => FileInput::UPLOAD_IMAGES,\n 'medialib' => false,\n 'fileDialog' => true,\n 'cloud' => false,\n 'delete' => true,\n 'edit' => true,\n 'maxCount' => 1,\n 'maxSize' => 5 * 1024 * 1024,\n])->show($currentFileId);\n?>\nmaxSize помогает интерфейсу показать предел, но пользователь может изменить запрос вручную. allowUpload ограничивает сценарий контрола, но не доверенный источник данных. Сервер заново проверяет размер, фактический тип содержимого, расширение, права и лимит PHP. Заголовок Content-Type нельзя принимать за доказательство типа файла.
Обработчик должен различать ошибку загрузки и ошибку привязки. Не сохраняйте локальный путь из браузера и не используйте имя файла как идентификатор. Получите файловый массив, проверьте его, зарегистрируйте файл, затем соберите массив для Update().
function saveCatalogImage(array $upload): int\n{\n if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {\n throw new RuntimeException('Upload did not finish');\n }\n\n $size = (int) ($upload['size'] ?? 0);\n if ($size < 1 || $size > 5 * 1024 * 1024) {\n throw new RuntimeException('Image size is outside the limit');\n }\n\n // Учебный пример: production-код должен добавить проверку типа,\n // расширения, прав, CSRF и настроек хранилища.\n $upload['MODULE_ID'] = 'catalog';\n $fileId = (int) CFile::SaveFile($upload, 'catalog');\n\n if ($fileId < 1 || !CFile::GetFileArray($fileId)) {\n throw new RuntimeException('Registered file was not found');\n }\n\n return $fileId;\n}\n\n$fileId = saveCatalogImage($_FILES['CATALOG_PREVIEW']);\n$element = new CIBlockElement();\n$picture = CFile::MakeFileArray($fileId);\n\n$updated = $element->Update($elementId, [\n 'PREVIEW_PICTURE' => $picture,\n]);\n\nif (!$updated) {\n throw new RuntimeException($element->LAST_ERROR);\n}\nЭто учебный пример порядка операций. В конкретном проекте нужно проверить формат массива, версию API, модуль, права и обработчики событий. GetFileArray() отделяет зарегистрированный ID от случайного числа. MakeFileArray() готовит описание существующего файла. Update() отдельно сообщает, удалось ли изменить элемент. Один положительный ответ не заменяет два других.
Если регистрация файла прошла, а Update() вернул ошибку, не показывайте пользователю успех. Новый файл уже может существовать без связи с карточкой. Политика очистки таких файлов зависит от проекта: временное состояние, очередь уборки или безопасная ручная обработка. Нельзя удалять файл вслепую, если другой элемент успел получить тот же ID.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Preview сменился, карточка нет | Обновился только DOM | Сравнить ID до и после свежего чтения элемента | Передать новый файловый массив в Update() |
| POST пустой | Нет multipart, поле переименовано или Ajax отправляет другой набор | Посмотреть Network и $_FILES без содержимого файла | Исправить контракт формы и обработчик |
| Есть ID, но файл не находится | Сохранение вернуло невалидный результат или ID прочитан не из того поля | Вызвать GetFileArray($fileId) | Остановить привязку и записать безопасную причину |
| Файл есть, поле старое | Вызвали SaveFile(), но не обновили элемент | Проверить вызов Update() и LAST_ERROR | Разделить этапы и проверять оба ответа |
| После ошибки растёт число файлов | Файл зарегистрирован до неудачной привязки | Сопоставить ID файла с элементом и временем операции | Определить безопасную уборку сиротских записей |
| Загружается не изображение | Доверие расширению или заголовку клиента | Проверить содержимое, размер, расширение и серверные ограничения | Отклонить файл до регистрации |
| Удаляется старая картинка без команды | Пустой preview приняли за флаг удаления | Различить отсутствие нового файла и явное DELETE | Сохранить старую картинку, если удаление не подтверждено |
Отсутствие нового файла не равно удалению. Пользователь мог открыть форму, ничего не выбрать и нажать «Сохранить». Для такого запроса правило должно быть явным: нет нового файла и нет флага удаления — оставить старую картинку; есть новый файл — заменить после успешной регистрации и обновления; есть явный флаг удаления — удалить по согласованному контракту.
\nНе выводите решение из пустого preview. DOM может исчезнуть после Ajax-перерисовки, ошибки загрузки или закрытия диалога. Удаление должно приходить отдельным проверяемым полем, а сервер должен проверить право пользователя и принадлежность текущего файла элементу.
\nЕсли FileInput сначала загружает файл отдельным запросом, а потом форма сохраняет карточку, временный ID нельзя считать готовым результатом. Пользователь может закрыть вкладку между запросами. Временный объект должен иметь понятный статус и срок жизни. Финальная операция должна повторно проверить владельца и связь с элементом.
\nPREVIEW_PICTURE до изменения.method=\"post\", enctype=\"multipart/form-data\", имя поля и CSRF-контракт.SaveFile().GetFileArray(). При ошибке остановите процесс.MakeFileArray() и вызовите CIBlockElement::Update().true от обновления и пустой LAST_ERROR при успехе.PREVIEW_PICTURE с ожидаемым ID.Код выше не является готовым обработчиком для любой версии Bitrix. Он не описывает транзакцию между файловым хранилищем и элементом, антивирус, ресайз, CDN, дисковую квоту, свойства инфоблока, несколько файлов и конкурентное редактирование. Для свойства типа «Файл» формат PROPERTY_VALUES проверяют отдельно. Для административной формы отдельно проверяют права и события Bitrix.
Учебные имена catalog, CATALOG_PREVIEW, лимит 5 MiB и фиксированный сценарий с одной картинкой не являются требованиями production. Пример не доказывает, что конкретная установка принимает любой JPEG, и не даёт production-результатов. Его задача — показать порядок и точки проверки.
Официальная документация Bitrix описывает API, но не знает правила вашего каталога. OWASP перечисляет меры для загрузки файлов, однако набор контролей зависит от угроз, типа данных и архитектуры. Без проверки реального POST, прав и настроек окружения нельзя объявлять интеграцию готовой.
\nСценарий готов к интеграционной проверке, когда для одного тестового элемента можно показать четыре факта: сервер получил ожидаемый файловый массив; Bitrix зарегистрировал новый ID; Update() вернул успех; свежее чтение элемента вернуло этот ID в PREVIEW_PICTURE. Дополнительно пустая отправка сохраняет старый файл, неверный тип отклоняется, а явное удаление не возникает из пустого preview.
Если виден только preview или только ID в b_file, работа не завершена. Источник истины для карточки — поле элемента после успешного обновления. Именно его нужно проверять новым запросом.
b_file.