【21日目】ひたすら進める【作曲の補助ツールを作るまでの日記】

プログラミング

2023年11月20日~2023年12月30日

前回の約3週間は知識の部分を補完してばっかで、全く進捗に繋がってなかった。今回はしっかり進捗につなげたい。というか、もうリリースに移りたい。

焦るのも良くないと思うんだけど、簡易的にリリースしちゃって、改善しながら開発を進めていきたいので、最低限のところだけ完成してもうリリースしようと思う。

同じ大学生でも、こんなに出来る人がいると、やっぱり焦る。
私の目標は作曲補助ツールを作ることなので、別に焦る必要なんか無いと思うんだけど、堅実に着実にリリースに進みたいと思う。

  1. あと最低限何が必要か
  2. Dependabot alerts
    1. Dependabot alertsとは
    2. Dependabot alertsの仕組み
    3. 依存関係グラフとは
    4. GitHub Advisory Databaseとは
    5. Dependabot alertsの自動修正システム
    6. CVSSとは
    7. 他、注意点
    8. Dependabot alertsとどう向き合っていくか
  3. ユーザーに一意で変更不可の名前を与える
    1. ユーザーネームを不変にする
    2. IDとニックネームという区分け
    3. ミューテタで指定カラムを不変にする
    4. ユーザーネームにURLで使えない文字を使用不可にする
  4. テストをたくさん作成する
    1. 下書き機能をしっかり整理する
      1. 下書き機能の目的
      2. 下書き機能の大雑把な理解
        1. 下書きが新しく作成されるタイミング
        2. 下書きが更新されるタイミング
        3. 記事が新しく作成されるタイミング
        4. 記事が更新されるタイミング
    2. 下書き機能を変える
    3. ユーザーBan機能を追加する
      1. カラムタイプについて
      2. time/date/dateTime/timestampの違い
      3. DATETIMEとTIMESTAMPの違い
      4. 2038年問題の回避
      5. timestampをdatetimeにする
    4. ユーザーBan機能の作成
      1. httpリクエスト時、BANユーザーなら専用画面へ遷移
        1. ミドルウェアの順序
      2. BANされているユーザーのパスワードリセットメールを送れないようにする
      3. BANしたユーザーの記事・マイページを非表示
    5. 記事BAN機能の作成
  5. クローラー・SEOについて少しだけ考える
    1. リンク先への評価の共有
    2. 重複するインデックス
    3. 非公開・限定公開の記事にはnoindexを付ける
  6. やはり記事BANは必要なのでは
  7. あまりにも今の環境面倒すぎる
  8. シェルスクリプトで楽をする
  9. ペジネーションを再検討する
    1. 3つのペジネーション
      1. オフセット・ペジネーション
        1. 利点
        2. 欠点
      2. カーソル・ペジネーション
        1. 仕組み
        2. 利点
        3. 欠点
    2. どう使い分けるか
      1. データ数が多かったり、ページを指定しなくていい場合はカーソルがいい
      2. データ数が少なくて、ページを指定したい場合はpaginateがいい
    3. 無限スクロールのはなし
      1. メリット
      2. デメリット
    4. ペジネーションの結論
  10. JavaScriptを再検討する
    1. どこに.jsファイルを置くか
    2. TypeScriptってなんなの
    3. LaravelからJSに変数を渡す
    4. JSが読み込まれるまでフォームを表示しないようにする
  11. Livewireの採用を考える
  12. コメントでAIと会話する時代
  13. ユーザーが1ヵ月にアップロードできる量を設定できるようにする
    1. システムを考える
    2. ポリシーによる認可処理の勘違い
    3. CKEdtiorの認可処理
      1. CKEditorの認可処理になぜ戸惑ったか
      2. 実装
    4. ミスでauthミドルウェアが適用されていなかった話
      1. 何故勘違いしたか
    5. 自作ヘルパ関数を作成する
      1. 自作ヘルパ関数の作成方法
    6. タスクスケジュールで、毎月のアップロード制限を0にする
    7. 完成形
  14. バリデーションを再検討する
    1. バリデーションルールを一元的に管理する
  15. バリデーションのテスト
    1. 【重要】1個のテストにリクエストは1個まで
    2. PHPUnitのdataProviderで制限を回避する
    3. 見えない文字はBad
      1. 見えない文字とは
  16. PHPUnit実行時のみPHPのメモリを増やす
  17. emailバリデーションを見直す
    1. RFCとは
    2. RFCバリエーションだけで問題ないのか?
    3. passwordバリデーションを見直す
  18. 画像のEXIF情報を消したい
    1. EXIF情報を削除する
    2. 最終的な実装
  19. 終わりに

あと最低限何が必要か

少なくとも、

  • クラウドサーバー・そこへのデプロイ
  • メールサーバー
  • プライバシーポリシー
  • 利用規約
  • 名前の仕組み変更

は恐らく必要。

更に言えば、基盤を固める為にテストコードの補充はやっておきたい。

コメントとか、PHPDocは後からでも問題は無さそう。可能ならやっちゃう。

で、あれば

  1. 名前の仕組み変更
  2. テストコードの作成
  3. プライバシーポリシー・利用規約の作成
  4. クラウドサーバー&デプロイ
  5. メールサーバー
  6. 手動でもろもろテスト

という感じでいいのだろうか。

Dependabot alerts

Githubを覗いたらGithubのセキュリティBotから警告が2個来ていた。

前に一回laravel-admin関連で来ていたが、あの時はDependabot alerts自体をあまり理解していなかったので、理解していく。

Dependabot alertsとは

公式ドキュメント

パッケージの依存関係から脆弱性やマルウェアを自動検出し、アラートで教えてくれるサービス。
元々、GitHubのサービスではなかったけど、2019年にGitHubに吸収されたらしい。

結構この「パッケージの依存関係から」というのが重要で、逆に言えばパッケージに載っていない脆弱性とかは診てくれないので注意しなければならない。

Dependabot alertsの仕組み

Dependabot alertsはどんな仕組みで動いているの? というところを知りたい。

具体的には

  1. 依存関係のスキャン
  2. 依存関係グラフの作成
  3. 依存関係グラフを参考にGitHub Advisory Databaseを参照
  4. 該当するパッケージがあれば、警告を飛ばす

のような順序で処理されている(はず)

依存関係グラフとは

公式ドキュメント

npmのpackage-lock.jsonやcomposerのcomposer.lockからパッケージの依存関係を取得し、記録するもの。

[リポジトリ]→[insights]→[Dependency graph]で見ることが出来る。

ここで、そのリポジトリで利用しているパッケージの依存関係を記録しているという感じみたい。

GitHub Advisory Databaseとは

公式ドキュメント

GitHub Advisory Databaseとは対応したパッケージの脆弱性の情報などを纏めたもの。
公式ドキュメントにある通り、複数の情報源から脆弱性やマルウェアの情報を随時取得している。

ここにある脆弱性やマルウェアの情報と各リポジトリの依存関係グラフを比較して警告を発行しているという感じ。

Dependabot alertsの自動修正システム

公式ドキュメント

Dpendabotに警告されたものを見てみると

このような[Create Dependabot security update]というボタンがある。

これは、対象の警告を解決するプルリクエストを自動で生成してくれるというもの。
依存関係や互換性は完全保証されていないので、マージ後はテストした方が良い。

マージすると、自動的にDependabot alertsはcloseされる。

CVSSとは

参考元
GitHubドキュメント

警告されたものを見てみると

このようなスコアが付いているのがわかる。

これはCVSS(Common Vulnerability Scoring System)、日本語で共通脆弱性評価システムというシステム。

「世界共通基準で脆弱性を評価しましょう」というルールみたいな。

スコアが高いほど緊急度が高く、速やかな対処が求められる。
GitHubではFirstの重要度レベルを採用しているみたいなので、以下みたいな区分になっている。

None0.0
Low0.1 – 3.9
Medium4.0 – 6.9
High7.0 – 8.9
Critical9.0 – 10.0

評価基準はかなり複雑というか、多岐にわたっているのであんまり理解したくないんだけど、大雑把に頑張ると

  • 攻撃の難しさ
    権限が必要か、複雑か、どのような攻撃方法なのか等。
  • 影響範囲
    脆弱性があるパッケージに依存しているシステムがどれくらいあるかみたいな。
  • ユーザーの関与レベル
    フィッシング詐欺ならユーザーがURLを押す必要があるよね。そんな感じ。
  • 機密性
    攻撃によって情報がどれだけ漏洩するか。
  • 完全性
    攻撃によってどれくらいデータが不正に変更されるか否か。
  • 可用性
    攻撃によってどれくらいパフォーマンスやシステムの利用に影響があるか。

これらから評価されている。

とにかく、重要度がCriticalとかなら直ぐに対応するべきって感じ。

他、注意点

  • 依存関係グラフに対応しているものでないと診てくれない
    対応しているパッケージ管理システム以外は診てくれないので注意しなきゃいけない。
    対応しているパッケージは公式ドキュメントに記述がある。

    私の場合はcomposerとnpmが関係してくるのかな。
  • 言語やOSなど、パッケージ以外の脆弱性は診てくれない
    依存関係グラフから脆弱性やマルウェアを発見しているだけなので、全ての脆弱性をカバーできている訳では勿論無い。
  • プライベートリポジトリは設定をしないと診てくれない
    [対象リポジトリ]→[Setting]→[Code security and analysis]→[Dependabot]で設定を変えないと自動検出をしてくれない。
  • 勿論限界がある。
    以下はGitHubのドキュメントにある注意事項
    他にもセキュリティ対策はした方がいいよということ。

Dependabot alertsとどう向き合っていくか

Dependabotが対応している範囲の脆弱性は、正直これで監視すれば十分なんじゃないかという感じがする。
つまり私の場合、Composerとnpm関連の脆弱性はDpendabotで十分という感じ。

でも、それ以外の脆弱性は監視してくれないので他の対策をする必要があるのかな。

ユーザーに一意で変更不可の名前を与える

今、私のユーザーテーブルには”name”という一意の名前を与えている。

一意な理由は、ユーザーマイページのURLに使いたいから。
GitHubみたいに、URLをユーザーネームにしたいという感じ。

でも、ユーザーネームをURLにするには何個か気を付ける点があって

  1. 他のURLと被らないようにする
    「github.com/settings」で設定画面に行くなら「settings」という名前でユーザーネーム登録はできないようにする。
  2. ユーザーネームを一意にする
    勿論複数あったら意図しない動作になる
  3. ユーザーネームを不変にする(URLを不変にする)
    別に必須ではないと思うんだけど、URLは不変の方が良いのでURLが変わらないようにユーザーネームも不変の方がいい。
  4. ユーザーネームにURLで使えない文字を使用不可にする
    !とか@とかは使えないみたい。そういうのは使えないようにしないとURLが正常に動かない。

この中で私は3番と4番を満たせていなかった。

ユーザーネームを不変にする

今まで「github/tamakoma1129」というURLだったのに、ユーザーネームをtamakoma39に変えたことによってその人のマイページURLを「github/tamakoma39」に動的に変更してしまうと、今まで「github/tamakoma1129」にリンクしていたものが全部ダメになるよね。

だから、不変にしなきゃいけない。

IDとニックネームという区分け

そこで良くとられているのが、IDとニックネームという区分け。
IDは一意で未来永劫変えられないが、ニックネームは一意でなくていつでも変えられるというもの。

私もこの仕組みを導入する。

導入するといっても、新たにnicknameカラムを追加し、登録・更新処理とViewを変更すれば良い。

ミューテタで指定カラムを不変にする

ミューテタという特定のモデルカラムの変更を監視し、変更する際にミューテタに記述されたコードを実行するという便利機能がある。

Laravel9のドキュメントはこちら。

Laravel8とLaravel9でミューテタの記述に違いがあるので注意。

この機能を使い、もしnameを変更しようとしてもnameの変更をできないようにする。

// ミューテタで、nameを不変に
protected function name(): Attribute
{
   return Attribute::make(
       set: function ($value) {
           if ($this->exists) {
               // 初作成時以外は変更を拒否
               throw new \Exception("ユーザーIDは変更不可です");
           }
           return $value;
       }
   );
}

恐らくこんな感じにすればいいと思われる。

ユーザーネームにURLで使えない文字を使用不可にする

何が使えないか、何が使えるかを確認して色々バリデーションするのがいいと思うんだけど、今回はSoundCloudを参考にする。

SoundCloudでは、URLのみを自由に変更できるんだけどバリデーションルールはこんな感じになってるみたい。

つまり、

  • 半角数字
  • 英小文字
  • _と-

だけが使える。

URLは英大文字と英小文字を区別してくれたりするけど、このルールの方が統一感あるので、SoundCloudのバリデーションルールをお借りする。

テストをたくさん作成する

下書き機能を追加したり、ニックネームを追加したりと色々変更があったのでテストをそれに応じて作りたい。

本当はテスト→機能実装がいいんだけど、ちょっと億劫でやってなかった。

下書き機能をしっかり整理する

下書き機能がかなりややこしく、そもそもこの実装で問題がないのかも怪しいので、一回整理する。

下書き機能の目的

今までは記事の作成を始めたら最後まで書いて投稿しないと途中保存が出来ない仕様だった。また、間違えて記事を上書きすると前の文章を復元できない仕様だった。

そこで、ArticleテーブルとDraftテーブルに分け、1対多の関係を作り、編集する毎にArticleに紐づくDraftを作成。そのおかげで途中保存可能&変更履歴を確認できる仕様を実装した。

下書き機能の大雑把な理解

  • 1つの記事に対して、複数の下書きで成り立っている
  • 記事は公開・非公開・限定公開を選べる
  • 下書きを公開すると、記事がその下書きの内容に上書きされ、公開状態になる
  • そのため、下書きが直接公開されることはなく、記事に上書きして公開される形
下書きが新しく作成されるタイミング
  • ユーザーが「新規投稿」ボタンを押下、つまり、新規記事作成画面へ遷移した時
  • ユーザーが下書き一覧から下書きの編集画面へ遷移し、「下書きを保存する」ボタンを初めて押下した時
  • ユーザーが下書き一覧から下書きの編集画面へ遷移し、「下書きを保存する」ボタンを一回も押下せずに、「記事を公開する」または「記事を更新し公開」ボタンを押下した時
下書きが更新されるタイミング
  • ユーザーが下書き一覧から下書きの編集画面へ遷移し、「下書きを保存する」ボタンを押下し下書きが新しく作成された上で、更に「下書きを保存する」ボタンを押下した時
  • ユーザーが下書き一覧から下書きの編集画面へ遷移し、「下書きを保存する」ボタンを押下し下書きが新しく作成された上で、更に「記事を公開する」または「記事を更新し公開」ボタンを押下した時
記事が新しく作成されるタイミング
  • 「新規投稿」ボタンを押下、つまり、新規記事作成画面へ遷移した時
記事が更新されるタイミング
  • 「記事を公開する」ボタンを押下したとき、現在開いている下書きを記事に上書き
  • 「記事を更新し公開」ボタンを押下したとき、現在開いている下書きを記事に上書き
  • 例外的だが、記事一覧から公開状態を変更した際

複雑というか長ったらしい説明になるんだけど、正確に表現するにはしょうがないね。

とりあえず、ここに出てきた動作をテストすればいいのかな。

下書き機能を変える

とりあえず簡単なテストは作成したんだけど……今の下書き機能、滅茶苦茶ややこしい上にデータ容量を記事の何倍も取ってしまうのが難点。

なんでかって言うと、下書きを更新する度にそのコピーを取る仕様になっているから。これは下書きの履歴を追う為。

どうしたもんかと悩んでたんだけど、Qiitaでは下書きと記事は1対1になっていることに気づいた。
そんなことなかった。普通に履歴を追う形ではあったけど、私のサイトでは一旦追わないことにした。

つまり、下書きの履歴は追わない仕様。
規模的にもそっちの方がいいかなと思うので、下書きと記事は1対1の仕様に今更変更する。

もう少し技術力とか余裕が出てきたらgitみたいに差分だけ保存する機能とか作りたい。

出来るだけシンプルに、個人の規模で

を意識してなかった。

ユーザーBan機能を追加する

サービスを開始するにあたって、Ban機能は必須だと思う。

最初は一時的なBanもできるようにとか考えたんだけど、「出来るだけシンプルに、個人の規模で」に反するのでとりあえずban_at->nullableで日付が入っていたらBanという処理にする。

恐らく、この実装なら一時的なBanというのも将来的に実装できる。

カラムタイプについて

日付を入れるときのカラムタイプなんだけど、Laravelのドキュメントを見ると結構あるのねこれ。

よくLaravelで使われているのはtimestampだよね。

でも、他にもtimeとdateTimeというカラムタイプがあるみたい。

time/date/dateTime/timestampの違い

それぞれどう違うのかってところなんだけど、MySQLのドキュメントによると

  • TIME
    値を’hhh:mm:ss’形式で値を取得・表示できるカラムタイプ。
    つまり、日付などの情報は無く、’時:分:秒’の情報のみを保存できる。

    範囲=’-838:59:59′ から ‘838:59:59’
  • DATE
    YYYY-MM-DD形式で取得・表示ができるカラムタイプ。
    つまり、’年:月:日’の情報のみ保存できる。

    範囲 = ‘1000-01-01’ から ‘9999-12-31’
  • DATETIME
    値を’YYYY-MM-DD hh:mm:ss’形式で取得・表示できるカラムタイプ。
    つまり、TIMEとDATEカラムを合わせた情報を保存できる。

    範囲 = ‘1000-01-01 00:00:00’ から ‘9999-12-31 23:59:59’
  • TIMESTAMP
    こちらも日付と時間の両方を保存できるカラムタイプ。
    しかし、保存できる範囲は

    範囲 = ‘1970-01-01 00:00:01’ UTC から ‘2038-01-19 03:14:07’ UTC

    そう、1970年〜2038年の68年しか保存できない。
    これは、データを32bitで保存しているから。

    これがいわゆる2038年問題とかいうやつ。

DATETIMEとTIMESTAMPの違い

DATETIMEとTIMESTAMPの違いは、タイムゾーンを気にするかどうか。

2023-09-01 12:00:00というデータがある時、DATETIMEはそのままこの「2023-09-01 12:00:00」を保存する。

それに対してTIMESTAMPはサーバーに設定されているタイムゾーンを参照し、UTCに変換してから保存する。つまり、「2023-09-01 12:00:00」というデータがタイムゾーンがAsia/Tokyoなら、「2023-09-01 03:00:00」として保存される。

なんでこんなことをするかというと、国際的なアプリになったときにどこの国の人でも日本で保存した「2023-09-01 12:00:00」をその瞬間のその国の人の時間に変換する為。

つまり、1つの国だけでサービスを続けるつもりならDATETIMEでも良い。

2038年問題の回避

現在、MySQLのtimestampはUTCで2038年の1月19日3時14分7秒を超えるとバグる。

このWebサービスが15年弱も持つのかは置いておいて、そのくらい続けたいという意思はあるので、対応しときたいところ。

私が取れる対応は以下の通り

  1. MySQLが対応するのを待つ
    64bitカラムにしてくれれば数千兆年は持つらしいので、MySQLが64bitに対応してくれるのを待つ。サボるともいう。
    かなり有名なDBなので、対応してくれそうだが、意外と難しいのだろうか。
  2. カラムタイプを全てDATETIMEにする
    Laravelのmigrationファイルにあるtimestampを全部datetimeにしてしまう。
    今だからこそできる方法ともいえる。

    なんかバグりそうで億劫。
  3. PostgreSQLを利用する
    色々調べているとProstgreSQLというものを知った。

    なんだそれ? 初めて聞いたからニッチなDBなんだろうなぁと思ったら。

    結構有名だった。

    DBランキングでも4位になるくらいには有名みたい。

最初はPostgreSQLに乗り換えようか悩んだんだけど、労力・技術力・LaravelのデフォがMySQLというところを鑑みて、DATETIMEカラムに変更することにする。

timestampをdatetimeにする

timestampカラムはそのままdateTimeに変えてしまって、timestampsカラムの場合は、中身が

public function timestamps($precision = 0)
{
   $this->timestamp('created_at', $precision)->nullable();


   $this->timestamp('updated_at', $precision)->nullable();
}

のようになっているので、

$table->dateTime('created_at')->nullable();
$table->dateTime('updated_at')->nullable();

でおkなはず。

やってみた。

なんとか問題は無さそう。

ユーザーBan機能の作成

とりあえず、userテーブルにban_atカラムをdatetimeで追加した。

ここで実装するのは

  1. httpリクエスト時、BANユーザーなら専用画面へ遷移
  2. 全てのリクエストで専用画面へ遷移するが、ログアウトだけできるようにする
  3. BANユーザーの記事・マイページなどをみれないようにする

というところ

つまり、BANされたユーザーはログイン・ログアウト以外何もできないという状況になる。

なぜ必要かというところなんだけど

  • ユーザーの動きを止める=凍結したいときに使いたい
  • ユーザーの削除=記事のデータも全て消えるので、出来るだけやりたくない
  • softdeleteとは違い、ログインだけは出来るようにしてアカウントが残っていることは知らせたい

httpリクエスト時、BANユーザーなら専用画面へ遷移

httpリクエスト時、つまりルートを通るアクセス全てに制限をかけたい。

全てのコントローラに一々if文書いて

if ($user->ban_at) {
   return view("BANした時の画面")
}

みたいな感じにしても恐らくいいんだけど、全てにこのif文を付けるのは面倒なのでミドルウェアを使う。

ミドルウェアの公式ドキュメントはこちら

ミドルウェアの中身は以下みたいにする

public function handle(Request $request, Closure $next)
{
   // ログアウト処理はスルー
   if ($request->is("logout") && $request->method() === "POST") {
       return $next ($request);
   }
   // もしBANユーザーなら専用画面へ遷移
   else {
       if ($request->user() && $request->user()->ban_at) {
           return response(view("ban.banned_account"));
       }
   }

   return $next($request);
}

そして、これをKernelのグループでwebとauthに登録すればおk。

ミドルウェアの順序

私が少し引っかかった部分なんだけど、このミドルウェアの登録には順序があり、この順序を間違えると正常に作動しないことがある。

以下は私がwebグループに登録したミドルウェアだけど、この順序であれば正常に動く。

'web' => [
   \App\Http\Middleware\EncryptCookies::class,
   \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
   \Illuminate\Session\Middleware\StartSession::class,
   \Illuminate\View\Middleware\ShareErrorsFromSession::class,
   \App\Http\Middleware\VerifyCsrfToken::class,
   \Illuminate\Routing\Middleware\SubstituteBindings::class,
   \App\Http\Middleware\CheckBannedUser::class, // 追加
]

逆に、

'web' => [
   \App\Http\Middleware\EncryptCookies::class,
   \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
   \App\Http\Middleware\CheckBannedUser::class, // 追加
   \Illuminate\Session\Middleware\StartSession::class,
   \Illuminate\View\Middleware\ShareErrorsFromSession::class,
   \App\Http\Middleware\VerifyCsrfToken::class,
   \Illuminate\Routing\Middleware\SubstituteBindings::class,
]

これだと正常に動かない。

なんでかって言うと、StartSessionミドルウェアがセッションを開始するミドルウェアらしく、こいつが処理した後でないとauth()->user()や$response->user()でユーザー情報を取得できないから。

だから、グローバルミドルウェアという、全てのリクエストにミドルウェアをかけてくれる便利な登録方法もあるんだけど、それもたぶんできない。
私はできなかったのでグループミドルウェアにした。

よし、登録したらこれでおk。

しっかりban_atに日付が入っているとこの画面が出ることを確認した。

別に記事一覧とか記事を見る分には許可してもいいんだけど、少し手間なので、その時はログアウトしてゲスト状態で見てもらうことにする。

BANされているユーザーのパスワードリセットメールを送れないようにする

BAN関連のテストを作っていて思ったんだけど、これBANされたユーザーのパスワードリセットメールとか送れないようにしたほうがいいよね。

BAN=アカウントの凍結、一時停止を意味したいので、できるだけユーザーの情報を変えることはさせたくないというのが、この実装の意味。

これは、PasswordResetLinkControllerのstoreメソッドで、banされていたらメールを送らないようなコードにすればおk。

BANしたユーザーの記事・マイページを非表示

こちらは、indexの場合は検索クエリで除外したり、showの場合はポリシーで弾くのがいいのかな。というか、それしかやりようないよね。

具体的に、今の実装であれば

  • 記事一覧画面
  • 記事閲覧画面
  • マイページ画面
  • マイページのいいねした記事一覧画面

を制限すれば良いと思われる。

記事一覧系は

->whereDoesntHave('user', function ($query) {
   $query->whereNotNull('ban_at');
})

みたいな感じでban_atがあったら除去する。

記事閲覧時とかは

if ($article->user->ban_at) {
   return view('article.banned-user');
}

をコントローラで条件分岐するのがいいかなぁ。

とりあえず、実装&テスト作成

うーん。恐らく実装はこれで良いと思うんだけど、怖いし意外と気にしなきゃいけないところが多い。

記事BAN機能の作成

よくよく考えたら記事こそBAN機能が必要なのでは? と思った。

  • 記事にBAN機能を実装したとして、本当に使うのか?
  • 非公開・限定公開で良いのでは
  • できる限りシンプルに・運営の負担にならないようにを考えると記事BANは要らない気がする
  • 実際にQiitaでは、ガイドライン違反記事は限定公開にするという実装をしているみたい?

で、あれば

  • 法に反するもの or 荒らしはユーザーBAN
    • 荒らしはユーザー削除でも良い
    • 法に反する系は、一応窓口的な感じでBAN猶予があってもいいかもしれないが、運営負担による
  • ガイドライン違反は非公開
  • 緩いガイドライン違反は限定公開

にすればいいかなぁ。

実装を詰めれば詰めるほど、必要な物・考えなきゃいけないことが増えてくる。

本当にQiitaとか、Youtubeとか作ってるエンジニアすごいなぁ。

というか、この実装はもう実質softdeleteみたいなところあるよね。
本人が閲覧可能というところで違いはあるけど。

ちょっとややっこいので一回置いとく。

クローラー・SEOについて少しだけ考える

一応、Googleのドキュメントはこちら

Googleは色んな方法でサイトを巡回し、採点・インデックスしている。
つまり、巡回されやすく&採点結果が高くなりやすいようなWebページを作るとGoogleの検索結果に載りやすくなる。

逆に言えば、限定公開にしている記事の場合、インデックスを拒否する仕組みを入れないと検索結果に表示されてしまう。

巡回することをクロール、巡回するbotをクローラー、検索エンジンへの最適化をSEOという。

リンク先への評価の共有

Googleのドキュメントによると、他のサイトへのリンクは自分のサイトの評価を一部与えることになるみたい。

逆に言えば、記事内で変なサイトへのリンクを張られてしまうと、私のサイトも同じジャンルのサイトだと判断されてしまい、評価が落ちたりする。

そういう時に
<a href=”https://www.example.com” rel=”nofollow”>

とnofollowを付けておくと、クローラはその先をクロールせずに関係を拾わないので、記事に張るURLにはnofollowを自動付与するようにだね
※追記:正確には、nofollowを考慮するだけで、関係を拾うこともある。

重複するインデックス

他にも、ペジネーションされるページや検索が可能なページの場合、ページは動的に増えてしまうので、どれをインデックスしてほしいかをクローラに教えてあげると優しいよね。

ペジネーションがあるページだと、似ているURLでも1ページ目、2ページ目、…、nページ目と増えていってしまい、インデックスが分散してしまうのが分かると思う。

こういう時に、ペジネーションであれば1ページ目、検索できるのであれば検索前のページをインデックスしてね! とクローラーに教えてあげると評価が上がり易いとかなんとか。

他にもいろいろ工夫をしなきゃいけないんだけど、纏めると

  • 記事内のリンクにはnofollowをつける
  • ペジネーションページ・検索できるページに代表ページを教える
  • 非公開・限定公開の記事にはnoindexをつける
  • titleを適切に付けるようにする

が必要かなぁ。

SEOは実際にクラウドと調整しながらやりたいので、とりあえず非公開・限定公開の記事にはnoindexを付ける所からやりたいと思う。

非公開・限定公開の記事にはnoindexを付ける

付ける意味はSEO対策というより、限定公開なのに検索結果に出たらおかしいから付けるという意味合いが強い。

controllerにこんな感じの条件分岐を付けて実装した。

// 限定公開記事、下書き記事はnoindex,nofollowを付与
if ($article->isLimitedPublic() || $article->isUnpublished()) {
   return response(view("article.show", compact("article")))
       ->header('X-Robots-Tag', 'noindex,nofollow');
}

こうしてcontrollerの肥大化が進んでいく。

やはり記事BANは必要なのでは

上でも言ったけど

  • 法に明らかに反するもの or 荒らしはユーザーBAN
    • 荒らしはユーザー削除でも良い
    • 法に反する系は、一応窓口的な感じでBAN猶予があってもいいかもしれないが、運営負担になるよね
  • ガイドライン違反は非公開
  • 緩いガイドライン違反は限定公開

非公開時でも、下書きの編集だけは可能にし、コピペして修正した上で新しい記事を投稿してくるといいのかな。

もうそれ、非公開=記事BANみたいなものだよね。

非公開を消して、ban_atをArticleにも追加しちゃおう

最終的に、Articleにもban_atは追加した。
本当はban_atでなく、ban専用のテーブルを作って管理するのがいいとは思うんだけど、とりあえず簡易的に。

あまりにも今の環境面倒すぎる

今の私の環境はホストPCのWindowsに、VMでLinux起動してそこにサーバーを建てて実行を確認している。

VMのLinuxはCUIなので、「Windowsでプログラムを編集→commit→push→VM側でpull→実行確認」というとても面倒な作業を経ている。

これだとちょっとしたCSSの調整でも毎回git commitするので、本当に面倒。その代わりGitHubの草はとても生える。

流石に環境を整えてわざわざgitを経由しなくても実行環境を確認できるようにしたい。まじでVite使っている意味がない。


前、共有フォルダを試したんだけどダメだったんだよなぁ。一回やってみる。

とりあえず、共有フォルダを作ってそこにコピーしてみる。

うーん……案の定ではある。

シンボリックリンクはnode_modulesとstorageのみっぽいので、Windows側で

php artisan storage:link
npm install

して、apacheに読み込ませる。

いけた!

こんな感じで、共有フォルダにLaravelプロジェクトを移して恐らく問題なく共有できた。

テストもしっかり動いているみたい。

めっちゃ楽。本当に早く共有フォルダにしておけばよかったねこれ。

シェルスクリプトで楽をする

Linuxを起動するとすることは毎回

cd …
npm install && npm run dev

を打ってるんだけど、tabによる予測を入れてもまあまあ面倒なんだよねこれ。

なので、とうとうシェルスクリプトなるものを使いたいと思う。

まずは

sudo vim setqtm.sh

でファイルを作っちゃう。場所はhomeとかで良いのではと思う。

作ったら中身は

#!/bin/bash
cd {プロジェクトのフォルダ}
npm install
npm run de

みたいな感じ。
1行目は、実行シェルを選択している。

次に
sudo chmod +x /home/setqtm.sh
で実行権限を与える。

最後に、エイリアスに登録する
sudo echo “alias setqtm=’/home/setqtm.sh'” >> ~/.bashrc
ターミナルを再起動後、実行してみよう

いいね、めっちゃ楽になった。

ペジネーションを再検討する

今、私はArticleのIndexにだけペジネーションをLaravelのpaginateで付けてる。
ペジネーションとは、以下の画像みたいにデータをページ付けて複数に分けて表示すること。

無限スクロールとかも実装してみたいっちゃしてみたいんだけど、必須ではないので今はスルー。

勿論ArticleIndex以外にも使う余地はあるので、他にも適用しようと思ったんだけど、ドキュメントを見る感じ、奥がちょっとだけ深い。

そもそも、Laravelのペジネーションは3種類あったりするので、そこから理解していく。

3つのペジネーション

ペジネーションには、paginateとsimplePaginateとcursorPaginateの3種類がある。

この中のpaginateとsimplePaginateはオフセット・ペジネーションと言われるもので、cursorPaginateはカーソル・ペジネーションというもの。

オフセット・ペジネーション

オフセット・ペジネーションは、SQL文の中でOFFSETというデータの開始位置を指定するコードを利用する方法。

具体的には以下のように使う。

SELECT * FROM table LIMIT 10 OFFSET 10
LIMITが何件取得するか、OFFSETが〇件以降から取得するという意味。
つまり、このコードは11〜20番目のデータを取得するコード。

利点

メリットは理解のしやすさと、以下の画像みたいにページ数を指定して移動できること。

10ページに移動したければURLのクエリでpage=10みたいにすれば移動できる。

LIMITが決まっていれば、 OFFSETLIMIT*(取得したいページ数-1)にすることで次ページに必要なデータの開始位置になるので、簡単に次のページに必要なデータが取得できるということになる。

欠点

しかし、このOFFSETは値が大きくなればなるほどパフォーマンスが落ちるらしい。

これは、指定されたページまで行を数えるからみたい。ソース
確かに、何のカラムでソートしてるかもわかんないし、数え上げるしかないのか。

SQLがどれくらいの速度で捌けるか走らないけど、競プロの感覚で言えばPythonの場合10^6で1秒以上かかるかも、って感じなので探索データが100万件ぐらいになりそうならOFFSETは使わない方がいいのかもねって感じ?

カーソル・ペジネーション

仕組み

カーソルペジネーションは、SQL文の中でWHEREを使いデータ開始位置を指定する方法。

1から順に増えていくIDを用いて昇順ソートし、そこから10件ずつ取得するとする。

その場合、
SELECT * FROM table LIMIT 10 Where `id` >{現在ページに表示してる最後のデータid}
とすることで、次のデータ10件を取得できるという感じ。

こうすればインデックスを用いた検索ができるので、全ての行を数える必要が無くなり高速にデータ取得が出来るということみたい。

利点

処理速度の向上。

先述した通り、インデックスを用いた検索ができるので検索が速い。

欠点

欠点は、一意のカラムでソートしなきゃいけないということと、ページ数を指定した移動ができないこと。

一意のカラムによるデータ開始位置の指定なので、ページ数という概念がない。

また、一意のカラムでソートしなきゃいけないので、ない場合は別で作ったりしなきゃいけない。

でもさこれ、ULIDなら時間の順序が保存されていてかつ一意なので、滅茶苦茶良いのでは?

paginateとsimplePaginateの違い

オフセット・ペジネーションに含まれるpaginateとsimplePaginateの違いは以下みたいな感じ。

paginateは


みたいに表示できるけど、simplePaginateは

みたいに、前ページ・次ページでしか表示できない。

内部的には、paginateは全てのデータの総数をカウントしていて、simplePaginateはしていない。その為、simplePaginateの方が速い。

でも、simplePaginateも現在のページ数まではOFFSETしてるはずなので、そんなになぁという感じ。

どう使い分けるか

正直、直ぐにでもデータ件数が100万件に行くことは無いのでオフセットでもカーソルでもいいんだけど、一応考えてみる。

データ数が多かったり、ページを指定しなくていい場合はカーソルがいい

ユーザーに対して動的に変わるおすすめページや、全てのデータから最新順で並べるページはカーソルの方がいいよね。

データ数は全データだし、ページを指定したいこともない……よね?

データ数が少なくて、ページを指定したい場合はpaginateがいい

ユーザーのいいねした記事一覧とか、投稿した記事一覧とかはページ指定したいかなぁと思うし、データ数が100万行くことは無いと思うのでpaginateでいいのかな。

他にも、検索結果とかのページはpaginateがいいかなぁ。

無限スクロールのはなし

QiitaもNoteも記事一覧に無限スクロール(正確にはボタン発火による無限スクロール)を実装している。

ここら辺は先述した通り必須のものではないけど、無限スクロールとカーソルペジネーションは相性がいいっぽいので調べてみる。

無限スクロールは、X(Twitter)とかYoutubeとかでも実装されている方法で、クリックでなくページを下にスクロールすることでデータを表示していく方法。

メリット

  • クリックより、スクロールの方が簡単
    ページとかボタンクリックより楽に次のデータを閲覧できる
  • スクロールなので、スマホ操作に向いている
    クリックなどの細かい操作が要らないからね
  • ユーザー滞在時間が伸びやすい
    SEOってこういうところも見てくるもんね

デメリット

  • JavaScriptが必要
    私的に、JavaScriptがまだあまり分かんないのでかなり大きなデメリット
  • 再発見の困難
    ページ分けがされないので、再度同じ情報にたどり着くのが難しくなる
  • フッターへアクセスできない
    ボタン発火型や、固定してしまえば可能
  • SEOのデメリット
    Googleのドキュメントにある通り、全てのコンテンツを表示できないのでSEO的に工夫が必要みたい。
    他にも、表示速度が下がるって言われてたりするよね

って感じかなぁ。

一応、考えられるユーザーはDTMerなので、パソコン率が高いのではないかなぁと思ったりしている。で、あれば直ぐにでも無限スクロールを採用する理由はないのかな。

ペジネーションの結論

とりあえず、トップページはカーソルペジネーションにする。

いいねした記事一覧、投稿した記事一覧、検索結果は普通のpaginateにする。

無限スクロールは一旦保留。

JavaScriptを再検討する

今、JavaScriptはAlpine.jsというライブラリの利用と、viewファイル内に<script>タグで直書きという状況。

Alpine.jsの利用はいいんだけど、<script>による直書きは管理のしやすさ・セキュリティ的にも? あまり良くないみたいなので別ファイルに分けることにする。

どこに.jsファイルを置くか

.jsファイルは「/resources/js」に置くのが標準みたい。

使う時は

@vite(“resources/js/no-enter-submit.js”)
をheadにおいて使う。

@vite読み込ませておけば勝手にViteがホットリロードで読み込んでくれるみたい。
※@viteの方法だとnpm run buildで反応してくれなかったので、他の方法を模索中

ここら辺のソースは日本語ドキュメントを参照。

フォームでenterキーを押すと提出されてしまうのを防ぐJavaScriptを分けてみた……これTypeScriptの方が良かったりする? あんまわかんないので調べる。

TypeScriptってなんなの

Qiitaで人の開発日記とかを見ているとJavaScriptでなくTypeScriptを使ってる事が多い。
そもそもなぜTypeScriptを使うのか、TypeScriptって何なのかを説明できないので、そこら辺を調べる。

TypeScriptの公式ページはこちら

  • TypeScriptとは
    • JavaScriptに型構文を追加したもの
    • JS自体はTC39が管理していて、TSはMicrosoftに管理されている。
  • TypeScriptを何故使うのか
    • 型がある方が読みやすい+バグの発見につながるから
    • 型があると、エディタの自動補完が働きやすいから
  • TypeScriptのしくみ
    • TSはコンパイルが必要で、コンパイルするとJSになる
    • コンパイルする際に型の確認もしている
  • JSを学ばずTSを学んでもよいか
    • 別にいいけど「TS⊃JS」なので、JSが使えないとTSは使えない
    • JSができてTSができない人はいるけど、TSができてJSが出来ない人はいない
    • 一応、2021年のQiita記事ではTSはJSの完全互換性は無いという記事もあるが、殆どJSの構文がそのまま使えるみたい

って感じだった。

うーん。私はJSもままならないのでとりあえずJSに慣れて、後々TSに移行していく感じで良いかなぁと思った。

LaravelからJSに変数を渡す

結構迷ったので記述しておく。

CKEdtiorの画像・音源投稿で本人認証が欲しかったので、controllerにarticle_idを渡したかった。その為にはCKEditorのJSを一旦経由する必要があったので探してた。

色々調べると、Laravelの公式ドキュメントに簡単に渡す方法の記述があった。

<script>
   const article_id = {{ Js::from($draft->article_id) }};
</script>

こんな感じjsonに変換して渡してあげればいいみたい。

内部的に
<?php echo json_encode($変数); ?>
が実行されてjsonになってるみたい。

{{}}でエスケープもしてあるし、恐らく問題ない。
Laravel8以上じゃないと出来ないのかな。

JSが読み込まれるまでフォームを表示しないようにする

@vite()でJSを読み込むようにしたのはいいんだけど、恐らく非同期で読み込むようになっちゃって、下のGIFみたいに一瞬JS非適用の画面になっちゃう。

読み込みが速くなるのでSEO的には悪くないんだろうけど、流石にちょっと気になるのでCKEditorの読み込みが終わるまではformを隠す処理を施す。

どうやらCKEditorの.createはPromiseを返すらしい。
であれば.thenをつなげて非表示を表示にしてあげれば問題なさそう。

具体的には、

<div id="form-container" style="display: none;">

で隠したいところを囲んで、ClassciEditor.create()の後に

.then(editor => {
   // CKEditorの初期化が終わり次第、フォームを表示する。
   const formContainer = document.getElementById('form-container');
   if (formContainer) {
       formContainer.style.display = 'block';
   }
})

を付け加えればいいかんじ。
display:noneをdisplay: blockに更新してあげることで非表示→表示にしている。

いいね。

Livewireの採用を考える

Livewireという、Blade上でPHPのままJSの処理がある程度簡単に書けるJSのライブラリ? がある。

内部でAlpine.jsが動いてるっぽいので、Alpine.jsのよく使う機能を纏めてくれたライブラリという感じなのかな。

フォームの動的バリデーションや、写真のアップロードのプレビューとかが簡単にできるっぽいのが魅力的。

Alpine.jsを使いこなせれば必要ないとは思うんだけど、使いこなせないので使用も視野に。

ドキュメントを見ると、最新のLivewireはLaravel10以降しか対応してないっぽいので、一旦保留。

コメントでAIと会話する時代

ホントになんてことないんだけど、GitHubCopilotを入れているとエディタ上で会話ができる。

こんな感じで、矢印とかで発言を促してあげるとコメントで会話してくれる。

すごい時代だけど、コメントでの会話はポンコツっぽい。癒しにはなる。

あと、GithubCopilotはなんだかんだ使わないようにした。
理由は、実装理由とかが訳わかんなくなっちゃって混乱する時間が増えたから。

確かにコーディングは速くなるんだけど、コードの意図が薄まっちゃって理解とか再構成する時間が増えた感じ。なら最初から要らないかなぁという。

困った時はChatGPTもいるからね。

ユーザーが1ヵ月にアップロードできる量を設定できるようにする

現在、画像や音源は際限なくアップロードできるようになっている。

このまま制限をかけないと途方もない請求金額をクラウド会社から求められちゃうので、制限をかけたい。

どうやら、Qiitaを見てみると

月に合計100MBまで、単体で10MBまでの画像ファイルをアップロードすることが可能です。月の制限を超えてしまった場合は翌月1日までお待ちいただくか、当月にアップロードされた不要な画像ファイルの削除を行っていただくと、削除容量分が復活します。

https://help.qiita.com/ja/articles/qiita-image-upload

とある。

このシステムを超参考にし、実装を目指す。

因みに、こんな感じで投稿した画像の確認・削除もできるっぽい。

便利だなぁ。

システムを考える

  • usersに投稿した容量を記録するカラムを追加する
    • 画像・音源を投稿する度そのカラムに追加
    • 上限を超えたらアップロードできないように
    • 毎月1日になったら自動でそのカラムを0に
  • 新しく、画像・音源を管理するテーブルを追加
    • カラム
      • ユーザーID
      • ファイルの種類
      • 日付
      • 容量
      • パス
      • ファイル名
    • 画像・音源の削除時に日付を参照し、今月投稿のものであればその分usersの容量カラムを復活

こんな感じでいいのでは? と思った。

Laravelには定期的に自動で何かやってくれるシステムがあった気がするので、それで0にする。

ポリシーによる認可処理の勘違い

ポリシーについてなんだけど、私は超恥ずかしながらてっきりコントローラに紐づいたモデルの認可処理をしていると思ってた。

つまり、UserControllerで
$this->authorize(‘update’, $article);
を使ったら、UserPolicyのupdateポリシーが適用されると思ってた。

しかし、実際は第二引数のインスタンス元のモデルが参照されるらしい。

つまり、どのコントローラで使っても
$this->authorize(‘update’, $article);
は$articleインスタンス元のArticleモデル→ArticlePolicyが適用されるという感じ。

ちょっと勘違いしていたので、ここに残しておく。

というかそもそも、コントローラとモデルの紐づけはないっぽい?

CKEdtiorの認可処理

CKEditorで他人が勝手に記事に対し画像や音源を投稿できないよう、画像と音源の投稿に認可処理を加えたい。

認可処理の処理方法に右往左往した結果、いつも通りの方法で問題ない事に気づいたので記しておく。

CKEditorの認可処理になぜ戸惑ったか

私の場合、CKEditorのSImpleUploadというのを使って画像の投稿を処理している。

認可処理は「$article->user->id === auth()->id()」のようにやりたいので、ルート以降の部分にどうにかArticleIDを持っていく必要がある。

つまり、ArticleIDが一旦CKEditorのJSを経由する必要があるし、JSだとURLをroute(xxx,$article)みたいにできないので、ルートの暗黙の結合をしっかり理解してないと実装に手間取ってしまった。

実装

今回は記事の編集ページ(edit.blade.php)から、CKEditorの画像投稿機能を通じて画像を投稿した場合。

edit.blade.phpのScript

<script>
   // CKEditorの画像・音源投稿の本人認証の為に、javascriptにarticle_idを渡す
   const article_id = {{ Js::from($draft->article_id) }};
</script>

SimpleUpload

simpleUpload: {
   // 本人のみが画像・音声のアップロードをする認証の為、article_idを渡す。
   uploadUrl: '/upload-image/'+article_id,
},

ルート

Route::post('/upload-image/{article}', [UserUploadedFileController::class, "imageUpload"])->name('image.upload')

CKEditor経由で画像投稿するコントローラ

public function imageUpload(UploadImageRequest $request,Article $article)

こうやってみるとめちゃ単純なんだけど、気付かなかったのでメモ。

ミスでauthミドルウェアが適用されていなかった話

これも、私の知識不足+勘違いだったんだけど、ある時ふとauthミドルウェアが適用されていないことに気づいた。

実は自作のミドルウェアで「authAndVerified」という「ログインかつメール認証もしている場合はTrue」というのを作っていて、殆どそれを利用していたのでauthミドルウェアが動いていない事に全く気付かなかった。
あと、唯一authミドルウェアを適用してたものに、非認証時のテストケースがなく、気づけなかった。

適用されていなかった原因の結論から言えば、Karnel.php$middlewareGroups

'auth' => [
	//
],

を付けていたから。

恐らく、この定義をKarnel.phpにしてしまったことでauthが上書きされてしまってauthミドルウェアが動かなくなってた。

何故勘違いしたか

日本語ドキュメントのミドルウェアグループの部分を見ていただくとわかるんだけど、

Laravelは、一般的にWebおよびAPIルートへ適用される可能性のあるミドルウェアをwebおよびapiミドルウェアグループへ予め定義しています。これらのミドルウェアグループは、アプリケーションのApp\Providers\RouteServiceProviderサービスプロバイダによって、対応するwebおよびapiルートファイル内のルートに自動的に適用されることに注意してください。

https://readouble.com/laravel/9.x/ja/middleware.html

とある。

この、「webおよびapiミドルウェアが自動的にルートに適用される」というところがミソで、私はauthというグループミドルウェアを作ればroutes/auth.phpに自動的に適用されるもんだと思ってた。

しかも、実際にroutes/auth.phpに適用されたようにみえた。

何で適用された様に見えたかっていうと、routes/web.phpに

require __DIR__ . '/auth.php';

という記述があり、webグループミドルウェアに適用されたミドルウェアがroutes/auth.phpに適用されていたから。

結果、authグループミドルウェアが正常に動いていると勘違いしてしまい、authの上書きに気付かなかったという感じ。

色々仕組みを学べたけど、危なかった。気づけて良かった。

auth関連のテストを追加していれば気づけたことなので、もう少しテストを補強する。

自作ヘルパ関数を作成する

ヘルパ関数っていうのは、asset()とか、route()とか、そういうやつ。
Laravelの日本語ドキュメントはこちら

どんな自作ヘルパ関数が欲しいかっていうと、
「Byteを適切な単位に変換して表示してくれる関数」
が欲しいんだよね。

私は今ユーザーがアップロードしたファイルをByte単位で保存しているので、それを表示するときに使いたい。

1024Byte = 1KB
1024KB =1024 * 1024 Byte = 1MB

という感じなので、これに合わせた表示にしたい。

自作ヘルパ関数の作成方法

こちらの記事を参考に追加する。

方針としては、ヘルパ関数用のサービスプロバイダを作って、そこでapp/Helpersフォルダ内のPHPファイルを全て登録するという感じみたい。

php artisan make:provider HelperServiceProvider

でサービスプロバイダを作成して

public function register()
{
   $allHelperFiles = glob(app_path('Helpers').'/*.php');
   foreach ($allHelperFiles as $key => $helperFile) {
       require_once $helperFile;
   }
}

を記述

  1. glob関数app_pathヘルパ関数で「app/Helpaers/」にある.phpファイルを全て取得
  2. require_onceで全ての.phpファイルを読み込む

という処理がされている。

普通、PHPで外部ファイルを読み込むときはrequireが必要だけど、Laravelはautoloadというので一括読み込みをしている。

その為、autoloadに作成したヘルパ関数を毎回追加してもいいんだけど、それだとヘルパ関数を作る度に登録が必要で面倒なので、この形を取ってるということみたい。

次に、config/app.phpにサービスプロバイダを追加して
App\Providers\HelperServiceProvider::class, // 追加

最後に、作りたいヘルパ関数をapp/Helpersに作成する

<?php


// Byteを適切な単位に変換する


if (!function_exists("formatSizeUnits")) {
   function formatSizeUnits($bytes)
   {
       if ($bytes >= 1024*1024) {
           return number_format($bytes / (1024*1024), 2)."MB";
       } else{
           return number_format($bytes / (1024), 2)."KB";
       }
   }
}

これで、formatSizeUnits($file->file_size)みたいな感じで使える。

この
if (!function_exists(“formatSizeUnits”)) {

というのは結構大事で、この関数が使える状態かを保証してくれる役割があるみたい。

タスクスケジュールで、毎月のアップロード制限を0にする

ユーザーがアップロードできるのは、毎月100MBにしたいので毎月1日になったら更新しなきゃいけない。
タスクスケジュールのドキュメントはこちら

スケジュールは/app/Console/Kerne.phpのscheduleメソッドで定義できる。
ここで、
$schedule->command(‘emails:send Taylor –force’)->daily();
みたいにコマンドメソッドを動かすのが普通みたい。

早速、コマンドファイルを作成して、
php artisan make:command ResetUploadedSize

記述してみる

<?php


namespace App\Console\Commands;


use App\Models\User;
use Illuminate\Console\Command;


class ResetUploadedSize extends Command
{
   /**
    * コンソールコマンドの名前と使い方
    *
    * @var string
    */
   protected $signature = 'reset:uploadedsize';


   /**
    * コンソールコマンドの説明
    *
    * @var string
    */
   protected $description = '全てのユーザーの1ヵ月当たりのアップロード容量を0にリセットする';


   /**
    * コマンドのロジック部分
    *
    * @return void
    */
   public function handle()
   {
       User::query()->update(['uploaded_size_this_month' => 0]);
       $this->info('全てのユーザーのアップロード容量を0にしました');
   }
}

signatureはこのコマンドを実行するときの、コマンドの定義。
descriptionは「php artisan list」時の説明。
handleにロジックを書く。

そして、scheduleメソッドに
$schedule->command(“reset:uploadedsize”)->monthly();
って定義して

Linuxのcronというのにドキュメントの方法で「php artisan schedule:run」を定義してあげれば、勝手にscheduleメソッドを実行してくれるという仕組み。

ってことは、一回実行できるかどうかを、scheduleをeveryMinute()にして「php artisan schedule:run」をやってみていいね。

おk、しっかり動いているしデータも0になった。

本番のサーバーでcronの定義をし忘れないようにしないと。

完成形

一応、以下のようになった。

自分がアップロードした画像・音源をここで閲覧・削除できるという感じ。
URLをコピーすれば他記事でそのまま利用できるし、容量とかも一応小っちゃく表示されてる。

改善の余地はいくらでもあるんだけど、最低限なので。

バリデーションを再検討する

下書き記事にバリデーションを施してなかったりと甘いところがあるし、バリデーション条件をviewに表示してなかったりするので、見直す。

バリデーションルールを一元的に管理する

今、バリデーションルールはRequestに直書き状態なんだけど、これだと全体のバリデーションルールを把握できないし、リクエストのルールを変えてもViewに表示されるルールは動的に変わらないので、ちょっと不便。

だから、前もちょっとやったんだけどconfig/validation.phpを作ってそこでバリデーションルールを管理することにする。

こんな感じで値だけ入れといて、取り出す形にする。

"storeArticle" => [
   "title" => [
       "max" => 80,
       "min" => 1,
       "regex" => "/^[a-zA-Z0-9\sぁ-んァ-ヶア-ン゙゚ー一-龠々〆〤[:punct:]<>〈〉《》「」『』]+$/",
   ],
   "body" => [
       "max" => 8388608,
       "min" => 1,
   ],
   "tags" => [
       "maxTags" => 5,
       "maxLength" => 50,
   ]
],

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

public function rules()
{
   $validationPath = "validation.storeArticle";
   return [
       'title' => "required|string|
           max:" . config("{$validationPath}.title.max") . "|
           min:" . config("{$validationPath}.title.min") . "|
           regex:" . config("{$validationPath}.title.regex"),
       'body' => "required|string
           max:" . config("{$validationPath}.body.max") . "|
           min:" . config("{$validationPath}.body.min"),
       'tags' => ['required|string', new TagValidation(
           config("{$validationPath}.tags.maxTags"),
           config("{$validationPath}.tags.maxLength")
       )],
       'publication_status' => ['required', Rule::in(["public", "limited_public"])],
   ];
}

バリデーションのテスト

バリデーション関連のテストを書いてなかったのでここで書いちゃう。


バリデーションの値を「config/validation」で管理していて、テストも分けて管理した方が分かりやすいと思ったので、「tests/Feature/config/validationTest.php」のように別ファイルで作っちゃうことにする。

【重要】1個のテストにリクエストは1個まで

Laravelの公式ドキュメント

In general, each of your tests should only make one request to your application. Unexpected behavior may occur if multiple requests are executed within a single test method.

https://laravel.com/docs/9.x/http-tests#making-requests

と書いてある。

つまり、1個のテストにリクエストは1個までということ。

私はこの記述に気づかず以下みたいなテストコードを書いてた。

public function test_記事投稿時のバリデーションチェック()
{
   // titleのチェック
   $this->post($url,["title"=>""])->assertInvalid(["title"=>"タイトルが空です"]);
   $this->post($url,["title"=>["aa"=>"bb"]])->assertInvalid(["title"=>"タイトルは文字列である必要があります"]);
   $this->post($url,["title"=> str_repeat("あ",80)])->assertValid("title");
   $this->post($url,["title"=> str_repeat("あ",81)])->assertInvalid(["title"=>"タイトルの最大文字数は80文字です"]);
   $this->post($url,["title"=>implode($this->invisible_character)])->assertInvalid(["title"=>"タイトルが不正です"]);
   $this->post($url,["title"=>"[必見]100%DTM初心者_19選/ いろいろ,.。"])->assertValid("title");


   // bodyのチェック
   $this->post($url,["body"=>""])->assertInvalid(["body"=>"記事本文が空です"]);
   $this->post($url,["body"=>["aa"=>"bb"]])->assertInvalid(["body"=>"記事本文は文字列である必要があります"]);
   $this->post($url,["body"=>str_repeat("あ",1024*1024*4)])->assertValid("body");
   $this->post($url,["body"=> str_repeat("あ",1024*1024*4+1)])->assertInvalid(["body"=>"記事本文の文字数が多すぎます。本文に使える文字は4194304文字までです"]);
}

一番最後の

$this->post($url,["body"=> str_repeat("あ",1024*1024*4+1)])->assertInvalid(["body"=>"記事本文の文字数が多すぎます。本文に使える文字は4194304文字までです"]);

は本来バリエーションエラーになって、テストPassするはずなんだけど。

なぜかテストエラーになっちゃうんだよね。

これ、もう一個テスト作って

$this->post($url,["body"=> str_repeat("あ",1024*1024*4+1)])->assertInvalid(["body"=>"記事本文の文字数が多すぎます。本文に使える文字は4194304文字までです"]);

のリクエストを1個だけにしてみると

通るんだよね。

恐らくこれが公式のいうUnexpected behaviorになる。
私の環境だと、内容はなんでもpostを10個入れるとこの現象になるっぽい。

こんな感じで複数のリクエストは予期しない動作に繋がるので、リクエストは1個にしなきゃいけない。

これ、テスト全体を見直す必要が出てきたかもしれないなぁ。
正直めっちゃ面倒なんだけど、テストが基盤みたいな感じなので、テストが信頼できないのはまずい。頑張ろう。

PHPUnitのdataProviderdependsを駆使していく必要がありそう。

PHPUnitのdataProviderで制限を回避する

公式ドキュメントはこちら
Laravel9のPHPUnitのバージョンは9.6.10だった。

さっき、1つのテストにリクエストは1個までっていう制限があることを知った訳だけど、バリデーションルールのテストとか何回も書かなきゃいけないの? って思うよね。

一応、PHPUnitにdataProviderという引数を渡して同じテストを使いまわす方法があるみたい。

さっきのテストをdataProviderを使い作成すると

/**
* @return array
*/
public static function 記事投稿バリデーションエラーのProvider(): array
{
   return [
       ["title", "", "タイトルが空です"],
       ["title", ["aa" => "bb"], "タイトルは文字列である必要があります"],
       ["title", Str::random(81),"タイトルの最大文字数は80文字です"],
       ["title", implode(self::$invisible_character),"タイトルが不正です"],
       ["body", "", "記事本文が空です"],
       ["body", ["aa" => "bb"], "記事本文は文字列である必要があります"],
       ["body", str_repeat("あ", 1024 * 1024 * 4 + 1),"記事本文の文字数が多すぎます。本文に使える文字は4194304文字までです"]
   ];
}


/**
* @dataProvider 記事投稿バリデーションエラーのProvider
* @param string $inputField バリエーションのフィールド名
* @param string $inputValue フィールドに指定する値
* @param string $expectedError 期待するエラーメッセージ
* @return void
*/
public function test_記事投稿時バリデーションエラーチェック(
   string $inputField,
   mixed $inputValue,
   string $expectedError
) {
   $article = Article::factory()->create();
   $draft = Draft::factory()->for($article)->create();
   $url = route("article.storeAndUpdate", $article);
   $this->login($article->user);

   $this->post($url, [$inputField => $inputValue])->assertInvalid([$inputField => $expectedError]);
}

こんな感じになる。

引数を2次配列にすることで、各データを独立したテストで実施してくれるみたい。

わかりやすいし、管理しやすくなった。めっちゃいいねこれ。

また、テストに渡すdataProviderの配列はPHPDocの

* @dataProvider 記事投稿バリエーションエラーのProvider

の部分で指定できる。

見えない文字はBad

さっきのバリデーションルールに
“regex” => “/^[a-zA-Z0-9\sぁ-んァ-ヶア-ン゙゚ー一-龠々〆〤[:punct:]<>〈〉《》「」『』]+$/”,
というのがあったと思うんだけど、これは見えない文字を阻止するという意味合いがあった。

しかし、実際見えない文字をタイトルに使ってみると

うん、これ阻止できてないね。

だから、ここもしっかり考える。

見えない文字とは

私が見えない文字を知ったきっかけはこちらのQiita記事
目に見えない文字を悪用してサイトを好き放題荒らされた話

unicode文字には見えないものが何個かある。
私のサイトの場合、見えない文字だけでタイトルを形成されるとタイトルが押せなくなり、記事へ飛べなくなってしまうので阻止したい。

Qiita記事のコメントをみると、「/\p{L}|\p{N}|\p{P}|\p{S}/gu」のような正規表現で、1文字でも見える文字が入っていればおkという感じで阻止するのが良さそう。

p{L}はpがプロパティの指定で、LがLetter(文字)という意味。そんな感じで見えるプロパティを探索してる感じ。

詳しくはとほほさんを。

LaravelのRequestバリデーションで利用するとなると「/[\p{L}\p{N}\p{P}\p{S}]/u」になるのかな。

「/[\p{L}\p{N}\p{P}\p{S}]/u」とrequiredで弾けない文字

Invisible Charactersというそのまんまのサイトに掲載されてる情報によると、現時点で57文字ほど見えない文字は存在する。

流石に試しとくかと思って全部の文字を「/[\p{L}\p{N}\p{P}\p{S}]/u」とrequiredのバリデーションルールで試してみたんだけど、

  • U+115F HANGUL CHOSEONG FILLER
  • U+1160 HANGUL JUNGSEONG FILLER
  • U+2800 BRAILLE PATTERN BLANK
  • U+3164 HANGUL FILLER
  • U+FFA0 HALFWIDTH HANGUL FILLER
  • U+1D159 MUSICAL SYMBOL NULL NOTEHEAD

は投稿できてしまった。

といっても、どれも文字幅は存在するので押せはする感じ。

どうしようねこれ。

別に押せるならいい気はするんだけど、扱いにくそうなので弾いておきたい気持ちもある。

うーん。とりあえずここら辺は許可することにする。
「文字が押せるか押せないか」を基準にバリデーションして、もし後々問題がありそうな文字があればそれは弾く方針で。

テストに使った見えない文字の配列を置いとく。
※ChatGPTにURLを渡して作成してもらったので、文字列部分はチェックはしたけどコメントに間違いがある可能性あり。

$invisible_character = [
   "\u{0009}", // U+0009 CHARACTER TABULATION
   "\u{0020}", // U+0020 SPACE
   "\u{00A0}", // U+00A0 NO-BREAK SPACE
   "\u{00AD}", // U+00AD SOFT HYPHEN
   "\u{034F}", // U+034F COMBINING GRAPHEME JOINER
   "\u{061C}", // U+061C ARABIC LETTER MARK
   "\u{115F}", // U+115F HANGUL CHOSEONG FILLER
   "\u{1160}", // U+1160 HANGUL JUNGSEONG FILLER
   "\u{17B4}", // U+17B4 KHMER VOWEL INHERENT AQ
   "\u{17B5}", // U+17B5 KHMER VOWEL INHERENT AA
   "\u{180E}", // U+180E MONGOLIAN VOWEL SEPARATOR
   "\u{2000}", // U+2000 EN QUAD
   "\u{2001}", // U+2001 EM QUAD
   "\u{2002}", // U+2002 EN SPACE
   "\u{2003}", // U+2003 EM SPACE
   "\u{2004}", // U+2004 THREE-PER-EM SPACE
   "\u{2005}", // U+2005 FOUR-PER-EM SPACE
   "\u{2006}", // U+2006 SIX-PER-EM SPACE
   "\u{2007}", // U+2007 FIGURE SPACE
   "\u{2008}", // U+2008 PUNCTUATION SPACE
   "\u{2009}", // U+2009 THIN SPACE
   "\u{200A}", // U+200A HAIR SPACE
   "\u{200B}", // U+200B ZERO WIDTH SPACE
   "\u{200C}", // U+200C ZERO WIDTH NON-JOINER
   "\u{200D}", // U+200D ZERO WIDTH JOINER
   "\u{200E}", // U+200E LEFT-TO-RIGHT MARK
   "\u{200F}", // U+200F RIGHT-TO-LEFT MARK
   "\u{202F}", // U+202F NARROW NO-BREAK SPACE
   "\u{205F}", // U+205F MEDIUM MATHEMATICAL SPACE
   "\u{2060}", // U+2060 WORD JOINER
   "\u{2061}", // U+2061 FUNCTION APPLICATION
   "\u{2062}", // U+2062 INVISIBLE TIMES
   "\u{2063}", // U+2063 INVISIBLE SEPARATOR
   "\u{2064}", // U+2064 INVISIBLE PLUS
   "\u{206A}", // U+206A INHIBIT SYMMETRIC SWAPPING
   "\u{206B}", // U+206B ACTIVATE SYMMETRIC SWAPPING
   "\u{206C}", // U+206C INHIBIT ARABIC FORM SHAPING
   "\u{206D}", // U+206D ACTIVATE ARABIC FORM SHAPING
   "\u{206E}", // U+206E NATIONAL DIGIT SHAPES
   "\u{206F}", // U+206F NOMINAL DIGIT SHAPES
   "\u{3000}", // U+3000 IDEOGRAPHIC SPACE
   "\u{2800}", // U+2800 BRAILLE PATTERN BLANK
   "\u{3164}", // U+3164 HANGUL FILLER
   "\u{FEFF}", // U+FEFF ZERO WIDTH NO-BREAK SPACE
   "\u{FFA0}", // U+FFA0 HALFWIDTH HANGUL FILLER
   "\u{1D159}", // U+1D159 MUSICAL SYMBOL NULL NOTEHEAD
   "\u{1D173}", // U+1D173 MUSICAL SYMBOL BEGIN BEAM
   "\u{1D174}", // U+1D174 MUSICAL SYMBOL END BEAM
   "\u{1D175}", // U+1D175 MUSICAL SYMBOL BEGIN TIE
   "\u{1D176}", // U+1D176 MUSICAL SYMBOL END TIE
   "\u{1D177}", // U+1D177 MUSICAL SYMBOL BEGIN SLUR
   "\u{1D178}", // U+1D178 MUSICAL SYMBOL END SLUR
   "\u{1D179}", // U+1D179 MUSICAL SYMBOL BEGIN PHRASE
   "\u{1D17A}", // U+1D17A MUSICAL SYMBOL END PHRASE
];

PHPUnit実行時のみPHPのメモリを増やす

何気なくテストを実行したんだけど、

みたいなエラーが出た。

どうやらメモリが足りないらしい。
一番賢いのはメモリを食ってる原因を探してそこを改善することだと思うんだけど、512MBに一回上げてみたいと思う。

調べて見ると、phpunit.xmlに

のように<ini name=”memory_limit” value=”512M”/>を追加するとphpunit実行時だけメモリが増えるみたい。

512MBにしたら流石に動いた。

emailバリデーションを見直す

ユーザー登録時のバリデーションを見ていたんだけど、

‘email’ => [‘required’, ‘string’, ‘email’, ‘max:255’, ‘unique:’ . User::class],

この、”email”っていうバリデーションルールはなんだろう?

Laravelのドキュメントを見てみると、egulias/email-validatorというパッケージを使ってバリエーションしてくれるみたい。

デフォルト、つまりemailだけだとRFCValidationになるんだね。

RFCとは

RFCValidationのRFCとはなんだろうか。

RFCの説明はこちらのQiita記事「RFC 5322 & 5321に沿ったメールアドレス(local-part)に使える文字まとめ」がわかりやすい。

RFCを簡潔に言えば、インターネット標準化団体IETFが発行する技術使用の文章群。

この文章群にはemail以外に関することもあり、emailに関するRFCは2023年12月23日現在

の6つ。

つまり、この6つのRFCに基づいたバリエーションをしてくれるみたいな感じ。


RFCValidationのソースコードを詳しく見た訳じゃないのであんまり下手なことは言えないんだけど、

  • tama@koma@tamakoma
    @が2個ある
  • tamakoma.@tamakoma
    @の直前に.がある
  • tamakomatamakoma
    @が無い

とか色々弾かれるようにはなってるみたい。

RFCバリエーションだけで問題ないのか?

では、RFCバリエーションだけで問題ないのか? なんだけど、答えはノーで、他のバリエーションもした方が良い。

Laravel(egulias/email-validator)では

  • rfc: RFCValidation
    さっき説明した標準のバリエーションルール。
    RFCに違反してても、広義に受け入れられるものはTrue。
  • strict: NoRFCWarningsValidation
    警告が見つかった時もエラーにする。
    警告の基準はRFCに違反しているが、広義に受け入れられるもの。
  • dns: DNSCheckValidation
    サーバーが電子メールを受け入れることを示すDNS レコードがあるかどうかを確認する。つまり、ドメインが存在するか。
    メールアドレスが存在するかではないので注意。
  • spoof: SpoofCheckValidation
    なりすましのメールアドレスを弾くもの。
    アルファベットの「a」とキリル文字の「а」、超似てるよね。こういうのを使ったemailを弾いてくれる。
  • filter: FilterEmailValidation
    PHPのfilter_varというフィルタ関数を使ってLaravelで実装しているバリデーションルール。
    \vendor\laravel\framework\src\Illuminate\Validation\Concerns\FilterEmailValidation.php」に定義されており、PHPの検証フィルタのFILTER_VALIDATE_EMAILを使っているっぽい。
  • filter_unicode: FilterEmailValidation::unicode()
    こちらも上に同じく。

これら6つのバリエーション方法があって併用ができるので、併用しちゃう。

大体、「strict,dns,spoof」の組み合わせか「rfc,dns,spoof」になるのかなぁと思う。

私はとりあえず厳しめの
‘email’ => [‘required’, ‘string’, ‘email:strict,dns,spoof’, ‘max:255’, ‘unique:’ . User::class],
にする。

また、dnsとspoofを使う時はPHPのintl拡張が必要なので注意。

以下みたいな感じで、存在しないドメインやら基準に合っていないメールアドレスを弾いてくれるようになった。えらい。

passwordバリデーションを見直す

LaravelBreezeの初期だとパスワードは
‘password’ => [‘required’, ‘confirmed’, Rules\Password::defaults()],
のようにバリデーションされてる。

こちらも同じく理解できていないので理解する。
日本語ドキュメントはこちら

confirmedはpassword_confirmationフィールドと同じ値かのバリエーションチェック。

じゃあ、Rules\Password::defaults()はなんだろうか。

どうやら、Rules\Password::defaults()はBreeze側で勝手に定義してくれた? パスワードルールみたい。defaultsが定義されたソース「\vendor\laravel\framework\src\Illuminate\Validation\Rules\Password.php」を見てみると。

/**
* Set the default callback to be used for determining a password's default rules.
*
* If no arguments are passed, the default password rule configuration will be returned.
*
* @param  static|callable|null  $callback
* @return static|null
*/
public static function defaults($callback = null)
{
   if (is_null($callback)) {
       return static::default();
   }


   if (! is_callable($callback) && ! $callback instanceof static) {
       throw new InvalidArgumentException('The given callback should be callable or an instance of '.static::class);
   }


   static::$defaultCallback = $callback;
}

/**
* Get the default configuration of the password rule.
*
* @return static
*/
public static function default()
{
   $password = is_callable(static::$defaultCallback)
                       ? call_user_func(static::$defaultCallback)
                       : static::$defaultCallback;


   return $password instanceof Rule ? $password : static::min(8);
}

コールバック関数を渡さない限り8文字以上というルールしか適用されてないっぽいね。
このdefaults()が定義されたファイルはvendor内にあるので、自分でサービスプロバイダにdefaults()を定義してオーバーライドするのが良さそう。

私は以下みたいにルールを加えた

Password::defaults(function () {
   return Password::min(12)    // 12文字以上
       ->mixedCase()   // 大文字小文字含む
       ->numbers()     // 数字を含む
       ->uncompromised(3); // haveibeenpwned.comで3回以上リークしてる
});

よわよわパスワードを打ってみると

いいね。
更に、ルールを「->uncompromised(3);」だけにして、「password」を入れてみると。

スゴイ。えらい。

最終的にパスワードのバリエーションは以下のようにした。

'password' => [
   'required',
   'string',
   'confirmed',
   'max:' . config("validation.user.password.max"),
   Password::defaults(),
],

画像のEXIF情報を消したい

あるときQiitaで「あなたの作った画像アップローダー、投稿者の個人情報ダダ洩れだよ!」という記事を見つけた。

簡潔に言うと、スマホ等で取った画像は様々な個人情報が詰まっており、そのままアップロードできるシステムにしているとユーザーの個人情報が載ったままになっちゃうよ! というもの。

そんなわけ無いだろうと思いながら、私が北海道に行ったときに撮った写真のEXIF情報を見てみる。

この写真をPCに移して確認してみると

おー。思ったより情報が載ってる。
撮影日時からスマホの機種、そして緯度経度まで載ってしまってる。

この緯度経度をGoogleMapで見てみると

しっかりと私が撮影した北海道の小樽が出てくる。

これでユーザーの家バレとか洒落にならないので、EXIF情報を自動的に消すシステムを導入する。

※PNG拡張子にも一部Exif情報が入ることはあるが、今のところ基本JPEGの問題

EXIF情報を削除する

EXIFはinterventionというOSSを利用して処理していく。

composer require intervention/image
でintervention-imageをインストールする。

このinterventionはEXIFを削除する以外にもいろいろできるみたいなので、ウォーターマークを追加してみる。

コードは以下の通り

use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;

public function imageUpload(UploadImageRequest $request, Article $article)
{
   $file = $request->file('upload');
   $UserUploadedFile = new UserUploadedFile();

   // 一旦画像を保存
   $path = $UserUploadedFile->uploadFile($file, $article->id);

   // 保存した画像を取得し、"水"を付け同じ場所に再保存
   $manager = new ImageManager(new Driver());
   $image = $manager->read("storage/" . $path);
   $image->place("storage/images/水.png");
   $image->save();

   $url = asset('storage/' . $path);

   return response()->json(['url' => $url]);
}

というか、インストールされたintervention-ver.3の日本語情報が無さ過ぎて苦戦した。

こんな感じで画像投稿時に画像を加工することができた。

Qiita記事によるとEXIF情報の削除はorientate()というのでできるみたいなんだけど、ver.3でorientate()は削除されてた
どうやら、自動的に処理してくれるようになったみたい。

実際に加工されたこの画像をダウンロードして情報を見てみると。

ちゃんとイメージ以外の情報が無くなってる。
ということは、ただsaveするだけでいいんだねこれ。

また、
$image->save($path,90);
のように第二引数にintを渡すことで圧縮品質を変えられる。

/**
* Save the image to the specified path in the file system. If no path is
* given, the image will be saved at its original location.
*
* @param null|string $path
* @return ImageInterface
*/
public function save(?string $path = null, int $quality = 75): ImageInterface;

以上のように、初期値は75で最高値は恐らく100。GIMPで画像保存するときもこういうのあるよね。

数値が高いほど圧縮劣化が少ない=容量は増える。

最終的な実装

なんかややこしくなったので記述しとく。

最終的にInterventionでEXIFを取る仕組みをヘルパ関数にした。
内容は以下の通り

<?php

// 画像のExifを削除する

use Illuminate\Http\UploadedFile;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;

/**
*
* intervention使ってEXIF情報を削除し保存
* interventionインスタンスにした時点でEXIFが削除され、向きが修正される。
*
* [注意] tmpデータの削除コードを記述していない為、任意でunlinkする必要あり。
* 私のローカル環境では/tmpに保存され、勝手に削除される模様。
*/
if (!function_exists("deleteExif")) {
   function deleteExif(UploadedFile $file)
   {
       // gifファイルはinterventionを通す時点で何故か画像がおかしくなるのでスルー
       if ($file->getMimeType() == "image/gif") {
           return $file;
       }
       $manager = new ImageManager(new Driver());
       $image = $manager->read($file->getRealPath());
       $tempPath = tempnam(sys_get_temp_dir(), 'img') . "." . $file->extension();
       $image->save($tempPath, 75);

       return new UploadedFile(
           $tempPath,
           $file->getClientOriginalName(),
           $file->getClientMimeType(),
       );
   }
}

$validFile = deleteExif($file);
みたいな感じで使う。

少し詳しく説明すると、tempnam()が衝突しない一時保存用の名前を生成してくれるメソッドで、sys_get_temp_dir()がphp.iniに指定されたtmp用のフォルダパスを出してくれるメソッド。

私のローカル環境の場合、/tmpフォルダが指定される。

どうやら、私のOSだと/tmpフォルダの場合は勝手に一時保存したファイルを削除してくれるみたい。その為、一時保存したファイルの削除コードを書いていないんだけど、システムによっては書く必要がある。

また、gifファイルを入れると圧縮バグ? GDドライバーを利用しているから? かわからないんだけど、gif画像がおかしくなるので例外に。

gif画像に個人情報は載らないだろうという判断だけど、いずれ対応しなきゃいけないかなという感じ。

上が下になる。qualityは関係なかった。

終わりに

とりあえず、一回ここで区切る。

まだ他にもしなきゃいけないことが少しだけあるんだけど、大きな実装は一通り終わった感じがする。

おやすみなさい。