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

Хранилище файлов (диски)

Как сохранять, читать и удалять файлы, не привязываясь к папке upload

Аватары, вложения форума, обложки статей, скриншоты — всё это файлы, которые надо куда-то положить и потом отдать посетителю. Раньше каждый модуль делал это сам: UPLOAD_PATH . 'users/album/' . $userId . '/', mkdir(), unlink(). Теперь между кодом и файловой системой стоит дискJohncms\Storage\StorageInterface.

Диск — это место, где лежат файлы: папка на сервере или объектное хранилище S3. Код работает с диском одинаково в обоих случаях, поэтому переезд файлов в облако — это правка конфигурации, а не модулей.

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

Обращайтесь к Johncms\Storage\StorageInterface, а не к библиотеке напрямую.

За интерфейсом стоит Flysystem, но её имя не должно встречаться нигде, кроме единственной реализации FlysystemStorage. Так обновление библиотеки — правка одного файла, а не всех мест, где сохраняется файл.

Это тот же приём, что у обработки изображений и санитайзера HTML: вызывающий код говорит, что ему нужно, а чем это сделано — деталь реализации.

Как пользоваться

Внедрите интерфейс через конструктор — так вы получите диск, который указан в конфигурации как основной:

<?php

declare(strict_types=1);

namespace Johncms\Modules\MyModule\Application\UseCases;

use Johncms\Http\UploadedFileDTO;
use Johncms\Storage\StorageException;
use Johncms\Storage\StorageInterface;
use Johncms\Modules\MyModule\Application\Exceptions\DocumentUploadException;

final readonly class UploadDocumentUseCase
{
    public function __construct(
        private StorageInterface $storage,
    ) {
    }

    public function execute(int $id, UploadedFileDTO $file): void
    {
        try {
            $this->storage->storeFile('documents/' . $id . '.pdf', $file->tmpPath);
        } catch (StorageException $exception) {
            throw new DocumentUploadException($exception->getMessage());
        }
    }
}

Пути всегда относительные и через прямой слеш. Они считаются от корня диска: у стандартного диска корень — папка public/upload, поэтому documents/5.pdf окажется в public/upload/documents/5.pdf. Никаких UPLOAD_PATH и DS в путях быть не должно.

Папки создаются сами. Диск создаёт всё, чего не хватает в пути, и сразу выставляет права из конфигурации.

Операции

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

store($path, $contents)

Записывает строку в файл, заменяя то, что там было

storeStream($path, $resource)

То же, но из открытого потока — файл не нужно держать в памяти целиком

storeFile($path, $localFile)

Копирует на диск файл, который уже лежит на сервере (загрузка, импорт)

storeGenerated($path, $handler)

Даёт обработчику путь для записи и кладёт результат на диск

read($path)

Содержимое файла строкой

readStream($path)

Содержимое потоком — для отдачи файла посетителю

exists($path)

Есть ли файл

delete($path)

Удаляет файл. Файла нет — не ошибка

deleteDirectory($path)

Удаляет папку со всем содержимым

copy($from, $to)

Копирует файл внутри диска

size($path)

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

mimeType($path)

MIME-тип

lastModified($path)

Время последней записи, Unix-время

url($path)

Адрес, по которому файл отдаётся, или пустая строка

withLocalCopy($path, $handler)

Даёт обработчику файл на локальной файловой системе

Всё, что диск не смог сделать, приходит как Johncms\Storage\StorageException — ловите её, а не исключения Flysystem.

Запись картинок

Обработчик изображений пишет по пути, а не в память, поэтому для картинок есть storeGenerated(): он даёт временный путь, а результат кладёт на диск сам.

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

Чтение туда, где нужен реальный путь

Обработчик изображений, FileInfo, getID3 умеют работать только с путём к файлу. Если диск не на этом сервере, такого пути нет — его даёт withLocalCopy():

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

Настройка дисков

Диски описаны в config/autoload/filesystem.global.php, а переопределить их можно в filesystem.local.php — этот файл не входит в поставку и переживает обновление CMS.

Ключ
Назначение

driver

На чём построен диск: local или s3

root

Корневая папка диска (для local)

url

Базовый адрес, по которому веб-сервер отдаёт файлы диска. Пустой — диск непубличный

visibility

public или private — какие права получают записанные файлы

permissions

Каким режимам соответствует видимость

options

Настройки драйвера: доступы, регион, бакет

Неизвестное имя драйвера — ошибка при чтении конфигурации, а не «сделаем вид, что это папка на сервере».

Права доступа

Видимость — свойство диска, а не отдельного файла, и она применяется при каждой записи. Это не формальность: локальный драйвер иначе оставляет режим файла на усмотрение umask, и на сервере со строгим umask (например, 0077) загруженный файл получается с правами 0600 — веб-сервер такой файл отдать не сможет. Папки, созданные записью, тоже получают права из конфигурации.

Драйвер S3

Для объектных хранилищ, совместимых с S3 (сам AWS, Selectel, Yandex Object Storage, MinIO и прочие), есть драйвер s3. Он требует пакет, который CMS не поставляет — за ним тянется AWS SDK, а сайту, который хранит файлы у себя, он не нужен:

Если драйвер настроен, а пакет не установлен, диск не соберётся и в сообщении будет команда установки.

Публичные и приватные диски

Диск с непустым url — публичный: файлы отдаёт веб-сервер напрямую, а url() возвращает адрес под этим префиксом.

Диск с пустым url — приватный. Его корень имеет смысл вынести за пределы public/, например в data/. Такие файлы веб-сервер не видит: их отдаёт контроллер CMS по адресу /file/{id}, и url() возвращает пустую строку.

Для файлов, зарегистрированных в базе, переход на приватный диск ничего не ломает: адрес в StoredFileDTO::$url сам меняется на /file/{id}, а модули продолжают выводить то, что им дали.

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

Несколько дисков

Если диск известен заранее, внедряйте StorageInterface — контейнер отдаст диск по умолчанию.

Если имя диска становится известно только во время работы (например, оно записано в базе рядом с файлом), возьмите Johncms\Storage\StorageRegistryInterface:

Где лежат файлы самой CMS

Каждая область файлов знает свои пути в одном месте — так путь не размазывается по десятку use case'ов:

Класс
Файлы

Johncms\Users\UserImages

аватар и фото профиля

Johncms\Modules\Album\Infrastructure\Storage\AlbumPhotoStorage

снимки в альбомах

Johncms\Modules\Forum\Infrastructure\Storage\ForumAttachmentStorage

вложения сообщений форума

Johncms\Modules\Library\Infrastructure\Storage\LibraryCoverStorage

обложки статей библиотеки

Johncms\Modules\Mail\Application\Services\MailFileService

вложения личных сообщений

Johncms\Files\FileStore

Заводите такой же класс, когда в модуле появляется своя область файлов. Внутри — диск и методы, названные по смыслу (store, delete, url), снаружи — идентификаторы, а не пути.

Модуль «Загрузки» (downloads) намеренно работает с файловой системой напрямую, и переводить его на диски не планируется. Его категории — это настоящие папки: администратор заливает файлы по FTP и сканирует каталог, а дерево разделов строится по дереву папок на диске. Через диск такое невозможно: в объектном хранилище нет каталогов для сканирования, а путь, известный только CMS, нельзя наполнить по FTP.

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

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