Category: "EzPlatform / Ibexa"
eZ 4.x Custom User Group Assignment Workflow
Feb 3rd
This is a simple workflow event that will extract a member type attribute (single select) and use it to assign the member into a group on register.
It catches not only self-registration, but updates made through the admin interface.
<?php
class HPMemberRegisterType extends eZWorkflowEventType
{
const WORKFLOW_TYPE_STRING = 'hpmemberregister';
public function __construct()
{
parent::__construct( HPMemberRegisterType::WORKFLOW_TYPE_STRING, 'HP Member Register' );
}
public function execute ( $process, $event )
{
$parameters = $process->attribute( 'parameter_list' );
$ini = eZINI::instance( 'hpmember.ini' );
$objectID = $parameters['object_id'];
$object = eZContentObject::fetch( $objectID );
$nodeID = $object->attribute( 'main_node_id' );
$node = eZContentObjectTreeNode::fetch( $nodeID );
if ( $object->contentClassIdentifier() === 'member' ) {
$dataMap = $object->dataMap();
$memberTypeValue = $dataMap[ 'member_type' ]->content();
$contentClass = $object->contentClass();
$memberTypes = $contentClass->fetchAttributeByIdentifier( 'member_type' )->content();
$memberType = $memberTypes['options'][$memberTypeValue[0]]['name'];
$memberGroup = $ini->variable( 'MemberGroup', $memberType );
if ( $memberGroup !== null )
{
$node->setAttribute ( 'parent_node_id', $memberGroup );
$node->store();
}
}
return eZWorkflowType::STATUS_ACCEPTED;
}
}
eZWorkflowEventType::registerEventType( HPMemberRegisterType::WORKFLOW_TYPE_STRING, 'hpmemberregistertype' );
.ini file:
<?php /* #?ini charset="utf-8"?
# The node id members with that type should be placed in
[MemberGroup]
Contractor=60
Homeowner=61
Lender=62
Realtor=63
*/ ?>
You can place your nodes where you like. Be sure the names under [MemberGroup] are identical to the options in the selection attribute added to the class used for user registration. This code uses a custom Member class to distinguish eZ Users from site members.
eZ Publish - LiteSpeed - Conditional header
Nov 14th
I have an eZ Publish site, running in host access mode, with three subdomains, admin, mobile and down.
I don’t want the subdomains to be indexed by search engines, and I especially want to keep the admin interface out of the search engines.
Somehow, the admin interface got into Google. I updated the robots.txt file, but that only prevents further scans, it won’t cause pages to be removed (http://support.google.com/webmasters/bin/answer.py?hl=en&answer=156449).
To prevent the subdomains from being listed in Google search results, I tried to use these directives on a LiteSpeed server:
SetEnvIfNoCase Host ^(admin|mobile|down).*$ NO_INDEX=true
Header set X-Robots-Tag "noindex" env=NO_INDEX
It seems that the conditional always evaluates to true (or is ignored).
In that case, placing the Header directive in a VirtualHost section would be the best approach, but I don’t have access to the server configuration. I sent a ticket in to the hosting company, but they’re really busy. That’s a nice way of saying they didn’t fix it quickly.
One of the nicest features of eZ is its robust configuration, which includes HTTP headers. Adding these settings in siteaccess/admin/site.ini.append.php worked.
[HTTPHeaderSettings]
CustomHeader=enabled
OnlyForAnonymous=enabled
HeaderList[]=X-Robots-Tag
X-Robots-Tag[]
X-Robots-Tag[/]=noindex, nofollow
To remove the admin interface from Google, I’ll send them a request, just to be sure.
eZ Publish Workflow - electronic media delivery
Aug 7th
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
Jul 18th
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® open source content management system %linkEndTag and development framework."|i18n("design/base",,hash('%linkStartTag',"<a href='http://ez.no'
>",'%linkEndTag',"</a>" ))}<br />Copyright © 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: /
How to Keep a Mobile Site Out of Search Engines
Jul 3rd
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.