Перейти к содержимому
Поиск
Разработка

FilePond: загрузка файлов — мастерская, PHP и 6 примеров

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

Александр Некрасов
FilePond — удобная загрузка файлов: предпросмотр изображений, документы и прогресс загрузки

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

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

Как работают примеры на этой странице. Выбранные файлы остаются в браузере. Индикаторы очереди показывают подписанную локальную симуляцию; файлы на сервер сайта не отправляются. Настоящие HTTP-запросы и PHP-хранилище доступны в скачиваемом проекте после локального запуска.

FilePond, обычный input и FormData: что выбираем

Если нужно прикрепить один небольшой файл и отправить форму, стандартного HTML часто достаточно. Укажите подпись, допустимые расширения и enctype. Проверки на сервере нужны при любом интерфейсе.

<form action="./upload.php" method="post" enctype="multipart/form-data">
  <label for="attachment">Документ для заявки</label>
  <input id="attachment" type="file" name="attachment"
         accept=".pdf,.txt" required>
  <button type="submit">Отправить документ</button>
</form>

FormData позволяет отправить выбранный файл JavaScript-кодом без перезагрузки. Однако список файлов, удаление, прогресс, повтор и сообщения всё равно придётся написать. FilePond берёт на себя значительную часть этой работы, сохраняя обычный объект File доступным приложению.

// Фрагмент для собственного сервера: input и csrf уже получены.
const file = input.files[0];
if (!file) throw new Error('Сначала выберите файл');
const body = new FormData();
body.append('filepond', file, file.name);
const response = await fetch('./api/process.php', {
  method: 'POST',
  headers: { 'X-CSRF-Token': csrf },
  body
});
if (!response.ok) throw new Error('Сервер не принял файл');
const serverId = (await response.text()).trim();
Задача input / FormData FilePond
Один файл в простой форме Минимум кода и зависимостей Полезен, если нужен более наглядный выбор
Несколько файлов с удалением и ошибками Состояния и список пишет приложение Есть список, действия и события
Предпросмотр фотографий Нужно создавать и освобождать preview Подключается Image Preview
Хранение, права и привязка к заявке Работа сервера Также работа сервера: библиотека её не заменяет

Не задавайте вручную Content-Type: multipart/form-data при передаче FormData: браузер сам добавит необходимую границу частей. И не считайте атрибут accept доказательством правильного содержимого — он помогает выбрать файл, но не проверяет его на сервере.

Источник: FormData и отправка файлов.

Установка и полный минимальный пример

В материале закреплены FilePond 4.32.12, Image Preview 4.6.12, File Validate Type 1.2.9 и File Validate Size 2.2.8. Эти пакеты распространяются по MIT. FilePond работает в обычном HTML5-проекте: React, Vue и сборщик для первого запуска не обязательны.

npm install filepond@4.32.12 filepond-plugin-image-preview@4.6.12 filepond-plugin-file-validate-type@1.2.9 filepond-plugin-file-validate-size@2.2.8

Этот полный HTML можно сохранить как index.html. Он показывает изображения, проверяет тип и размер, но ничего не отправляет. CDN используется только для подключения библиотек; в архиве те же зависимости лежат локально.

<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Выбор изображения с FilePond</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/filepond@4.32.12/dist/filepond.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/filepond-plugin-image-preview@4.6.12/dist/filepond-plugin-image-preview.min.css">
<main style="max-width:640px;margin:32px auto;padding:16px">
  <h1>Выберите изображение</h1>
  <p>PNG, JPEG или WebP, до 2 МиБ. Файл останется в браузере.</p>
  <label for="picture">Изображение для примера</label>
  <input id="picture" type="file" name="filepond"
         accept="image/png,image/jpeg,image/webp">
</main>
<script src="https://cdn.jsdelivr.net/npm/filepond@4.32.12/dist/filepond.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/filepond-plugin-image-preview@4.6.12/dist/filepond-plugin-image-preview.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/filepond-plugin-file-validate-type@1.2.9/dist/filepond-plugin-file-validate-type.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/filepond-plugin-file-validate-size@2.2.8/dist/filepond-plugin-file-validate-size.min.js"></script>
<script>
FilePond.registerPlugin(FilePondPluginImagePreview,
  FilePondPluginFileValidateType, FilePondPluginFileValidateSize);
const pond = FilePond.create(document.querySelector('#picture'), {
  allowMultiple: false,
  server: null,
  allowProcess: false,
  acceptedFileTypes: ['image/png', 'image/jpeg', 'image/webp'],
  maxFileSize: 2 * 1024 * 1024,
  labelIdle: 'Перетащите файл или <span class="filepond--label-action">выберите</span>',
  labelFileTypeNotAllowed: 'Этот формат не подходит',
  fileValidateTypeLabelExpectedTypes: 'Нужен PNG, JPEG или WebP',
  labelMaxFileSizeExceeded: 'Файл слишком большой',
  labelMaxFileSize: 'Максимальный размер — 2 МиБ',
  labelButtonRemoveItem: 'Удалить файл'
});
</script>
</html>

Плагины регистрируются до создания экземпляра. Для предпросмотра требуется отдельный CSS. Подключение только JavaScript без стилей — частая причина непонятного вида виджета. В проекте со сборщиком вместо глобальных объектов используйте импорты:

import * as FilePond from 'filepond';
import ImagePreview from 'filepond-plugin-image-preview';
import ValidateType from 'filepond-plugin-file-validate-type';
import ValidateSize from 'filepond-plugin-file-validate-size';
import 'filepond/dist/filepond.min.css';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css';

FilePond.registerPlugin(ImagePreview, ValidateType, ValidateSize);

Источник: установка FilePond без фреймворка и через npm.

Сначала выберите один способ подключения. Импорты CSS рассчитаны на сборщик, а обычная HTML-страница использует теги link и script. Не загружайте одновременно две копии FilePond и не создавайте два экземпляра на одном поле. При динамическом переходе между экранами сначала завершайте жизненный цикл старого экземпляра, затем создавайте новый.

Мини-мастерская: соберите свой загрузчик

Настройте допустимые файлы, количество, размеры и поведение обработки. Добавьте подготовленный пример или выберите свой файл, затем посмотрите на список, ошибки и сформированную конфигурацию. Здесь удобно проверить, подходит ли загрузчик конкретной форме, ещё до подключения PHP.

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

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

Экспорт JavaScript сохраняет настройки; JSON описывает выбранный набор и параметры. Автономный HTML содержит библиотеки и конфигурацию, но не включает выбранные вами файлы. После открытия такого HTML файлы нужно выбрать заново. Он остаётся демонстрацией в браузере и не подключает скрытый обработчик загрузки.

{
  "allowMultiple": true,
  "maxFiles": 4,
  "acceptedFileTypes": ["application/pdf", "text/plain"],
  "maxFileSize": 2097152,
  "maxTotalFileSize": 6291456,
  "instantUpload": false
}

Это пример данных конфигурации, а не готовое соединение с сервером. Размеры заданы в байтах: 1 МиБ равен 1 048 576 байтам. Так лимит в PHP и интерфейсе можно сравнить без неоднозначности между десятичными мегабайтами и двоичными мебибайтами.

Функции обработки нельзя сохранить обычным JSON без потери поведения. Поэтому настройки, исполнимый JavaScript и полный HTML выгружаются отдельно. Если собираете свой конструктор, храните разрешённые параметры как данные, а обработчики выбирайте из собственного кода. Строка, введённая в поле настройки, не должна незаметно превращаться в исполняемую функцию.

Шесть сценариев для интерфейсов веб-сервисов

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

Скачать проект с шестью примерами, мастерской и настоящим PHP API. Публичная версия показывает работу интерфейса; серверный режим запускается отдельно на вашем компьютере.

Сценарий Лимит примера Где пригодится
Аватар 1 изображение, до 2 МиБ Профиль, логотип организации, обложка карточки
Галерея 6 изображений, до 3 МиБ каждое и 12 МиБ суммарно Портфолио, карточка товара, фотографии работ
Документы 3 PDF/TXT, до 2 МиБ каждый и 6 МиБ суммарно Техническое задание, вложения к обращению
Пакет 8 файлов, до 5 МиБ каждый и 20 МиБ суммарно Материалы проекта, несколько вложений за один раз
Ручной запуск 4 файла, до 5 МиБ каждый и 20 МиБ суммарно Предварительная проверка набора перед подтверждением
Очередь и ошибки 5 файлов, до 5 МиБ каждый и 20 МиБ суммарно Отмена, повтор и понятное восстановление после сбоя

1. Аватар: один файл и понятная замена

Профилю обычно нужна одна фотография. Интерфейс должен позволить быстро заменить её, увидеть выбранное изображение и удалить ошибочный вариант. Здесь разрешены PNG, JPEG и WebP до 2 МиБ; PDF и остальные форматы не подходят.

Попробуйте: добавьте образец изображения, затем замените его другим. Посмотрите, что в наборе остаётся один файл. Выберите документ и сравните сообщение об ошибке с нормальным предпросмотром.

const avatarOptions = {
  allowMultiple: false,
  allowReplace: true,
  acceptedFileTypes: ['image/png', 'image/jpeg', 'image/webp'],
  maxFileSize: 2 * 1024 * 1024,
  imagePreviewHeight: 180
};

Image Preview создаёт уменьшенное отображение, но не уменьшает исходный файл для отправки. Круглая маска аватара тоже не означает, что изображение обрезано физически. Если нужно изменять пиксели, подключайте соответствующую обработку отдельно и объясняйте человеку её результат. Полноценный редактор Pintura — отдельный продукт, он для этих примеров не требуется.

Закрытие системного окна без выбора нового файла — нормальное действие, а не ошибка загрузки. Сохраняйте предыдущий вариант, пока пользователь явно не заменил или не удалил его. Для формы профиля это особенно важно: кнопка отмены выбора не должна выглядеть как команда стереть уже установленную фотографию.

Источник: что делает Image Preview.

Открыть пример с аватаром ↑

В галерее важен не только набор, но и последовательность. Первое изображение может стать обложкой, остальные — иллюстрациями проекта. Лимиты помогают удержать форму понятной: до шести изображений, каждое до 3 МиБ, суммарно до 12 МиБ.

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

pond.setOptions({
  allowMultiple: true,
  allowReorder: true,
  maxFiles: 6,
  maxFileSize: 3 * 1024 * 1024,
  maxTotalFileSize: 12 * 1024 * 1024
});
pond.on('reorderfiles', (items) => {
  const order = items.map(item => item.id);
  console.log('Текущий порядок в браузере:', order);
});
// Команда «выше» для собственной доступной панели файлов.
function moveUp(pond, id) {
  const index = pond.getFiles().findIndex(item => item.id === id);
  if (index > 0) pond.moveFile(id, index - 1);
}

Встроенное перетаскивание FilePond для изменения порядка рассчитано на одну колонку. Сетку фотографий и сортировку нельзя считать взаимозаменяемыми настройками. Сохранение порядка в карточке товара выполняет приложение: сначала оно получает идентификаторы сохранённых файлов, затем записывает их последовательность.

Источник: allowReorder и параметры экземпляра.

Открыть галерею ↑

3. Документы: формат, размер и полезный отказ

Вложение к заявке не всегда требует картинки. Для PDF и текста полезнее показать имя, размер, причину отказа и возможность удаления. Пример принимает до трёх PDF/TXT, каждый до 2 МиБ, общий размер до 6 МиБ.

Попробуйте: добавьте PDF и текстовый образец, затем изображение. Ошибка должна объяснить, какие форматы ожидаются. Длинное имя не должно закрывать кнопку удаления или растягивать страницу на телефоне.

const documentOptions = {
  allowMultiple: true,
  maxFiles: 3,
  acceptedFileTypes: ['application/pdf', 'text/plain'],
  maxFileSize: 2 * 1024 * 1024,
  maxTotalFileSize: 6 * 1024 * 1024,
  fileValidateTypeLabelExpectedTypes: 'Разрешены PDF и TXT',
  labelFileTypeNotAllowed: 'Этот документ не подходит'
};

Проверка типов требует File Validate Type, проверка размера — File Validate Size. MIME в браузере иногда отсутствует или определяется неточно. Приложение может уточнять определение для удобства выбора, но окончательное решение принимает PHP по содержимому и правилам хранилища. Переименование файла в .pdf не превращает его в PDF.

Источник: плагин типов и ограничения браузерного MIME.

Источник: размер одного файла и всего набора.

Открыть документы ↑

4. Пакет: обработка нескольких файлов

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

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

pond.setOptions({
  allowMultiple: true,
  maxFiles: 8,
  maxFileSize: 5 * 1024 * 1024,
  maxTotalFileSize: 20 * 1024 * 1024,
  instantUpload: true,
  maxParallelUploads: 2
});

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

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

Открыть пакетную обработку ↑

5. Ручной запуск: сначала проверить набор

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

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

pond.instantUpload = false;
startButton.addEventListener('click', async () => {
  if (!pond.getFiles().length) {
    message.textContent = 'Сначала добавьте файлы';
    return;
  }
  startButton.disabled = true;
  try {
    await pond.processFiles();
    message.textContent = 'Обработка завершена. Проверьте набор.';
  } catch {
    message.textContent = 'Есть незавершённые файлы. Исправьте ошибки.';
  } finally {
    startButton.disabled = false;
  }
});

Фрагмент предполагает уже подключённый обработчик и существующие элементы кнопки и сообщения. Если server не настроен, вызов метода сам по себе не создаст сервер. Проверять готовность нужно по каждому файлу, а подтверждать только конкретный набор идентификаторов.

Открыть ручной запуск ↑

6. Очередь, отмена и повтор после ошибки

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

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

// id — идентификатор элемента FilePond в текущем списке.
function cancelItem(pond, id) {
  const item = pond.getFile(id);
  if (item) item.abortProcessing();
}
async function retryItem(pond, id) {
  try {
    await pond.processFile(id);
  } catch {
    // FilePond оставит ошибку у файла; приложение обновляет свою сводку.
  }
}

Ниже — самостоятельная модель процесса для изучения состояний. В ней нет fetch и XMLHttpRequest; идентификатор с префиксом demo_ существует только в этой демонстрации. Не используйте такое завершение как доказательство сохранения файла.

function createDemoProcess() {
  const attempts = new WeakMap();
  return (fieldName, file, metadata, load, error, progress, abort) => {
    const attempt = (attempts.get(file) || 0) + 1;
    attempts.set(file, attempt);
    let percent = 0;
    let done = false;
    const timer = setInterval(() => {
      if (done) return;
      percent += 20;
      progress(true, percent, 100);
      if (attempt === 1 && percent === 40) {
        done = true;
        clearInterval(timer);
        error('Демонстрационная ошибка. Повторите обработку.');
      } else if (percent >= 100) {
        done = true;
        clearInterval(timer);
        load('demo_' + crypto.randomUUID());
      }
    }, 150);
    return { abort() {
      if (done) return;
      done = true;
      clearInterval(timer);
      abort();
    }};
  };
}

Источник: FileItem и abortProcessing.

Открыть очередь с ошибками ↑

События, идентификаторы и экспорт текущего набора

У элемента есть клиентский id, серверный serverId, состояние и исходный объект File. Клиентский идентификатор нужен для кнопок интерфейса; серверный — для операций с принятым вложением. Имя файла не заменяет ни один из них: одинаковые имена встречаются постоянно.

const snapshot = pond.getFiles().map(item => ({
  id: item.id,
  serverId: item.serverId || null,
  name: item.filename,
  size: item.fileSize,
  type: item.file.type,
  status: item.status
}));
console.log(JSON.stringify(snapshot, null, 2));

Такой JSON содержит описание набора, а не байты файлов. Показать имя в таблице можно через textContent. Если создаёте собственные URL предпросмотра через URL.createObjectURL, освобождайте их при удалении и смене состояния. Копирование объекта File обычным JSON не создаёт резервную копию содержимого.

function updateSummary() {
  const files = pond.getFiles();
  const ready = files.filter(item =>
    item.status === FilePond.FileStatus.PROCESSING_COMPLETE).length;
  summary.textContent = `Готово ${ready} из ${files.length}`;
}
pond.on('updatefiles', updateSummary);
pond.on('processfile', updateSummary);
pond.on('processfileabort', updateSummary);
pond.on('processfilerevert', updateSummary);
// При удалении экрана приложения.
pond.off('updatefiles', updateSummary);
pond.off('processfile', updateSummary);
pond.off('processfileabort', updateSummary);
pond.off('processfilerevert', updateSummary);
pond.destroy();

Для событий экземпляра применяется имя processfile, для DOM-события — FilePond:processfile. Не смешивайте эти варианты. Статусы лучше сравнивать с FilePond.FileStatus, а не с числом, смысл которого придётся искать при следующем изменении кода.

Источник: события экземпляра и DOM.

Как устроен настоящий серверный цикл

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

Шаг проекта Запрос Результат
Получить состояние GET api/status.php Токен CSRF, лимиты и файлы текущей сессии
Принять файл POST api/process.php, multipart/form-data Непрозрачный идентификатор в text/plain
Отменить временную загрузку DELETE api/revert.php, идентификатор в теле Удаление принадлежащего сессии временного файла
Восстановить предпросмотр GET api/restore.php?id=… Байты доступного файла
Подтвердить набор POST api/commit.php, JSON с ids Подтверждение набора приложением

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

// Выполняется только в локальном PHP-проекте.
const statusResponse = await fetch('./api/status.php', { cache: 'no-store' });
if (!statusResponse.ok) throw new Error('Не удалось получить состояние');
const status = await statusResponse.json();
const csrf = status.csrf;

const server = {
  process: makePhpProcess(csrf),
  revert: {
    url: './api/revert.php',
    method: 'DELETE',
    headers: { 'X-CSRF-Token': csrf }
  },
  restore: './api/restore.php?id=',
  load: null,
  fetch: null
};

Переменная makePhpProcess определена в следующем разделе. Здесь используется один origin: интерфейс и PHP открыты с одного адреса. Публичные примеры статьи такую конфигурацию не получают. Запуск HTML напрямую через file:// также не запускает PHP.

Источник: контракт process, revert, restore и load.

Process через XMLHttpRequest: прогресс и отмена

Для измерения передачи файла удобно использовать событие xhr.upload.onprogress. Индикатор получает количество переданных байтов, а окончательное завершение — только после успешного ответа PHP. Переданные 100% ещё не означают, что сервер проверил и сохранил содержимое.

function makePhpProcess(csrf) {
  return (fieldName, file, metadata, load, error, progress, abort) => {
    const xhr = new XMLHttpRequest();
    let settled = false;
    const finish = (callback, value) => {
      if (settled) return;
      settled = true;
      callback(value);
    };
    xhr.open('POST', './api/process.php');
    xhr.timeout = 30000;
    xhr.setRequestHeader('X-CSRF-Token', csrf);
    xhr.upload.onprogress = event => {
      if (!settled) progress(event.lengthComputable, event.loaded, event.total);
    };
    xhr.onload = () => {
      if (settled) return;
      const id = xhr.responseText.trim();
      if (xhr.status >= 200 && xhr.status < 300 && /^[a-f0-9]{64}$/.test(id)) {
        progress(true, file.size, file.size);
        finish(load, id);
      } else {
        finish(error, 'Сервер не принял файл. Проверьте формат и лимиты.');
      }
    };
    xhr.onerror = () => finish(error, 'Соединение прервано');
    xhr.ontimeout = () => finish(error, 'Сервер не ответил вовремя');
    xhr.onabort = () => finish(abort);
    const body = new FormData();
    body.append(fieldName, file, file.name);
    xhr.send(body);
    return { abort() { xhr.abort(); finish(abort); } };
  };
}

Функция передаёт FilePond успех, ошибку или отмену ровно один раз. Проверка формата идентификатора соответствует нашему PHP-контракту, а не универсальному требованию библиотеки. При своём API согласуйте формат ответа отдельно. Метаданные можно передавать дополнительными полями, но имя, MIME и размер из браузера нельзя принимать за проверенный факт.

Для API на другом домене потребуется корректный CORS на сервере, включая используемые методы и заголовки. Сессионные запросы требуют согласованной передачи cookie; no-cors не является исправлением. При восстановлении имени файла через другой origin сервер должен разрешить чтение соответствующего Content-Disposition.

Источник: прогресс отправки XMLHttpRequest.

Источник: CORS, заголовки и запросы с cookie.

Что проверяет PHP до сохранения

PHP сначала проверяет результат загрузки, действительный размер временного файла и допустимость содержимого. Значения $_FILES['filepond']['type'] и исходное имя поступают от клиента. Сервер определяет MIME самостоятельно и сопоставляет его с разрешёнными расширениями.

function inspectUploadedFile(array $file): array
{
    if (($file['error'] ?? null) !== UPLOAD_ERR_OK) {
        throw new RuntimeException('PHP не получил файл целиком');
    }
    $tmp = $file['tmp_name'] ?? null;
    if (!is_string($tmp) || !is_uploaded_file($tmp)) {
        throw new RuntimeException('Недопустимый источник файла');
    }
    $size = filesize($tmp);
    if ($size === false || $size < 1 || $size > 5 * 1024 * 1024) {
        throw new RuntimeException('Размер должен быть от 1 байта до 5 МиБ');
    }
    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmp);
    $allowed = [
        'image/jpeg' => ['jpg', 'jpeg'],
        'image/png' => ['png'],
        'image/webp' => ['webp'],
        'application/pdf' => ['pdf'],
        'text/plain' => ['txt'],
    ];
    $name = $file['name'] ?? '';
    if (!is_string($name)) throw new RuntimeException('Некорректное имя');
    $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
    if (!isset($allowed[$mime]) || !in_array($extension, $allowed[$mime], true)) {
        throw new RuntimeException('Формат файла не разрешён');
    }
    return ['tmp' => $tmp, 'size' => $size, 'mime' => $mime, 'name' => $name];
}

Это отдельный проверяющий фрагмент, не полный endpoint. В архиве дополнительно проверяются изображения и текст, квоты, владелец сессии и CSRF. getimagesize помогает узнать параметры изображения, но сам по себе не является универсальной проверкой безопасности. Для TXT ожидается обычный UTF-8 текст; SVG, HTML и PHP не входят в разрешённые форматы.

// После проверки загрузки, сессии, CSRF и квот.
// $storageRoot — заранее настроенный каталог вне public.
$info = inspectUploadedFile($_FILES['filepond']);
$id = bin2hex(random_bytes(32));
$target = $storageRoot . DIRECTORY_SEPARATOR . $id . '.blob';
if (!move_uploaded_file($info['tmp'], $target)) {
    throw new RuntimeException('Не удалось сохранить файл');
}
// Полный проект также сохраняет метаданные и владельца.
header('Content-Type: text/plain; charset=utf-8');
echo $id;

Оригинальное имя остаётся подписью, а путь задаёт приложение. Временное хранилище вне public не выдаёт содержимое произвольному посетителю; получение файла проходит через проверку владельца. Это место ожидания дальнейшего решения. Если продукт требует антивирусной проверки, её нужно встроить отдельным этапом до выдачи пользователям — в данном проекте такой сервис не заявлен.

Для полей самой заявки — названия, email, дат, состава данных — пригодится Symfony Validator. Проверка формы и проверка байтов файла дополняют друг друга, но решают разные задачи.

Источник: загрузка файлов и структура $_FILES.

Источник: определение MIME с Fileinfo.

Источник: перемещение загруженного файла.

Источник: возможности и ограничения getimagesize.

Revert, restore и удаление: не путайте операции

Удалить карточку из браузера и убрать файл с сервера — разные действия. Для отмены временной загрузки FilePond использует revert. Восстановление временного файла выполняется через restore и происхождение limbo. Уже сохранённые серверные вложения обозначаются local; это слово здесь не означает «файл только в браузере».

// existingTemporaryIds получены от своего API с проверкой владельца.
pond.setOptions({
  files: existingTemporaryIds.map(id => ({
    source: id,
    options: { type: 'limbo' }
  }))
});
async function removeTemporaryItem(pond, itemId) {
  try {
    await pond.removeFile(itemId, { revert: true });
  } catch {
    throw new Error('Не удалось отменить временную загрузку');
  }
}

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

Если пользователь закрывает вкладку, вызов отмены не гарантирован. Поэтому временным объектам нужен срок жизни и уборка. Срок в один час в этом проекте относится также к демонстрационным подтверждённым наборам; в настоящем сервисе правила хранения будут зависеть от назначения документов.

Подтверждение набора после загрузки

В форме заказа файлы часто отправляются раньше самой формы. Перед окончательным действием убедитесь, что каждый файл обработан, у каждого есть серверный идентификатор и набор не меняется во время подтверждения. PHP заново проверяет принадлежность всех идентификаторов; клиентский зелёный статус не заменяет эту проверку.

async function commitSelection(pond, csrf) {
  const files = pond.getFiles();
  if (!files.length || files.some(item =>
      item.status !== FilePond.FileStatus.PROCESSING_COMPLETE ||
      !/^[a-f0-9]{64}$/.test(item.serverId || ''))) {
    throw new Error('Сначала завершите загрузку всех файлов');
  }
  const ids = files.map(item => item.serverId);
  const response = await fetch('./api/commit.php', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
    body: JSON.stringify({ ids })
  });
  const result = await response.json();
  if (!response.ok || !result.ok) throw new Error('Набор не подтверждён');
  return result.order;
}
function requireOpaqueId(mixed $value): string
{
    if (!is_string($value) || !preg_match('/\A[a-f0-9]{64}\z/', $value)) {
        throw new InvalidArgumentException('Некорректный идентификатор');
    }
    return $value;
}
// Затем приложение проверяет владельца, состояние и срок жизни записи.
// Проверка формата не является проверкой доступа.

На время вызова приложение блокирует повторное подтверждение и изменение набора, затем снимает блокировку в finally. При ошибке сохраняйте список и понятное сообщение. Если отправка оборвалась после принятия запроса сервером, сначала уточните состояние: повторная операция не должна незаметно создавать ещё одну заявку.

В PHP-проекте повторное подтверждение того же массива идентификаторов в том же порядке возвращает прежний результат. Перестановка уже подтверждённого набора отклоняется, чтобы порядок галереи не потерялся молча. Подтверждение также не продлевает часовой срок хранения. Эти условия относятся к демонстрационному API; в своём продукте зафиксируйте соответствующее поведение явно.

Телефон, клавиатура и доступные состояния

Перетаскивание удобно на десктопе, но на телефоне основным действием остаётся системный выбор файла. В загрузчике должна быть понятная нажимаемая область. Для удаления, отмены и перемещения нужен различимый текст или доступная подпись; действия не должны зависеть только от наведения мыши.

.upload-section { min-width: 0; }
.upload-section .filepond--root { width: 100%; }
.upload-summary { overflow-wrap: anywhere; }
.upload-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.upload-actions button { min-height: 44px; }
@media (max-width: 600px) {
  .upload-actions { display: grid; grid-template-columns: 1fr; }
}

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

Показывайте словами «ожидает», «обрабатывается», «ошибка» и «готово». Не объявляйте через live region каждый процент: человеку достаточно значимых изменений. После ошибки сохраняйте доступ к повтору и удалению. Если есть собственная сортировка, добавьте команды «выше» и «ниже» и возвращайте фокус к перемещённому элементу.

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

Источник: стилизация и размеры элементов FilePond.

Что проверить, если загрузка не работает

Симптом Что проверить Практический смысл
Файл выбран, запроса нет server, allowProcess, instantUpload и ручной запуск Выбор файла сам по себе не означает отправку
Типы или размеры не ограничиваются Подключение и регистрация соответствующих плагинов acceptedFileTypes и maxFileSize требуют своих модулей
Картинка есть, размер не уменьшился Используется только Image Preview Предпросмотр не меняет отправляемые байты
PHP получил пустой $_FILES post_max_size, upload_max_filesize, имя поля, multipart boundary Запрос мог быть отклонён до прикладной проверки
Прогресс дошёл до конца, затем ошибка HTTP-статус и ответ process Передача байтов и принятие файла — разные этапы
После крестика файл остался revert/remove и состояние вложения Удаление карточки не всегда удаляет серверный объект
В другом домене запрос блокируется CORS и разрешённые заголовки на API Клиент не может самостоятельно разрешить CORS
Включены чанки, но нет продолжения Поддержка протокола POST/PATCH/HEAD сервером chunkUploads не реализует серверный протокол автоматически

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

Исходники, локальный PHP и следующий шаг

Распакуйте архив и откройте терминал в каталоге PHP-проекта. Нужен PHP 8.2 или новее с Fileinfo; зависимости браузера уже включены. Composer для запуска этого примера не требуется. Если добавите PHP-пакеты для хранения или обработки, порядок установки разобран в руководстве по Composer.

php -v
php -m
php -d display_errors=0 -d upload_max_filesize=6M -d post_max_size=7M -S 127.0.0.1:8080 -t public

Откройте http://127.0.0.1:8080/. В архиве уже настроен серверный режим; его параметры находятся в public/runtime.js. В нём HTTP-загрузка ограничена 5 МиБ на файл, десятью файлами и 20 МиБ в сессии, включая подтверждённые. Параметр PHP post_max_size немного больше лимита файла, потому что запрос содержит служебные части. display_errors=0 сохраняет JSON-ответ чистым при превышении этого лимита: диагностическое предупреждение PHP не попадает перед ответом API. Ограничения конкретного сценария интерфейса могут быть строже общего серверного лимита.

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

Скачать исходники FilePond: мастерская, шесть интерфейсов и PHP-проект.

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