Загруженные файлы (реестр)
Загрузка вложений, запись о них в базе и удаление без осиротевших файлов
Есть файлы, про которые достаточно знать путь: аватар лежит в 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). Третий, необязательный, — имя диска, если файлы этой области хранятся не на основном.
Загрузку из запроса достаём в контроллере и превращаем в UploadedFileDTO через UploadedFileMapper. Передавать Request в use case или сервис нельзя — HTTP-типы живут только в HTTP-слое.
Откуда можно сохранить файл
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. Файл, который уже лежал на диске раньше (то же содержимое), не трогается — он принадлежит другим записям.
При удалении сначала удаляется строка, потом файл. Если диск отказал, ошибка попадает в лог, но наружу не выбрасывается: записи уже нет, значит для сайта файл удалён. Обратный порядок оставил бы битую ссылку в списке вложений, а это хуже, чем несколько лишних байт на диске.
Привязка файлов к своим записям
Идентификаторы приходят из формы, то есть от посетителя. Прежде чем прикреплять их к своей записи, проверьте, что это файлы вашей области:
Метод вернёт только те идентификаторы, которые лежат в указанной папке. Чужие — например, вложение из личных сообщений, чей номер кто-то подставил в форму, — отсеются.
Как связывать файлы со своими записями, модуль решает сам: форум держит отдельную таблицу-связку, новости и гостевая — список идентификаторов в JSON-колонке.
Чтение и отдача
find($id)
StoredFileDTO или null
getByIds($ids)
список StoredFileDTO
openStream($id)
StoredFileStream: поток, имя, MIME-тип и размер
openStream() нужен, когда файл отдаёте вы сами — например, лежащий на приватном диске или требующий проверки прав. Готовый контроллер /file/{id} в ядре работает через него же.
Ошибки
Всё, что помешало сохранить файл, приходит как Johncms\Files\FileStoreException — и отказ диска, и ошибка базы. Ловите её, а не \Exception: широкий catch вокруг загрузки прячет настоящие ошибки вместе с неудачной записью.
Последнее обновление
Это было полезно?