今回のカスタマイズ内容。
- 管理者(admin)ログインを追加
- これまで作成してきたbakeによるデータの登録更新機能は実際には管理者のようなユーザーがログインした後の画面で使えることが望ましい
- bakeで生成されるソースは/adminのプレフィックスルーティング以下に移動
- bakeするときに–prefix=Adminを追加するだけ
- 管理者自体をbakeするタスクを作成
- 新規の入力項目としてファイルのアップロードを追加
管理者テーブルを作成
以下のテーブルを作成しテーブルクラスとかエンティティクラスをbakeしました。
CREATE TABLE `admins` ( `id` int(11) NOT NULL AUTO_INCREMENT, `mail` varchar(255) NOT NULL, `password` varchar(255) NOT NULL, `created` datetime DEFAULT NULL, `modified` datetime DEFAULT NULL, `delete_flag` char(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
ログイン機能を作成
Authコンポーネントを使用した標準的なログイン機能を追加しました。
パスワードハッシャはデフォルトのものではなく別途用意しました。暗号化/複合化の処理を後述するbakeタスククラスでも使用するためどこからでも呼び出せるようfunctions.phpに定義し、その関数を参照するようにしています。
Router::scope('/', function (RouteBuilder $routes) { /** * Here, we are connecting '/' (base path) to a controller called 'Pages', * its action called 'display', and we pass a param to select the view file * to use (in this case, src/Template/Pages/home.ctp)... */ - $routes->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']); + $routes->redirect('/', ['controller' => 'Auth', 'action' => 'login', 'prefix' => 'admin']); + /** + * 管理者権限 + */ + Router::prefix('admin', function (RouteBuilder $routes) { + $routes->fallbacks(DashedRoute::class); + }); ~~省略~~ });
<?php namespace App\Controller\Admin; use Cake\Event\Event; use Cake\ORM\TableRegistry; /** * Auth Controller * * @property \App\Model\Table\AdminsTable $Admins */ class AuthController extends AppController { /** * * {@inheritDoc} * @see \Cake\Controller\Controller::beforeFilter() */ public function beforeFilter(Event $event) { parent::beforeFilter($event); $this->Auth->allow(['logout']); $this->viewBuilder()->setLayout(false); } /** * ログイン */ public function login() { if ($this->request->is('post')) { $user = $this->Auth->identify(); if ($user) { $this->request->getSession()->destroy(); $this->Auth->setUser($user); return $this->redirect($this->Auth->redirectUrl()); } $this->Flash->error('ログインIDかパスワードが正しくありません。'); } } /** * ログアウト * @return \Cake\Http\Response|NULL */ public function logout() { return $this->redirect($this->Auth->logout()); } }
<?php namespace App\Controller\Admin; use Cake\Event\Event; class AppController extends \App\Controller\AppController { /** * * {@inheritDoc} * @see \Cake\Controller\Controller::beforeFilter() */ public function beforeFilter(Event $event) { parent::beforeFilter($event); } public function initialize() { parent::initialize(); $this->viewBuilder()->setLayout('default_admin'); $this->loadComponent('Auth', [ // 認証設定 'authenticate' => [ 'Form' => [ 'userModel' => 'Admins', 'fields' => [ 'username' => 'mail', 'password' => 'password' ], 'finder' => 'auth', 'passwordHasher' => [ 'className' => 'Ex' ], ], ], // ログイン画面 'loginAction' => [ 'controller' => 'Auth', 'action' => 'login', 'prefix' => 'admin', ], // ログイン後のリダイレクト先 'loginRedirect' => [ 'controller' => 'Top', 'action' => 'index', 'prefix' => 'admin', ], // ログアウト後のリダイレクト先 'logoutRedirect' => [ 'controller' => 'Auth', 'action' => 'login', 'prefix' => 'admin', ], // sessionストレージ設定 'storage' => [ 'className' => 'Session', 'key' => 'Auth.Admin' ], // 許可されていないアクセスがあったときのエラーメッセージ 'authError' => 'ログインでエラーが発生しました', ]); } }
<?php /** * @var \App\View\AppView $this */ $this->assign('title', "ログイン"); ?> <!DOCTYPE html> <html> <head> <?= $this->Html->charset() ?> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><?= $this->fetch('title') ?></title> <?= $this->Html->meta('icon') ?> <?= $this->Html->css('//use.fontawesome.com/releases/v5.8.2/css/all.css') ?> <?= $this->Html->css('bootstrap.min.css') ?> <?= $this->Html->css('adminlte.min.css') ?> <?= $this->Html->css('admin_style.css') ?> <?= $this->fetch('meta') ?> <?= $this->fetch('css') ?> <?= $this->fetch('script') ?> </head> <body class="hold-transition login-page"> <div class="login-box"> <div class="login-logo"> <b>MyCakePHP3Init</b> Login </div> <div class="card"> <div class="card-body login-card-body"> <?= $this->Flash->render() ?> <?= $this->Form->create(null) ?> <div class="form-group has-feedback"> <?= $this->Form->control('mail', ['id' => 'login-mail', 'class' => 'form-control rounded-0', 'label' => 'ログインID']) ?> </div> <div class="form-group has-feedback"> <?= $this->Form->control('password', ['id' => 'login-password', 'class' => 'form-control rounded-0', 'label' => 'パスワード']) ?> </div> <div class="row"> <div class="col-12"> <button type="submit" class="btn btn-primary btn-block btn-flat">Sign In</button> </div> <!-- /.col --> </div> <?= $this->Form->end() ?> </div> <!-- /.login-card-body --> </div> </div> <?= $this->Html->script('jquery-3.4.1.min.js') ?> <?= $this->Html->script('bootstrap.bundle.min.js') ?> <?= $this->Html->script('adminlte.min.js') ?> </body> </html>
ログイン画面は以下のような感じ。
管理者を作成するタスククラスを作成
–mailでメールアドレス、–passwordでパスワードをパラメータとして取得し、管理者情報を生成しています。–initializeをパラメータに含めたbakeを行ったとき、管理者のテーブルをトランケートし、暗号化に使用するセキュリティソルトを更新するようにしています。
タスククラスが受け取るパラメータはgetOptionParserという関数で弄れる模様。あまり調べてませんがだいたい以下のような感じで目的とした動作はしてました(^q^
<?php namespace App\Shell\Task; use Cake\Console\Shell; use Cake\ORM\Table; use Cake\Validation\Validation; use Cake\Core\Configure; use Cake\ORM\TableRegistry; use Cake\Datasource\ConnectionManager; use Cake\Console\ConsoleOptionParser; /** * 管理者テーブルに関するBakeタスク * オプション * --initialize 任意 * --mail=[mail] 必須 * --pass=[pass] 任意 * * 例) bake create_admin --initialize --mail=test@imo-tikuwa.com --pass=asdf1234 * @author tikuwa * * @property \App\Model\Table\AdminsTable $Admins * */ class CreateAdminTask extends BakeTask { ~~~main関数とかパラメータの入力値チェックとか省略~~~ /** * Bake処理 * @return void */ public function bake($initialize = false, $mail = null, $password = null) { // 初期化処理 if ($initialize === true) { $new_salt = create_random_str(16); Configure::write('AdminConfig.CakeEncryptionSalt', $new_salt); // ファイル生成 $out = "<?php" . PHP_EOL; $out .= "return [" . PHP_EOL; $out .= " 'AdminConfig' => [" . PHP_EOL; $out .= " 'CakeEncryptionSalt' => '" . $new_salt . "'," . PHP_EOL; $out .= " ]," . PHP_EOL; $out .= "];" . PHP_EOL; // ファイル出力 $this->out('Baking admin_config', 1, Shell::QUIET); $this->createFile(BAKED_ADMIN_CONFIG_FILE, $out); // トランケート $connection = ConnectionManager::get('default'); $connection->execute("TRUNCATE TABLE admins"); } // 会員作成 if (is_null($password)) { $password = create_random_str(8); $this->out($mail . '\'s password : ' . $password, 1, Shell::NORMAL); } $new_admin = $this->Admins->patchEntity($this->Admins->newEntity(), [ 'mail' => $mail, 'password' => encrypt_password($password), ]); $this->Admins->save($new_admin); $this->out('CreateAdminTask complete', 1, Shell::CODE_SUCCESS); return; } /** * Gets the option parser instance and configures it. * * @return \Cake\Console\ConsoleOptionParser */ public function getOptionParser() { $parser = new ConsoleOptionParser(); $parser->setDescription( 'Bake admin_config classes.' )->addOption('initialize', [ 'boolean' => true, 'help' => 'Execute truncate admins and update security salt.' ])->addOption('mail', [ 'help' => 'Input Admin Account ID.' ])->addOption('password', [ 'help' => 'Input Admin Account Password.' ])->setEpilog( 'Omitting all arguments and options will list the table names you can generate models for' ); return $parser; } }
<?php return [ 'AdminConfig' => [ 'CakeEncryptionSalt' => '1234567890abcdef', ], ];
新規の入力項目としてファイルのアップロードを追加
bootstrap4に対応したbootstrap fileinputというjqueryプラグインを使用しました。
英語のドキュメント読むのが大変でした。
Bootstrap File Input – © Kartik
初期表示の際のinitialPreviewのあたりはなんとなーくのレベルでしか理解できてません。もしかしたら不要なオプションの指定とかあるかも。画像以外のファイルをアップロードすることも考えられるのでエクスプローラー風のテーマの方を使用してみました。
日本語の翻訳ファイルがデフォルトで入っているのが有難かった。
参考:Bootstrap File Input Krajee Explorer Theme Demo – © Kartik
以下は私が行った修正の抜粋です。たくさん修正をプッシュしたのでいろいろ漏れてそう。
CREATE TABLE `estates` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID', `name` varchar(20) NOT NULL COMMENT '物件名', `zip1` char(3) NOT NULL COMMENT '郵便番号1', `zip2` char(4) NOT NULL COMMENT '郵便番号2', `pref` varchar(10) NOT NULL COMMENT '都道府県', `city` varchar(100) NOT NULL COMMENT '市区町村', `address1` varchar(255) NOT NULL COMMENT '住所1', `address2` varchar(255) NOT NULL COMMENT '住所2', `tikunen` char(2) NOT NULL COMMENT '築年数', `menseki` int DEFAULT NULL COMMENT '専有面積', `limit_date` date DEFAULT NULL COMMENT '公開期限', `memo` text NOT NULL COMMENT 'メモ', + `pdf` json DEFAULT NULL COMMENT 'PDFファイル', + `image` json DEFAULT NULL COMMENT '画像(最大5ファイル)', `created` datetime DEFAULT NULL COMMENT '作成日時', `modified` datetime DEFAULT NULL COMMENT '更新日時', `delete_flag` char(1) NOT NULL DEFAULT '0' COMMENT '削除フラグ', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='物件';
<?php return [ 'function_title' => '物件', 'columns' => [ ~~~省略~~~ + 'pdf' => [ + 'search' => false, + 'listview' => false, + 'label' => 'PDFファイル', + 'col_md_size' => 6, + 'col_sm_size' => 12, + 'input_type' => 'file', + 'max_file_num' => 1, + 'allow_file_extensions' => [ + 'pdf', + ], + ], + 'image' => [ + 'search' => false, + 'listview' => true, + 'label' => '画像(最大5ファイル)', + 'col_md_size' => 6, + 'col_sm_size' => 12, + 'input_type' => 'file', + 'max_file_num' => 5, + 'allow_file_extensions' => [ + 'jpg', + 'gif', + 'png', + ], + ], ], ];
<?php /** * @var \App\View\AppView $this * @var \App\Model\Entity\User[]|\Cake\Collection\CollectionInterface $users */ ?> <!DOCTYPE html> <html> <head> <?= $this->Html->charset() ?> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><?= $this->fetch('title') ?></title> <?= $this->Html->meta('icon') ?> <?= $this->Html->css('//use.fontawesome.com/releases/v5.8.2/css/all.css') ?> <?= $this->Html->css('bootstrap.min.css') ?> <?= $this->Html->css('adminlte.min.css') ?> <?= $this->Html->css('select2.min.css') ?> <?= $this->Html->css('select2-bootstrap4.min.css') ?> <?= $this->Html->css('bootstrap-material-datetimepicker.min.css') ?> <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> + <?= $this->Html->css('/components/bootstrap-fileinput-master/css/fileinput.min.css') ?> + <?= $this->Html->css('/components/bootstrap-fileinput-master/themes/explorer-fas/theme.min.css') ?> <?= $this->Html->css('admin_style.css') ?> <?= $this->fetch('meta') ?> <?= $this->fetch('css') ?> </head> <body class="hold-transition sidebar-mini layout-fixed sidebar-collapse"> <div class="wrapper"> ~~~省略~~~ </div> <?= $this->Html->script('jquery-3.4.1.min.js') ?> <?= $this->Html->script('bootstrap.bundle.min.js') ?> <?= $this->Html->script('adminlte.min.js') ?> <?= $this->Html->script('select2.min.js') ?> <?= $this->Html->script('moment-with-locales.js') ?> <?= $this->Html->script('bootstrap-material-datetimepicker.min.js') ?> +<?= $this->Html->script('/components/bootstrap-fileinput-master/js/plugins/piexif.min.js') ?> +<?= $this->Html->script('/components/bootstrap-fileinput-master/js/plugins/purify.min.js') ?> +<?= $this->Html->script('/components/bootstrap-fileinput-master/js/plugins/sortable.min.js') ?> +<?= $this->Html->script('/components/bootstrap-fileinput-master/js/fileinput.min.js') ?> +<?= $this->Html->script('/components/bootstrap-fileinput-master/themes/explorer-fas/theme.min.js') ?> +<?= $this->Html->script('/components/bootstrap-fileinput-master/js/locales/ja.js') ?> <?= $this->Html->script('adminlte_layout_customize.js') ?> <?= $this->Html->script('admin_script.js') ?> <?= $this->fetch('script') ?> </body> </html>
<div class="col-md-6 col-sm-12"> <div class="form-group"> <?= $this->Form->control('image_file', ['type' => 'file', 'id' => 'image-file-input', 'label' => '画像(最大5ファイル)', 'multiple']); ?> <?= $this->Form->hidden('image', ['id' => 'image-file-hidden', 'value' => !empty($estate['image']) ? json_encode($estate['image']) : '']); ?> </div> </div> <?= $this->Html->scriptStart(['block' => true, 'type' => 'text/javascript']) ?> var csrf_token = $("input[name='_csrfToken']").val(); $("#image-file-input").fileinput({ language: "ja", theme: "explorer-fas", maxFileSize: 264000, uploadUrl: "/admin/estates/fileUpload/image_file", uploadAsync: true, showUpload: true, showClose: false, showRemove: false, dropZoneEnabled: false, removeFromPreviewOnError: true, uploadExtraData: { "_csrfToken":csrf_token, }, deleteExtraData: { "_csrfToken":csrf_token, }, overwriteInitial: false, initialPreview: [ <?php if (!empty($estate['image'])) foreach ($estate['image'] as $each_data) { ?> "<?= "/<?= UPLOAD_FILE_BASE_DIR_NAME ?>/estates/{$each_data['cur_name']}"?>", <?php }?> ], initialPreviewAsData: true, initialPreviewConfig: [ <?php if (!empty($estate['image'])) foreach ($estate['image'] as $each_data) { ?> { caption: "<?= $each_data['org_name']; ?>", size: <?= $each_data['size']; ?>, url: "<?= $each_data['delete_url']; ?>", key: "<?= $each_data['key']; ?>", downloadUrl: "/<?= UPLOAD_FILE_BASE_DIR_NAME ?>/estates/<?= $each_data['key']; ?>", }, <?php }?> ], preferIconicPreview: true, previewFileIconSettings: { 'doc': '<i class="fas fa-file-word text-primary"></i>', 'xls': '<i class="fas fa-file-excel text-success"></i>', 'ppt': '<i class="fas fa-file-powerpoint text-danger"></i>', 'pdf': '<i class="fas fa-file-pdf text-danger"></i>', 'zip': '<i class="fas fa-file-archive text-muted"></i>', 'htm': '<i class="fas fa-file-code text-info"></i>', 'txt': '<i class="fas fa-file-text text-info"></i>', 'mov': '<i class="fas fa-file-video text-warning"></i>', 'mp3': '<i class="fas fa-file-audio text-warning"></i>', 'jpg': '<i class="fas fa-file-image text-danger"></i>', 'gif': '<i class="fas fa-file-image text-muted"></i>', 'png': '<i class="fas fa-file-image text-primary"></i>' }, previewFileExtSettings: { 'doc': function(ext) { return ext.match(/(doc|docx)$/i); }, 'xls': function(ext) { return ext.match(/(xls|xlsx)$/i); }, 'ppt': function(ext) { return ext.match(/(ppt|pptx)$/i); }, 'zip': function(ext) { return ext.match(/(zip|rar|tar|gzip|gz|7z)$/i); }, 'htm': function(ext) { return ext.match(/(htm|html)$/i); }, 'txt': function(ext) { return ext.match(/(txt|ini|csv|java|php|js|css)$/i); }, 'mov': function(ext) { return ext.match(/(avi|mpg|mkv|mov|mp4|3gp|webm|wmv)$/i); }, 'mp3': function(ext) { return ext.match(/(mp3|wav)$/i); }, }, allowedFileExtensions: [ 'jpg', 'gif', 'png', ], maxFileCount: 5, maxTotalFileCount: 5, validateInitialCount: true, }).on('fileuploaded', function(e, params) { let file_data = ($('#image-file-hidden').val() != '') ? JSON.parse($('#image-file-hidden').val()) : []; file_data[file_data.length] = { org_name: params.response.org_name, cur_name: params.response.cur_name, size: params.response.size, delete_url: params.response.delete_url, key: params.response.key, }; $('#image-file-hidden').val(JSON.stringify(file_data)); }).on('filedeleted', function(e, key, jqXHR, data) { let file_data = ($('#image-file-hidden').val() != '') ? JSON.parse($('#image-file-hidden').val()) : []; let delete_index = file_data.findIndex((v) => v.cur_name === key); if (delete_index > -1) { file_data.splice(delete_index, 1); } $('#image-file-hidden').val(JSON.stringify(file_data)); }).on('filesorted', function(e, params) { $('#image-file-hidden').val(JSON.stringify(params.stack)); }); <?= $this->Html->scriptEnd() ?>
<?php class EstatesTable extends AppTable { + /** + * patchEntityのオーバーライド + * ファイル項目のJSON文字列を配列に変換する + * {@inheritDoc} + * @see \Cake\ORM\Table::patchEntity() + */ + public function patchEntity(EntityInterface $entity, array $data, array $options = []) + { + if (isset($data['pdf']) && !empty($data['pdf']) && is_string($data['pdf'])) { + $data['pdf'] = json_decode($data['pdf'], true); + } + if (isset($data['image']) && !empty($data['image']) && is_string($data['image'])) { + $data['image'] = json_decode($data['image'], true); + } + return parent::patchEntity($entity, $data, $options); + } }
<?php namespace App\Controller; use Cake\Controller\Controller; use Cake\Event\Event; use Cake\Utility\Inflector; use Cake\Core\Exception\Exception; /** * Application Controller * * Add your application-wide methods in the class below, your controllers * will inherit them. * * @link https://book.cakephp.org/3.0/en/controllers.html#the-app-controller */ class AppController extends Controller { ~~~省略~~~ /** * Ajaxファイルアップロード処理 * @param unknown $input_name */ public function fileUpload($input_name = null) { $error = null; $response_data = []; try { $this->viewBuilder()->setLayout(false); $this->autoRender = false; ~~~汚ないアップロード処理なので省略~~~ $response_data['initialPreview'] = [ $url . "/" . UPLOAD_FILE_BASE_DIR_NAME . "/" . Inflector::underscore($this->name) . "/" . $cur_name, ]; $response_data['initialPreviewConfig'][] = [ // ←ここ 'caption' => $file['name'], 'size' => $file['size'], 'url' => $delete_action, 'key' => $cur_name, ]; $response_data['append'] = true; // 以下はhiddenフォームのJSON文字列用 $response_data['org_name'] = $file['name']; $response_data['cur_name'] = $cur_name; $response_data['size'] = $file['size']; $response_data['delete_url'] = $delete_action; $response_data['key'] = $cur_name; } catch (\Exception $e) { $this->log($e->getMessage()); $error = $e->getMessage(); } if (!empty($error)) { $response_data['error'] = $error; } echo json_encode($response_data); return; } /** * Ajaxファイル削除処理 * * @param unknown $input_name * @throws Exception */ public function fileDelete($input_name = null) { $error = null; $response_data = []; try { $this->viewBuilder()->setLayout(false); $this->autoRender = false; ~~~省略~~~ $response_data['status'] = true; // ←適当なステータスを返す } catch (\Exception $e) { $this->log($e->getMessage()); $error = $e->getMessage(); } if (!empty($error)) { $response_data['error'] = $error; } echo json_encode($response_data); return; } }
上記のソースはbakeされたもので実際にはedit.twigやtable.twigを修正しています。
サーバーサイドのアップロード処理はAppController内に作成しました。
出来上がった画面は以下のような感じ。
MySQLのJSON型は初めて使いましたがフレームワークを使ってるからなのかあまり意識せずに使えました。MySQL Workbenchで中身を確認するとjsonエンコードされた風な文字列が入ってました。
CakePHPでデータを取得した直後のデータをdebugしたところすでにプログラムで扱いやすい配列の形になってました。
データ登録の際はjsonエンコードせずに配列のまま渡してあげたところ登録されました。
サーバーサイドのレスポンスのinitialPreviewConfigの返し方が間違ってることに気づかなくて1日近く動かなかったりしましたが、何とか解決できた( ・`ー・´)
メモ
今回追加した画像アップロードのプラグインとか本当はcomposerに含めるべきなんだと思う。
次回はGoogleMapでクリックした緯度経度を取得して保存するような入力項目を追加する。