Блог на Laravel 7, часть 14. Валидация данных и права доступа при загрузке изображений

08.02.2021

Теги: AJAXLaravelMySQLPHPWeb-разработкаБлогИзображениеПраваДоступаПрактикаФорма

Валидация данных формы

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

namespace App\Http\Controllers\User;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;

class ImageController extends Controller {
    /**
     * Загружает изображение, которое было добавлено в wysiwyg-редакторе и
     * возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
     */
    public function upload(Request $request) {
        $validator = Validator::make($request->all(), ['image' => [
            'mimes:jpeg,jpg,png',
            'max:5000' // 5 Мбайт
        ]]);
        if ($validator->fails()) {
            return response()->json(['errors' => $validator->errors()->all()], 422);
        }
        $path = $request->file('image')->store('page', 'public');
        $url = Storage::disk('public')->url($path);
        return response()->json(['image' => $url]);
    }
}

Если валидация прошла успешно, клиенту будет отправлен ответ в формате json с http-кодом 200:

{
    "image": "http://www.server.com/storage/upload/Z3cnFCYN4rqgpCQgeZQr1HTonTppMKfekW9qyora.jpg"
}

В противном случае клиенту будет отправлен json-ответ с ошибками валидации с http-кодом 422:

{
    "errors": [
        "Поле image должно быть файлом одного из следующих типов: jpeg,jpg,png.",
        "Размер файла в поле image не может быть больше 1000 Килобайт(а)"
    ]
}

На клиенте нам надо обработать как ответ с http-кодом 200, так и ответ с кодом 422:

$(document).ready(function () {
    /* ... */
    (function () {
        /* ... */
        function uploadImage(image, textarea) {
            var data = new FormData();
            data.append('image', image);
            data.append('entity', entity);
            $.ajax({
                data: data,
                type: 'POST',
                url: '/user/upload/' + entity + '/image',
                cache: false,
                contentType: false,
                processData: false,
                // все прошло успешно, вставляем изображение
                success: function(data) {
                    $(textarea).summernote('insertImage', data.image, function ($img) {
                        $img.css('max-width', '100%');
                    });
                },
                // показываем все сообщения об ошибках
                error: function (data) {
                    $.each(data.responseJSON.errors, function (key, value) {
                        alert(value);
                    });
                }
            });
        }
        /* ... */
    })();
});

Мы сделали проверку сами, но документация Laravel говорит по этому поводу:

При использовании метода validate() во время AJAX-запроса Laravel не будет генерировать ответ перенаправления (редирект). Вместо этого Laravel генерирует ответ в формате JSON, содержащий все ошибки валидации. Этот JSON-ответ будет отправлен с кодом состояния 422 HTTP.

Так что наш код метода upload() почти ничем не будет отличаться от кода, который обрабатывает обычный запрос:

class ImageController extends Controller {
    /**
     * Загружает изображение, которое было добавлено в wysiwyg-редакторе и
     * возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/>
     */
    public function upload(Request $request) {
        $this->validate($request, ['image' => [
            'mimes:jpeg,jpg,png',
            'max:5000' // 5 Мбайт
        ]]);
        $path = $request->file('image')->store('upload', 'public');
        $url = Storage::disk('public')->url($path);
        return response()->json(['image' => $url]);
    }
}

Вот такой ответ сформирует фреймворк в случае ошибки:

{
    "message": "The given data was invalid.",
    "errors": {
        "image": [
            "Поле image должно быть файлом одного из следующих типов: jpeg,jpg,png.",
            "Размер файла в поле image не может быть больше 1000 Килобайт(а)."
        ]
    }
}

Проверка прав доступа

Теперь разберемся с правами доступа. Нам нужно, чтобы изображение для страницы сайта мог загружать пользователь, у которого есть право create-page или edit-page. Аналогично и с постами блога — нужно право create-post или edit-post. Вообще, в контроллере допускается использовать сразу несколько middleware:

class PageImageController extends ImageController {
    public function __construct() {
        $this->middleware(['perm:create-page', 'perm:edit-page']);
    }
    /* ... */
}

Но нам это не подходит, потому что здесь для загрузки изображения мы требуем create-page AND edit-page, а нам нужно create-page OR edit-page. Кроме того, нам нужно различать — когда загружается изображение для поста блога, а когда — для страницы сайта. Потому что права для этих действий нужны разные.

namespace App\Http\Middleware;

use Closure;

class CheckUserPermission {
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next, ...$perms) {
        if (!auth()->user()->hasAnyPerms(...$perms)) {
            abort(404);
        }
        return $next($request);
    }
}

Теперь метод handle() может принимает не только три параметра, но и четыре, пять, шесть. Третий, четвертый, пятый параметры — это права доступа, которые нужны для выполнения действия. Теперь, если у пользователя есть хотя бы одно право — действие будет разрешено — работает логика create-page OR edit-page.

namespace App\Http\Controllers\User;

class PageImageController extends ImageController {
    public function __construct() {
        $this->middleware(['perm:create-page,edit-page']);
    }
}
namespace App\Http\Controllers\User;

class PostImageController extends ImageController {
    public function __construct() {
        $this->middleware(['perm:create-post,edit-post']);
    }
}

Удаление поста и страницы

При удалении поста блога или страницы сайта — нужно удалить все связанные изображения. Для этого нам надо добавить в контроллеры Admin\PostControlle, User\PostController и Admin\PageController метод вроде этого — чтобы вызывать его перед удалением.

function removeAllImages($content) {
    $pattern = '~/storage/upload/([0-9a-z]+\.(jpeg|jpg|png))~i';
    preg_match_all($pattern, $content, $matches);
    foreach ($matches[1] as $name) {
        Storage::disk('public')->delete('upload/' . $name);
    }
}

Но дублировать метод в трех контроллерах — не слишком удачная идея. Так что создадим отдельный класс ImageUploader в директории Helpers.

namespace App\Helpers;

use Illuminate\Support\Facades\Storage;

class ImageUploader {

    public function upload() {
        request()->validate(['image' => [
            'mimes:jpeg,jpg,png',
            'max:5000' // 5 Мбайт
        ]]);
        $path = request()->file('image')->store('upload', 'public');
        $url = Storage::disk('public')->url($path);
        return response()->json(['image' => $url]);
    }

    public function remove() {
        $path = parse_url(request()->remove, PHP_URL_PATH);
        $path = str_replace('/storage/', '', $path);
        Storage::disk('public')->delete($path);
    }

    public function destroy($content) {
        $pattern = '~/storage/upload/([0-9a-z]+\.(jpeg|jpg|png))~i';
        preg_match_all($pattern, $content, $matches);
        foreach ($matches[1] as $name) {
            Storage::disk('public')->delete('upload/' . $name);
        }
    }
}

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

namespace App\Http\Controllers\User;

use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;

class ImageController extends Controller {
    public function upload(ImageUploader $imageUploader) {
        return $imageUploader->upload();
    }

    public function remove(ImageUploader $imageUploader) {
        $imageUploader->remove();
    }
}

И осталось только доработать контроллеры Admin\PostControlle, User\PostController и Admin\PageController:

namespace App\Http\Controllers\Admin;

use App\Category;
use App\Helpers\ImageSaver;
use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;
use App\Http\Requests\PostRequest;
use App\Post;

class PostController extends Controller {
    /* ... */
    public function destroy(Post $post, ImageUploader $imageUploader) {
        $imageUploader->destroy($post->content);
        $post->delete();
        /* ... */
    }
}
namespace App\Http\Controllers\User;

use App\Helpers\ImageSaver;
use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;
use App\Http\Requests\PostRequest;
use App\Post;

class PostController extends Controller {
    /* ... */
    public function destroy(Post $post, ImageUploader $imageUploader) {
        /* ... */
        $imageUploader->destroy($post->content);
        $post->delete();
        /* ... */
    }
}
namespace App\Http\Controllers\Admin;

use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;
use App\Page;
use Illuminate\Http\Request;

class PageController extends Controller {
    /* ... */
    public function destroy(Page $page, ImageUploader $imageUploader) {
        /* ... */
        $imageUploader->destroy($page->content);
        $page->delete();
        /* ... */
    }
}

Поиск по блогу

Форма в шабке у нас уже есть, искать будем по полям name и content таблицы posts, полю name таблицы users и полю name таблицы tags. Вклад в релевантность у полей будет разный, самый высокий — у заголовка поста, самый низкий — у автора поста.

SELECT DISTINCT
    `posts`.*,
    IF (`posts`.`name` LIKE '%хорош%', 4, 0) + IF (`posts`.`content` LIKE '%хорош%', 2, 0) +
    IF (`users`.`name` LIKE '%хорош%', 1, 0) + IF (`tags`.`name` LIKE '%хорош%', 3, 0) +
    IF (`posts`.`name` LIKE '%человек%', 4, 0) + IF (`posts`.`content` LIKE '%человек%', 2, 0) +
    IF (`users`.`name` LIKE '%человек%', 1, 0) + IF (`tags`.`name` LIKE '%человек%', 3, 0)
    AS `relevance`
FROM
    `posts`
    INNER JOIN `users` ON `users`.`id` = `posts`.`user_id`
    LEFT JOIN `post_tag` ON `post_tag`.`post_id` = `posts`.`id`
    LEFT JOIN `tags` ON `post_tag`.`tag_id` = `tags`.`id`
WHERE
    (`posts`.`name` LIKE '%хорош%' OR `posts`.`content` LIKE '%хорош%' OR `users`.`name` LIKE '%хорош%'
    OR `tags`.`name` LIKE '%хорош%' OR `posts`.`name` LIKE '%человек%' OR `posts`.`content` LIKE '%человек%'
    OR `users`.`name` LIKE '%человек%' OR `tags`.`name` LIKE '%человек%')
ORDER BY
    `relevance` DESC

Чтобы решить проблему окончания слов — используем стеммер Портера, который отсекает лишнее, оставляя только корень. Мне удалось найти на packagist.org готовый класс стеммера для русского языка.

Стеммер Портера — алгоритм стемминга, опубликованный Мартином Портером в 1980 году. Оригинальная версия стеммера была предназначена для английского языка. Впоследствии Мартин создал проект «Snowball» и, используя основную идею алгоритма, написал стеммеры для распространённых индоевропейских языков, в том числе для русского.

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

Итак, устанавливаем пакет с использованием composer:

> composer require ladamalina/lingua-stem-ru

Все, теперь нам доступен класс LinguaStemRu. Пример использования класса:

$stemmer = new LinguaStemRu();
echo $stemmer->stem_text('Любовь к Родине – это очень сильное чувство');
любов к родин – это очен сильн чувство

Добавляем новый маршрут в файле route/web.php:

/*
 * Блог: все посты, посты категории, посты тега, страница поста
 */
Route::group([
    'as' => 'blog.', // имя маршрута, например blog.index
    'prefix' => 'blog', // префикс маршрута, например blog/index
], function () {
    /* ... */
    // страница результатов поиска
    Route::get('search', [BlogController::class, 'search'])
        ->name('search');
});

Изменяем форму поиска в layout-шаблоне site.blade.php:

<form action="{{ route('blog.search') }}" class="form-inline my-2 my-lg-0">
    <input class="form-control mr-sm-2" type="search" name="query"
           placeholder="Поиск по блогу" aria-label="Search">
    <button class="btn btn-outline-info my-2 my-sm-0"
            type="submit">Искать</button>
</form>

Добавляем новый метод search() в контроллер BlogController:

class BlogController extends Controller {
    /**
     * Результаты поиска по постам, авторам и тегам
     */
    public function search(Request $request) {
        $search = $request->input('query');
        $posts = Post::search($search)->paginate()->withQueryString();
        return view('blog.search', compact('posts', 'search'));
    }
}

Добавляем новый метод scopeSearch() в модель Post:

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Stem\LinguaStemRu;

class Post extends Model {
    /**
     * Поиск постов блога по заданным словам
     */
    public function scopeSearch($builder, $search) {
        // обрезаем поисковый запрос
        $search = iconv_substr($search, 0, 64);
        // удаляем все, кроме букв и цифр
        $search = preg_replace('#[^0-9a-zA-ZА-Яа-яёЁ]#u', ' ', $search);
        // сжимаем двойные пробелы
        $search = preg_replace('#\s+#u', ' ', $search);
        $search = trim($search);
        if (empty($search)) {
            return $builder->whereNull('id'); // возвращаем пустой результат
        }
        // разбиваем поисковый запрос на отдельные слова
        $temp = explode(' ', $search);
        $words = [];
        $stemmer = new LinguaStemRu();
        foreach ($temp as $item) {
            if (iconv_strlen($item) > 3) {
                // получаем корень слова
                $words[] = $stemmer->stem_word($item);
            } else {
                $words[] = $item;
            }
        }
        $relevance = "IF (`posts`.`name` LIKE '%" . $words[0] . "%', 4, 0)";
        $relevance .= " + IF (`posts`.`content` LIKE '%" . $words[0] . "%', 2, 0)";
        $relevance .= " + IF (`users`.`name` LIKE '%" . $words[0] . "%', 1, 0)";
        $relevance .= " + IF (`tags`.`name` LIKE '%" . $words[0] . "%', 3, 0)";
        for ($i = 1; $i < count($words); $i++) {
            $relevance .= " + IF (`posts`.`name` LIKE '%" . $words[$i] . "%', 4, 0)";
            $relevance .= " + IF (`posts`.`content` LIKE '%" . $words[$i] . "%', 2, 0)";
            $relevance .= " + IF (`users`.`name` LIKE '%" . $words[$i] . "%', 1, 0)";
            $relevance .= " + IF (`tags`.`name` LIKE '%" . $words[$i] . "%', 3, 0)";
        }

        $builder->distinct()->join('users', 'users.id', '=', 'posts.user_id')
            ->leftJoin('post_tag', 'post_tag.post_id', '=', 'posts.id')
            ->leftJoin('tags', 'post_tag.tag_id', '=', 'tags.id')
            ->select('posts.*', DB::raw($relevance . ' as relevance'))
            ->where('posts.name', 'like', '%' . $words[0] . '%')
            ->orWhere('posts.content', 'like', '%' . $words[0] . '%')
            ->orWhere('users.name', 'like', '%' . $words[0] . '%')
            ->orWhere('tags.name', 'like', '%' . $words[0] . '%');
        for ($i = 1; $i < count($words); $i++) {
            $builder = $builder->orWhere('posts.name', 'like', '%' . $words[$i] . '%');
            $builder = $builder->orWhere('posts.content', 'like', '%' . $words[$i] . '%');
            $builder = $builder->orWhere('users.name', 'like', '%' . $words[$i] . '%');
            $builder = $builder->orWhere('tags.name', 'like', '%' . $words[$i] . '%');
        }
        $builder->orderBy('relevance', 'desc');
        return $builder;
    }
}

Осталось только создать шаблон views/blog/search.blade.php:

@extends('layout.site', ['title' => 'Поиск по блогу'])

@section('content')
    <h1 class="mb-3">Поиск по блогу</h1>
    <p>Поисковый запрос: {{ $search ?? 'пусто' }}</p>
    @if ($posts->count())
        @foreach ($posts as $post)
            @include('blog.part.post', ['post' => $post])
        @endforeach
        {{ $posts->links() }}
    @else
        <p>По вашему запросу ничего не найдено</p>
    @endif
@endsection
Много позже заметил, что запрос возвращает дубли строк. Причина этого в том, что у поста может быть несколько тегов, а теги могут как содержать слова поискового запроса, так и не содержать. В итоге значение relevance может быть разным для одного и того же поста — и DISTINCT не помогает. Так что запрос надо бы изменить, добавив группировку записей:
SELECT
    `posts`.*,
    MAX(IF (`posts`.`name` LIKE '%хорош%', 4, 0) + IF (`posts`.`content` LIKE '%хорош%', 2, 0) +
        IF (`users`.`name` LIKE '%хорош%', 1, 0) + IF (`tags`.`name` LIKE '%хорош%', 3, 0) +
        IF (`posts`.`name` LIKE '%человек%', 4, 0) + IF (`posts`.`content` LIKE '%человек%', 2, 0) +
        IF (`users`.`name` LIKE '%человек%', 1, 0) + IF (`tags`.`name` LIKE '%человек%', 3, 0))
    AS `relevance`
FROM
    `posts`
    INNER JOIN `users` ON `users`.`id` = `posts`.`user_id`
    LEFT JOIN `post_tag` ON `post_tag`.`post_id` = `posts`.`id`
    LEFT JOIN `tags` ON `post_tag`.`tag_id` = `tags`.`id`
WHERE
    (`posts`.`name` LIKE '%хорош%' OR `posts`.`content` LIKE '%хорош%' OR `users`.`name` LIKE '%хорош%'
    OR `tags`.`name` LIKE '%хорош%' OR `posts`.`name` LIKE '%человек%' OR `posts`.`content` LIKE '%человек%'
    OR `users`.`name` LIKE '%человек%' OR `tags`.`name` LIKE '%человек%')
GROUP BY
    1
ORDER BY
    `relevance` DESC

Проблема только в том, что Laravel 7-ой версии не умеет корректно разбивать на страницы при использовании GROUP BY и рекомендует делать это вручную. Но мне уже лень было возвращаться к старому проекту, так что оставил как есть.

Currently, pagination operations that use a groupBy statement cannot be executed efficiently by Laravel. If you need to use a groupBy with a paginated result set, it is recommended that you query the database and create a paginator manually.

Корзина (Soft Deletes)

Будет обидно, если какой-нибудь пост блога будет удален по ошибке безвозвратно. К счастью, кроме обычного удаления записей из базы данных, Eloquent также умеет удалять без удаления. При этом запись на самом деле остаётся в таблице базе данных, но устанавливается атрибут deleted_at. Для включения soft deletes нужно использовать трейт Illuminate\Database\Eloquent\SoftDeletes и добавить столбец deleted_at в свойство $dates модели.

Создадаем миграцию для добавления поля deleted_at:

> php artisan make:migration alter_posts_table --table=posts
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AlterPostsTable extends Migration {
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up() {
        Schema::table('posts', function (Blueprint $table) {
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down() {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });
    }
}
> php artisan migrate:fresh --seed

Теперь добавляем в модель Post трейт SoftDeletes:

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Stem\LinguaStemRu;
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model {

    use SoftDeletes;

    /**
     * Атрибуты, которые должны быть преобразованы в дату
     *
     * @var array
     */
    protected $dates = ['deleted_at'];
    
    /* ... */
}

Поиск: Laravel • MySQL • PHP • Web-разработка • Блог • Изображение • Права доступа • Практика • Форма • Валидация • ajax • Поиск • Search

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.