【13日目】認可処理とバリデーション【作曲の補助ツールを作るまでの日記】

プログラミング

2023年8月3日〜2023年8月6日

12日目の続きをやる。

ユーザーによって編集できたり、削除できたりの認証部分からやろう。

そういえば、Laravelの指定本に認証の説明があったので、認証の理解から進めていこう……と思ってたんだけど、ちょっとむずい。というか、初心者がやるもんなんかいなこれ。

ちょっとむずいと感じるので、認可処理の部分だけとりあえずやる。

認可処理

認可処理とは、ユーザーの状態を条件に処理を分岐させること。簡単に言えばアクセス制限。

条件の分岐が出来ればいいので、正直
$id = Auth::id();
でid取得してそれの照合をif文でしてしまってもいいんだけど、これだとコントローラが肥大化しちゃうし、再利用できない。
だから、LaravelではGateとPolicyという物が用意されているみたい。

これはどちらも標準ではApp\Providers\AuthServiceProvider.phpに記述する。

認可の日本語ドキュメントはこれ

ゲートとポリシ―の前に

以下はドキュメントにあったAuthServiceProviderのサンプルコード

<?php

namespace App\Providers;

// use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * アプリケーションのポリシーマッピング
     *
     * @var array
     */
    protected $policies = [
        Post::class => PostPolicy::class,
    ];

    /**
     * 全アプリケーション認証/認可サービス登録
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();


        Gate::define('update-post', function (User $user, Post $post) {
            return $user->id === $post->user_id;
        });
    }
}

この上のコードの

Gate::define('update-post', function (User $user, Post $post) {
            return $user->id === $post->user_id;
        });

の部分がゲートの部分。

このゲートは、ユーザーidと記事のidが一致しているかの判定。

使う時は以下の通り

public function update(Request $request, Post $post)
{
    if (! Gate::allows('update-post', $post)) {
        abort(403);
    }
    // 投稿を更新…
}

すっげえ基礎知識だと思うけど、私はこのサンプルコードに何個か疑問点があった。

  • $thisって何?
  • $this->registerPolicies();で何をしているの?
  • そういえばUser $userみたいな引数の受け取り方は何?
  • 何故userの情報を与えてないのにidを取得できるの?
  • ===と==の違いは何?
  • protectedって何?

全部説明していく。

$thisって何?

今までも何回か出会ったことあったんだけど、ggって10秒くらいで「ふーーん」って感じで何となくだけ理解しちゃってて、しっかり学んでなかったのでここで理解しちゃう。

$thisはPHPのルール? で自分自身のインスタンスを示す。だから、Laravelとか関係ない。
今回の場合は自身「PostController」を示すってこと。

Pythonを使う人は、self的な認識で良いと思う。

$this->registerPolicies();で何をしているの?

と、いうことは$thisは自身インスタンスを指定しているので、PostControllerのregisterPolicies()メソッドを実行していることになる。でも、クラス内ではそんなの定義してないよね。

これは、クラス継承をしているからで、「extends ServiceProvider」で継承したクラスのメソッドを利用している。

そして、ServiceProviderは
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
と取得されているので、AuthServiceProviderを見れば詳細が分かる。

これはIlluminateはgitignore…というかそれより上のディレクトリがgitignoreに入っているので、VM側で確認してみる。

「cat QTM/vendor/laravel/framework/src/Illuminate/Foundation/Support/Providers/AuthServiceProvider.php」

以下はAuthServiceProvider.php

<?php

namespace Illuminate\Foundation\Support\Providers;

use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * The policy mappings for the application.
     *
     * @var array<class-string, class-string>
     */
    protected $policies = [];

    /**
     * Register the application's policies.
     *
     * @return void
     */
    public function registerPolicies()
    {
        foreach ($this->policies() as $model => $policy) {
            Gate::policy($model, $policy);
        }
    }

    /**
     * Get the policies defined on the provider.
     *
     * @return array<class-string, class-string>
     */
    public function policies()
    {
        return $this->policies;
    }
}

うーーーーん。
さらに理解するには
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
を理解しないといけないっぽいんだけど、やってることはpolicies配列を作り、registerPliciesメソッドでポリシーとモデルの紐づけを行っているみたいな感じ。

一旦今はゲートのことを知りたいので、ここまでやればいいかな。

そういえばUser $userみたいな引数の受け取り方は何?

ここら辺は本当に初心者にとって沼。
サービスコンテナ→依存性注入→依存性注入は何故必要なのかみたいな話になって、訳わかんなくなる。

結論だけ言えば、クラスの依存性を解決するためにサービスコンテナっていう機能があって、その機能を使ってクラスのインスタンスを作成している。
つまり、依存性を解決したインスタンスを作成している。

この依存性というのは、”前提”みたいな意味。クラス内で必要になる他クラスのインスタンスやメソッドの引数っていうのは前提条件で必要=それらに依存している。

サービスコンテナは前も少し触れたんだけど、今回改めて学ぶと更に訳わかんなくなるなぁ。

サービスコンテナっていうのはクラスのインスタンスを作成するときに、これら依存性を自動で解決するシステム。
なぜ、解決した方がいいのかっていう疑問になるとかなり沼るし、初心者では手が負えないので、今は”便利だから”程度の認識で。

今回のUserクラスではサービスコンテナ内で、”恐らく”Userインスタンスを作成する際は認証しているユーザーインスタンスを返すという定義がされている。

何故userの情報を与えてないのにidを取得できるの?

上から繋がっているので注意。

つまり、サービスコンテナ内で「認証しているuserインスタンスを取得してそのインスタンスを返す」という定義がされているので、Userの情報を引数に与えなくても問題なくUserインスタンスを作成できている。

===と==の違いは何?

これは、値の型まで比較するかの違い。
1 == “1”はTrue。1 === “1”はFalse。というだけ。

更に言えば===の方が型推論が働かないので処理速度が速いらしい。

否定は!==

protectedって何?

protected $policies = [
        Post::class => PostPolicy::class,
    ];

これはスコープの範囲らしい。
範囲にはpublic,protected,privateがある。

  • public
    どこからでもアクセスできる
  • private
    同じクラス内からだけアクセスできる
  • protected
    親クラス(継承元)、自分のクラス、子クラス(継承先)からのみアクセスできる

ということらしい。
恐らくSwiftとかPythonにもあるんだろうけど、クラスあんま使ったことなかったから初めて知った。

親クラスのregisterPolicies()で使うからこの設定が必要なんだねたぶん。

ゲートとは

これまでを踏まえた上で、理解していく。
ゲートは文字通り、ゲートとしての処理、つまりAuth専用のif文の判定部分だけ記述する感じ。

Gate::define('update-post', function (User $user, Post $post) {
            return $user->id === $post->user_id;
        });

上のコードみたいに定義する。第一引数がゲート名で、第二引数が条件。

使う時は

if (! Gate::allows('update-post', $post)) {
        abort(403);
    }

みたいな感じで使う。allowsは条件が正だったらTrueを、denisは条件が負だったら正を返す。ややこしいね。

Blade内では

@can('update–post', $post)
    <!-- 現在のユーザーは投稿を更新可能 -->
@elsecan('create', App\Models\Post::class)
    <!-- 現在のユーザーは新しい投稿を作成不可能 -->
@else
    <!-- ... -->
@endcan
@cannot('update-post', $post)
    <!-- 現在のユーザーは投稿を更新不可能 -->
@elsecannot('create', App\Models\Post::class)
    <!-- 現在のユーザーは新しい投稿を作成可能 -->
@endcannot

みたいな感じでHTMLの見え隠れを制御できる。

ポリシーとは

ポリシーでは特定のモデルのメソッドに対して制御を付けられる。
例えば、「PostモデルのUpdateメソッドは$user->id === $post->user_id;じゃなきゃ実行できない」みたいな。

ポリシーの設定手段は、

php artisan make:policy PostPolicy –model=Post

でモデルPostのポリシーファイルを作成して、

public function update(User $user, Post $post)
{
    return $user->id === $post->user_id;
}

みたいな感じで記述する。

使う時は以下みたいな感じ。

public function update(Request $request, Post $post)
{
    if ($request->user()->cannot('update', $post)) {
        abort(403);
    }
    // 投稿を更新…
}

認証の実践

やりたいことは

  • ゲストユーザーは記事の新規作成をできなくする
  • 自分の作成した記事以外は編集できなくする

意外とこんなもんかもしれない。

因みに、現在の設定だとそもそもゲストユーザーだとWebページが閲覧できないので、そこらへんの修正もする。

ログインしている時だけボタンを見せる

こんなにゲートとポリシーの説明をしておいてあれなんだけど、Bladeテンプレートにログインしているか否かのディレクティブ(@から始まるやつ)がある。

@auth
    <a href={{ route('article.create') }} class='btn btn-outline-primary'>新規作成</a>
@endauth

でボタンの表示を操れる。

guestは@guestと@endguestで操れる。

ゲストユーザーでも記事一覧と記事詳細は見れるようにする

エラーメッセージを見てみると、ナビゲーションバーのとこでユーザーの名前を取得しようとしてエラーが出てるみたい。

これは、ナビゲーションバーのテンプレレイアウトをログイン時と非ログイン時で分ければ問題なさそう。

ChatGPTにこのナビゲーションバーを元にゲスト時のナビゲーションバーを作ってもらい、少しだけ修正した。

ログインボタン、レジスターボタンもしっかり反応するようにし、ロゴとホームは記事一覧に飛ぶようにした。

コードはちょっとごり押し感あるけど、ナビゲーションバーのテンプレートを@authと@guestで分岐させた。

一応、@guestのコード

@guest
<nav x-data="{ open: false }" class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
    <!-- Primary Navigation Menu -->
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between h-16">
            <div class="flex">
                <!-- Logo -->
                <div class="shrink-0 flex items-center">
                    <a href="{{ route('articles.index') }}">
                        <x-application-logo class="block h-9 w-auto fill-current text-gray-800 dark:text-gray-200" />
                    </a>
                </div>


                <!-- Navigation Links -->
                <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
                    <x-nav-link :href="route('articles.index')" :active="request()->routeIs('articles.index')">
                        {{ __('Home') }}
                    </x-nav-link>
                </div>
            </div>


            <!-- Login/Registration Links -->
            <div class="hidden sm:flex sm:items-center sm:ml-6">
                <x-nav-link :href="route('login')" :active="request()->routeIs('login')">
                    {{ __('Login') }}
                </x-nav-link>
                <x-nav-link :href="route('register')" :active="request()->routeIs('register')">
                    {{ __('Register') }}
                </x-nav-link>
            </div>


            <!-- Hamburger -->
            <div class="-mr-2 flex items-center sm:hidden">
                <button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out">
                    <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
                        <path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
                        <path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                    </svg>
                </button>
            </div>
        </div>
    </div>


    <!-- Responsive Navigation Menu -->
    <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
        <div class="pt-2 pb-3 space-y-1">
            <x-responsive-nav-link :href="route('articles.index')" :active="request()->routeIs('articles.index')">
                {{ __('Home') }}
            </x-responsive-nav-link>
        </div>


        <!-- Responsive Settings Options -->
        <div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600">
            <div class="px-4">
                <x-responsive-nav-link :href="route('login')">
                    {{ __('Login') }}
                </x-responsive-nav-link>
                <x-responsive-nav-link :href="route('register')">
                    {{ __('Register') }}
                </x-responsive-nav-link>
            </div>
        </div>
    </div>
</nav>
@endguest

自分の作成した記事以外は編集できなくする

ここでようやくゲートやポリシーが出てくるのでは。

恐らくどちらを使っても問題ないんだけど、ポリシーの方が面倒そうなので経験も兼ねてポリシーでやってみる。

上で説明しなかったんだけど、
$this->authorize(‘update’, $post);
のようなauthorizeをコントローラに入れることで条件にあってたらそれ以下のコードを実行、合ってなければ403を返すということが出来るので、これを使っていく。

まず、
php artisan make:policy ArticlePolicy –model=Article
でポリシーファイルの作成。

作るポリシーは

  • create、storeはゲスト時403
  • edit、update、destroyは同一idじゃないと403

くらいかな?

よし、認証外のユーザーでeditに飛べなくなった。

ArticlePolicyは以下みたいな感じで設定した。

    public function create(User $user)
    {
        // デフォルトでは、受信HTTPリクエストが認証済みユーザーによって開始されたものでない場合、すべてのゲートとポリシーは自動的にfalseを返します。
        // ゲスト時無効
        return True;
    }
    public function update(User $user, Article $article)
    {
        // 同一idじゃないと無効
        return $user->id === $article->user_id;
    }

コントローラは以下みたいな感じ

    public function create()
    {
        $this->authorize('create',Article::class);
        ・・・
    }
    public function edit($id)
    {
        $article = Article::find($id);
        $this->authorize('edit', $article);
        ・・・
    }

Bladeは以下のようにした

@can("edit",$article)
    <a href={{ route("article.edit", ["id" => $article->id]) }} class="btn btn-outline-primary">編集</a>
@endcan

いいねボタンとかも変えといた。

これで、とりあえず認証周りはいいのかな?
でも、何かしら穴はあるんだろうなぁ。

とりあえず、バリデーションに行ってみよう。

バリデーション

ここら辺も超大事だよね。やろう。

バリデーションとは?

そもそもバリデーションとは、入力されたデータを検証する仕組み。

今回の場合、タグは5つまでとか、文字は128文字までとかも勿論検証しなきゃいけないんだけど、それ以外にも悪意のあるコマンドを実行しようとしてないかとか、そういうのも検証しなきゃいけない。

しかも、今回の場合はWYSIWYGでHTMLの保存をしていたりするので、このままだと恐らくかなり危ない。

Laravelバリデーションの種類

日本語ドキュメントのバリデーションを見てもらうとわかるんだけど、エグい長い。

正直、全部は読めてないんだけど、まとめるとバリデーションはLaravel側で3通りの方法が提供されている。

  1. コントローラに直接書くバリデーション
  2. フォームリクエストに書くバリデーション
  3. ルールオブジェクトに書くバリデーション

コントローラに直接書くバリデーション

ドキュメントにある例は以下の通り

public function store(Request $request)
{
    $validated = $request->validate([
        'title' => 'required|unique:posts|max:255',
        'body' => 'required',
    ]);
    // バリデーション通過後の処理
}

この場合は

  • rqeuired=存在する
  • unique:posts=postsテーブルで一意
  • max:255=最大255文字

といったルールが付けられている

この条件のどれかに引っかかると「Illuminate\Validation\ValidationException」例外というのが投げられ、直前のURLにリダイレクトする。
その際に$errorという要素も投げられるので、Blade内に「@if ($errors->any())」とかで分岐を作っておけば、エラー発生時の表示も操れる。

そして、このvalidateメソッドを見てもらうと分かるんだけど、Requestのメソッドなんだよね。だから、あらかじめ自分でRequestクラスを作りValidateを作っておけば、コントローラに直接書く必要がないよね! っていうのがフォームリクエストに書くバリデーション。

また、Validatorファザードを利用した書き方もある。

$validator = Validator::make($request->all(), [
    'title' => 'required|unique:posts|max:255',
    'body' => 'required',
]);

if ($validator->fails()) {
    return redirect('post/create')
                ->withErrors($validator)
                ->withInput();
}

こうすると、エラー時の処理を書ける。

フォームリクエストに書くバリデーション

まず、以下のようなコマンドでリクエストの下地を作ってもらう
php artisan make:request StoreArticleRequest
作成されたファイルは「app/Http/Requests」にある。

中身は以下の通り(コメントは除外)

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreArticleRequest extends FormRequest
{
    public function authorize()
    {
        return false;
    }

    public function rules()
    {
        return [
            //
        ];
    }
}

authorizeは上でやった認可処理で、ここで認可処理もできちゃう。
ここで認可処理をする必要がなければreturn trueで全員通す設定でおk。

さっきのバリデーション条件を適用させたい場合はrulesを以下の通りにする。

    public function rules()
    {
        return [
            'title' => 'required|unique:posts|max:255',
            'body' => 'required',
        ];
    }

それを使う時はコントローラで以下のようにする。

public function store(StorePostRequest $request)
    {
        // 送信されたリクエストデータはバリデーションを通過
    }

また、messageメソッドをオーバーライド(上書き的な)することで、エラーメッセージを操れたりする。

public function messages()
{
    return [
        'title.required' => 'A title is required',
        'body.required' => 'A message is required',
    ];
}

ルールオブジェクトに書くバリデーション

これは、ルールを自分で作りたかったり、複雑な条件を課したいときに使うのかな。

php artisan make:rule ExampleRule
でルールファイルをapp/Rulesに作る。

生成されるファイルは以下の通り

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class ExampleRule implements Rule
{
    public function __construct()
    {
        //
    }
    public function passes($attribute, $value)
    {
        //
    }
    public function message()
    {
        return 'The validation error message.';
    }
}

passesにtrue or falseになる条件を書いて、メッセージにエラー時のメッセージを書く。

ちょっと上級者向け?

バリデーションの実践


とりあえずやりたいことは

  • CKEditorのHTMLのバリデーション
  • 検索窓のバリデーション
  • その他ユーザーが自由に文字列を送れる系のバリデーション
  • もし必要ならほかのPostのバリデーション

現状ではクロスサイトスクリプティング(XXS)が出来てしまうのか確認する

一回テストしてみよう。

「<script>alert(‘かかったな!’);</script>」というjavascript? をCKEditorに入れてみる。

入れてみたけど、普通に表示することが出来た。

編集画面を押すと<script>部分は消えるので、編集時にもしっかり危ない文字は消されるみたい。

DBを見てみると、本文は「 <p>&lt;script&gt;alert(‘かかったな!’);&lt;/script&gt;</p>」と保存されていた。
つまり、<と>が&lt;と&gt;に置き換えられて保存されていて、サニタイズという処理が勝手にされているみたい。

これはCKEditorが恐らくサニタイズしてくれている。
CKEditorを通さずにPOSTを送ると恐らくXSS出来てしまうので、POSTを送ってみようと思ったんだけど、POST送るの難しすぎ(というか、POST送るって日本語おかしい?)。

普通にPOSTを送るだけだと、Laravelの謎の力(恐らくCSRF保護)でなんかブロックされちゃったり、ユーザー認証とか、ユーザーidから名前を引っ張ってくる処理とかしてるから、めっちゃいろいろ考慮して送んないといけない。

よく出来てるわ本当に。

でも、何か特殊な方法でPOSTを直接送れちゃう可能性はあると思うので、処理は施そう。

また、普通のタイトルとかは{{ }} みたいな感じで変数を囲むことでView表示をするときLaravelが適切な処理をしてくれているので、恐らく問題ない。

DBに直接入れてみる。

DBに直接入れて、表示するときどうなるのかを見てみる。

「<script>alert(‘かかったな!’);</script>          | <script>alert(‘かかったな!’);</script>」みたいな感じでDBに入れた。

見てみよう。

よし、実行されるのが確認できた。

つまり、DBに入るときに<とかが置き換えられる処理がされていて、DBから取り出して使う時は何もされていない。

バリデーションではなく、サニタイズの対策をしてみる。

つまり、CKEditorがDBに入れる前に<を&lt;にサニタイズをしてくれているけど、CKEditorを通さずにDBに入れるとそのままScriptが発動してしまうので、DBから取り出して使う時にサニタイズをするようにしたい。

ここで、自分で関数を組んでもいいと思うんだけど、知識があまりにもないので、「HTMLPurifier for Laravel」というのを使用してみる。

こいつをまずComposerでインストールする
“composer require mews/purifier”

これでもう使えるようになっているみたいなのでコントローラで

// HTMLPurifierを使用してサニタイズ
        $article->body = Purifier::clean($article->body);

みたいな感じでbody文だけサニタイズする。

本来はサービスコンテナに勝手に登録されるはずなんだけど、PurifierがNot Foundになってしまったので、
use Mews\Purifier\Facades\Purifier;
で空間指定した。

これでさっきのサイトを閲覧してみよう。

お、サニタイズされているっぽい!
といっても画像じゃわかんないんだよね。

とにかく、アラートが出なくなった。素晴らしい。

普通にバリデーションもする

なぜバリデーションをするのか

意味のない、何となくするバリデーションやサニタイズはダメとQiita記事にあったので、なぜバリデーションをするのかを意識しながらバリデーションをしていく。

先にまとめちゃうと、

  • DBに負担をかけないように
  • 空白記事や視認性の問題を解消する為
  • ユーザーの為

DBの負担っていうのは、単純に制限をかけないとbodyとかはlongTextを指定しているので結構重くなっちゃうと思う。だから、予め文字数とかに制限をかけとく。

また、タイトルに文字がないと仕組み上あまりよろしくない。
見てもらえるとわかるんだけど、今回はタイトルをそのままリンクにしている。だからタイトルは必ず1文字以上見える文字が欲しい。

ユーザーの為っていうのは、何故エラーが起きたのかをユーザーに知らせる為みたいな意味合いがある。

今回titleとかはDB側でstringで型指定しているので、確か255文字までしか入らない。だから、ユーザーが256文字入れると自ずとエラーが出るんだけど、ユーザー側に分かりやすいレスポンスは無いんだよね(たぶん)。
そこで分かりやすいエラーメッセージを表示してあげればユーザーに優しい設計になるかなと思って。

新規記事作成のバリデーション

ここでは、タイトル・タグ・本文のバリデーションを行いたい。

考えられるバリデーション

  • タイトル
    文字数の制限→〇文字以上、〇文字以下みたいな
    使用できる文字の制限→特殊文字の禁止
  • タグ
    文字数の制限→上と同じく
    使用できる文字の制限→上と同じく
    タグの個数制限→5個以下とか
  • 本文
    文字数の制限→上と同じく
    使用できる文字の制限→上と同じく

こんな感じだろうか。

使用できる文字の制限はちょっと難しそうだったので最後にやる。

“php artisan make:request StoreArticleRequest”
でリクエストファイルを作り、そこに記述していく。

とりあえず、titleとbodyは以下の通り

    public function rules()
    {
        return [
            'title' => 'required|max:255',
            'body' => 'required|max:8388608',
        ];
    }

bodyの文字数制限は2^23にした。知識もないので特に深い理由はないが、まあ800万文字数あれば大丈夫だろうという安易な考え。

ここに加えてタグのルールも作る。

php artisan make:rule MaxTags
でルールの作成。

指定したルールは以下の通り

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class TagValidation implements Rule
{
    private $maxTags;
    private $maxTagLength;

    public function __construct($maxTags, $maxTagLength)
    {
        $this->maxTags = $maxTags;
        $this->maxTagLength = $maxTagLength;
    }

    public function passes($attribute, $value)
    {
        $tags = explode(",",$value);

        // タグの個数制限チェック
        if (count($tags) > $this->maxTags) {
            return false;
        }

        // 各タグの文字数制限チェック
        foreach ($tags as $tag){
            if (mb_strlen(trim($tag)) > $this->maxTagLength) {
                return false;
            }
        }

        return true;
    }

    public function message()
    {
        return "タグは最大 {$this->maxTags} 個まで指定でき、各タグは {$this->maxTagLength} 文字以下である必要があります。";
    }
}

使用するときは以下の通り

    public function rules()
    {
        return [
            'title' => 'required|max:255',
            'body' => 'required|max:8388608',
            'tags' => [new TagValidation(5, 64)]
        ];
    }

自分の為にもこのコードの説明すると、先ず

private $maxTags;
private $maxTagLength;

でプライベートなプロパティ(クラス内変数)の宣言をして、

public function __construct($maxTags, $maxTagLength)
    {
        $this->maxTags = $maxTags;
        $this->maxTagLength = $maxTagLength;
    }

コンストラクタ(pythonで言うinit)、つまり初期化時に2つの変数を取得し、さっき宣言したプロパティに代入している。
つまり、

'tags' => [new TagValidation(5, 64)]

のように引数を指定して使うと、maxTags=5,maxTagLength=64となる。

そして、passesは条件を書いてtrue or falseを返すところ。

    public function passes($attribute, $value)
    {
        $tags = explode(",",$value);

        // タグの個数制限チェック
        if (count($tags) > $this->maxTags) {
            return false;
        }

        // 各タグの文字数制限チェック
        foreach ($tags as $tag){
            if (mb_strlen(trim($tag)) > $this->maxTagLength) {
                return false;
            }
        }

        return true;
    }

結構そのまんまだけど、私がわからなかったのは$attributeと$value。
これは、passesが呼び出されるときに勝手に指定される変数で、
$attributeはバリデーション対象のフィールド名
$valueはそのフィールドの値
を示す。
つまり、

'tags' => [new TagValidation(5, 64)]

と呼びだすと、$attributeはtagsに。$valueはその値になる。

また、message()はバリデーションエラー時のメッセージ。

    public function message()
    {
        return "タグは最大 {$this->maxTags} 個まで指定でき、各タグは {$this->maxTagLength} 文字以下である必要があります。";
    }

最後に、これをstoreメソッドに適用すればおkかな?

public function store(StoreArticleRequest $request)
    {
	…

テストしてみよう。

タグを5個以上作って、作成!

うん。超わかりにくいけど、新しい記事は作成されずにリダイレクトされたので正しく動いているっぽい。

エラーメッセージの表示とかは最後にやろうか。

記事編集時のバリデーション

といっても、新規記事作成と変わんないよねこれ。

もし、「タイトルは一意じゃないきゃダメ」みたいなバリデーションをしている時は考慮しなきゃいけないっぽいんだけど、今回はそういうのも一切ないので新規記事作成のバリデーションルールをそのまま適用する。

public function update(StoreArticleRequest $request, $id)
    {

一応、軽いテストをしとこう。

タグを複数にして編集しようとしてみる。

うん、戻された。おk。
一応、5つの場合で更新してみる。

いいね。しっかりできている。

検索窓のバリデーション

検索窓も必要だよね、恐らく。
普通はSQLインジェクションとかも考慮したバリデーションが必要なんだけど、LaravelではeloquentというORMがDBとの橋渡し的なことをしてくれているので必要ないはず。
また、XSSも{{ $message }}みたいな感じでエスケープしてるので恐らく問題ない。

と、なると特殊文字は最後にやるとして、文字数制限くらいか?

じゃあ、同じくリクエストファイルを作成して、
“php artisan make:request IndexArticleRequest”

中身を編集して

    public function rules()
    {
        return [
            'keyword' => 'max:255'
        ];
    }

おk。

試しに255文字を超える文字列を入れてみたけど、リダイレクトされたので正常に動いているっぽい。

特殊文字の禁止

ここがちょっと面倒くさい。
前、暇なときにQiita記事見てたらこんな記事があったんだよね→「目に見えない文字を悪用してサイトを好き放題荒らされた話

要約すると、見えない文字でユーザー名登録されちゃった→ユーザー名にリンクを付けてたので、誰もその人のプロフィールに飛べなかった→誰も通報できず荒らされた。というもの。

私のサイトでも同じようなことは起こり得るわけで、タイトルからリンクを飛ばしているので、タイトルは絶対に見えるようにしないといけない。

質が悪いところが、”見えない文字”というところで、表示されないけど内部的には文字がある判定だから、requiredとか空白判定とかで恐らく弾けないんだよね。
だから、ちょっとごり押しな解決策なんだけど、入力できる文字をこちらで指定してしまって解決する。

使うのがLaravelのバリデーションにある「regex:正規表現」というもの。
この正規表現を使って入力できる文字を制限する。

例えば先ほどの記事の執筆者様は「a-zA-Zぁ-んァ-ヶア-ン゙゚一-龠」のような定義をしていた。これは、

  • 小文字英語
  • 大文字英語
  • ひらがな
  • カタカナ
  • 半角カタカナ
  • 漢字

を使えるようにした形。

私の場合、他にも色々使える文字を増やしたかったので、ChatGPTや他の記事を見て作ってみた。ちなみに、ChatGPTは正規表現が苦手らしく、あまり使えなかった。

今回使用するのがこれ

regex:/^[a-zA-Z0-9\sぁ-んァ-ヶア-ン゙゚ー一-龠々〆〤[:punct:]<>〈〉《》「」『』]+$/

これは

  • 小文字英語
  • 大文字英語
  • 数字
  • スペース
  • ひらがな
  • カタカナ
  • 半角カタカナ
  • 漢字
  • 々 等の少し特殊な感じ
  • 他、通常使われそうな記号

を入れた正規表現。

今回苦戦したのが記号のところで、/とか’とかはPHP、正規表現共に意味を持っているから、適切にエスケープしないといけない。でも、それが全然上手くいかなかった。

そこで、いろいろ調べてたらPHPの正規表現について解説しているサイトがあって、そこで[:punct:]というPHPで定義済みの文字クラスをしったという漢字。
この[:punct:]は [ -!”#$%&'()*+,./:;<=>?@[\\\]^_{|}~]という感じで、いろんな記号を正規表現の対象にしてくれる。

とりあえず、この正規表現を適用してみる。

        return [
            'title' => 'required|max:255|regex:/^[a-zA-Z0-9\sぁ-んァ-ヶア-ン゙゚ー一-龠々〆〤[:punct:]<>〈〉《》「」『』]+$/',
            'body' => 'required|max:8388608',
            'tags' => [new TagValidation(5, 64)]
        ];

一回これを適用してみる。

とりあえず、全部の要素入れて記事作ってみよう

できた。

次はiOSの顔文字とか入れてみよう。

お、こっちはしっかり弾かれるね。
できてるっぽい。

これを検索窓の方にも適用しておいて

        return [
            'keyword' => 'max:255|regex:/^[a-zA-Z0-9\sぁ-んァ-ヶア-ン゙゚ー一-龠々〆〤[:punct:]<>〈〉《》「」『』]+$/'
        ];

よし、これで基本的なバリデーションは完了なのでは。
いや、エラー時のView表示をやってなかったわ。

バリデーションエラー時の対応

ここを忘れてた。

今回あり得るエラーを挙げていく

  • 各要素の文字数超過
  • タイトルと検索時の特殊文字の利用
  • タグの個数超過
  • requireの要素に値が無かった時

こんなもんか?

これ等それぞれのエラーメッセージを書いて、Viewに表示しなきゃいけない。

とりあえず簡単そうなrequireからやっていく。

StoreArticleRequestにmessageをオーバーライドさせて書けばいいのかな?

    public function messages()
    {
        return [
        "title.required" => "タイトルが空です",
        "body.required" => "記事の本文が空です"
        ];
    }

これで、Viewの方でエラーを受け取ればいける?

    @if ($errors->any())
        <div class="alert alert-danger">
            @foreach ($errors->all() as $error)
                <li>{{$error}}</li>
            @endforeach
        </div>
    @endif

やってみる

おー
めっっっっちゃそれっぽい。

やっぱり見た目って大事だよね。

他にも色々やってみよう。

検索時の特殊文字と文字数おk!

タイトルとかタグもおk!

因みに、8388608字を本文に入れようとしたら、

Out of Memoryで落ちた。

流石に800万字は厳しいか。
といっても日本語1文字最大8バイトとして、
800万*8バイト=6400万バイト。

6400万バイト=64メガバイトらしい

そんなんで落ちるのか? いや、表示処理とかにメモリを食うのか。pythonのprintも思いのほか重いもんね。

恐らくしっかりバリデーションは機能していると思うのでおkという事に一回しておく。

一応、以下のような感じでエラーメッセージを設定した。

    public function messages()
    {
        return [
        "title" => [
            "required" => "タイトルが空です",
            "max" => "タイトルの最大文字数は255文字です",
            "regex" => "タイトルに使用できない文字が入っています"
        ],
        "body" => [
            "required" => "記事本文が空です",
            "max" => "記事本文の最大文字数は8388608文字です"
            ]
        ];
    }

他も同じような感じ。

よし! とりあえずバリデーションやら、認可処理はこれでいいんじゃないか?

次は画像や音を投稿できるようにしよう。

画像や音を投稿できるようにしたい

画像はDAWとか音声波形とかプラグインの設定とかを見せられるし、音はDTMをする上で絶対必要だから絶対に投稿できるようにしたい。

だから、これから画像や音の投稿をできるようにしようと思ったんだけど……ちょっとこの記事も長いし、今日も後2時間弱しかないので一回区切る。

とりあえず、寝ないけどおやすみなさい