Перейти к основному содержимому

Хранение и раздача файлов через Ensi Storage

Файлы в платформе делятся на 2 вида: публичные и приватные. Все они хранятся на физическом диске Ensi Storage, который доступен в каждом сервисе в директории storage/ensi.


Структура этого диска:

.
├── public
│ ├── domain_1
│ │ └── hash
│ │ └── filename.png
│ └── domain_2
└── protected
├── domain_1
└── domain_2

При использовании [Ensi Local Ctl] можно увидеть содержимое диска в ensi-local-ctl/data/es/data


Для скачивания файлов существует 3 способа:

  • Домен https://es-public.project.ru - раздаёт файлы директории public. Доступен всем в интернете. При необходимости эту ссылку можно передать в imgproxy и получить обработанную картинку
  • Домен https://es-protected.project.ru - раздаёт файлы директории protected. Доступен только внутри защищенной среды, например под vpn
  • Эдпоинт раздачи приватных файлов на витрины/админки (Подробнее ниже)

При использовании [Ensi Local Ctl] домены подниманиюся с помощью ensi global start es


Диски в сервисе

Нужны одни и те же логические диски — независимо от языка:

  • Диски для работы с файлами текущего домена — загрузка и ссылки для раздачи
    • ensi_{domain}_public — папка public/domain/
    • ensi_{domain}_protected — папка protected/domain/
  • Диск для чтения файлов чужих доменов
    • ensi — корень физического диска

Laravel. Пакет laravel-ensi-filesystem регистрирует эти диски через EnsiStorageConfig::addDisk(...) в config/filesystems.php. Код текущего домена задаётся в config/ensi-filesystem.php (default_domain_code).

Go (Goravel). Отдельного пакета пока нет: те же диски описывают вручную в config/filesystems.go. Локально корень обычно storage/ensi, в кластере том часто смонтирован в /var/data. Пример для домена catalog:

"disks": map[string]any{
"ensi_catalog_public": map[string]any{
"driver": "local",
"root": path.Storage("ensi/public/catalog"),
"url": config.Env("ENSI_PUBLIC_DISK_URL", "").(string) + "/catalog",
},
"ensi_catalog_protected": map[string]any{
"driver": "local",
"root": path.Storage("ensi/protected/catalog"),
"url": config.Env("ENSI_PROTECTED_DISK_URL", "").(string) + "/catalog",
},
"ensi": map[string]any{
"driver": "local",
"root": path.Storage("ensi"),
},
// local / public — как в шаблоне Goravel
},

Имена дисков и раскладка каталогов должны совпадать с PHP-сервисами того же домена, иначе пути в БД и URL разъедутся.

Как загружать файлы

Для загрузки файла создаётся отдельный эндпоинт, например /module/entity/{id}:upload-file

Принимает запрос в формате multipart/form-data. Ниже — типичный Action: случайное имя, хеш-поддиректории из md5 имени файла (ab/3f), запись на public-диск домена, в БД — только относительный путь.

Laravel

use Ensi\LaravelEnsiFilesystem\EnsiFilesystemManager;

class SaveFileAction
{
public function __construct(protected EnsiFilesystemManager $fileManager)
{
}

public function execute(int $modelId, UploadedFile $file): Model
{
/** @var Model $model */
$model = Model::findOrFail($customerId);

$hash = Str::random(20);
$extension = $file->getClientOriginalExtension();
$fileName = "{$modelId}_{$hash}.{$extension}";
$hashedSubDirs = $this->fileManager->getHashedDirsForFileName($fileName);

$disk = Storage::disk($this->fileManager->publicDiskName());

$path = $disk->putFileAs("model/{$hashedSubDirs}", $file, $fileName);
if (!$path) {
throw new RuntimeException("Unable to save file $fileName to directory model/{$hashedSubDirs}");
}

if ($model->file) {
$disk->delete($model->file);
}

$model->file = $path;
$model->save();

return $model;
}
}

Go (Goravel)

Файл из multipart берут через ctx.Request().File(...) (тип filesystem.File) и передают в action. Хеш-поддиректории считают так же, как в PHP (md5 от имени → первые 4 hex-символа как xx/yy):

func hashedDirsForFileName(fileName string) string {
sum := md5.Sum([]byte(fileName))
h := hex.EncodeToString(sum[:])
return h[0:2] + "/" + h[2:4]
}

type SaveFileAction struct{}

func (a *SaveFileAction) Execute(ctx context.Context, modelID uint, file filesystem.File) (*db.Model, error) {
model := &db.Model{}
if err := facades.Orm().WithContext(ctx).Query().FindOrFail(model, modelID); err != nil {
return nil, err
}

ext := file.GetClientOriginalExtension()
fileName := fmt.Sprintf("%d_%s.%s", modelID, str.Random(20), ext)
hashedSubDirs := hashedDirsForFileName(fileName)
dir := "model/" + hashedSubDirs

disk := facades.Storage().Disk("ensi_catalog_public")
path, err := disk.PutFileAs(dir, file, fileName)
if err != nil || path == "" {
return nil, fmt.Errorf("unable to save file %s to directory %s: %w", fileName, dir, err)
}

if model.File != nil && *model.File != "" {
_ = disk.Delete(*model.File)
}

model.File = &path
if err := facades.Orm().WithContext(ctx).Query().Save(model); err != nil {
return nil, err
}
return model, nil
}

Удаление файлов происходит по аналогии (disk.Delete / $disk->delete).

Какие данные о файлах хранить в БД

В БД хранится только путь до файла

Какие данные о файле сервис отдаёт

В ответе API обычно отдают объект с полями path, root_path и url.

Laravel. Класс \Ensi\LaravelEnsiFilesystem\Models\EnsiFile собирает их из пути: EnsiFile::public($model->file). В BaseJsonResource есть хелперы вроде $this->mapPublicFileToResponse($this->file).

Go. Тот же JSON удобно собрать маленькой структурой: path — как в БД, root_path — с префиксом public/{domain}/ или protected/{domain}/, url — через facades.Storage().Disk(...).Url(path) (URL диска из ENSI_PUBLIC_DISK_URL / ENSI_PROTECTED_DISK_URL):

type EnsiFile struct {
Path string `json:"path"`
RootPath string `json:"root_path"`
URL string `json:"url"`
}

func PublicEnsiFile(path string) EnsiFile {
return EnsiFile{
Path: path,
RootPath: "public/catalog/" + strings.TrimLeft(path, "/"),
URL: facades.Storage().Disk("ensi_catalog_public").Url(path),
}
}

Как раздать приватный файл пользователю

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

Для этого в gui-backend реализуется эндпоинт, который получает данные о запрашиваевом файле, проверяет доступ пользователя, и отдаёт файл, если проверка пройдена

Данные о файле также генерирует предварительно gui-backend. Состоят они из следующих полей:

  • entity - сущность, файл которой мы пытаемся скачать, например pim/category
  • entity_id - конкретная сущность, файл которой мы пытаемся скачать, например ID категории
  • file_type - если у сущности несколько видов файлов, то тут указывается типа (по сути это обозначение поля в БД), например preview
  • file - если файлов одного типа много, то можно указать path|id на конкретный файл

Пример реализации можно посмотреть тут