A friend of mine asked for a custom website, so here I am, writing a custom cms. I know, there are plenty of systems out there that would handle his needs, but it’s also a good excuse to play around with CakePHP 3, so here we are.

For the lazy, the codebase we’ll be working on will be available on GitHub. I will be pushing each set of changes on the date when each blog post in this series is published. No cheating!

Managing a User’s account

Now that we can login, we’ll probably want to be able to update our profile without needing to go through the reset password flow. For that, we’ll need a account page. I’d also love to be able to personalize the account so that the user will feel at home in his CMS, so we’ll allow them to upload a custom image as well. We’ll start on account management first. First, lets start by making the UsersController::edit() action open to all authenticated users by modifying our UsersController::isAuthorized() method:

/** * Check if the provided user is authorized for the request. * * @param array|\ArrayAccess|null $user The user to check the authorization of. * If empty the user fetched from storage will be used. * @return bool True if $user is authorized, otherwise false */ public function isAuthorized($user = null) { if (in_array($this->request->param('action'), ['edit', 'logout'])) { return true; } return parent::isAuthorized($user); }

Next, lets go to the /users/edit page in our browser. You should get a NotFoundException . This is because the UsersController::edit() action is currently mapped to the Crud.Edit action class in your AppController::initialize() , and that action class expects a user id to be passed in. We can fix that and force the edit page to always map to the currently logged in user by handling the beforeHandle Crud event in our UsersListener . First, lets add the following to the list of events handled in our UsersListener::implementedEvents() method:

'Crud.beforeHandle' => 'beforeHandle',

Next, we’ll need to implement the UsersListener::beforeHandle() method. As the beforeHandle event occurs for all executed Crud actions, we’ll need to take extra care to only set the action arguments when the current action is the edit action.

/** * Before Handle * * @param \Cake\Event\Event $event Event * @return void */ public function beforeHandle(Event $event) { if ($event->subject->action === 'edit') { $this->beforeHandleEdit($event); return; } } /** * Before Handle Edit Action * * @param \Cake\Event\Event $event Event * @return void */ public function beforeHandleEdit(Event $event) { $userId = $this->_controller()->Auth->user('id'); $event->subject->args = [$userId]; }

Browse to the /users/edit page now and you’ll see a lovely form with our current user’s information filled out. Yay! Unfortunately, it leaks the existing password, which isn’t great. Honestly, I think we should clean up this form a bit:

The password field should not have the pre-hashed password set

The password field should only be changed when the password is confirmed

The avatar_dir field shouldn’t be shown on the form

field shouldn’t be shown on the form The avatar field is actually a form upload.

Let’s take care of the first three tasks. We’ll start by adding an event handler to remove the hashed password during the Crud.beforeRender event. Add the following to your UsersListener::implementedEvents() method:

'Crud.beforeRender' => 'beforeRender',

Next, we’ll handle the event in the same UsersListener class and unset the password property on the Crud-produced entity:

/** * Before Render * * @param \Cake\Event\Event $event Event * @return void */ public function beforeRender(Event $event) { if ($this->_controller()->request->action === 'edit') { $this->beforeRenderEdit($event); return; } } /** * Before Render Edit Action * * @param \Cake\Event\Event $event Event * @return void */ public function beforeRenderEdit(Event $event) { $event->subject->entity->unsetProperty('password'); }

If you refresh the /users/edit page, you should see that the hashed password was removed. Now that this is set, we’ll need tomodify the edit form. We previously baked this on the first day of development, so you should have a src/Template/Users/edit.ctp file. We’ll edit the form section to show the following for now (ignore the sidebar section!):

<div class="users form large-9 medium-8 columns content"> <?= $this->Form->create($user) ?> <fieldset> <legend><?= __('Edit User') ?></legend> <?php echo $this->Form->input('email'); echo $this->Form->input('password', ['required' => false]); echo $this->Form->input('confirm_password'); echo $this->Form->input('avatar'); ?> </fieldset> <?= $this->Form->button(__('Submit')) ?> <?= $this->Form->end() ?> </div>

The above adds a confirm_password field and also removes the avatar_dir field. Finally, add password confirmation, and only save the updated password if it matches the confirm_password field and both have a value. We’ll create a custom validation method - validationAccount - to handle this. Place the following within a trait at src/Model/Table/Traits/AccountValidationTrait.php :

<?php namespace App\Model\Table\Traits; use Cake\Validation\Validator; trait AccountValidationTrait { /** * Account validation rules. * * @param \Cake\Validation\Validator $validator Validator instance. * @return \Cake\Validation\Validator */ public function validationAccount(Validator $validator) { $validator = $this->validationDefault($validator); $validator->remove('password'); $validator->allowEmpty('confirm_password'); $validator->add('confirm_password', 'no-misspelling', [ 'rule' => ['compareWith', 'password'], 'message' => 'Passwords are not equal', ]); return $validator; } }

I really love traits. Sorry not sorry?

In this custom validation rule, we inherit from the default rules - defined in the UsersTable::validationDefault() method - remove the rules that require a password to be set, and add a rule that requires the password and confirm_password fields to match.

Next, we’ll need to add the proper use statement to the inside of our UsersTable class.

use \App\Model\Table\Traits\AccountValidationTrait;

To ensure that our custom validation method is actually invoked, we’ll need to modify the UsersListener::beforeHandleEdit() to tell the Edit action class to use it. Here is what I added to that method:

$this->_controller()->Crud->action()->saveOptions(['validate' => 'account']);

One thing to note is that we never want to update the password when no password has been set. The Edit action class doesn’t currently provide an event to directly edit event data, but we still have two options:

If no password / confirm_password is set at the time of the beforeHandle event, we can just unset it from the request.

/ is set at the time of the event, we can just unset it from the request. If no password / confirm_password is set at the time of the beforeSave event, we can mark the password field as not dirty, and it won’t be overwritten.

I prefer the latter, because I don’t like screwing around with the incoming request data. Where you perform the scrubbing is up to you. If you do as I do, you’ll have to check if confirm_password is empty instead of password . This is because at the beforeSave event, the data has already been set upon the entity, and an empty string has been hashed by the User::_setPassword() method. The confirm_password field will only be empty if both are empty, otherwise we wouldn’t even have gotten to the save phase.

I’ll add the following to handle my event to UsersListener::implementedEvents() :

'Crud.beforeSave' => 'beforeSave',

And here are the methods to add to the UsersListener :

/** * Before Save * * @param \Cake\Event\Event $event Event * @return void */ public function beforeSave(Event $event) { if ($this->_controller()->request->action === 'edit') { $this->beforeSaveEdit($event); return; } } /** * Before Render Edit Action * * @param \Cake\Event\Event $event Event * @return void */ public function beforeSaveEdit(Event $event) { if ($event->subject->entity->confirm_password === '') { $event->subject->entity->unsetProperty('password'); $event->subject->entity->dirty('password', false); } }

Woot! Very close. If you try to submit the form now, you will probably get a validation error - if your browser even lets you submit. Why? The avatar field is empty. Even though we’ve set it to allow null values, we need to remove the validation rules surrounding them in our UsersTable::validationDefault() method. Remove the rules regarding avatar and avatar_dir , and you should be off to the races.

Let’s save our position now.

git add src/Controller/UsersController.php src/Listener/UsersListener.php src/Model/Table/Traits/AccountValidationTrait.php src/Model/Table/UsersTable.php src/Template/Users/edit.ctp git commit -m "Implement initial account management, including password changing"

Setting an image avatar

While image uploading isn’t baked into cake - lol - by default, I’ve included my Upload plugin with the composer app skeleton we used to create the calico app. If you don’t have it installed, you’ll want to install it.

# install the plugin composer require josegonzalez/cakephp-upload # load it in your app bin/cake plugin load Josegonzalez/Upload

You are welcome and encouraged to try other plugins that might better suit your needs. I wrote mine and like mine, but maybe you prefer a different one.

Next, we’ll need to modify our UsersTable::initialize() method to add the behavior for our avatar and avatar_dir fields:

$this->addBehavior('Josegonzalez/Upload.Upload', [ 'avatar' => [ 'fields' => [ 'dir' => 'avatar_dir', ], ], ]);

Next, we’ll need to modify our form to show the correct input type for the avatar field. I’m also going to conditionally show the avatar on the page so we know what it looks like when it has been uploaded. This is what the form section of the edit.ctp should look like:

<div class="users form large-9 medium-8 columns content"> <?= $this->Form->create($user, ['type' => 'file']) ?> <fieldset> <legend><?= __('Edit User') ?></legend> <?php echo $this->Form->input('email'); echo $this->Form->input('password', ['required' => false]); echo $this->Form->input('confirm_password'); echo $this->Form->input('avatar', ['type' => 'file']); if (!empty($user->avatar)) { $imageUrl = '../' . preg_replace("/^webroot/", "", $user->avatar_dir) . '/' . $user->avatar; echo $this->Html->image($imageUrl, [ 'height' => '100', 'width' => '100', ]); } ?> </fieldset> <?= $this->Form->button(__('Submit')) ?> <?= $this->Form->end() ?> </div>

If you try it out now, you should get a working image upload. Here is what the form looks like for me after an avatar upload:

My cat looks handsome, doesn’t she?

Before closing out image uploads, we’ll want to ignore the webroot/files directory in our .gitignore . If we do not, we’ll end up accidentally committing uploaded files. Please ensure the following line is in your .gitignore :

/webroot/files

Lets commit all our changes as well.

git add .gitignore src/Model/Table/UsersTable.php src/Template/Users/edit.ctp git commit -m "Enable avatar uploads"

Validating image uploads

The following are only some of the things you can do to validate that images uploaded are, in fact, images. I would recommend you also: resize the images to remove extra metadata that you may not wish to show

only display images that have been sanitized

use the metascan tool to verify the validity of uploads before referencing them on your site. This list is also by no means exhaustive, and as security is an important subject, I defer to the experts. Please keep this in mind!

Before allowing just any file uploads, lets be sure that they are indeed images. I’d also like to ensure we’re not allowing a save to occur when the image upload fails for whatever reason. This will ensure we surface the errors to the users before the UploadBehavior gets to it. The following should be added to your AccountValidationTrait::validationAccount() method:

$validator->allowEmpty('avatar'); $validator->add('avatar', 'valid-image', [ 'rule' => ['uploadedFile', [ 'types' => [ 'image/bmp', 'image/gif', 'image/jpeg', 'image/pjpeg', 'image/png', 'image/vnd.microsoft.icon', 'image/x-windows-bmp', 'image/x-icon', 'image/x-png', ], 'optional' => true, ]], 'message' => 'The uploaded avatar was not a valid image' ]); $validator->add('avatar', 'not-upload-error', [ 'rule' => ['uploadError', true], 'message' => 'There was an error uploading your avatar', ]);

We’re allowing the avatar field to be empty. If you don’t do this, you’re going to see errors when saving the form without an uploaded avatar.

We’re only allowing valid images to be uploaded. Hell, our user can even upload an icon as his avatar if they want.

We want to make sure that there are no upload errors. Note that not uploading a file should not be considered an error. PHP will report it as such, and if we want to allow no files to be uploaded, we have to pass true as the first option to the uploadError rule.

The above validation rules are included with CakePHP, but you can also use custom rules - such as file and image size limiting - that are available from the Upload plugin. Documentation for that is available here.

Now that we’ve validated our image uploads, lets save our changes to the git repository.

git add src/Model/Table/Traits/AccountValidationTrait.php git commit -m "Ensure avatar uploads are actually images"

For those that may just want to ensure their codebase matches what has been done so far, the codebase is available on GitHub and tagged as 0.0.4.

Our app now has proper image uploading and account management. We’ve learned a few new tricks regarding the Crud plugin event system, added advanced validation rules for managing our account, and even showed off our avatar on the form. I think we’re more or less done with account management for now. Tomorrow, we’ll get into the nitty-gritty of our blog internals, beginning with the initial stages of our posts admin panel.

Be sure to follow along via twitter on @savant. If you’d like to subscribe to this blog, you may follow the rss feed here. Also, all posts in the series will be conveniently linked on the sidebar of every post in the 2016 CakeAdvent Calendar. Come back tomorrow for more delicious content.