> 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/files.md).

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

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

Реестр отвечает за две вещи сразу: файл на [диске](/10.0/obshie-svedeniya/storage.md) и строку в базе. Разъехаться они не должны — строка без файла даёт битые ссылки, файл без строки просто занимает место.

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

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

## Загрузка

```php
<?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`). Третий, необязательный, — имя диска, если файлы этой области хранятся не на основном.

{% hint style="warning" %}
Загрузку из запроса достаём в контроллере и превращаем в `UploadedFileDTO` через `UploadedFileMapper`. Передавать `Request` в use case или сервис нельзя — [HTTP-типы живут только в HTTP-слое](/10.0/obshie-svedeniya/rabota-s-zaprosom-request.md).
{% endhint %}

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

| Метод                                                           | Источник                                                                    |
| --------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `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`. Отсюда два следствия.

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

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

## Удаление

```php
$this->files->delete($fileId);
$this->files->deleteMany($post->attached_files);
```

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

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

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

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

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

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

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

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

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

```php
$myFileIds = $this->files->filterIdsInDirectory($fileIds, 'my_module');
```

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

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

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

| Метод             | Что делает                                        |
| ----------------- | ------------------------------------------------- |
| `find($id)`       | `StoredFileDTO` или `null`                        |
| `getByIds($ids)`  | список `StoredFileDTO`                            |
| `openStream($id)` | `StoredFileStream`: поток, имя, MIME-тип и размер |

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

## Ошибки

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