> For the complete documentation index, see [llms.txt](https://docs.johncms.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.johncms.com/10.0/obshie-svedeniya/storage.md).

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

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

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

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

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

За интерфейсом стоит [Flysystem](https://flysystem.thephpleague.com/), но её имя не должно встречаться нигде, кроме единственной реализации `FlysystemStorage`. Так обновление библиотеки — правка одного файла, а не всех мест, где сохраняется файл.

{% hint style="info" %}
Это тот же приём, что у [обработки изображений](/10.0/obshie-svedeniya/images.md) и [санитайзера HTML](/10.0/obshie-svedeniya/html-sanitizer.md): вызывающий код говорит, **что** ему нужно, а чем это сделано — деталь реализации.
{% endhint %}

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

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

```php
<?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()`: он даёт временный путь, а результат кладёт на диск сам.

```php
$this->storage->storeGenerated(
    'covers/' . $id . '.jpg',
    fn(string $target) => $this->imageProcessor->saveScaledDown($file->tmpPath, $target, 800, 600)
);
```

Временный файл удаляется в любом случае — и когда обработчик отработал, и когда он бросил исключение. Расширение временного файла совпадает с расширением цели, потому что [формат картинки берётся из расширения](/10.0/obshie-svedeniya/images.md).

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

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

```php
$preview = $this->storage->withLocalCopy(
    'covers/' . $id . '.jpg',
    fn(string $path): string => $this->thumbnails->scaledDown($path, 200, 200)
);
```

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

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

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

```php
return [
    'filesystem' => [
        // Диск, который используется, когда явно не указан другой
        'default' => 'local',

        'disks' => [
            'local' => [
                'driver'     => 'local',
                'root'       => UPLOAD_PATH,
                'url'        => '/upload',
                'visibility' => 'public',
                'permissions' => [
                    'file' => ['public' => 0644, 'private' => 0600],
                    'dir'  => ['public' => 0755, 'private' => 0700],
                ],
            ],
        ],
    ],
];
```

| Ключ          | Назначение                                                                          |
| ------------- | ----------------------------------------------------------------------------------- |
| `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, а сайту, который хранит файлы у себя, он не нужен:

```bash
composer require league/flysystem-aws-s3-v3
```

```php
'media' => [
    'driver'  => 's3',
    // адрес бакета или CDN перед ним
    'url'     => 'https://files.example.com',
    'options' => [
        'bucket'     => 'my-bucket',
        'region'     => 'eu-central-1',
        'key'        => '…',
        'secret'     => '…',
        // для хранилищ, которые не являются AWS
        'endpoint'   => 'https://s3.example.com',
        'path_style' => true,
        'prefix'     => '',
    ],
],
```

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

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

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

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

{% hint style="info" %}
Для файлов, [зарегистрированных в базе](/10.0/obshie-svedeniya/files.md), переход на приватный диск ничего не ломает: адрес в `StoredFileDTO::$url` сам меняется на `/file/{id}`, а модули продолжают выводить то, что им дали.
{% endhint %}

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

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

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

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

```php
$disk = $this->storages->disk($fileRow->storage);
```

{% hint style="warning" %}
Не запрашивайте у реестра диск с заранее известным именем. Так зависимость перестаёт быть видна в конструкторе, а реестр превращается в service locator.
{% endhint %}

## Где лежат файлы самой 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`                                             | всё, что [зарегистрировано в таблице `files`](/10.0/obshie-svedeniya/files.md) |

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

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