Александр Некрасов · Автономное руководство

PHP: полный мануал с примерами, функциями и ООП

Разделы этой страницы
  1. Загрузка файлов: проверка содержимого, хранение и выдача
  2. Выбрать фотографию и понять, где она окажется
  3. Сначала разобрать результат приёма, затем читать файл
  4. Передать настоящий PNG и сохранить его вне публичного каталога
  5. Файл проходит собственный конвейер
  6. Получаем только ожидаемую структуру
  7. Случайное имя и каталог вне public
  8. Выдача — отдельное авторизованное действие
  9. Карантин, квоты и восстановление после сбоя
  10. Практика: проверьте решение, а не только успешный путь
  11. Функции и методы этой темы
  12. Код и руководство с собой
PHP 8.5.11149 глав4458 карточек справочника
Дерево руководства

Глава 31 · Практика

Загрузка файлов: проверка содержимого, хранение и выдача

Аватар, PDF и импорт: почему имя, расширение и MIME клиента не являются доказательством безопасности.

Выбрать фотографию и понять, где она окажется

Посетитель прикладывает фотографию сломанной дрели к заявке. Нужно получить реальные байты изображения, проверить ограничения и сохранить файл для сотрудника. Поле с локальным путём C:\Pictures\drill.jpg само по себе не даёт PHP доступа к диску посетителя: браузер должен передать файл.

Сначала разберите обычную форму. У файловой формы три обязательных части: method="post", enctype="multipart/form-data" и input type="file" с name. enctype задаёт формат тела с отдельными частями для полей и файла. Например name="photo" создаёт ключ $_FILES["photo"], а не $_POST["photo"].

Фотография из браузера становится временным файлом PHP
  1. Выбор drill.png в input name="photo"
  2. POST multipart: токен + имя файла + байты
  3. PHP кладёт байты во временный каталог
  4. $_FILES['photo'] содержит error, tmp_name, size, name, type
  5. Наш код проверяет и переносит в закрытый storage
  6. Человек видит подтверждение получения
Ключ $_FILES["photo"]Значение и предел доверия
errorЦелый код состояния приёма; проверяется до чтения tmp_name. 0 означает UPLOAD_ERR_OK.
tmp_nameСозданный PHP временный путь; это вход для is_uploaded_file и move_uploaded_file. Не берите исходный путь из отдельного POST-поля.
nameИмя, присланное клиентом. Подходит только для осторожно показанной справки; не определяет путь хранения.
typeЗаявленный клиентом MIME; не доказательство содержания.
sizeЧисло байтов, сообщённое механизмом приёма; для политики ниже дополнительно читаем размер файла.
full_pathМожет передаваться при загрузке каталогов в поддерживаемых версиях; клиентский путь недоверен.

Без переноса временный upload обычно исчезает после запроса. move_uploaded_file проверяет, что исходник получен PHP через HTTP POST upload, и переносит его; rename такой проверки происхождения не делает. Ни одна из этих функций не доказывает, что файл — безопасная фотография. Это различие отвечает на реальный вопрос о move_uploaded_file.

Сначала разобрать результат приёма, затем читать файл

КодЧто сказать человеку или оператору
UPLOAD_ERR_OK = 0Приём успешен; переходите к проверкам приложения.
UPLOAD_ERR_INI_SIZE = 1Превышен upload_max_filesize; предложите уменьшить файл.
UPLOAD_ERR_FORM_SIZE = 2Превышен MAX_FILE_SIZE из формы; это удобство, не надёжный серверный барьер.
UPLOAD_ERR_PARTIAL = 3Получена только часть; отправьте заново.
UPLOAD_ERR_NO_FILE = 4Человек не выбрал файл; у обязательного поля попросите выбор.
UPLOAD_ERR_NO_TMP_DIR = 6На сервере отсутствует временный каталог; оператору исправить конфигурацию.
UPLOAD_ERR_CANT_WRITE = 7Сервер не смог записать временный файл; это не ошибка формата фотографии.
UPLOAD_ERR_EXTENSION = 8Приём остановило расширение PHP; выяснить серверную причину.

После error проверяем ожидаемую форму массива: посетитель может отправить photo[] и получить вложенные значения вместо одного файла. Неотмеченный файловый выбор и полное отсутствие ключа — разные ситуации. На уровне прокси задают ограничение размера запроса; post_max_size должен вмещать файл и служебные части multipart. Превышение общего POST-лимита может обнулить $_POST и $_FILES ещё до нашего обработчика.

Следующий обработчик принимает только один PNG/JPEG до 100 КиБ. finfo_file определяет MIME по данным файла с помощью расширения Fileinfo; расширение для хранения выбираем из своей карты. Это первичная сортировка для закрытого хранилища проверки. Картинка пока не выдаётся посетителям и не декодируется графической библиотекой. Для публичных изображений нужны отдельный проверенный этап обработки, пределы размеров/декодирования и безопасная выдача.

Передать настоящий PNG и сохранить его вне публичного каталога

Распакуйте ZIP в новую папку. public/index.php подключает example.php, storage существует рядом с public. Запуск: php -d display_errors=0 -d log_errors=1 -S 127.0.0.1:8080 -t public. Откройте /index.php, выберите небольшой PNG и нажмите «Передать фотографию». Нужны сессии, Fileinfo, file_uploads=On и доступный PHP временный каталог.

Токен сессии здесь работает так же, как в форме заявки. Новые действия: is_uploaded_file подтверждает происхождение tmp_name; filesize читает фактический размер; finfo_open создаёт анализатор, finfo_file возвращает MIME. Массив extensions разрешает только два формата. random_bytes и bin2hex дают своё имя; move_uploaded_file возвращает true либо false. Ветвь успеха появляется только после true.

example.php: форма и полный приём одного изображенияПример для указанного окружения
<?php
session_start(['use_strict_mode' => true, 'cookie_httponly' => true, 'cookie_samesite' => 'Lax']);
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
$message = 'Выберите PNG или JPEG размером до 100 КиБ.';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = $_POST['csrf'] ?? null;
    $file = $_FILES['photo'] ?? null;
    if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
        http_response_code(403);
        $message = 'Обновите форму и попробуйте снова.';
    } elseif (!is_array($file) || !is_int($file['error'] ?? null)) {
        http_response_code(422);
        $message = 'Отправьте один файл через поле фотографии.';
    } elseif ($file['error'] === UPLOAD_ERR_NO_FILE) {
        http_response_code(422);
        $message = 'Сначала выберите фотографию.';
    } elseif ($file['error'] !== UPLOAD_ERR_OK) {
        http_response_code(422);
        $message = 'Файл не получен полностью или превышает лимит сервера.';
    } elseif (!is_string($file['tmp_name'] ?? null) || !is_uploaded_file($file['tmp_name'])) {
        http_response_code(422);
        $message = 'Не удалось подтвердить получение файла.';
    } else {
        $path = $file['tmp_name'];
        $size = filesize($path);
        $mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path);
        $extensions = ['image/png' => 'png', 'image/jpeg' => 'jpg'];
        if ($size === false || $size < 1 || $size > 102400 || !isset($extensions[$mime])) {
            http_response_code(422);
            $message = 'Нужен PNG или JPEG размером от 1 байта до 100 КиБ.';
        } else {
            $id = bin2hex(random_bytes(16));
            $destination = __DIR__ . '/storage/' . $id . '.' . $extensions[$mime];
            if (!move_uploaded_file($path, $destination)) {
                http_response_code(503);
                $message = 'Сейчас не удалось сохранить фотографию. Попробуйте позже.';
            } else {
                $message = 'Фотография получена и сохранена для проверки.';
            }
        }
    }
} elseif ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    http_response_code(405);
    header('Allow: GET, POST');
    exit('Допустимы GET и POST.');
}
?>
<!doctype html><html lang="ru"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Фотография для заявки</title><h1>Фотография для заявки</h1>
<p role="status"><?= htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
<form action="/index.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
<p><label for="photo">PNG или JPEG, до 100 КиБ</label>
<input id="photo" name="photo" type="file" accept="image/png,image/jpeg" required style="max-width:100%"></p>
<button type="submit">Передать фотографию</button></form></html>

Ожидается 200 и «Фотография получена и сохранена для проверки.». В storage появляется случайно названный .png или .jpg с теми же байтами. URL /storage/имя.png недоступен при -t public. Имя на диске намеренно непредсказуемо, поэтому точное содержимое проверяется хешем в HTTP-тесте. Повторная отправка в этом небольшом примере создаёт ещё один файл; политика дедупликации здесь не реализована.

ПроверкаРезультат
Не выбирать файл, обойдя required422, «Сначала выберите фотографию.»
Текстовый файл с именем image.png и MIME image/png от клиента422; серверное определение не принимает текст за PNG.
Файл размером более 102400 байтов, но в пределах серверного POST-лимита422; прикладной лимит не обходится поддельным именем.
Изменить csrf403 без сохранения.
Подать photo[]422 вместо чтения массива как пути.
Передать обычный локальный файл в move_uploaded_file в CLIПереноса нет: это не HTTP upload. HTTP fixture обязателен для доказательства рабочего приёма.

Для публичной системы добавляют привязку вложения к заявке/владельцу, права доступа, квоты и срок хранения, ограничение частоты и при необходимости проверку содержимого. CSRF сам не аутентифицирует отправителя. Ни accept в HTML, ни расширение имени, ни MIME от клиента не заменяют серверные правила. Далее сохранённые справочные разделы развивают множественную загрузку, ограничения сервера и безопасную выдачу.

Файл проходит собственный конвейер

Наглядная модель

Файл клиента сначала проходит проверку

Аватар должен стать разрешённым изображением в хранилище, а не исполняемым файлом в каталоге сайта.

  1. Браузер прислал файл

    $_FILES содержит статус и временный путь. Имя photo.jpg и MIME клиента ещё ничего не доказывают.

  2. Проверить приём и содержимое

    Проверяем ошибку загрузки, размер, допустимый MIME и нужные свойства изображения.

  3. Переместить разрешённый файл

    move_uploaded_file(): случайное серверное имя, каталог вне public, связь с владельцем в базе.

  4. Выдать по разрешённому запросу

    Снова проверяем доступ; задаём тип ответа. Путь на диске не берём напрямую из запроса.

При отказе файл не становится аватаром. Расширение и MIME — части проверки; одного имени файла недостаточно.

В магазине менеджер загружает фотографию товара, клиент — документ на возврат, интеграция — CSV с остатками. Эти файлы требуют разных правил. Для фотографии разумно разрешить несколько растровых форматов и выполнить контролируемое декодирование с ограничением размеров. Документ можно хранить как скачиваемое вложение, не исполняя и не открывая его на основном домене. SVG и HTML содержат активный контент; разрешать их как обычный аватар опасно.

Сначала нужны аутентификация, полномочие на объект, CSRF и лимит частоты. Размер ограничивают в прокси/веб-сервере, post_max_size, upload_max_filesize и прикладной политике. Если превысить общий POST-лимит, $_POST и $_FILES могут быть пустыми; это не означает, что пользователь просто забыл выбрать файл. Согласуйте лимиты и понятный ответ 413. Атрибут accept помогает диалогу выбора, но не серверу.

$_FILES содержит error, tmp_name, size, name и type. Форму массива тоже проверяют: неожиданные вложенные значения не должны попадать в файловые функции. Затем проверяют UPLOAD_ERR_OK и фактический размер временного файла. Клиентские name и type не используют для принятия решения. Определение MIME через Fileinfo — один слой, а не доказательство безвредности содержимого.

Получаем только ожидаемую структуру

После работающего приёма картинки выделим уже знакомую проверку error в отдельную функцию. Значение null в её ответе означает «ошибки приёма нет»; строки choose-file/incomplete — внутренние коды, которые интерфейс переведёт человеку. match выбирает код по константе UPLOAD_ERR_*, default описывает оставшиеся отказы. Это небольшая проверка одного слоя, она не заменяет multipart-проект выше.

Ошибки загрузки не смешиваются с ошибками форматаСамостоятельный пример
<?php
declare(strict_types=1);

function uploadProblem(array $file): ?string {
    if (!isset($file['error']) || !is_int($file['error'])) return 'bad-shape';
    return match ($file['error']) {
        UPLOAD_ERR_OK => null,
        UPLOAD_ERR_NO_FILE => 'choose-file',
        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'too-large',
        UPLOAD_ERR_PARTIAL => 'incomplete',
        default => 'storage-unavailable',
    };
}
foreach ([['error'=>UPLOAD_ERR_OK],['error'=>UPLOAD_ERR_PARTIAL],['error'=>[]]] as $file) {
    echo uploadProblem($file) ?? 'continue';
    echo "
";
}
Ожидаемый результат
continue
incomplete
bad-shape

Это только первый этап. После continue ещё предстоят проверка временного файла, размера, MIME, содержимого, назначения и прав. Не повторяйте частично полученный файл как успешный.

Случайное имя и каталог вне public

Новое требование — принимать PDF и вернуть его внутреннее имя вызывающему коду. storePdf получает метаданные одного файла и закрытый каталог, а возвращает строку имени. throw сообщает вызывающему коду об отказе вместо ложного имени. Запись (new finfo(FILEINFO_MIME_TYPE))->file(...) — объектный вариант знакомых finfo_open/finfo_file: создаём анализатор и вызываем его метод file. DIRECTORY_SEPARATOR задаёт разделитель пути платформы; 5_000_000 — целое 5000000 с разделителями для чтения. Ниже контекстная функция, которую HTTP-контроллер вызывает после метода, CSRF и проверки доступа.

Сохранение PDF после HTTP-проверокПример для указанного окружения
<?php
declare(strict_types=1);

function storePdf(array $file, string $privateDirectory): string {
    if (($file['error'] ?? null) !== UPLOAD_ERR_OK
        || !is_string($file['tmp_name'] ?? null)
        || !is_uploaded_file($file['tmp_name'])) {
        throw new InvalidArgumentException('Invalid upload');
    }
    $size = filesize($file['tmp_name']);
    if ($size === false || $size < 1 || $size > 5_000_000) {
        throw new InvalidArgumentException('File size rejected');
    }
    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
    if ($mime !== 'application/pdf') throw new InvalidArgumentException('Expected PDF');
    $name = bin2hex(random_bytes(24)).'.pdf';
    $path = $privateDirectory.DIRECTORY_SEPARATOR.$name;
    if (file_exists($path) || !move_uploaded_file($file['tmp_name'], $path)) {
        throw new RuntimeException('Cannot store upload');
    }
    return $name;
}

Нужны HTTP multipart upload, Fileinfo и заранее созданный закрытый каталог вне webroot с безопасными правами. Вызывать только после доступа и CSRF. PDF здесь помещается в карантин: MIME не заменяет антивирус/проверку формата, а выдача ещё не разрешена. Случайное имя имеет 192 бита; каталог не должен быть доступен для записи посторонним.

Не подставляйте исходное имя в путь. Последовательности ../, разделители, одинаковые имена и различия регистров файловых систем создают ошибки и уязвимости. Исходное имя можно хранить как метаданные, а физический ключ генерировать случайно. move_uploaded_file дополнительно проверяет происхождение источника как HTTP-upload; обычный rename этого не делает. При этом функция может перезаписать существующее назначение, поэтому проектируйте уникальность и приватность хранилища.

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

Выдача — отдельное авторизованное действие

Закрытый файл отдаёт контроллер, который проверяет пользователя и принадлежность вложения, выбирает путь только из доверенных метаданных и задаёт Content-Type, Content-Disposition и X-Content-Type-Options: nosniff. Для документов предпочтительна загрузка attachment. Имя в Content-Disposition требует безопасного формирования: нельзя вставлять сырые кавычки и переводы строк. Большие файлы лучше передавать веб-серверу через контролируемый внутренний механизм после авторизации, чтобы не занимать PHP-воркер долгой отдачей.

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

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

Карантин, квоты и восстановление после сбоя

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

Состояния pending, scanning, ready и rejected делают процесс понятным человеку. Пока проверка не завершилась, файл не выдаётся как готовый. Если сканер недоступен, выбирают явное отложенное состояние, а не автоматически принимают документ. Отказ объясняют без раскрытия внутренних путей; исходное имя можно показать только после HTML-экранирования.

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

Практика: проверьте решение, а не только успешный путь

Нужно принимать аватары JPEG/PNG. Почему проверки размера и finfo недостаточно?

Разбор решения

Нужны ограничения пикселей и ресурсов, безопасное декодирование/перекодирование, случайное имя, закрытое или отдельное хранилище, запрет исполнения и корректные заголовки. Проверка доступа/CSRF не исчезает. Изображение с неверным содержимым или чрезмерными размерами должно отклоняться до дорогой обработки.

Функции и методы этой темы

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

is_uploaded_fileПроверить, что PHP зарегистрировал данный временный путь как файл текущей HTTP POST-загрузки.

PHP4.0.3+ · standard

is_uploaded_file(string $filename): bool

Проверить, что PHP зарегистрировал данный временный путь как файл текущей HTTP POST-загрузки.

$name — переменная; = сохраняет результат, кавычки задают строку, ; завершает команду. name(...) вызывает функцию с аргументами через запятую. echo выводит; "\n" переносит строку, var_dump показывает также тип. __DIR__ — папка текущего PHP-файла; точка . соединяет строки. if выбирает ветку; === false отличает отказ от 0 байтов и пустой строки, а !== false допускает строку "0". [...] создаёт массив; $row["size"] читает поле по ключу; [$a, $b] = ... раскладывает список. Переменные; Вызов и return; Поток и позиция чтения.

Параметр или результатДоговор
filenameЗначение $_FILES[поле][tmp_name], а не клиентское name и не самостоятельно присланная строка пути.
Возвратtrue для зарегистрированной загрузки, false иначе.
ОтказОбычный существующий CLI-файл даёт false. true ничего не утверждает о безопасном содержимом, MIME или праве пользователя.
ВерсияPHP4.0.3+

Синтаксис формы: $_FILES — массив информации, $_POST — текстовые поля, ?? null даёт запасное значение. session_start открывает состояние сессии; hash_equals сравнивает токен. http_response_code задаёт HTTP-статус, exit завершает запрос. Форма и CSRF; Полная последовательность безопасной загрузки.

Путь данных в первом опыте
  1. Обычный fixture data.txt
  2. Файл существует
  3. is_uploaded_file → false
  4. Он не прошёл механизм HTTP upload
Наличие файла не равно происхождению из uploadСамостоятельный пример
<?php
$path = __DIR__ . '/data.txt';
var_dump(is_file($path), is_uploaded_file($path));
Ожидаемый результат
bool(true)
bool(false)

PHP ведёт внутренний список загруженных файлов; имя нельзя сделать upload простым присваиванием.

Проверить происхождение после проверки POST и CSRFПример для указанного окружения
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$token = $_POST['csrf'] ?? null;
if (!is_string($token) || !isset($_SESSION['csrf']) || !is_string($_SESSION['csrf'])
    || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(403); exit; }
$upload = $_FILES['document'] ?? null;
if (!is_array($upload) || ($upload['error'] ?? null) !== UPLOAD_ERR_OK
    || !is_string($upload['tmp_name'] ?? null) || !is_int($upload['size'] ?? null)
    || $upload['size'] < 1 || $upload['size'] > 1024) { http_response_code(400); exit; }

if (!is_uploaded_file($upload['tmp_name'])) { http_response_code(400); exit; }
header('Content-Type: text/plain; charset=UTF-8');
echo "origin accepted\n";

HTTP-контекст: форма multipart/form-data, имя document, CSRF из той же сессии; route должен предварительно проверить аутентификацию/право загрузки. Код проверяет только этап происхождения, не принимает содержимое в постоянное хранилище. При валидном запросе ожидается origin accepted.

Применение и границы. Перед чтением/показом временного файла важно проверить происхождение, затем формат, размер и право доступа. Не выводите клиентское name без экранирования. move_uploaded_file уже включает проверку происхождения при переносе; повторная is_uploaded_file перед ним не заменяет остальные проверки.

Сравните: move_uploaded_file, is_file. Полный выбор файловых операций.

Продолжение: сохранённая прежняя практика.

Возвращает true, если PHP знает файл как загруженный через HTTP POST. Обычный локальный файл не проходит проверку.

Отклонить произвольный локальный путьСамостоятельный пример
<?php
declare(strict_types=1);

echo is_uploaded_file(__FILE__) ? "upload\n" : "not HTTP upload\n";
Ожидаемый результат
not HTTP upload

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

Проверить tmp_name перед обработкойПример для указанного окружения
<?php
declare(strict_types=1);

function requireUpload(array $file): string {
    $path=$file['tmp_name'] ?? null;
    if (!is_string($path) || !is_uploaded_file($path)) throw new InvalidArgumentException('upload');
    return $path;
}

HTTP-функция; вызывающий код сначала проверяет error и затем продолжает лимитами/типом/содержимым.

Рядом по задаче. move_uploaded_file включает проверку происхождения при перемещении; Fileinfo проверяет тип отдельно.

move_uploaded_fileПеренести только файл настоящей HTTP POST-загрузки в выбранное приложением хранилище.

PHP4.0.3+ · standard

move_uploaded_file(string $from, string $to): bool

Перенести только файл настоящей HTTP POST-загрузки в выбранное приложением хранилище.

$name — переменная; = сохраняет результат, кавычки задают строку, ; завершает команду. name(...) вызывает функцию с аргументами через запятую. echo выводит; "\n" переносит строку, var_dump показывает также тип. __DIR__ — папка текущего PHP-файла; точка . соединяет строки. if выбирает ветку; === false отличает отказ от 0 байтов и пустой строки, а !== false допускает строку "0". try выполняет попытку, catch обрабатывает указанный отказ, finally освобождает ресурс и не подавляет ошибку. throw new RuntimeException сообщает о сбое. [...] создаёт массив; $row["size"] читает поле по ключу; [$a, $b] = ... раскладывает список. Переменные; Вызов и return; Поток и позиция чтения.

Параметр или результатДоговор
fromВременный tmp_name, зарегистрированный PHP.
toПуть назначения приложения. Существующий файл будет перезаписан; клиентское имя не используйте как путь.
Возвратtrue при переносе; false без действий для не-upload; false с warning если настоящий upload не удаётся перенести.
ОтказОграничение open_basedir проверяется для to, позволяя from находиться в системной upload-temp. Поддержка переноса не даёт разрешения публиковать содержимое.
ВерсияPHP4.0.3+

В HTTP-примере new finfo(...) создаёт анализатор MIME, ->file(...) вызывает его метод; это чтение содержимого на сервере. $moved отмечает успешность переноса для finally. Перед этим применён такой же POST/CSRF/структурный контроль, как в is_uploaded_file. Форма, права и жизненный цикл загрузки.

Путь данных в первом опыте
  1. Обычный data.txt
  2. move_uploaded_file отвергает источник
  3. false
  4. Исходник остаётся
Обычный файл нельзя выдать за uploadСамостоятельный пример
<?php
$from = __DIR__ . '/data.txt';
var_dump(move_uploaded_file($from, __DIR__ . '/destination.txt'));
var_dump(file_exists($from), file_exists(__DIR__ . '/destination.txt'));
Ожидаемый результат
bool(false)
bool(true)
bool(false)

Для обычного файла существует rename; отказ здесь — часть защиты происхождения.

Принять маленький текст в закрытое хранилищеПример для указанного окружения
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$token = $_POST['csrf'] ?? null;
if (!is_string($token) || !isset($_SESSION['csrf']) || !is_string($_SESSION['csrf'])
    || !hash_equals($_SESSION['csrf'], $token)) { http_response_code(403); exit; }
$upload = $_FILES['document'] ?? null;
if (!is_array($upload) || ($upload['error'] ?? null) !== UPLOAD_ERR_OK
    || !is_string($upload['tmp_name'] ?? null) || !is_int($upload['size'] ?? null)
    || $upload['size'] < 1 || $upload['size'] > 1024) { http_response_code(400); exit; }

if (!is_uploaded_file($upload['tmp_name'])) { http_response_code(400); exit; }
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']);
if ($mime !== 'text/plain') { http_response_code(415); exit; }
$storage = realpath(__DIR__ . '/../storage');
if ($storage === false || !is_dir($storage)) { throw new RuntimeException('storage'); }
$target = tempnam($storage, 'doc');
if ($target === false) { throw new RuntimeException('reserve'); }
$moved = false;
try {
    if (realpath(dirname($target)) !== $storage) { throw new RuntimeException('wrong directory'); }
    if (!move_uploaded_file($upload['tmp_name'], $target)) { throw new RuntimeException('move'); }
    $moved = true;
    header('Content-Type: text/plain; charset=UTF-8');
    echo "stored\n";
} finally { if (!$moved) { unlink($target); } }

HTTP-контекст: endpoint лежит в public/, существующая storage/ рядом с public и не обслуживается веб-сервером. Есть fileinfo, сессия/CSRF, проверка права пользователя до route. tempnam резервирует собственное имя: оно не удаляется перед переносом. Успех stored. Текстовые файлы всё равно экранируются при будущем HTML-показе; это не универсальный антивирус.

Применение и границы. $_FILES[type] заявляет клиент, поэтому тип проверяется сервером; одной MIME-проверки недостаточно для всех форматов. Дальше нужны квоты, политика хранения/очистки, связь с владельцем документа и контролируемая выдача. Полный обработчик должен иметь единый отказ без раскрытия системного пути.

Сравните: is_uploaded_file, tempnam, rename. Полный выбор файловых операций.

Продолжение: сохранённая прежняя практика.

Проверяет происхождение источника, перемещает файл и возвращает bool. При проблеме назначения возможны предупреждение и false.

Произвольный скрипт не перемещается как uploadСамостоятельный пример
<?php
declare(strict_types=1);

$ok=move_uploaded_file(__FILE__,'must-not-exist.php');
echo $ok ? "unexpected\n" : "rejected source\n";
Ожидаемый результат
rejected source

Источник не является HTTP-upload, поэтому файл не перемещается и назначение не создаётся.

Сохранить уже проверенный файлПример для указанного окружения
<?php
declare(strict_types=1);

function moveVerifiedUpload(string $temporary,string $privateDirectory): string {
    $name=bin2hex(random_bytes(24)).'.bin';
    $destination=$privateDirectory.DIRECTORY_SEPARATOR.$name;
    if (file_exists($destination) || !move_uploaded_file($temporary,$destination)) throw new RuntimeException('store failed');
    return $name;
}

HTTP-контекст после CSRF, доступа, лимитов и проверки формата. Каталог заранее создан вне webroot и недоступен для записи посторонним.

Рядом по задаче. rename перемещает обычные файлы без upload-проверки; полный конвейер загрузки.

finfo::fileОпределить тип файла по его содержимому, а не по суффиксу имени.

PHP 5.3+ / PECL fileinfo 0.1+ · fileinfo

public function file(string $filename, int $flags = FILEINFO_NONE, ?resource $context = null): string|false

Определить тип файла по его содержимому, а не по суффиксу имени.

Часть вызоваДоговор
filenameПуть к анализируемому файлу. Строка пути не является содержимым. Для загруженного файла берут проверенный $_FILES[…]["tmp_name"], не клиентское name.
flagsНеобязательный ненулевой набор временно заменяет флаги объекта на один вызов, затем прежние восстанавливаются. FILEINFO_NONE = 0 здесь сохраняет текущие настройки; для постоянного сброса set_flags(FILEINFO_NONE).
contextКонтекст потока, nullable с 8.0. Может влиять на открытие через поддерживаемую обёртку. Не позволяйте посетителю выбирать произвольный URL для проверки: это создаёт сетевой запрос сервера.
ВозвратСтрока описания/типа/кодировки по флагам или false при ошибке. Не равняйте неизвестный application/octet-stream ошибке вызова.
ОшибкиНедоступный файл — предупреждение и false. Пустой путь — ValueError. Нулевой байт в пути с 8.5 даёт ValueError, раньше TypeError. Это не то же правило, что для нулевого байта в buffer.

В коде $ начинает имя переменной, = сохраняет возвращённое значение, скобки передают аргументы вызова. new finfo(...) создаёт настроенный объект; ->file(...) или ->buffer(...) вызывает его метод. === false проверяет именно неуспех, не любую пустую строку. throw new RuntimeException(...) останавливает обычный путь выполнения с объектом ошибки; catch перехватывает указанный тип, а finally завершает обязательную очистку даже при ошибке. Разбор try, catch и finally.

Данные → предположение о типе
  1. Файл document.jpg содержит Hello world
  2. libmagic проверяет содержимое по правилам
  3. MIME_TYPE возвращает text/plain
  4. Приложение отдельно решает, разрешён ли этот тип
Распознать обычный текстСамостоятельный пример
<?php
declare(strict_types=1);

$detector = new finfo(FILEINFO_MIME_TYPE);
$type = $detector->file('document.jpg');
if ($type === false) { throw new RuntimeException('Cannot detect type'); }
echo $type, "\n";
Ожидаемый результат
text/plain

Файл назван .jpg, но его содержимое — текст. Имя не превратило его в изображение; MIME-тип остаётся эвристическим результатом, не полным разбором формата.

Запросить кодировку на один вызов, сохранив режим типаСамостоятельный пример
<?php
declare(strict_types=1);

$detector = new finfo(FILEINFO_MIME_TYPE);
echo $detector->file('document.jpg', FILEINFO_MIME_ENCODING), "\n";
echo $detector->file('document.jpg'), "\n";
echo $detector->file('document.jpg', FILEINFO_NONE), "\n";
Ожидаемый результат
us-ascii
text/plain
text/plain

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

Отличить неверный путь от ошибки распознаванияСамостоятельный пример
<?php
declare(strict_types=1);

$detector = new finfo(FILEINFO_MIME_TYPE);
try { $type = $detector->file("bad\0path"); }
catch (ValueError $error) { echo "Invalid path\n"; }
Ожидаемый результат
Invalid path

Пример рассчитан на PHP 8.5+. Нулевой байт отвергнут до открытия; раньше этот случай имел TypeError. Не убирайте такой байт молча, превращая исходное имя в другой путь.

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

Сравните: finfo::buffer, finfo_set_flags, mime_content_type. Тип, кодировка и границы определения.

Объект finfo(FILEINFO_MIME_TYPE) возвращает MIME-строку либо false. Нужны расширение Fileinfo и доступный локальный файл.

Определить тип собственного PHP-файлаСамостоятельный пример
<?php
declare(strict_types=1);

$type=(new finfo(FILEINFO_MIME_TYPE))->file(__FILE__);
echo is_string($type) ? "type detected\n" : "failed\n";
Ожидаемый результат
type detected

Точный MIME зависит от базы magic, поэтому тест проверяет успешный контракт без утверждения конкретной строки.

Разрешённые MIME для растрового аватараПример для указанного окружения
<?php
declare(strict_types=1);

function allowedAvatarMime(string $path): bool {
    $mime=(new finfo(FILEINFO_MIME_TYPE))->file($path);
    return in_array($mime,['image/jpeg','image/png'],true);
}

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

Рядом по задаче. finfo::buffer работает с байтами в памяти; is_uploaded_file проверяет происхождение.

Источники и обсуждения

Код и руководство с собой

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

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.