Студия разработки сайтов и приложений

Netspark.ru

Приватное файловое хранилище в Laravel

Время от времени бывает необходимо организовать файловое хранилище за пределами /public. Например, для того, чтобы контролировать доступ к файлам (недоступным по прямой ссылке) по неким правилам.

Сходу из коробки в Laravel 6 такая фича на 100% не предусмотрена, но сделать её руками достаточно нетрудно.

Для начала разобьем задачу на части. Нам нужно:

  1. Определить, где будут храниться файлы (private storage).
  2. Прикреплять файлы к некой модели, чтобы на основе этой модели управлять доступом.
  3. Обеспечить сохранение файлов в private storage.
  4. Реализовать, собственно, контроль доступа и выдачу файлов по некоему пути.
  5. На закуску добавим автоматическую генерацию миниатюр.

1. Private storage

Эта часть как раз реализуется из коробки. Нужно просто пройти в config/fylesystems.php и добавить новый приватный диск, который мы так и назовём — private:

    'disks' => [

        'local' => [
            'driver' => 'local',
            'root' => storage_path('app'),
        ],

        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
        ],

        'private' => [
            'driver' => 'local',
            'root' => storage_path('app/private'),
            'visibility' => 'public',
        ],

    ],

Поскольку вновь созданный диск находится за пределами и не зеркалится в public/, задача создания приватного пространства уже выполнена. Теперь мы можем обращаться к этому диску из фасада Storage:

Storage::disk('private')->put('file.txt', 'Содержимое текстового файла');

Visibility мы ставим в public, а не в private, поскольку для локального диска private visibility означает просто, что файлу будут выставлены пермишны, которые не позволят серверу отдать файл клиенту. Нам этого не нужно.

2. Прикрепляем файлы к модели

Для определенности дальше будем считать, что мы хотим решить задачу: сохранять фотографии сотрудников и сервить их из приватного хранилища. Чтобы те, кто не имеет доступа к условной организации, не могли фотографии сотрудников по прямым ссылкам видеть, а те кто имеет — могли.

Пускай сотрудники описываются моделью Employee. Очевидно, нам нужно связать сохраненные файлы с моделями соответствующих сотрудников. Чтобы не реализовывать хранение-перемещение-рилейшны с файлами на модели самим, воспользуемся одной из библиотек для работы с загрузками и менджментом файлов в Laravel.

Три самых популярных известных мне пакета, позволяющих аттачить файлы к моделям, это:

Воспользуемся первым пакетом, laravel-medialibrary.

Я особо не сравнивал их между собой по фичам. Просто часто пользуюсь пакетами spatie, да и звездочек у них больше :)

Установить пакет можно, как обычно, композером. А для использования достаточно в нужной модели (Employee) реализовать интерфейс HasMedia и добавить трейт HasMediaTrait:

use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia\HasMedia;
use Spatie\MediaLibrary\HasMedia\HasMediaTrait;
use Spatie\MediaLibrary\Models\Media;

class Employee extends Model implements HasMedia
{

    use HasMediaTrait;

    // ...
}

Если в двух словах, библиотека задаст модель Media, которая содержит связь с закачанным файлом, и зарегистрирует рилейшн с этой моделью для нашей модели Employee.

3. Сохраняем загруженные файлы

Чтобы библиотека узнала, что файлы требуется сохранять именно в наше приватное хранилище, надо пройти в конфиг config/medialibrary.php и там указать:

    /*
     * The disk on which to store added files and derived images by default. Choose
     * one or more of the disks you've configured in config/filesystems.php.
     */
    'disk_name' => 'private',

Теперь внесем изменения в контроллер, в котором происходит сохранение/обновление сотрудника. Получаем загруженные файлы и добавляем их в медиаколлекцию:

<?php

namespace App\Http\Controllers;

use App\Employee;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\Exceptions\FileCannotBeAdded\FileDoesNotExist;
use Spatie\MediaLibrary\Models\Media;

class EmployeeController extends Controller
{

    public function store(Request $request)
    {
        $data = $this->validateRequest($request);
        /** @var Employee $employee */
        $employee = Employee::create($data);

        // отдельный метод для прикрепления файлов, т.к. он же и в update()
        $this->attachFiles($request, $employee);

        return response()->json($employee, 201);
    }

    public function update(Request $request, Employee $employee)
    {
        $data = $this->validateRequest($request, $employee);
        $employee->update($data);

        // отдельный метод для прикрепления файлов, т.к. он же и в store()
        $this->attachFiles($request, $employee);

        return response()->json($employee, 200);
    }

    /**
     *
     * Если были загружены файлы, прикрепляем их
     *
     * @param Request $request
     * @param Employee $employee
     */
    protected function attachFiles(Request $request, Employee $employee)
    {
        // не забудьте, что файлы, как и всё остальное в запросе, надо пропустить через валидатор
        if ($request->hasFile('pictures')) {
            foreach ($request->file('pictures') as $file) {
                $employee->addMedia($file)->toMediaCollection();
            }
        }
    }

    // ...
}

То есть вызвать $employee->addMedia($file)->toMediaCollection() это всё, что нужно сделать. А остальное (переместить файл на приватный диск, создать модель Media и релейшн) — сделает пакет автоматически.

Стоит отметить, что в методе toMediaCollection() можно указывать коллекцию. То есть коллекций файлов может быть много, и у каждой могут быть какие-то свои настройки.

4. Выдача файлов из хранилища

Теперь нам нужно как-то отдать сохраненный файл пользователю по запросу. Для этого конечно нужен какой-нибудь Url, по которому контроллер сможет определить, что требуется именно этот файл. Добавим новый путь и укажем контроллер и метод-обработчик. Поскольку мы возвращаем картинки сотрудников, укажем это явно в пути, чтобы контроллер понимал, к какой модели Employee относится данный файл.

Route::get('/private/employees/{employee}/pictures/{filename}', 'PrivateFilesController@get');

Здесь у нас {employee} — это модель сотрудника, от которого файл, а {filename} — некое имя файла, из которого мы должны понять, о каком именно медиа-объекте идет речь. В качестве такого имени хорошо годится id модели Media. По id мы легко найдем модель и возвратим связанный с ней файл.

Теперь создадим контроллер и обработаем запрос пользователя:

<?php

namespace App\Http\Controllers;

use App\Employee;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
use Spatie\MediaLibrary\Models\Media;

class PrivateFilesController extends Controller
{

    public function get(Employee $employee, $filename) {
        // проверим доступ пользователя к $employee
        // например, что это модель, принадлежащая текущему юзеру
        if (Auth::user()->id != $employee->user_id) {
            return response()->json([], 403);
        }

        // теперь выделим id модели из имени файла
        $filename = explode('.', pathinfo($filename, PATHINFO_FILENAME));
        $media_id = $filename[0];

        /** @var Media $media */
        // находим медиа-модель среди файлов сотрудника
        $media = $employee->getMedia()->where('id', $media_id)->first();
        // и сервим файл из этой медиа-модели
        return response()->file($media->getPath());
    }

    public function __construct()
    {
        // используем middleware чтобы автоматически отсечь анонимов
        $this->middleware('auth');
    }

}

Здесь мы сразу получаем модель Employee из запроса, чтобы не описывать отдельно её проверку на существование.

Затем получаем модель авторизованного юзера и проверяем некое правило доступа (например, что Employee принадлежит данному пользователю). И если правило доступа выполняется, возвращаем, собственно, файл. Чтобы получить файл — достаем id модели Media из имени файла.

Конечно, можно создать и более общий путь, возвращающий вообще все приватные файлы, главное чтобы по модели Media (или как-то еще) можно было получить данные, на основании которых принять решение о доступе.

А как получить такой url?

Теперь клиентский браузер, запросив картинку с описанным выше url-ом, сможет её получить. Но тут же встает вопрос — а откуда этот url возьмётся, как он попадет в браузер? Очевидно, надо этот Url каким-то образом сгенерировать. В laravel-medialibrary это делается так:

// получим url первого из приаттаченных изображений
$model->getMedia()->first()->getUrl();

где $model->getMedia() возвращает коллекцию всех прикрепленных медиа-объектов.

Однако если мы выполним такой код, то немедленно получим исключение. Потому что библиотека laravel-medialibrary не знает, как сгенерировать доступный пользователю url к приватному файлохранилищу. Мы же выше придумали собственное правило генерации пути к таким файлам, соответственно, теперь надо о нём рассказать библиотеке.

Чтобы выдача урла заработала, напишем свой собственный UrlGenerator. В документации на библиотеку об этом написано довольно мало, но ясно главное — нам нужно реализовать метод getUrl(), который сгенерирует нужный нам урл к картинке. А также метод getPath(), возвращающий путь к файлу на диске (нужен Ларавелу чтобы отдать файл клиенту):

<?php

namespace App\Files;

use App\Employee;
use DateTimeInterface;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\Exceptions\UrlCannotBeDetermined;
use Spatie\MediaLibrary\UrlGenerator\BaseUrlGenerator;

class PrivateUrlGenerator extends BaseUrlGenerator
{

    public function getUrl(): string
    {
        $media = $this->media;
        // parent - это модель Employee, получаем её из отношения
        $parent = $this->media->model;

        // имя файла у нас - <id модели>.расширение
        $name = [];
        $name[] = $media->id;
        $name[] = $media->getExtensionAttribute();
        $filename = implode('.', $name);

        return "/private/employees/$parent->id/pictures/$filename";
    }

    public function getTemporaryUrl(DateTimeInterface $expiration, array $options = []): string
    {
        throw UrlCannotBeDetermined::filesystemDoesNotSupportTemporaryUrls();
    }

    public function getResponsiveImagesDirectoryUrl(): string
    {
        throw new \Exception("No responsive directory url for private files.");
    }

    public function getPath(): string {
        return Storage::disk($this->media->disk)->path('') . $this->getPathRelativeToRoot();
    }

}

Генератор нужно зарегистрировать в конфиге библиотеки:

    /*
     * When urls to files get generated, this class will be called. Leave empty
     * if your files are stored locally above the site root or on s3.
     */
    'url_generator' => App\Files\PrivateUrlGenerator::class,

Теперь мы можем возвращать url-ы для загрузки картинок клиенту, вызывая getUrl() из контроллера, или же переопределив метод toArray() на модели.

Вот так например мы можем вручную сгенерировать и вернуть в JSON массив путей к картинкам, приаттаченным к одной модели $employee.

$pictures = $employee->getMedia()->map(function (Media $media) {
    return $media->getUrl();
});
return response()->json($pictures);

Заметьте, что генератор в методе getUrl() обращается к медиаобъектам и их родителям. Поэтому чтобы избежать проблемы N+1 запроса, не забудьте про eager loading, прогрузите отношения заранее, до циклических вызовов генерации url:

$employee->load(['media.model']);

5. Генерация миниатюр

Также библиотека laravel-medialibrary умеет сама генерировать загруженным картинкам миниатюры (и другие разнообразные преобразования тоже умеет). Для этого нужно только зарегистрировать преобразование на модели:

class Employee extends Model implements HasMedia
{

    use HasMediaTrait;

    // ...

    public function registerMediaConversions(Media $media = null) {
        $this->addMediaConversion('thumb')
            ->width(180)
            ->height(180);
    }

}

И миниатюра thumb будет автоматически сгенерирована при сохранении оригинального изображения (также есть возможность подвешивать генерацию на очередь задач).

Чтобы сгенерировать url для миниатюры, нужно в метод getUrl() передать название преобразования:

// миниатюра от первого прикрепленного изображения
$thumb = $employee->getMedia()->first()->getUrl('thumb');

Но чтобы наш генератор url-ов мог отличить миниатюру от оригинального изображения, нужно его немного доработать:

class PrivateUrlGenerator extends BaseUrlGenerator
{

    public function getUrl(): string
    {
        $media = $this->media;
        /** @var Employee $parent */
        $parent = $this->media->model;

        // name is <id>.<conversion>.<extension>
        $name = [];
        $name[] = $media->id;
        // если запрошено преобразование, его название будет в $this->conversion
        if ($this->conversion) {
            $name[] = $this->conversion->getName();
        }
        $name[] = $media->getExtensionAttribute();
        $filename = implode('.', $name);

        return "/private/employees/$parent->id/pictures/$filename";
    }

    // ...
}

Таким образом мы закодируем имя преобразования в имени файла, так что для жипега с id модели Media равном 1, имя файла получится 1.thumb.jpeg.

Теперь нужно подправить наш контроллер, отвечающий за выдачу файлов — чтобы выдавал миниатюры, когда они запрошены.

<?php

class PrivateFilesController extends Controller
{

    public function get(Employee $employee, $filename) {
        // проверим доступ пользователя к $employee
        // например, что это модель, принадлежащая текущему юзеру
        if (Auth::user()->id != $employee->user_id) {
            return response()->json([], 403);
        }

        // теперь выделим id модели из имени файла
        $filename = explode('.', pathinfo($filename, PATHINFO_FILENAME));
        $media_id = $filename[0];
        // забираем название конверсии из имени файла
        $conversion = $filename[1] ?? '';

        /** @var Media $media */
        // находим медиа-модель среди файлов сотрудника
        $media = $employee->getMedia()->where('id', $media_id)->first();
        // и сервим файл из этой медиа-модели, передавая название конверсии (если оно есть)
        return response()->file($media->getPath($conversion));
    }

}

На этом всё. Мы сделали приватное файловое хранилище, научились возвращать из него картинки с примитивным контролем доступа, научились сохранять и возвращать миниатюры для этих картинок.

Комментарии