- 投稿日:2021-08-09T18:49:46+09:00
PHP、CakePHP、LaravelのそれぞれのCSRF対策
CSRFとは CSRF(Cross Site Request Forgery)とは、Webアプリのユーザー自身が、意図しない処理を実行するようにする脆弱性または攻撃手法のことです。 悪意のあるWebサイトにスクリプトや自動転送(HTTPリダイレクト)を仕込むことによって、ユーザーに別のWebサイト上で何らかの操作を意図せずに行わせる攻撃となっています。 CSRF対策をしていないサイトの例と攻撃方法 今回、会員サイトのパスワード変更画面を例にします ログイン後にパスワード変更を行う場合を想定しております (今回テーマから逸れてしまうので、ログイン画面とパスワード変更完了時のサニタイズ処理は省略しております)。 パスワード変更_入力画面 <?php // ログイン確認 session_start(); if (!isset($_SESSION[‘id’])) { header('Location: http://hoge.com/login.php'); } ?> <h1>パスワード変更</h1> <form action="http://hoge.com/password_change_complete.php" method="post"> <input type="hidden" name="password" value="password"> <input type="password" name="password"> <input type="submit" value="変更する"> </form> パスワード変更_完了画面 <?php session_start(); if (!isset($_SESSION[‘id’])) { header('Location: http://hoge.com/login.php'); } $id = $_SESSION[‘id’]; $password = filter_input(INPUT_POST, ‘password’); ?> <h1>パスワード変更完了</h1> <p><?php echo $id; ?>さんのパスワードを<?php echo $password; ?>に変更しました。</p> 図:id:hoge、変更後のパスワード:passwordで変更を行った場合 このようなCSRF対策のされていない画面でパスワードの変更を行うと、一定時間ブラウザにcookieの情報が保持されてしまいます。 cookieが保持されている状態で、以下のようなタグが入っている悪意のある別のWebページにアクセスしてしまうと、会員情報変更処理ページに自動でPOST送信されて、パスワードが変更されてしまいます。 CSRF攻撃(パスワードを勝手に変更) // password_change.html <body onload="document.forms[0].submit()"> <form action="http://hoge.com/password_change_complete.php" method="POST"> <input type="hidden" name="password" value="cracked"> </from> 図:id:hogeのcookie情報が残っている状態で上記のCSRF攻撃にあった場合 CSRFの対策 主な対策としては、データを送信するページにトークンを埋め込む方法がとられます。 これは、正規利用者の意図したリクエストかを確かめるために行います。 リクエスト送信フォーム内にhidden値でワンタイムのtokenを埋め込むことで、同一セッション内での一致の確認を行います。 対策に必要なページは、他のサイトから勝手に実行されてはいけないページです。 例えばECサイトでは、物品購入の確定ページや、パスワード変更など個人情報の編集確定画面などが挙げられます。 PHPの場合:inputタグでトークンの埋め込み(送信側)+トークンのチェック(受信側) パスワード変更_入力画面 <?php session_start(); if (!isset($_SESSION[‘id’])) { header('Location: http://hoge.com/login.php'); } if (empty($_SESSION['token'])) { // トークンが空の場合、生成する $token = bin2hex(openssl_random_pseudo_bytes(24)); $_SESSION['token'] = $token; } else { // トークンがあれば使用する $token = $_SESSION['token']; } ?> <h1>パスワード変更画面</h1> <form action="http://hoge.com/password_change_complete.php" method="post"> <input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_COMPAT, 'UTF-8'); ?>"> <input type="text" name="password"> <input type="submit" value="変更する"> </form> パスワード変更_完了画面 <?php session_start(); if (!isset($_SESSION[‘id’])) { header('Location: http://hoge.com/login.php'); } $token = filter_input(INPUT_POST, 'token'); if (empty($_SESSION['token']) || $token !== $_SESSION['token']) { die('CSRF攻撃が発生 !!'); } $id = $_SESSION[‘id’]; $password = filter_input(INPUT_POST, ‘password’); ?> <h1>パスワード変更完了</h1> <p><?php echo $id; ?>さんのパスワードを<?php echo $password; ?>に変更しました。</p> CakePHPの場合:コントローラーにコンポーネントを追加する CakePHPには共通のコントローラごとに共通の処理を支援する、「コンポーネント」という機能があります。 この中でトークンを埋め込み、CSRF対策をしてくれる機能が提供されています。 AppContorller.phpに以下の記述を行うことで、自動的にCSRF対策を行ってくれるようになるので、View側での記述は特に必要ありません。 AppContorller.php <?php class AppController extends Controller { { public $components = array('Security'); } } Laravel(Blade)の場合:viewファイルのformタグ内に「@csrf」と記述する <h1>パスワード変更画面</h1> <form action="{{ url('/password_change_complete') }}" method="post"> @csrf // もしくは {{ csrf_field() }} <input type="text" name="password"> <input type="submit" value="変更する"> </form> LaravelではミドルウェアにデフォルトでCSRF対策が入っています(Formタグが存在するページでCSRF対策ができていないと、自動的に419ページにリダイレクトします)。 /app/Http/Kernel.php <?php protected $middlewareGroups = [ 'web' => [ \App\Http\Middleware\VerifyCsrfToken::class, ... ], ]; XSSとの違いについて XSS(Cross Site Scripting):とは、Webアプリのユーザーが、Webページにアクセスすることで不正なスクリプトが実行されてしまう攻撃手法のことです。 文章で簡潔に表すと、かなり類似していますが、攻撃が実行される場所が異なります。 攻撃シナリオをを示すと以下のようになります。 ①ユーザーが罠サイト閲覧する ②仕掛けのあるHTMLの実行される ③②により、ユーザーから別のサイトに攻撃用のリクエストが送られる(CSRF) ④仕掛けを含むレスポンスが返ってくる(XSS) CSRFでは、③のリクエストに対するサーバー側の処理を悪用しています。 不正なスクリプトが実行されるのは「 Webサーバー」です。 そのため、CSRF対策が必要となるページは、ログイン後に変更処理が絡んでくる箇所のみとなります。 対してXSSの場合は、③のリクエストに対して④が返ってきてそれがユーザーのWebブラウザ上で実行されます。不正なスクリプトが実行されるのは「被害にあうユーザーの Webブラウザ」となっています。 まとめ PHP CakePHP Laravel トークン埋め込み処理書く コンポーネントの追加 @csrf 参考 体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 徳丸 浩 (著) https://book.cakephp.org/3/ja/controllers/components/csrf.html https://readouble.com/laravel/7.x/ja/csrf.html
- 投稿日:2021-08-09T17:40:47+09:00
プログラミング初心者がphp artisan db:seedしたらエラーが出た
はじめに 現在プログラミングスクールにてGitHubを用いて共同開発中なのですが 相方が作ってくれたコードをローカルでマージして、DBにシーダーファイルのデータを登録しようとしたところ エラーが発生しました。 ※Laravelのverは6.20.16です。 エラーの内容 php artisan db:seedをしたところ下記のようなエラーが出ました。 UsersTableSeederクラスが存在しないよと書かれていますが ちゃんとフォルダの中には存在しています笑 困ったので色々と調べたところ下記のコマンドが有効でした。 composer dump-autoload こちらを打ち込んだところGenerating optimized autoload filesとなり 無事にシーダーファイルの中のデータを登録することができました! しかしこのdump-autoloadが具体的にどのような動きをしているのかまでは まだ調べきれていないため、わかり次第追記したいと思います。 ご覧いただきありがとうございました! 参考記事 https://qiita.com/teneye/items/1fdedd8ffe5a5ebd6c71
- 投稿日:2021-08-09T16:23:51+09:00
【不具合修正】Laravel-admin の Grid 表示で列固定 (fixed)すると表示がズレる
どんな不具合か 【概要】 Laravel-admin の Grid 表示で、「表示固定された列」と「通常の列」とで表示が上下にズレてしまう(上画像のようになる)。これはレコード数(行数)が少なければ気にならないレベルだが、多くなると顕著になる。 ↓Laravel-admin のデモページを見てもらえれば一目瞭然。 【Laravel-admin バージョンごとの発生状況】 バージョン 不具合 1.7.3 あり 1.8.11 あり その他 不明 【ブラウザごとの発生状況】 ブラウザ バージョン 不具合 Chrome 92.0.4515.131 あり FireFox 90.0.2 あり Safari 14.1.2 なし 私が普段使っているブラウザでは、Chrome と Firefox でこの不具合が発生する。 修正概要 Laravel-admin バージョン:1.8.11 修正箇所 ソース:/vendor/encore/laravel-admin/resource/views/grid/fixed-table.blade.php 行番号:182(scriptタグ内) 原因:レコードオブジェクトの高さを取得している jQuery の outerHeight()メソッドは、ブラウザによっては整数に丸めてしまうらしい。 対策方法:getBoundingClientRect().heightを使うと、オブジェクトの高さを小数のまま取得できる。 修正コード fixed-table.blade.php(修正前) $('.table-main tbody tr').each(function(i, obj) { var height = $(obj).outerHeight(); // 【このコードを修正】 $('.table-fixed-left tbody tr').eq(i).outerHeight(height); $('.table-fixed-right tbody tr').eq(i).outerHeight(height); }); fixed-table.blade.php(修正後) $('.table-main tbody tr').each(function(i, obj) { var height = $(obj).get(0).getBoundingClientRect().height; // 【このコードを修正】 $('.table-fixed-left tbody tr').eq(i).outerHeight(height); $('.table-fixed-right tbody tr').eq(i).outerHeight(height); }); getBoundingClientRect() は DOMオブジェクトのメソッド。 $(obj)はjQueryオブジェクトなので、そこから直接は使えない。 なので、間に「get(0)」を付けてDOMオブジェクトとして扱う必要がある。
- 投稿日:2021-08-09T11:11:34+09:00
Google Drive のファイルを、Laravel-adminからダウンロードする方法
対象とする読者 Laravel と Laravel-admin の基本的な仕組みを理解している人。 Laravel-admin から Google Drive にアクセスしたいけど、どうすれば良いかよく分からない人。 (Laravel からアクセスしたい人にも参考になるはず) 何をしたいか まずは「どんな機能を作りたいか」を簡単な図にしてみる。 ユーザが、Laravel-admin画面でリンク(もしくはボタン)をクリック。(図①) LaravelコントローラからGoogle Drive にアクセスし、任意のファイルを取得する。(図②③) 取得したファイルをブラウザでダウンロードさせる。(図④) なぜ、こんなことをしたいのか Google Drive でフォルダやファイルを共有する際に、「ユーザとフォルダ」を「多:多」で設定したいことがある。 この図の共有環境を全て手作業で Google Drive に設定・管理する手間を想像してもらえれば、その悲惨さを分かってもらえると思う。さらに数が増えれば共有ミスのリスクも増える。 この問題点を、Laravel-admin を経由することで解決しようというのが今回の目的。Laravel-admin のユーザ認証機能は便利で、ユーザのロール(役割や所属)でもアクセス制御できる。なので「この組織のユーザは全員まとめて共有」みたいな設定もできる。 設計に落とし込む さてここからが本題。前述した機能を設計に落とし込む。今回は次の3つの設計図を作る。 Laravel-admin の操作画面 データフロー図 ファイル関連図 Laravel-admin の操作画面 <管理者用画面> 管理者用の画面では、データベースに「共有ユーザ」と「フォルダID」を表示&登録できるようにする。そして、そのフォルダ配下のファイルがクリック可能なリストを表示させる。 <ユーザ用画面> ユーザ用の画面では、管理者に共有が許可されたレコードのみを表示。カラムはファイルリストのみを表示。上図はユーザAが見る画面の例なので、共有されていないID:2のレコードは見れない。 データフロー図(アクティビティ図) 「ユーザがクリック」〜「ファイルがダウンロードされる」までの処理をデータフロー図(アクティビティ図)にするとこうなる。 いきなりこんなでかい図を見せられて困惑している人もいると思うので、簡単に解説する。 左上の●が開始で、左下の◉が終了。 基本的には「データ(青□)」と「処理(黄□)」が交互に繋がっている。これは「どんなデータを入力して、どんな処理をして、どんなデータを出力するか」を表してる。 あとは「前段の出力データが、次の処理の入力データ」となり繰り返しているだけ。 黒いバー(━)は分岐と合流を表している。 分岐:全ての分岐先に並列に遷移している点に注意(条件分岐ではない)。 合流:全てのフローが到達しないと次に進めない。 本図は「アクティビティ図を使ってデータフロー図を表している」ので、UMLで定義してるデータフロー図とは違うので注意。 本図は厳密にはUML2.0に準拠してないので注意(厳密さより分かりやすさを重視)。 データフロー図の右上にも示したが、外部からGoogle Drive にアクセスするには、下記3点の事前準備が必要になる。 Google Cloud Platform にサービスアカウントを作っておく。 サービスアカウントから秘密鍵を作成し、LaravelのStorageに格納しておく。 Google Driveのフォルダの共有アカウントに、サービスアカウントのメアドを登録しておく。 ↓事前準備の詳しい手順は、こちらの記事が分かりやすい。 ファイル関連図 「Grid表示」〜「ファイルがダウンロードされる」までの処理をファイル関連図に落とし込むとこうなる。 「ファイル関連図」というものは一般的に定義されてはいない。UMLで言えばコンポジット構造図やコンポーネント図に該当するものを、UMLが読めない人にも分かりやすいように私が単純化したもの。 この図の目的は、「どのメソッドとデータをどのファイルに実装し、どのファイル(のメソッドやデータ)にアクセスするか?」を表現&共有すること。なので、本来はファイルの中にメソッドやデータも書くべきだけど、ごちゃごちゃして見にくかったので今回は省略した。 前述のデータフロー図と見比べてもらえば理解しやすいと思う。 できれば vendor 配下を変更したくなかったが、Laravel-admin の Colum Action の機能ではファイルをブラウザにダウンロードさせるスクリプトを組み込めず、諦めて手を加えることにした。 ↓【参考】Laravel-admin公式ドキュメント|Colum Display|Colum Action 実装 実装コードを、ファイル関連図のフロー順に書き出していく。前述の設計図と見比べながら読んでもらえると、理解しやすいと思う。 (ちなみに、私のコードは不要なelse文があって読みにくいかもしれないけど悪しからず。これは意図しないバグを予防するための1つの手法なので。) Gridを表示するコントローラ (DemoController.php) app/Admin/Controllers/DemoController.php class DemoController extends AdminController { protected function grid() { $grid = new Grid(new Demo()); $grid->column('id', __('ID'))->sortable(); $grid->column('name', __('案件'))->sortable(); // Google Drive ファイルを表示するカラム $grid->column('google_drive', __('Google Drive ファイル')) ->googledownloadable(); // 新しくlaravel-adminのカラム拡張表示に追加したメソッドをコール return $grid; } } grid メソッド内の Google Drive のフォルダIDを表示するカラムの処理で、今回追加したカラム拡張表示メソッドをコールする。 (このファイルの基本実装は、Laravel-admin の標準的なコントローラの実装と同じ。なので、本記事の主機能と関係ないコードは省略している。) Laravel-adminのカラム拡張表示機能 (ExtendDisplay.php) vendor/encore/laravel-admin/src/Grid/Column/ExtendDisplay.php trait ExtendDisplay { public static $displayers = [ 'editable' => Displayers\Editable::class, 'image' => Displayers\Image::class, 'label' => Displayers\Label::class, 'button' => Displayers\Button::class, 'link' => Displayers\Link::class, 'badge' => Displayers\Badge::class, 'progressBar' => Displayers\ProgressBar::class, 'progress' => Displayers\ProgressBar::class, 'orderable' => Displayers\Orderable::class, 'table' => Displayers\Table::class, 'expand' => Displayers\Expand::class, 'modal' => Displayers\Modal::class, 'carousel' => Displayers\Carousel::class, 'downloadable' => Displayers\Downloadable::class, 'copyable' => Displayers\Copyable::class, 'qrcode' => Displayers\QRCode::class, 'prefix' => Displayers\Prefix::class, 'suffix' => Displayers\Suffix::class, 'secret' => Displayers\Secret::class, 'limit' => Displayers\Limit::class, 'googledownloadable' => Displayers\GoogleDownloadable::class, // このコードを追加 ]; } このファイルは各拡張表示機能のハブになっているっぽい。今回追加した表示機能のファイルパスを追加するのみ。 Googleファイルダウンロード用のカラム表示機能 (GoogleDownloadable.php) vendor/encore/laravel-admin/src/Grid/Displayers/GoogleDownloadable.php <?php namespace Encore\Admin\Grid\Displayers; use Encore\Admin\Facades\Admin; /** * Class GoogleDownloadable. */ class GoogleDownloadable extends AbstractDisplayer { /****** * Script */ protected function addScript() { $script = <<<SCRIPT $('#{$this->grid->tableID}').on('click','.grid-column-google-drive-downloadable',(function (event) { // クリックされた要素を無効化 (連続クリック防止のため) event.target.classList.add('disabled'); // クリックされた要素の中身を一時退避 (最後に元に戻すため) let inner_html_back = event.target.innerHTML; // クリックされた要素のテキストを「処理中」に設定 event.target.textContent = "ダウンロード処理中..."; // クリックされた要素のGoogle_File_IDを取得し、JSON形式に変換 const tx_data = JSON.stringify ({ google_file_id: event.target.nextElementSibling.value }); //--- XMLHttpRequest による非同期通信の準備 --- // 非同期通信URL let url = "/download" // XMLHttpRequest オブジェクト作成 let xhr = new XMLHttpRequest(); // リクエストを初期化 xhr.open('POST', url, true); // CSRFトークンを設定 xhr.setRequestHeader('X-CSRF-Token', $('meta[name="csrf-token"]').attr('content')); // レスポンスタイプは'blob'に設定 (ファイルデータを受け取るため) xhr.responseType = 'blob'; // 送信データのヘッダを設定 (JSONデータ) xhr.setRequestHeader("Content-Type", "application/json"); // xhr通信成功時の処理を定義 xhr.onload = function (event) { // 応答データからコンテンツタイプを取得 let content_type = xhr.getResponseHeader('Content-Type'); // コンテンツタイプによって処理を分岐 switch (content_type) { // [正常]ダウンロード対象のファイル形式 case "image/png": case "image/jpg": case "application/pdf": // 応答データから blob オブジェクト作成 const blob = new Blob([xhr.response], {type: xhr.response.type}); // レスポンスヘッダからファイル名を取得する const disposition = xhr.getResponseHeader('Content-Disposition'); // ヘッダー情報の有無で処理を分岐 if (disposition && (disposition.indexOf('attachment') !== -1)) { // ヘッダーに'attachment'情報がある場合 // ファイル名を取得するための正規表現を作成 let filename_regex = /filename=".*"/; // 正規表現で検索実行 let matches = filename_regex.exec(disposition); // ファイル名の有無で分岐 if ((matches != null) && matches[0]) { // [正常]ファイル名があった場合 // 不要文字列を消して、デコードしてサーバからのファイル名を取得 let file_name_decode = decodeURI(matches[0].replace(/['"]/g, '').replace('filename=', '')); // blobオブジェクトのURLを作成 let download_url = window.URL.createObjectURL(blob); // ダウンロードする'a'タグを作成 let link = document.createElement('a'); link.href = download_url; link.download = file_name_decode; // 'a'タグをクリックしてダウンロード実行 link.click(); // blobオブジェクトのURLを削除 window.URL.revokeObjectURL(download_url); } else { // [エラー]ファイル名がない場合 // [NOP]不正データと判断してダウンロードしない。 } } else { // ヘッダーに'attachment'情報がない場合 // [NOP]不正データと判断してダウンロードしない。 } // if-else ヘッダー判定 break; // [エラー]JSONデータ (エラーデータを想定) case "application/json": // Blobからデータを読み込み (JSONデータを想定) const reader = new FileReader() // 読み込みが完了時の動作を定義 reader.onload = () => { // JSONデータを取り出す let response_json = JSON.parse(reader.result); // アラート表示 alert('【エラー】\\n・' + response_json.message.error.message + '\\n・システム管理者に問い合せてください。'); } // 読み込み開始 reader.readAsText(this.response) break; // [想定外] default: // [NOP]何もしない break; } }; // function onload // xhrのエラー処理を定義 xhr.onerror = function (e) { console.error(xhr.statusText); }; // xhr通信状態に応じた処理 xhr.onreadystatechange = function () { if (this.readyState == 4) { // リクエスト完了 // クリックされた要素のテキストを元に戻す event.target.innerHTML = inner_html_back; // クリックされた要素の無効を解除 event.target.classList.remove('disabled'); } else { // その他の状態 // [NOP]何もしない } } // xhrリクエスト送信 xhr.send(tx_data); })); SCRIPT; // スクリプト実行 Admin::script($script); } /****** * display */ public function display() { $this->addScript(); // カラムの値 (Google Drive のファイルID)を取得 $content = $this->getColumn()->getOriginal(); // 選択行のIDを取得 $key = $this->getKey(); // 表示内容を書き込む変数 $response_get_file_property = ""; $view_files = []; // bladeに渡すファイル情報の配列 // データの有無で表示を切り替え if (is_null($content)) { // データなしの場合 // 何も表示しない $html = ""; } else { // データありの場合 // Google認証の設定 $client = new \Google_Client(); $client->setApplicationName('FileDownloadSampleApp'); $client->setScopes(['https://www.googleapis.com/auth/drive']); $client->setAccessType('offline'); $client->setAuthConfigFile(config_path('client_secrets.json')); // クライアント認証JSONを設定 // Googleドライブサービスを作成 $service = new \Google_Service_Drive($client); // ファイルリスト取得用のパラメータ作成 (フォルダIDを親フォルダとする場合のファイル一覧) $param = "'".$content."' in parents"; // ファイルリスト取得 $results = $service->files->listFiles([ "q" => $param ]); // ファイル配列のみを取り出す $files = $results->getFiles(); // ファイルがあるか判定 if (0 < count($files)) { // ファイルが1つ以上ある // ファイル数だけループ foreach ($files as $file) { // ファイルタイプを取得 $mimetype = $file->getMimeType(); // ファイルタイプで処理を分岐 if (strpos($mimetype, 'folder')) { // フォルダの場合 // [NOP]何もせず次のループへ ; } else { // ファイルの場合 (フォルダ以外) // 「ファイルID、ファイル名」の連想配列を作成 $tmp_ary = [[ 'id' => $file->getId(), 'name' => $file->getName(), ]]; // ビュー配列に追加 $view_files = array_merge($view_files, $tmp_ary); } // if-else ファイルの種類 } // foreach // ファイルダウンロード用のビューを描画 return view('admin.customs.google_drive', compact('view_files')); } else { // ファイルがない // [NOP]何も表示しない ; } // if-else ファイルの有無チェック } // if-else データの有無チェック // データがない場合は何も表示しない return; } } このファイルには、2つのメソッド「addScript ()」「display ()」を実装する。 「addScript ()」は、カラムのリンクがクリックされたときに呼び出される関数。 この中のスクリプトで「非同期POST通信でコントローラにファイル取得要求」を出し、「応答で返ってきたファイルをブラウザにダウンロード」させている。 「display ()」は、Grid画面ロード時に呼び出される関数。 Google Drive にアクセスし、フォルダIDを使って配下のファイル一覧を取得後、blade に「ファイルID、ファイル名」の配列を渡して描画させている。 Googleファイルダウンロード用のカラムの描画コード (Google_drive.blade.php) resource/view/Google_drive.blade.php <meta name="csrf-token" content="{{ csrf_token() }}"> <div> @foreach ($view_files as $file) <div> <a href="javascript:void(0);" class="btn btn-link btn-sm grid-column-google-drive-downloadable" title="ファイルをダウンロード" data-placement="bottom" id=""> <i class="fa fa-download"></i> {{ $file['name'] }} </a> <input type="hidden" value="{{ $file['id'] }}"> </div> @endforeach </div> 前段の拡張表示ファイルから呼び出されるカラム描画用のファイル。 Gridカラムの中に表示する内容をhtmlで実装する。 aタグのhref要素にjavascriptを指定することで、前述のスクリプトがコールされる仕組みになっているっぽい。 aタグの下にinputタグでファイルIDを用意しておく(スクリプトでファイルIDを読み込むため)。 【セキュリティ上の注意点】 「ファイルIDをhtml上に記述すると、そのファイルIDが漏洩してしまうし、共有してない人もファイルにアクセスできちゃうのでは?」 と思った人はセキュリティのことを考えており素晴らしい。その通り。 なので、実際の運用環境では「htmlに表示するファイルIDは何かしらの暗号化をしておき、クリック後のスクリプト側で復号化する」などの、何らかのセキュリティ対策が必要になる。今回は分かりやすさ重視でファイルIDをそのまま取り扱っている。 非同期通信の要求をルーティング (web.php) route/web.php <?php use Illuminate\Support\Facades\Route; Route::get('/', function () { return view('welcome'); }); Route::post('download', 'FileDownloadController@download'); // このコードを追加 このファイルはスクリプトからの非同期POST通信をコントローラにルーティングしているだけ。 Google Drive ファイル取得コントローラ (FileDownloadController.php) app/Http/Controllers/FileDownloadController.php <?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Http\Response; use Google\Auth\Credentials\UserRefreshCredentials; // Googleドライブアクセス class FileDownloadController extends Controller { public function download (Request $request) { // Google Drive のファイルIDをリクエストから取り出す $file_id = $request->input('google_file_id'); // Google 認証の設定 $client = new \Google_Client(); $client->setApplicationName('Google Drive File Downloader'); // アプリ名はなんでも良い $client->setScopes(['https://www.googleapis.com/auth/drive']); // アクセス先はGoogle APIのDrive用のアドレスを設定 $client->setAuthConfigFile(config_path('client_secrets.json')); // 秘密鍵JSONファイルのパスを設定 // Googleドライブサービスを作成 $service = new \Google_Service_Drive($client); // ファイル取得処理の成否で処理を分岐 try { // 正常処理 // ファイル本体をGoogleドライブから取得 $response_file_media = $service->files->get($file_id, ['alt' => 'media']); // ファイルデータを取り出す $file_body = $response_file_media->getBody(); // ファイル情報をGoogleドライブから取得 (ファイル名とメディアタイプ) $response_file_property = $service->files->get($file_id, ['fields' => 'name, mimeType']); // ファイル名を取り出す $file_name = $response_file_property->name; // ファイル名をエンコード (日本語の文字化け防止のため) $file_name_encoded = rawurlencode($file_name); // メディアタイプを取り出す $mime_type = $response_file_property->getMimeType(); // 応答用のヘッダー設定 $headers = [ 'Content-Type' => $mime_type, 'Content-Disposition' => 'attachment; filename="'.$file_name_encoded.'"' ]; // ファイル実体を返送する return response()->make($file_body, 200, $headers); } catch (\Exception $exception) { // エラー処理 // エラーメッセージを取得 $except_msg = $exception->getMessage(); // エラーログを残す \Log::error( $except_msg ); // エラーデータをJSON文字列からオブジェクトに変換 $except_json_msg = json_decode($except_msg, false); // エラーデータを返す return [ 'status_code' => $exception->getCode(), 'message' => $except_json_msg, ]; } // try-catch } // func } 非同期POST通信を受けて、Google Drive からファイルを取得するコントローラのコード。 データフロー図にも書いてあるとおり、ファイル本体とファイル情報を1つのメソッドで取得できないので、個別に取得している。 ↓google-api-php-client の使い方は、GitHub のドキュメントを参照(ただし説明が不十分なところも…)。 ↓Google Drive API のドキュメントと合わせて読むと理解しやすいかも。 あとがき 自分用の再理解&備忘録として書いたけど、私と同じように「Laravel-admin や Laravel から Google Drive にアクセスしたいけどどうすればいいか全然分からない…」って人の助けになれば幸い。 単純に Laravel からアクセスしたい場合は、本記事の内容をベースに「ユーザがクリックする画面」を各自の環境に合わせて作ってもらえればできるはず。 「Laravel の storage 機能で格納先を Google Drive に設定する」という方法もある。しかし、今回は「管理者側はすでに Google Drive でファイル管理しており、ユーザへの共有方法をどうにかしたい」に対する解決策なので、この記事の方法にした。 最後に、「データフロー図とかがよく分からない」って人もいると思うけど、これを機にUMLに興味を持って読み書きできる技術者が1人でも増えてくれると、個人的には嬉しい。
- 投稿日:2021-08-09T10:56:04+09:00
Laravelで気軽にバルクアップデートしたい
バルクアップデートとは バッチ処理などでは、データベースに複数の行を挿入したり、更新したりしたい場合がよくあると思います。挿入に関しては、大抵のRDBMSではINSERT INTO table (...) VALUES (...), (...)のような形で簡単に実行可能ですし、Laravelでもinsert()の第1引数に連想配列の配列を渡せば可能です。 更新に関してはそう簡単ではありませんが、MySQLに限っては、ELT()とFIELD()という2つの関数を使った方法で可能です。ただし、これをLaravelで実行するのはだいぶややこしいので、簡単に実行できるようにしてみました。 使い方 上記にあるサービスプロバイダクラスのファイルを、app/Providers以下に配置し、config/app.phpのprovidersにApp\Providers\MySqlBulkUpdateServiceProvider::classを追加、その上で、 <?php DB::table('users')->whereNull('email_verified_at')->updateBulk([ ['id' => 1, 'name' => 'admin1', 'email' => 'admin@example.com'], ['id' => 2, 'name' => 'admin2', 'email' => 'admin@example.com'], ['id' => 3, 'name' => 'admin3', 'email' => 'admin@example.com'], ]); あるいは、 <?php App\Models\User::whereNull('email_verified_at')->updateBulk([ ['id' => 1, 'name' => 'admin1', 'email' => 'admin@example.com'], ['id' => 2, 'name' => 'admin2', 'email' => 'admin@example.com'], ['id' => 3, 'name' => 'admin3', 'email' => 'admin@example.com'], ]); のように実行します。 詳細 第1引数で渡した配列から、id(Eloquent\Builderからの場合は、モデルに設定された主キー)あるいは第2引数で渡したカラムで検索し、残りの値を更新します。上記のQuery\Builderの例の場合は、以下のようなSQLにコンパイルされます。 UPDATE `users` SET `name` = ELT(FIELD(`id`, ?, ?, ?), ?, ?, ?), `email` = ? WHERE `email_verified_at` IS NULL AND `id` IN (?, ?, ?) emailは全行で同じとなっているため、ELT(), FIELD()は使わない形にします。また、updateBulk()以前に追加したWHERE句も別途設定されます。 さらに、Eloquent\Builder経由の場合はupdated_at等も自動で設定されます。 Query\Builder経由の実行時に、主キーがid以外の場合、あるいはEloquent\Builderで、主キー以外で検索したい場合は、第2引数にカラム名を指定します。 DB::table('users')->updateBulk([ ['name' => 'admin1', 'email' => 'admin1@example.com'], ['name' => 'admin2', 'email' => 'admin2@example.com'], ['name' => 'admin3', 'email' => 'admin3@example.com'], ], 'email'); この記事のライセンス この文書はCC BY(クリエイティブ・コモンズ表示4.0国際ライセンス)で公開します。
- 投稿日:2021-08-09T00:32:25+09:00
データベースで作成したビューをLaravelに取り込む
はじめに MySQLで作成したビューをLaravelに取り込む方法についてメモ感覚で。 MySQLで作成したビューのモデルを作成する。 ORM(Eloquent)を用いてレコードを取得する。 開発環境 ・Laravel 7.30.4 ・MySQL 5.7.32 プロジェクト作成 テキトーにプロジェクトを作成します。 composer create-project --prefer-dist "laravel/laravel=7.*.*" hoge_app .envファイルの変更 DBの設定を行います。 使用するDB名はテキトーに作ってください。 .env DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=world DB_USERNAME=########## DB_PASSWORD=########## .envファイルの設定が終わったらプロジェクトのディレクトリに移動して、下記コマンドを実行してください。 php artisan config:cache これを実行しないと変更内容が反映されないので必ずするように… 疎通確認(マイグレーションの実行) 下記のコマンドを実行して.envで指定したDB上にテーブルが作成出来ていれば、疎通確認はOKです。 php artisan migrate 失敗したらユーザ名やパスワードが間違っている可能性があるので再度ご確認を… モデルの作成 私は「v_countryinformation」というビューを作成しました。 ちなみにレコードはこんな感じです。 ビューの作成が終わったら、モデル作成を行うコマンドを実行します。 下記のコマンド実行後、Appディレクトリに「モデル名.php」というファイル(今回はCountryInfo.php)が作成されます。 php artisan make:model CountryInfo[=モデル名] 作成されたモデルクラスに以下のプロパティを追加し、参照するビューを指定してください。 CountryInfo.php <?php namespace App; use Illuminate\Database\Eloquent\Model; class CountryInfo extends Model { /** * * モデルと関連するビューを指定する */ protected $table = 'v_countryinformation'; } 備考:テーブル名とモデル名の規則性 モデルに対してどのテーブルを使用するかを$tableで指定しなければ、自動的にクラス名の複数形のテーブルを参照します。 例えば、Personモデルが参照するテーブルはPersonの複数系であるPeopleテーブルとなります。 Eloquentを使ってビューのレコードを取得 今回はtinkerで取得出来ているかの確認をするので、下記のコマンドからtinkerを起動します。 php artisan tinker 起動できたらEloquentを使用してレコードを取得します。 App\CountryInfo::where('Language', 'English')->first(); => App\CountryInfo {#3351 Code: "ABW", CountryName: "Aruba", CountryPopulation: 103000, Code2: "AW", Capital: 129, CityName: "Oranjestad", CityPopulation: 29034, Language: "English", IsOfficial: "F", Continent: "North America", Region: "Caribbean", } 上手く取得出来たようですね。 上記の結果は次のクエリの結果と同等です。 SELECT * FROM v_countryinformation WHERE Language = 'English' LIMIT 1 また、当然ですがクエリビルダを使っても取得出来ます。 DB::table('v_countryinformation')->where('Language', 'English')->first(); => {#3366 +"Code": "ABW", +"CountryName": "Aruba", +"CountryPopulation": 103000, +"Code2": "AW", +"Capital": 129, +"CityName": "Oranjestad", +"CityPopulation": 29034, +"Language": "English", +"IsOfficial": "F", +"Continent": "North America", +"Region": "Caribbean", } まとめ ダラダラと書きましたが、要するにモデルクラスの$tableプロパティにビューを指定すれば良いということです。 クエリビルダにはテーブルを結合するjoinメソッドもあるので、わざわざビューを作る必要も無い気がしますが、個人的にはプログラム内でテーブル結合させるよりはビュー内でそれを行い、そのビューに対してフィルターをかけた方が少ないコードの量でデータを取得できるので良いなと思いました。 参考