eZ Publish Workflow - electronic media delivery

I’m helping on a site that sells music.

I wanted a way to bundle a person’s purchases into a single file that could be downloaded once. There are two types of electronic media, mp3_product and album. Each may have an image associated with it. An mp3_product is an .mp3 files, an album is a .zip file, containing .mp3s.

The link above is a great resource. Using it, I created a custom workflow:

<?php
 
class CreateDownloadFileType extends eZWorkflowEventType
{
    const WORKFLOW_TYPE_STRING = "createdownloadfile";
    public function __construct()
    {
        parent::__construct( CreateDownloadFileType::WORKFLOW_TYPE_STRING, 'Create Download File');
    }
 
    public function execute( $process, $event )
    {
        $parameters = $process->attribute( 'parameter_list' );

	$order_id = $parameters['order_id'];
	$user_id = $parameters['user_id'];

	$order = eZShopFunctionCollection::fetchOrder ($order_id);

	$items = eZProductCollectionItem::fetchList( array( 'productcollection_id' => $order['result']->ProductCollectionID ) );

	$order_number = $order['result']->OrderNr;

	$zip_file='/home/account/www/download/'.md5($order_number.$user_id).'.zip';
	$zip = new ZipArchive();
	if ($zip->open($zip_file,ZIPARCHIVE::CREATE)!==true) {
		eZDebug::writeError('Order: '.$order_id.' - could not create zip file '.$zip_file);
	}

	$storageDir = eZSys::storageDirectory();
	$attributes_to_include = array('file','image');

	foreach ($items as $item)
	{
		$product = eZContentObject::fetch($item->ContentObjectID);

		if (in_array($product->ClassIdentifier,array('mp3_product','album')))
		{
			$product_data = $product->dataMap();
			$name = preg_replace('/[^\w]/','_',trim($product_data['name']->DataText));

			foreach ($attributes_to_include as $v)
			{
				if ($product_data[$v]!==null)
				{
					$attribute_data = $product_data[$v]->Content();
					$file = $attribute_data->Filename;
					$mime_type = $attribute_data->MimeType;
				        list( $group, $type ) = explode( '/', $mime_type );
					$filePath = $storageDir . '/original/' . $group . '/' . $file;
					$pathInfo = pathinfo($filePath);
					if ($zip->addFile($filePath,$name.'.'.$pathInfo['extension'])!==true) {
						eZDebug::writeError('Order: '.$order_id.' - failed to add '.$filePath.' to '.$zip_file);
					}
				}
			}
		}
		$zip->close();
	}

        return eZWorkflowType::STATUS_ACCEPTED;
    }
}
eZWorkflowEventType::registerEventType( CreateDownloadFileType::WORKFLOW_TYPE_STRING, 'createdownloadfiletype' );
?>

The zip file created is named with an md5 of the order number and user id, and placed in a download directory. It’s probably not the most secure approach, but I think it is okay for this application, and it can be changed later.

This triggers on shop/checkout/after, which means the person has paid.

The download link for the zip file is placed in both the order view and order email templates.

    {def $current_user=fetch( 'user', 'current_user' )}
    {if $current_user.is_logged_in}
    <h2>{"Download"|i18n("design/base/shop")}:</h2>
    <p><a href="/download/{concat($order.order_nr,$current_user.contentobject_id)|md5()}.zip">{"Click to download"|i18n("design/base/shop")}</
a></p>
    {/if}
    {undef $current_user}

You may need an Alias and a RewriteRule:


Alias download /home/account/www/download
RewriteRule ^download/.*\.zip - [L]

The final step was to create a mechanism to delete the files after they are downloaded. There are at least two ways to do this. The first approach is to use an Apache filter, with a bash script:

Apache configuration code:

ExtFilterDefine delete mode=output
cmd="/home/account/webfilter.sh"

<Directory /home/account/www/download>
SetOutputFilter delete
</Directory>


FILENAME="/home/account/www$DOCUMENT_URI";
cat -
if [ -e $FILENAME ]; then
        rm $FILENAME;
fi

The Apache filter code is courtesy of Lyrix, Inc..

If you can’t use a filter - which is likely if you are using shared hosting, you can also use a PHP script. This uses a RewriteRule to route the request through PHP.

Options -Indexes
<IfModule mod_rewrite.c>
        RewriteEngine On
        RewriteRule ^(.*\.zip)$ zip.php?path=$1 [L]
</IfModule>

Finally, this is the PHP code that will deliver the file, then delete it.

<?php
// downloading a file and then deleting it
$filename = $_GET['path'];
if (is_file($filename))
{
        // fix for IE catching or PHP bug issue
        header("Pragma: public");
        header("Expires: 0");
        header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
        header("Cache-Control: public");
        header("Content-Description: File Transfer");
        // browser must download file from server instead of cache

        // force download dialog
        header("Content-Type: application/zip");

        // use the Content-Disposition header to supply a recommended filename and
        // force the browser to display the save dialog.
        header("Content-Disposition: attachment; filename=".basename($filename).";");

        /*
        The Content-transfer-encoding header should be binary, since the file will be read
        directly from the disk and the raw bytes passed to the downloading computer.
        The Content-length header is useful to set for downloads. The browser will be able to
        show a progress meter as a file downloads. The content-lenght can be determines by
        filesize function returns the size of a file.
        */
        header("Content-Transfer-Encoding: binary");
        header("Content-Length: ".filesize($filename));

        @readfile($filename);
        unlink($filename);
        exit();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title>Requested Content Not Available</title>
</head>
<body>
<h1>Requested Content Not Available</h1>
<p>The content you requested is not available.</p>
<p>If you were unable to download your purchases, please contact us for assistance.</p>
</body>
</html>

jQuery eZ publish Mobile pagelayout.tpl

This code has been updated - use the github link above to get the latest code.

Using the book Head First Mobile Web to learn and as a reference, I created a simple mobile interface for an existing eZ publish installation.

pagelayout.tpl

pagelayout.tpl includes three ‘pages’, content, search, and menu.

{*?template charset=utf-8?*}
<!DOCTYPE html>
<html manifest="/manifest.appcache">
<head>
<meta NAME="robots" CONTENT="noindex,nofollow" />
<meta http-equiv="content-language" content="en" />
<meta http-equiv="content-type" content="text/html;charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" href="http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.css" />
<link rel="stylesheet" href="/design/mobile/stylesheets/core.css" />
<script src="http://code.jquery.com/jquery-1.6.4.min.js"></script>
<script src="http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.js"></script>
{include uri="design:page_head.tpl" enable_link=false}
</head>
<body>
{default current_user=fetch('user','current_user')}
<div data-role="page" id="content">
        {include uri="design:header.tpl"}
        <div data-role="content">
                {$module_result.content}
        </div>
        {include uri="design:footer.tpl" page="content"}
        <div id="footer">
                <address>{"Powered by %linkStartTag eZ publish&reg; open source content management system %linkEndTag and development framework."|i18n("design/base",,hash('%linkStartTag',"<a href='http://ez.no'
>",'%linkEndTag',"</a>" ))}<br />Copyright &copy;  2006-{currentdate()|datetime('custom','%Y')} <b>{ezini('SiteSettings','MetaDataArray','site.ini').copyright}</b><br />
                </address>
        </div><!--/footer-->
</div><!--/content-->
<div data-role="page" id="search">
        {include uri="design:header.tpl"}
        <div data-role="content">
                {include uri="design:searchbox.tpl"}
        </div>
        {include uri="design:footer.tpl" page="search"}
</div><!--/search-->
<div data-role="page" id="menu">
        {include uri="design:header.tpl"}
        {cache-block keys=array('nav',$uri_string, $current_user.role_id_list|implode( ',' ), $current_user.limited_assignment_value_list|implode( ',' ))}
        <div data-role="content">
                {menu name="LeftMenu"}
        </div>
        {include uri="design:footer.tpl" page="menu"}
        {/cache-block}
</div><!--/menu-->
{/default}
</body>
</html>

There are several supporting templates.

searchbox.tpl

{*?template charset=utf-8?*}
<form action={"/content/search/"|ezurl} method="get">
<h2>{"Search"|i18n("design/standard/toolbar/search")}</h2>
<input class="searchinput" type="text" name="SearchText" id="SearchText" value="" placeholder="{'Search'|i18n('/design/standard/toolbar/search')}">
<input type="submit" value="{'Submit'|i18n('/design/standard/node')}" data-icon="search" data-iconpos="left" />
</form>

header.tpl


{*?template charset=utf-8?*}
<div data-role="header" data-position="fixed">
<a href="/" data-rel="back" data-icon="back" data-role="button">{'Back'|i18n('design/standard/node')}</a>
<h1>{'Example'|i18n('design/standard/node')}</h1>
<a href="http://example.com/?notmobile" data-rel="external" data-icon="home" data-role="button" data-theme="b" data-iconpos="right">{'Full Site'|i18n('design/st
andard/node')}</a>
</div>

footer.tpl

{*?template charset=utf-8?*}
{if not(is_set($page))}
{def $page='unknown'}
{/if}
<div data-role="footer" data-position="fixed">
<div data-role="navbar">
<ul>
<li><a href="/Home" data-icon="home" data-role="button">{'Home'|i18n('design/standard/node')}</a></li>
{if $page|ne('menu')}
<li><a href="#menu" data-icon="menu" data-role="button">{'Menu'|i18n('design/standard/node')}</a></li>
{/if}
{if $page|ne('search')}
<li><a href="#search" data-icon="search" data-role="button">{'Search'|i18n('design/standard/node')}</a></li>
{/if}
</ul>
</div>
</div>

menu/flat_left.tpl

{*?template charset=utf-8?*}
{let docs=treemenu( $module_result.path,
                    is_set( $module_result.node_id )|choose( 2, $module_result.node_id ),
                    ezini( 'MenuContentSettings', 'LeftIdentifierList', 'menu.ini' ),
                    0, 5 )
                    depth=1
                    last_level=0}
        <ul data-role="listview" data-inset="true">
        {section var=menu loop=$:docs last-value}
            {set last_level=$menu.last|is_array|choose( $menu.level, $menu.last.level )}
            {section show=and( $last_level|eq( $menu.level ), $menu.number|gt( 1 ) )}
                </li>
            {section-else}
            {section show=and( $last_level|gt( $menu.level ), $menu.number|gt( 1 ) )}
                </li>
                    {"</ul>
                </li>"|repeat(sub( $last_level, $menu.level ))}
            {/section}
            {/section}

            {section show=and( $last_level|lt( $menu.level ), $menu.number|gt( 1 ) )}
                {'<ul><li>'|repeat(sub($menu.level,$last_level,1))}
                <ul>
                    <li class="menu-level-{$menu.level}">
            {section-else}
                <li class="menu-level-{$menu.level}">
            {/section}
            {let menu_node=fetch('content','node',hash('node_id',$menu.id))}
              {if $menu_node.class_identifier|ne('link')}
                <a {$menu.is_selected|choose( '', 'class="selected"' )} href={$menu.url_alias|ezurl}>{$menu.text|shorten( 25 )}</a>
              {else}
                <a href={$menu_node.data_map.location.content|ezurl} target="_blank">{$menu.text|shorten( 25 )}</a>
              {/if}
            {/let}
            {set depth=$menu.level}
        {/section}
           </li>

        {section show=sub( $depth, 0 )|gt( 0 ) loop=sub( $depth, 0 )}
            </ul>
        </li>
        {/section}
        </ul>

{/let}

The site includes .mp3 files, so an HTML5 audio tag is included. The .mp3s are also offered as links for native device playback.

datatype/ezbinaryfile.tpl

{default icon_size='normal' icon_title=$attribute.content.mime_type icon='no'}
{if $attribute.has_content}
{if $attribute.content}
{if $attribute.content.original_filename|ends_with('.mp3')}
<audio controls="controls">
        <source src="{$fileurl|ezurl('no','full')}" type="audio/mp3" />
        Audio playback not available.
</audio>
<div class="break"></div>
{/if}
{switch match=$icon}
    {case match='no'}
        <a href={concat("content/download/",$attribute.contentobject_id,"/",$attribute.id,"/file/",$attribute.content.original_filename)|ezurl}>{$attribute.content.original_filename|wash(xhtml)}</a> {$attribute
.content.filesize|si(byte)}
    {/case}
    {case}
        <a href={concat("content/download/",$attribute.contentobject_id,"/",$attribute.id,"/file/",$attribute.content.original_filename)|ezurl}>{$attribute.content.mime_type|mimetype_icon( $icon_size, $icon_tit
le )} {$attribute.content.original_filename|wash(xhtml)}</a> {$attribute.content.filesize|si(byte)}
    {/case}
{/switch}
{else}
        <div class="message-error"><h2>{"The file could not be found."|i18n("design/base")}</h2></div>
{/if}
{/if}
{/default}

core.css is included to support the navigation. Any other CSS required for proper page display could be added.

core.css


/* CORE CSS 20040217 */

/* BODY */

/* PAGE NAVIGATION */

div.pagenavigator
{
    text-align: center;    
}

div.pagenavigator span.previous
{
    float: left;
}

div.pagenavigator span.next
{
    float: right;
}
div#footer
{
    padding: 10px 0 10px 0;
    text-align: center;
}

I used eZ 4.7 (community edition equivalent), which includes auto device detection. This works extremely well.

Notes

Naming the mobile siteaccess and enabling autodetection in site.ini.


settings/override/site.ini.append.php

[SiteAccessSettings]
CheckValidity=false
AvailableSiteAccessList[]=site
AvailableSiteAccessList[]=mobile
AvailableSiteAccessList[]=edit
ForceVirtualHost=true
MatchOrder=host
HostMatchType=map
HostMatchMapItems[]=domain.com;site
HostMatchMapItems[]=m.domain.com;mobile
HostMatchMapItems[]=edit.domain.com;edit
HostMatchMapItems[]=www.edit.domain.com;edit
HostMatchMapItems[]=www.domain.com;site
MobileSiteAccessList[]=mobile
MobileSiteAccessURL=http://m.domain.com
DetectMobileDevice=enabled

The manifest.appcache file is used to reduce bandwidth requirements and speed pages. In this case, the Home page, css, and jQuery are cached.

manifest.appcache


CACHE MANIFEST

NETWORK:
content/search*
*

CACHE:
Home
design/mobile/stylesheets/core.css
http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.css
http://code.jquery.com/jquery-1.6.4.min.js
http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.js

You will probably need to add or update a RewriteRule to ensure proper delivery of the manifest.appcache file:

RewriteRule ^(robots\.txt|favicon\.ico|manifest\.appcache)$ - [L] 

It is a good idea to keep the mobile site out of search engines. This RewriteRule will allow you to deliver a different robots.txt file.

Excerpt from .htaccess

RewriteCond %{HTTP_HOST} ^m\..*
RewriteRule ^robots\.txt$ m.robots.txt [L]

m.robots.txt

User-agent: *
Disallow: /

Installing ffmpeg-php on CentOS 5.8

Follow the instructions in the link above, they’re excellent.

If you get a few compile errors related to missing files, add these RPMs (excerpt from /var/log/yum.log):

faad2-devel-2.7-1.el5.rf.i386
x264-devel-0.0.0-0.4.20101111.el5.rf.i386
lame-devel-3.99.5-1.el5.rf.i386
a52dec-devel-0.7.4-8.el5.rf.i386
dirac-devel-1.0.2-1.el5.rf.i386
faac-devel-1.26-1.el5.rf.i386
alsa-lib-devel-1.0.17-1.el5.i386
SDL-devel-1.2.10-9.el5.i386
gsm-devel-1.0.13-1.el5.rf.i386
opencore-amr-devel-0.1.2-1.el5.rf.i386
imlib2-1.4.4-1.el5.rf.i386
imlib2-devel-1.4.4-1.el5.rf.i386
ffmpeg-devel-0.6.5-1.el5.rf.i386
libXpm-devel-3.5.5-3.i386
gd-devel-2.0.33-9.4.el5_4.2.i386

I recommend using the package manager, it will save you a lot of time.

If you get an error referencing PIX_FMT_RGBA32, follow the instructions at: http://blog.hostonnet.com/ffmpeg-php-error-pix_fmt_rgba32

How to Keep a Mobile Site Out of Search Engines

I have a mobile version of a site, which is accessed through auto-detection of mobile user-agents.

Since the content is virtually the same as the regular site, I didn’t want it indexed by search engines, both to prevent site visitors from landing on a mobile site, and to avoid SEO penalties for duplicate content.

I used the following RewriteCond/RewriteRule to deliver an alternate robots.txt file for the mobile site.

        RewriteCond %{HTTP_HOST} ^m\..*
        RewriteRule ^robots\.txt$ m.robots.txt [L]

The alternate robots.txt file is:

User-agent: *
Disallow: /

In this case, the site is run by eZ publish, but the same strategy should work with any other mobile site that is run as a subdomain.

b2evolution - comment forms stalling - version 4+

This is a b2evolution blog. There’s a second blog running from the same installation, and the comment form was stalling on page load, displaying ‘Loading …’ and the icon, but never finishing.

The first action I took was to upgrade the blog. Many times, issues like that are resolved on an upgrade.

Unfortunately, that didn’t solve the problem.

Next, I reviewed the configuration of both blogs through the admin interface, taking care to clear the cache after each change.

Unfortunately this didn’t solve the problem either, so I grepped through the code until I found where the $baseurl is set. It is in these lines in conf/_basic_config.php. Uncommenting these lines solves the problem.

conf/_basic_config.php

// Use the following if you want to use the current domain:
if( isset($_SERVER['HTTP_HOST']) )
{       // This only works if HOSt provided by webserver (i-e DOES NOT WORK IN PHP CLI MODE)
        $baseurl = ( (isset($_SERVER['HTTPS']) && ( $_SERVER['HTTPS'] != 'off' ) ) ?'https://':'http://')
                                                        .$_SERVER['HTTP_HOST'].'/';
}