For the complete documentation index, see llms.txt. This page is also available as Markdown.

Загруженные файлы (реестр)

Загрузка вложений, запись о них в базе и удаление без осиротевших файлов

Есть файлы, про которые достаточно знать путь: аватар лежит в users/avatar/5.png, и всё. А есть вложения, которые посетитель загружает через редактор: их нужно показать в списке, прикрепить к сообщению, посчитать размер, а потом удалить вместе с сообщением. Для таких файлов в CMS есть реестр — таблица files и сервис Johncms\Files\FileStore.

Реестр отвечает за две вещи сразу: файл на диске и строку в базе. Разъехаться они не должны — строка без файла даёт битые ссылки, файл без строки просто занимает место.

Главное правило

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

Загрузка

<?php

declare(strict_types=1);

namespace Johncms\Modules\MyModule\Application\Controllers;

use Johncms\Files\FileStore;
use Johncms\Files\FileStoreException;
use Johncms\Http\Request;
use Johncms\Http\UploadedFileMapper;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;

final readonly class UploadFileController
{
    public function __construct(
        private FileStore $files,
        private UploadedFileMapper $uploadedFileMapper,
    ) {
    }

    public function __invoke(Request $request): JsonResponse
    {
        $upload = $request->files->get('upload');
        if (! $upload instanceof UploadedFile) {
            return new JsonResponse(['error' => ['message' => __('Wrong data')]]);
        }

        try {
            $file = $this->files->storeUpload(
                $this->uploadedFileMapper->fromUploadedFile($upload),
                'my_module'
            );
        } catch (FileStoreException $exception) {
            return new JsonResponse(['error' => ['message' => $exception->getMessage()]], 500);
        }

        return new JsonResponse([
            'id'   => $file->id,
            'name' => $file->name,
            'url'  => $file->url,
        ]);
    }
}

Второй аргумент — папка на диске, в которую складываются файлы этой области (forum_files, guestbook, news). Третий, необязательный, — имя диска, если файлы этой области хранятся не на основном.

Откуда можно сохранить файл

Метод
Источник

storeUpload($upload, $directory, $disk = null)

одна загрузка (UploadedFileDTO)

storeUploads($uploads, $directory, $disk = null)

несколько загрузок; те, что браузер не дослал, пропускаются

storeLocalFile($path, $directory, $name = null, $disk = null)

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

storeContents($contents, $name, $directory, $disk = null)

содержимое, которое CMS сформировала сама

Все четыре возвращают Johncms\Files\StoredFileDTO:

Свойство
Что в нём

id

Идентификатор записи — то, что вы храните у себя

name

Имя, под которым файл загрузили. Показывается посетителю, в пути не используется

size

Размер в байтах

url

Адрес файла: на публичном диске — прямой, на приватном — /file/{id}

Модель files наружу не выдаётся. Модулям достаётся DTO — так нельзя случайно изменить путь или удалить строку в обход реестра.

Путь на диске

Путь строится из MD5 содержимого: my_module/ab/cd/ef/abcdef….jpg. Отсюда два следствия.

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

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

Удаление

Удаление идемпотентно: если записи нет, ничего не происходит. Оборачивать вызов в try/catch не нужно.

deleteMany() пропускает всё, что не похоже на идентификатор, — вложения часто хранятся в JSON-колонке, и оттуда приходит что угодно.

Что происходит, если получилось только наполовину

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

При сохранении сначала пишется файл, потом строка. Если строку записать не удалось, только что записанный файл удаляется, и вызов заканчивается FileStoreException. Файл, который уже лежал на диске раньше (то же содержимое), не трогается — он принадлежит другим записям.

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

FileStore не участвует в транзакции вызывающего кода. Файл, сохранённый внутри Capsule::transaction(), останется на диске, даже если транзакция откатится. В CMS это штатная ситуация: редактор загружает вложение раньше, чем создано сообщение, поэтому «файл без владельца» существует всегда — для форума его подчищает задача CleanupOrphanForumFilesUseCase.

Привязка файлов к своим записям

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

Метод вернёт только те идентификаторы, которые лежат в указанной папке. Чужие — например, вложение из личных сообщений, чей номер кто-то подставил в форму, — отсеются.

Как связывать файлы со своими записями, модуль решает сам: форум держит отдельную таблицу-связку, новости и гостевая — список идентификаторов в JSON-колонке.

Чтение и отдача

Метод
Что делает

find($id)

StoredFileDTO или null

getByIds($ids)

список StoredFileDTO

openStream($id)

StoredFileStream: поток, имя, MIME-тип и размер

openStream() нужен, когда файл отдаёте вы сами — например, лежащий на приватном диске или требующий проверки прав. Готовый контроллер /file/{id} в ядре работает через него же.

Ошибки

Всё, что помешало сохранить файл, приходит как Johncms\Files\FileStoreException — и отказ диска, и ошибка базы. Ловите её, а не \Exception: широкий catch вокруг загрузки прячет настоящие ошибки вместе с неудачной записью.

Последнее обновление

Это было полезно?