KnpMenu - JSON Renderer to support Dojo Dijit/Tree

I wrote some code that parsed through the default KNP menu HTML and created a Dijit menu. It worked, but it was slow and awkward.

I couldn't find an easy way to get the KNP menu in the JSON form that would work well with Dijit/Tree, so I wrote my own.

JsonRenderer.php


<?php

namespace AppBundle\Menu;

use Knp\Menu\ItemInterface;
use Knp\Menu\Matcher\MatcherInterface;
use Knp\Menu\Renderer\RendererInterface;
use Translator;

class JsonRenderer implements RendererInterface
{

    /**
     * @var \Twig_Environment
     */
    private $environment;
    private $matcher;
    private $defaultOptions;

    /**
     * @param \Twig_Environment $environment
     * @param string            $template
     * @param MatcherInterface  $matcher
     * @param array             $defaultOptions
     */
    public function __construct( \Twig_Environment $environment, $template, MatcherInterface $matcher, array $defaultOptions = array() )
    {
        $this->environment = $environment;
        $this->matcher = $matcher;
        $this->defaultOptions = array_merge( array(
            'depth' => null,
            'matchingDepth' => null,
            'currentAsLink' => true,
            'currentClass' => 'current',
            'ancestorClass' => 'current_ancestor',
            'firstClass' => 'first',
            'lastClass' => 'last',
            'template' => $template,
            'compressed' => false,
            'allow_safe_labels' => false,
            'clear_matcher' => true,
            'leaf_class' => null,
            'branch_class' => null
                ), $defaultOptions );
   
    }

    public function render( ItemInterface $item, array $options = array() )
    {
        $options = array_merge( $this->defaultOptions, $options );
        
        $translator = $options['translator'];

        $itemIterator = new \Knp\Menu\Iterator\RecursiveItemIterator( $item );

        $iterator = new \RecursiveIteratorIterator( $itemIterator, \RecursiveIteratorIterator::SELF_FIRST );
        
        $items = [];
        foreach( $iterator as $item )
        {
            $translatedLabel = $translator->trans($item->getLabel());
            $id = $item->getName();
            $parentId = $item->getParent()->getName();
            $itemData = [ 'id' => strtolower( $item->getName() ), 'name' => $translatedLabel, 'uri' => $item->getUri()];
            if ($parentId !== $id) {
                $itemData['parent'] =strtolower($parentId);
            }
            $itemData['has_children'] = $item->hasChildren();
            $items[] = $itemData;
        }
        $lastItem = count( $items ) - 1;
        $items[$lastItem]['lastItem'] = true;

        $html = $this->environment->render( $options['template'], array('items' => $items, 'options' => $options, 'matcher' => $this->matcher) );

        if( $options['clear_matcher'] )
        {
            $this->matcher->clear();
        }
        return $html;
    }

}

Added JsonRenderer as a service in services.yml


    app.menu_renderer:
        # The class implements Knp\Menu\Renderer\RendererInterface
        class: AppBundle\Menu\JsonRenderer
        arguments: [ "@twig",  "knp_menu.html.twig", "@knp_menu.matcher", {"translator": "@translator" }]
        tags:
            # The alias is what is used to retrieve the menu
            - { name: knp_menu.renderer, alias: json }

I used a twig template (knp_menu.html.twig) to output the JSON, perhaps in the future I will update it to deliver it as a .js file.


var menuTreeStoreData = [{% for item in items%}{{item|json_encode()|raw}}{% if item.lastItem is not defined %},{%endif%}{% endfor %}];

The layout template calls the renderer with this line:


{{ knp_menu_render('admin',{'template': 'admin/parts/knp_menu.html.twig'}, 'json') }}

Finally, this is menu.js which uses the menu data produced by JsonRenderer to create a nice tree for a menu. You could use a different menu approach. I kind of like the tree for now.


define([
    "dojo/dom",
    "dojo/store/Memory",
    "dijit/tree/ObjectStoreModel",
    "dijit/Tree",
    "dojo/domReady!"
], function (dom,
        Memory, ObjectStoreModel, Tree) {
//"use strict";
    function run() {
        var store = new Memory({data: menuTreeStoreData
            ,
            getChildren: function (object) {
                return this.query({parent: object.id});
            }});
        var model = new ObjectStoreModel({
            store: store,
            query: {id: 'admin'}
        });
        // Create the Tree.
        var tree = new Tree({
            id: "admin-menu",
            model: model,
            persist: true,
            onClick: function (item) {
                if( typeof item.uri !== "undefined" && item.uri !== null ) {
                    location.href = item.uri;
                }
            },
            getIconClass: function (item, opened) {
                return (item && item.has_children) ? (opened ? "dijitFolderOpened" : "dijitFolderClosed") : "dijitLeaf"
            }
        });
        tree.placeAt(dom.byId("admin-left-menu"));
        tree.startup();
    }
    return {
        run: run
    };
});

Squirrel Mail - Change Password Plugin - Wicked Simple Version

It's a long sad story as to why I set up an email server. Mostly long. Arguably not sad, since it is collecting email.

However, lacking the usual accessories provided by a hosting company and having issued a collection of awful (secure) passwords, it was abundantly clear a password reset mechanism would be required.

This isn't really suitable for password recovery, because it relies on the person being able to log into Squirrel Mail to change their password. If they have already forgotten it - they're toast.

Thus - one must encourage the users to set the password to something they have a chance at typing correctly and maybe remembering.

Please note I'm not implying the users are incapable of handling a password. I issued passwords which would be difficult to type and virtually impossible to remember.

Here's the code ...

define('SM_PATH', '../../');
include_once(SM_PATH . 'include/validate.php');

// Make sure plugin is activated!
//
global $plugins;
if (!in_array('change_password', $plugins))
   exit;


global $color;
displayPageHeader($color, 'None');

sqgetGlobalVar('username',   $username,     SQ_SESSION);

define('MIN_PASSWORD',10);

$error = $message = null;
$dbConn = pg_connect('dbname=mail user=somebody password=supersecret');
if ($dbConn !== false) {
	if (!empty($_POST['current-password'])) {
		$currentPassword = preg_replace('/[^a-z0-9\_\@\#\$\%\^\&\*\(\)\+\?\>\<\;\:]/i','',$_POST['current-password']);
		$query = 'SELECT password FROM users WHERE userid = $1';
		$result = pg_query_params($query,[$username]);
		if (pg_affected_rows($result) === 1) {
			$row = pg_fetch_assoc($result);
			$passwordPieces = explode('$',$row['password']);
			$encryptedCheck = '{SHA512-CRYPT}' . crypt($currentPassword, '$6$'.$passwordPieces[2].'$');
			if ($encryptedCheck !== $row['password']) {
				$error = 'Current password mismatch';
			}
		} else {
			$error = 'Current user mismatch';
		}
	}
	
	if ($error === null && isset($_POST['new-password'],$_POST['confirm-password'])) {
		$newPassword = trim($_POST['new-password']);
		$confirmPassword = trim($_POST['confirm-password']);
		if (strlen($newPassword) < MIN_PASSWORD) {
			$error = 'Password must be at least '.MIN_PASSWORD.' characters';
		} else {
			if ($newPassword !== $confirmPassword) {
				$error = 'Passwords must match';
			} else {
				$checkForUppercase = preg_match('/[A-Z]/',$newPassword);
				$checkForLowercase = preg_match('/[a-z]/',$newPassword);
				$checkForDigit = preg_match('/\d/',$newPassword);
				$checkForSymbol = preg_match('/[\_\!\@\#\$\%\^\&\*\(\)\+\?\>\<\;\:]/',$newPassword);
				if ($checkForUppercase && $checkForLowercase && $checkForDigit && $checkForSymbol) {
					$random = str_shuffle(md5(rand(-1000,1000)));
                                        $encrypted = '{SHA512-CRYPT}' . crypt($newPassword, '$6$'.$random.'$');
					$query = 'UPDATE users SET password = $1 WHERE userid = $2';
					$result = pg_query_params($query,[$encrypted,$username]);
					if (pg_affected_rows($result) === 1) {
						$message = 'Success';
					} else {
						$error = 'Unable to update the password';
					}
				} else {
					$error = 'Password must contain at least one uppercase letter, one lowercase letter, one digit, and one symbol';
				}
			}
		}
	}
	pg_close();
} else {
	$error = 'Unable to update the password';
}

?>
<?php if ($error !== null) : ?>

<?= $error ?>

<?php endif ?> <?php if ($message !== null) : ?>

<?= $message ?>

<?php endif ?>







The rest of the files to make the plugin work and the internationalization are left to you dear reader. This is just the stuff you need to make it work.

Enjoy.

The blog code formatter modified my HTML. That's okay - what I'm really sharing is the PHP.

Session Timeout Recovery

It can be difficult for the client to know if the session has timed out on the server. This post describes the use of session storage to save and restore the data on a page input when a session times out.

Let's start with the timeout detection.

On the server side, this tiny piece of code checks if the user is logged in.

<?php
session_start();
header('HTTP/1.1 '.(!empty($_SESSION['logged_in']) ? '204 No content' : '403 Forbidden'));
exit;

The only response is either 204 No content which means the user is logged in, or 403 Forbidden, meaning the user isn't logged in or the session timed out.

On the client side, we'll start with the code that is "pinging" the server and saving the data:

"use strict";

// pingStatus is the session storage key which will contain either SAVED or NOT_SAVED to indicate whether there is session data saved.
var pingStatus = '_$ping_status$_';
var SAVED = 'saved';
var NOT_SAVED = 'not_saved';

// PREFIX is the session prefix key, intended to avoid namespace conflicts between other sessionStorage code
var PREFIX = '__$';

// The values of these types of inputs are not saved - you may want to save hidden inputs depending on how your page is coded
var DONT_SAVE = ["file", "button", "submit", "reset", "hidden"];

// The ping function 
function ping(key) {
    // key is used to identify the data - it can be any value you would like
    // A page name and id work well, that way, if there are multiple instances of the same page in the browser, conflicts should be avoided
    PREFIX = key;

    // pinger is the variable used to store the interval timer
    var pinger;

    // doPing pings the server
    function doPing() {
        var pReq = new XMLHttpRequest();
        pReq.addEventListener("load", function () {
            var i, items;

            // If the server doesn't respond with a 204, consider it a timeout
            if (this.status !== 204) {

                // Stop pinging
                clearInterval(pinger);

                // Allow the user to decide whether to save the data or abandon it
                if (confirm('Your session timed out.  Click OK save the page data or Cancel to discard any data that has not been sent to the server') === true) {
                    // Loop through all the inputs
                    items = document.querySelectorAll('input, select, textarea');
                    for (i = 0; i < items.length; i++) {
                        // Only save the items that aren't in the DONT_SAVE array
                        if (DONT_SAVE.indexOf(items[i].type) === -1 && items[i].id !== "") {

                            // If it isn't a checkbox or radio button - save the value
                            if (items[i].type !== "checkbox" && items[i].type !== "radio") {
                                sessionStorage.setItem(PREFIX + items[i].id, items[i].value);
                            } else {
                                // If it is a checkbox or radio button - save whether it was checked or not
                                sessionStorage.setItem(PREFIX + items[i].id, items[i].checked);
                            }
                        }
                    }
                    // Set the flag to indicate the data was saved
                    sessionStorage.setItem(pingStatus + key, SAVED);
                } else {
                    // Set the flag to indicate the data has not been saved
                    sessionStorage.setItem(pingStatus + key, NOT_SAVED);
                }

                // Location reload is assuming that when the page reloads, the server will redirect to the login page, then to the requested page - which is the current page
                location.reload();
            }
        });

        // Issue the ping request
        pReq.open("GET", "ping.php");
        pReq.setRequestHeader("Accept", "application/json");
        pReq.send();
    }
  
    // Set the flag to indicate the data has not been saved
    sessionStorage.setItem(pingStatus + key, NOT_SAVED);

    // Set up to ping every 10 seconds
    pinger = setInterval(doPing, 10000);

    // Do a first ping, which is effectively on page load.  This catches timeouts when the computer had gone to sleep
    doPing();
}

The restore code checks to see if the data has been saved and if there is data, allows the user to use it or discard it.


function restore(key) {
    var v, i, items;

    PREFIX = key;

    // If there is data saved for this session
    if (sessionStorage.getItem(pingStatus + key) === SAVED) {

        // Give the user the opportunity to use it or ignore it
        if (confirm('There is data saved for this page, click OK to load it or Cancel to discard it') === true) {

            // Get all the inputs
            items = document.querySelectorAll('input, select, textarea');
            for (i = 0; i < items.length; i++) {

                // Ignore the 'DONT_SAVE's and anything that doesn't have an id
                if (DONT_SAVE.indexOf(items[i].type) === -1 && items[i].id !== "") {
                    v = sessionStorage.getItem(PREFIX + items[i].id);

                    // If there is no data saved for the input, it can't be restored
                    if (v !== null) {

                        // If it's not a checkbox or radio button, restore the value
                        if (items[i].type !== "checkbox" && items[i].type !== "radio") {

                            // If the value is different than what loaded, restore the value
                            if (items[i].value !== v) {
                                items[i].value = v;
                            } 
                        } else {
                            // If it is a checkbox or radio button, set the checked state
                            items[i].checked = v;
                        }
                    }
                }
            }
        }
        // Indicate the data has not been saved
        sessionStorage.setItem(pingStatus + key, NOT_SAVED);
    }
}

Finally, to integrate it into the page, include the ping.js file in a script tag, create and save a key, then call restore(key) followed by ping(key).

var key = "page"+someId;restore(key);ping(key);

Post courtesy of Game Creek Video

Symfony 3 - EntityType / Dojo - Select Integration

If you use Dojo to provide a polished interface with a Symfony 3 application programmatically, you may use a data- attribute on a select tag to pass the options from Symfony 3 to Dojo.

The entity in this case is phone number type. This is a table with two columns, an id and the type which is a string.

dev=# \d+ phone_number_type
                          Table "public.phone_number_type"
 Column |         Type          | Modifiers | Storage  | Stats target | Description 
--------+-----------------------+-----------+----------+--------------+-------------
 id     | integer               | not null  | plain    |              | 
 type   | character varying(16) | not null  | extended |              | 
Indexes:
    "phone_number_type_pkey" PRIMARY KEY, btree (id)

dev=# select * from phone_number_type;
 id |   type    
----+-----------
  1 | office
  2 | home
  3 | mobile
  4 | alternate
  5 | emergency
  6 | on-call
  7 | other
  8 | security
(8 rows)

The form uses the entity to allow the user to indicate the type of phone number.

    public function buildForm( FormBuilderInterface $builder, array $options )
    {

        $builder
                ->add( 'type', EntityType::class, [
                    'class' => 'AppBundle:PhoneNumberType',
                    'choice_label' => 'type',
                    'multiple' => false,
                    'expanded' => false,
                    'required' => true,
                    'choice_translation_domain' => false
                ] )
                ->add( 'phonenumber', TextType::class, [
                    
                ] )
                ->add( 'comment', TextType::class )
        ;
    }

Next, the twig block is customized to render the options as a data- attribute on the select tag, rather than the options:

{%- block choice_widget_collapsed -%}
    {%- if required and placeholder is none and not placeholder_in_choices and not multiple and (attr.size is not defined or attr.size <= 1) -%}
        {% set required = false %}
    {%- endif -%}
    <select {{ block('widget_attributes') }}{% if multiple %} multiple="multiple" {% else %} {% endif %}
        {%- if placeholder is not none -%}
            {% set blank_value = (placeholder != '') ? (translation_domain is same as(false) ? placeholder : placeholder|trans({}, translation_domain)) %}
            {% set blank = { 'value': '', 'label': '{{ blank_value|raw }}'} %}
        {%- endif -%}
        {%- if preferred_choices|length > 0 -%}
            {% set choices = preferred_choices|merge(choices) %}
            {%- if choices|length > 0 and separator is not none -%}
                {% set choices = choices|merge([{'label':'{{separator}}','disabled':true}]) %}
            {%- endif -%}
        {%- endif -%}
        {%- set options = choices -%}
        data-options="{{options|json_encode()}}" data-selected="{% if required and value is empty %}{else}-1{% endif %}">
    
{%- endblock choice_widget_collapsed -%}

On the client side, the options are read into a MemoryStore

        // select is the select node
        data = JSON.parse(domAttr.get(select, "data-options"));
        // Convert the data to an array of objects
        storeData = [];
        for( d in data ) {
            storeData.push(data[d]);
        }
        memoryStore = new Memory({
            idProperty: "value",
            data: storeData});
        store = new ObjectStore({objectStore: memoryStore});

        typeSelect = new Select({
            store: store,
            placeholder: core.type,
            required: true
        }, select);
        typeSelect.startup();

This approach is good for cases where there aren't too many options and they are static. If you had a long list, or wanted an autocomplete, an Ajax solution would be better.

A similar approach may be used with other JavaScript libraries and frameworks.

Symfony 3 - Legacy Authentication Bridge - straight md5

If you are building a system which needs a fallback authentication system to welcome existing users into a new application architecture, you will need to support the authentication approach of the legacy application.

One very common method is to md5 the password and store it in the database. Symfony supports md5 password encoders, but needs a little extra configuration to disable the salt.

This post shows how to configure a connection into the legacy database, chain the authentication providers, and configure the password encoder.

I chose to put all the code that is strictly operating on the legacy system in its own bundle with the rationale that it may be removed in the future. If it isn't removed, it should be easier to set the context of tasks when working in the different bundles.

If you are transitioning from a legacy application to a new architecture, you will probably have two databases - one for the old system and one for the new one. The first step is to set up Symfony and Doctrine to connect to both.

app/config/config.yml

    dbal:
        default_connection: default
        connections:
            default:
                driver:   "%database_driver%"
                host:     "%database_host%"
                port:     "%database_port%"
                dbname:   "%database_name%"
                user:     "%database_user%"
                password: "%database_password%"
                charset:  UTF8
                
            legacy:
                driver:   '%legacy_database_driver%'
                host:     '%legacy_database_host%'
                port:     '%legacy_database_port%'
                dbname:   '%legacy_database_name%'
                user:     '%legacy_database_user%'
                password: '%legacy_database_password%'
                charset:  UTF8

     ...

    orm:
        auto_generate_proxy_classes: "%kernel.debug%"
        default_entity_manager: default

        entity_managers:
            default:        
                auto_mapping: true
                mappings:
                    AppBundle: ~
            legacy:
                connection: legacy
                mappings:                    
                    LegacyBridgeBundle: ~

Then you need to create the user provider as described here http://symfony.com/doc/current/cookbook/security/entity_provider.html. Be sure to make any column name updates and ensure they are reflected in the config files as well.

This is a fallback configuration, with the FOS User Bundle as the first provider (Ref: http://symfony.com/doc/current/cookbook/security/multiple_user_providers.html) and the legacy provider running next.

Add the legacy provider into your chain as follows:

app/config/security.yml


imports:
    - { resource: "@LegacyBridgeBundle/Resources/config/security.yml" }

security:

    ...

    providers:
        chain_provider:
            chain:
                providers: [ fos_userbundle, legacy_user_provider ]

    ...

        main:
            pattern: ^/
            form_login:
                provider: chain_provider

And set up the encoder the bundle security.yml

security.yml


security:
    encoders:
        LegacyBridgeBundle\Entity\LegacyUser: 
            algorithm: md5
            encode_as_base64: false
            iterations: 0
       
    providers:
        legacy_user_provider:
            entity: 
                class: LegacyBridgeBundle:LegacyUser
                property: username
                manager_name: legacy

Please note that I'm not recommending unsalted md5 (I do recommend unsalted butter), but sharing an approach that allows the authentication to run against an existing database.