Looking for something?

Welcome to thinklikeamage.com

Here you'll find in-depth articles and information gathered throughout the author's professional software development career.

Administrator Notifications

If you've been working with Magento for as long as I have, you've undoubtedly noticed some of the 101 messages (at the time of this writing) that have appeared in the administration panel.  This article serves to demystify the mechanisms behind the notifications and also leads into extending the existing system to safely incorporate custom notifications into your own extensions.

10,000 Foot Overview

Overall, the Magento administrator notification system is very simple.  It consists of two main components: the Core 'AdminNotification' Module (hereafter "module") and an RSS feed.  The module periodically checks the remote notification server1 for RSS updates.  If an update is found, then the RSS feed is parsed out and a new notification is added to the local database and displayed to the store administrator.  That's all for the big-picture view.  Now on to the details...

Detailed Overview

Installation Details

With my background of dealing with primarily low level technologies, I generally tend to strip a problem down to its most basic, raw elements.  In the web development world, this usually equates to figuring out what the raw data looks like.  That said, let's start things off by looking at the data schema that is being used.

In the 'app/code/core/Mage/AdminNotification/sql/adminnotification_setup' folder you will notice that there may be up to 3 files depending on your version of Magento.

  • mysql4-install-1.0.0.php
  • mysql4-upgrade-1.5.9.9-1.6.0.0.php
  • install-1.6.0.0.php

 

The first two files are more or less legacy cruft from an era where a modern DDL was not yet conceived.  mysql-install-1.0.0.php simply runs the (approximate) following SQL query in order to create the data structures used by the module:

CREATE TABLE IF NOT EXISTS `adminnotification_inbox` (
  `notification_id` int(10) unsigned NOT NULL auto_increment,
  `severity` tinyint(3) unsigned NOT NULL default '0',
  `date_added` datetime NOT NULL,
  `title` varchar(255) NOT NULL,
  `description` text,
  `url` varchar(255) NOT NULL,
  `is_read` tinyint(1) unsigned NOT NULL default '0',
  `is_remove` tinyint(1) unsigned NOT NULL default '0',
  PRIMARY KEY (`notification_id`),
  KEY `IDX_SEVERITY` (`severity`),
  KEY `IDX_IS_READ` (`is_read`),
  KEY `IDX_IS_REMOVE` (`is_remove`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

 mysql-upgrade-1.5.9.9-1.6.0.0.php then proceeds to add comments to the above table and rename the three non-primary indices to match exactly match what install-1.6.0.0.php creates.

Speaking of install-1.6.0.0.php, this setup script (which runs on every modern version of Magento) utilizes the Varien DDL utility in order to build the table.  The advantages to the DDL approach are that the installation script is no longer intrinsically bound to the mysql storage solution and that there is a standardized nomenclature of indices, foreign keys, etc...

$table = $installer->getConnection()
    ->newTable($installer->getTable('adminnotification/inbox'))
    ->addColumn('notification_id', Varien_Db_Ddl_Table::TYPE_INTEGER, null, array(
        'identity'  => true,
        'unsigned'  => true,
        'nullable'  => false,
        'primary'   => true,
        ), 'Notification id')
    ->addColumn('severity', Varien_Db_Ddl_Table::TYPE_SMALLINT, null, array(
        'unsigned'  => true,
        'nullable'  => false,
        'default'   => '0',
        ), 'Problem type')
    ->addColumn('date_added', Varien_Db_Ddl_Table::TYPE_TIMESTAMP, null, array(
        'nullable'  => false,
        ), 'Create date')
    ->addColumn('title', Varien_Db_Ddl_Table::TYPE_TEXT, 255, array(
        'nullable'  => false,
        ), 'Title')
    ->addColumn('description', Varien_Db_Ddl_Table::TYPE_TEXT, '64k', array(
        ), 'Description')
    ->addColumn('url', Varien_Db_Ddl_Table::TYPE_TEXT, 255, array(
        ), 'Url')
    ->addColumn('is_read', Varien_Db_Ddl_Table::TYPE_SMALLINT, null, array(
        'unsigned'  => true,
        'nullable'  => false,
        'default'   => '0',
        ), 'Flag if notification read')
    ->addColumn('is_remove', Varien_Db_Ddl_Table::TYPE_SMALLINT, null, array(
        'unsigned'  => true,
        'nullable'  => false,
        'default'   => '0',
        ), 'Flag if notification might be removed')
    ->addIndex($installer->getIdxName('adminnotification/inbox', array('severity')),
        array('severity'))
    ->addIndex($installer->getIdxName('adminnotification/inbox', array('is_read')),
        array('is_read'))
    ->addIndex($installer->getIdxName('adminnotification/inbox', array('is_remove')),
        array('is_remove'))
    ->setComment('Adminnotification Inbox');
$installer->getConnection()->createTable($table);

So based upon what we've seen in whichever flavor of installation script tickles our fancy, we now know a whole stock-pile of information about the module that we're attacking.

  1. This is the table for the 'adminnotification/inbox' model.  Through the standard Magento core patterns, this means that the corresponding file for this alias can be found at 'app/code/core/Mage/AdminNotification/Model/Inbox.php'.  For any business logic related questions that we have regarding this table, the aforementioned file should be the start of our search.
  2. The inbox model has the following fields (parallel to the table column names):
    1. notification_id - auto-incrementing primary key
    2. severity - an integer field (likely has an associated group of symbolic constants defined somewhere in the module.  The general pattern indicates that the definitions should be in the corresponding Model class.)
    3. date_added - the publication date of the notification
    4. title - the title of the notification (will always be less than 255 characters in length)
    5. description - the body of the notification (can be up to 65535 characters  in length)
    6. url - an associated URL of some sort (can be up to 255 characters in length)
    7. is_read - A boolean that marks which notifications have already been read
    8. is_remove - A boolean that marks which notifications have been deleted (this tells us that the module soft-deletes its models)

If the reader thinks at all like I do, the question buzzing around their head right now is 'How can I break this?'.  Most of the fields are fairly straight-forward and seemingly bullet-proof.  Save severity.  According to the schema, any integer from 0 to 65535 should be "valid".  It's conceivable that the range of acceptable values is much more narrow than the schema dictates.  In times like these, I leave a mental note to check into the exact constraints that won't break application logic -- making sure that it's either handled by the original author or providing my own solution that does properly sanitize inputs.  We'll come back to this later on in the article.

Periodic Update Checking

Earlier in the 10,000 foot overview, it was stated that the module periodically checks for updates by connecting to a remote RSS feed and gathering new notifications.  Stepping aside from abstract theoretical overviews, let's take a quick look at the module's configuration file found at 'app/code/core/Mage/AdminNotification/etc/config.xml'.  Particularly, we're interested in the 'config/adminhtml/events' element's children.

<?xml version="1.0"?>
<config>
<!-- ... -->
    <adminhtml>
        <events>
            <controller_action_predispatch>
                <observers>
                    <adminnotification>
                        <class>adminnotification/observer</class>
                        <method>preDispatch</method>
                    </adminnotification>
                </observers>
            </controller_action_predispatch>
        </events>
    </adminhtml>
<!-- ... -->

Notice that there is an observer in place that observes the controller_action_predispatch event.  This event fires before every time that an action occurs in the administration panel.  Let's crack that observer open to see what's going on.  The file containing the observer can be found at 'app/code/core/Mage/AdminNotification/Model/Observer.php'.

public function preDispatch(Varien_Event_Observer $observer)
{
    if (Mage::getSingleton('admin/session')->isLoggedIn()) {

        $feedModel  = Mage::getModel('adminnotification/feed');
        /* @var $feedModel Mage_AdminNotification_Model_Feed */

        $feedModel->checkUpdate();
    }
}

So, now we know that every time an action occurs (actually, before said action occurs), a call is made to the 'checkUpdate' method of the 'adminnotification/feed' class.  Diving deeper down the rabbit-hole, let's check out that method found in 'app/code/core/Mage/AdminNotification/Model/Feed.php'.

public function checkUpdate()
{
    if (($this->getFrequency() + $this->getLastUpdate()) > time()) {
        return $this;
    }

Now we're getting somewhere.  The third line in the 'checkUpdate' method checks to see whether it's time to look for an update or not.  This is accomplished by checking that the current time is greater than the last check plus the result of the 'getFrequency' method.  If it's not time to check for updates yet, then a reference to the current feed model is returned to the caller.  For the sake of full overage, the 'getFrequency' method returns a user-configurable time period as seen below (with the relevant constant thrown in for good measure):

const XML_FREQUENCY_PATH    = 'system/adminnotification/frequency';
public function getFrequency()
{
    return Mage::getStoreConfig(self::XML_FREQUENCY_PATH) * 3600;
}

The presence of this field in the user-visible system configuration can confirmed both by reviewing 'app/code/core/Mage/Adminhtml/etc/system.xml' and/or by visiting the system configuration of your favorite Magento instance (you'll be looking under Advanced/System/Notifications/Update Frequency'.  Astute readers might notice that the above code converts the value of the configuration field from hours to seconds.

Returning to the 'checkUpdate' method from the planned detour, the remote feed is read and a resulting 'SimpleXMLElement' is generated.

 

    $feedData = array();
    $feedXml = $this->getFeedData();

Stepping into the 'getFeedData' method, we can see that the 'Varien_Http_Adapter_Curl' class is used to fetch the response from the update server.  Notice that on lines 12 and 13 there are a couple of string manipulation operations.  These operations simply discard the HTTP header in the response.  Finally, the response is fed to the SimpleXMLElement constructor (which validates the markup internally).

public function getFeedData()
{
    $curl = new Varien_Http_Adapter_Curl();
    $curl->setConfig(array(
        'timeout'   => 2
    ));
    $curl->write(Zend_Http_Client::GET, $this->getFeedUrl(), '1.0');
    $data = $curl->read();
    if ($data === false) {
        return false;
    }
    $data = preg_split('/^\r?$/m', $data, 2);
    $data = trim($data[1]);
    $curl->close();

    try {
        $xml  = new SimpleXMLElement($data);
    }
    catch (Exception $e) {
        return false;
    }

    return $xml;
}

After getting the SimpleXMLElement from the above method, the Feed class then proceeds to iterate through each item, grabbing each field and storing it in an array.  Incase you're wondering: due to the behavior of dynamic properties in PHP it's not necessary to check whether or not the properties actually exist in the $item object prior to accessing them.  Arguably, any one of the fields missing from the feed (or a 'severity' that lies outside of the acceptable values) could indicate a corrupt notification item.  This author thinks so at least, so in the module we're going to create in a few minutes we'll be handling that.

    if ($feedXml && $feedXml->channel && $feedXml->channel->item) {
        foreach ($feedXml->channel->item as $item) {
            $feedData[] = array(
                'severity'      => (int)$item->severity,
                'date_added'    => $this->getDate((string)$item->pubDate),
                'title'         => (string)$item->title,
                'description'   => (string)$item->description,
                'url'           => (string)$item->link,
            );
        }

        if ($feedData) {
            Mage::getModel('adminnotification/inbox')->parse(array_reverse($feedData));
        }

    }
    $this->setLastUpdate();

    return $this;
}

 Finally, the '$feedData' is passed to the 'parse' method of the  'adminnotification/inbox' model.  The 'adminnotification/inbox' model can be found within the file located at 'app/code/core/Mage/Adminhtml/Model/Inbox.php'.

public function parse(array $data)
{
    return $this->getResource()->parse($this, $data);
}

 This method simply calls through to the corresponding resource model which can be found in the file located at 'app/code/core/Mage/Adminhtml/Model/Resource/Inbox.php'.

public function parse(Mage_AdminNotification_Model_Inbox $object, array $data)
{
    $adapter = $this->_getWriteAdapter();
    foreach ($data as $item) {
        $select = $adapter->select()
            ->from($this->getMainTable())
            ->where('title = ?', $item['title']);

        if (empty($item['url'])) {
            $select->where('url IS NULL');
        } else {
            $select->where('url = ?', $item['url']);
        }

        if (isset($item['internal'])) {
            $row = false;
            unset($item['internal']);
        } else {
            $row = $adapter->fetchRow($select);
        }

        if (!$row) {
            $adapter->insert($this->getMainTable(), $item);
        }
    }
}

 It's starting to look like we're getting down to the nitty-gritty again.  That's a good thing -- it means we're almost done.  Essentially the above method is checking to see whether or not a new record should be created and if so, creates it.  Lines 5 to 13 build a query that selects any and all records with the same 'title' and 'url' values as the current item.  Then on line 15, we see a new field, 'internal'.  This field is strictly transient -- that is, not persisted with the rest of the item.  Hence on line 17 it is unset if need be.  Notification items marked as 'internal' are unconditionally added as new regardless of whether or not a previous entry exists.  The 'internal' field will be revisited when creating a custom notifications module later in the article.

Breaking The Core

As promised earlier in the article, we're now going to revisit how the lack of adequate input validation has the potential to create a broken page in the administration panel.  Particularly, we're going to be attacking the 'severity' field of the notification feed.  Suppose for a second that the maintainer of the official Magento feed accidentally types in an extra digit when creating the latest notification or perhaps there was a man in the middle attack that modified the feed in-transit.  To simulate this, we'll temporarily modify the 'checkUpdate' method of the 'adminnotification/feed' class as follows:

public function checkUpdate()
{
//        Force an update for simulation purposes
//        
//        if (($this->getFrequency() + $this->getLastUpdate()) > time()) {
//            return $this;
//        }

    $feedData = array();

    $feedXml = $this->getFeedData();

    if ($feedXml &amp;& $feedXml->channel && $feedXml->channel->item) {
        foreach ($feedXml->channel->item as $item) {
            $feedData[] = array(
                /// Suppose someone made a typo at Magento HQ.
                'severity'      => 11111,
//                    'severity'      => (int)$item->severity,
                'date_added'    => $this->getDate((string)$item->pubDate),
                'title'         => (string)$item->title,
                'description'   => (string)$item->description,
                'url'           => (string)$item->link,
            );
        }

        if ($feedData) {
            Mage::getModel('adminnotification/inbox')->parse(array_reverse($feedData));
        }

    }
    $this->setLastUpdate();

    return $this;
}

Next, we'll clear out the notification inbox to ensure that the new records will be inserted.

TRUNCATE TABLE `adminnotification_inbox`;

Next, visit any authenticated page in the administration panel to trigger a notification check, and then head over to the inbox located under 'System/Notifications' and you'll be greeted with one of two things depending on how PHP is configured on your system: a white page if PHP is configured for a production environment, or a stack trace if you're set up for development.  In my case, I got the stack trace:

Notice: Undefined variable: class  in /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/Block/Notification/Grid/Renderer/Severity.php on line 66

#0 /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/Block/Notification/Grid/Renderer/Severity.php(66): mageCoreErrorHandler(8, 'Undefined varia...', '/usr/share/ngin...', 66, Array)
#1 /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/Block/Widget/Grid/Column.php(128): Mage_Adminhtml_Block_Notification_Grid_Renderer_Severity->render(Object(Mage_AdminNotification_Model_Inbox))
#2 /usr/share/nginx/html/magento/app/design/adminhtml/default/default/template/widget/grid.phtml(161): Mage_Adminhtml_Block_Widget_Grid_Column->getRowField(Object(Mage_AdminNotification_Model_Inbox))
#3 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(241): include('/usr/share/ngin...')
#4 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(272): Mage_Core_Block_Template->fetchView('adminhtml/defau...')
#5 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(286): Mage_Core_Block_Template->renderView()
#6 /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/Block/Template.php(81): Mage_Core_Block_Template->_toHtml()
#7 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Abstract.php(919): Mage_Adminhtml_Block_Template->_toHtml()
#8 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Abstract.php(637): Mage_Core_Block_Abstract->toHtml()
#9 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Abstract.php(581): Mage_Core_Block_Abstract->_getChildHtml('grid', true)
#10 /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/Block/Widget/Grid/Container.php(77): Mage_Core_Block_Abstract->getChildHtml('grid')
#11 /usr/share/nginx/html/magento/app/design/adminhtml/default/default/template/widget/grid/container.phtml(36): Mage_Adminhtml_Block_Widget_Grid_Container->getGridHtml()
#12 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(241): include('/usr/share/ngin...')
#13 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(272): Mage_Core_Block_Template->fetchView('adminhtml/defau...')
#14 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(286): Mage_Core_Block_Template->renderView()
#15 /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/Block/Template.php(81): Mage_Core_Block_Template->_toHtml()
#16 /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/Block/Widget/Container.php(308): Mage_Adminhtml_Block_Template->_toHtml()
#17 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Abstract.php(919): Mage_Adminhtml_Block_Widget_Container->_toHtml()
#18 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Text/List.php(43): Mage_Core_Block_Abstract->toHtml()
#19 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Abstract.php(919): Mage_Core_Block_Text_List->_toHtml()
#20 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Abstract.php(637): Mage_Core_Block_Abstract->toHtml()
#21 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Abstract.php(581): Mage_Core_Block_Abstract->_getChildHtml('content', true)
#22 /usr/share/nginx/html/magento/app/design/adminhtml/default/default/template/page.phtml(74): Mage_Core_Block_Abstract->getChildHtml('content')
#23 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(241): include('/usr/share/ngin...')
#24 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(272): Mage_Core_Block_Template->fetchView('adminhtml/defau...')
#25 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Template.php(286): Mage_Core_Block_Template->renderView()
#26 /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/Block/Template.php(81): Mage_Core_Block_Template->_toHtml()
#27 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Block/Abstract.php(919): Mage_Adminhtml_Block_Template->_toHtml()
#28 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Model/Layout.php(561): Mage_Core_Block_Abstract->toHtml()
#29 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Controller/Varien/Action.php(390): Mage_Core_Model_Layout->getOutput()
#30 /usr/share/nginx/html/magento/app/code/core/Mage/Adminhtml/controllers/NotificationController.php(45): Mage_Core_Controller_Varien_Action->renderLayout()
#31 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Controller/Varien/Action.php(418): Mage_Adminhtml_NotificationController->indexAction()
#32 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Controller/Varien/Router/Standard.php(254): Mage_Core_Controller_Varien_Action->dispatch('index')
#33 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Controller/Varien/Front.php(172): Mage_Core_Controller_Varien_Router_Standard->match(Object(Mage_Core_Controller_Request_Http))
#34 /usr/share/nginx/html/magento/app/code/core/Mage/Core/Model/App.php(365): Mage_Core_Controller_Varien_Front->dispatch()
#35 /usr/share/nginx/html/magento/app/Mage.php(684): Mage_Core_Model_App->run(Array)
#36 /usr/share/nginx/html/magento/index.php(83): Mage::run('default', 'store')
#37 {main}

So what gives?  The answer is simple: the Magento core team trusted input from the client.  In this case, the client was another server controlled by the Magento team, but a client none the less.  In this author's opinion, this type of bad data should have been blocked at the door before being inserted into the database.

In conclusion, congratulations for making it this far and I hope that the automatically updating notifications don't seem quite so magical anymore.  As we have seen, there is a little room for improvement that we'll be implementing in the continuation of this article.

 Custom Notification Integration

Custom notifications are found in the second part of this article.

Magento: Administrator Notifications (Part 2)

 

1 At the time of this writing, the current official notification feed is configured as "notifications.magentocommerce.com/community/notifications.rss"