Время от времени бывает необходимо организовать файловое хранилище за пределами /public. Например, для того, чтобы контролировать доступ к файлам (недоступным по прямой ссылке) по неким правилам.
Сходу из коробки в Laravel 6 такая фича на 100% не предусмотрена, но сделать её руками достаточно нетрудно.
Для начала разобьем задачу на части. Нам нужно:
- Определить, где будут храниться файлы (private storage).
- Прикреплять файлы к некой модели, чтобы на основе этой модели управлять доступом.
- Обеспечить сохранение файлов в private storage.
- Реализовать, собственно, контроль доступа и выдачу файлов по некоему пути.
- На закуску добавим автоматическую генерацию миниатюр.
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));
}
}
На этом всё. Мы сделали приватное файловое хранилище, научились возвращать из него картинки с примитивным контролем доступа, научились сохранять и возвращать миниатюры для этих картинок.