Блог на Laravel 7, часть 14. Валидация данных и права доступа при загрузке изображений
08.02.2021
Теги: AJAX • Laravel • MySQL • PHP • Web-разработка • Блог • Изображение • ПраваДоступа • Практика • Форма
Валидация данных формы
Загрузка изображений работает, но у нас нет валидации данных формы. Кроме того, загрузить изображение может любой желающий — нет проверки прав на выполнение этого действия. Так что давайте разберемся с этими двумя проблемами.
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 7, часть 13. Загрузка и ресайз изображений для категорий и постов блога
- Мини-блог на Laravel, часть 4. Создание нового поста, загрузка и обрезка изображения
- Блог на Laravel 7, часть 16. Роль нового пользователя, сообщение админу о новом посте
- Блог на Laravel 7, часть 15. Восстановление постов, slug для категории, поста и страницы
- Блог на Laravel 7, часть 11. Панель управления — назначение ролей и прав для пользователей
- Блог на Laravel 7, часть 9. Панель управления — создание, публикация, удаление комментариев
- Блог на Laravel 7, часть 8. Панель управления — CRUD для категорий, тегов и пользователей
Поиск: Laravel • MySQL • PHP • Web-разработка • Блог • Изображение • Права доступа • Практика • Форма • Валидация • ajax • Поиск • Search