Sunday, March 7, 2010

Image Protection in Zend Framework

Sometimes you want to only show certain images (or other media) to certain users (e.g. those that are logged in, or those that have paid for it). This quick tutorial will show how to write the controller for Zend Framework for serving images. This is for ZF 1.10, but as far as I can tell it is not using anything new or unusual.

This tutorial does not cover the validation part of the code.

We start with a controller that does not use views, layout and all that stuff:
class ImgController extends Zend_Controller_Action
{
public function init()
{
$this->_helper->viewRenderer->setNoRender(true);
$this->_helper->layout()->disableLayout();
}
}


Now add an action that will serve all images:
public function imgAction(){
$type=$this->getRequest()->getParam(1);
$fname=$this->getRequest()->getParam(2);
$ext=$this->getRequest()->getParam(3);

echo "type=$type, fname=$fname, ext=$ext";
}

public function badAction(){
$this->getResponse()->setHeader('Content-Type','image/jpeg');
}


The idea is that URLs such as http://127.0.0.1/img/folder/abc.jpg will end up at the imgAction() function, and $type will get set to "folder", $fname to "abc" and $ext to "jpg". To do that we need to set up what is called a router, which we will do next. The badAction() will handle any problems; this crude code will send back a 0 byte jpeg.

To create a router, jump to your bootstrap file and create a function something like this:
public function _initCustomRouting(){
$frontController=Zend_Controller_Front::getInstance();
$router=$frontController->getRouter();
$router->addRoute(  //To catch any that are not formatted correctly
'imgHandlingBad',
new Zend_Controller_Router_Route('img/*',
array('controller'=>'img', 'action'=>'bad')
)
);
$router->addRoute(  //Formatted as /url/type/fname.ext
'imgHandling',
new Zend_Controller_Router_Route_Regex('img/(.+)/(.+)\.(.+)',
array('controller'=>'img', 'action'=>'img')
)
);
}


Note that order: imgHandlingBad must come before imgHandling. I found this introduction (7 minute screencast) to using Zend_Controller_Router_Route very useful; then some trial and error and study of the ZF source code.

Now you should be able to test http://127.0.0.1/img/folder/abc.jpg and see our debug comment. But without a folder (e.g. http://127.0.0.1/img/abc.jpg) it will get handled by the badAction(), as will those without an extension: http://127.0.0.1/img/folder/abcjpg

Now the final step is to feed back some images, so in imgAction() replace the echo line with this code:
$path=$basePath.$type;
$fullPath=$path.$fname.'.'.$ext;
if(!file_exists($fullPath))return $this->badAction();

switch($ext){
case 'jpg':$mime='image/jpeg';break;
case 'png':$mime='image/png';break;
case 'gif':$mime='image/gif';break;
default:return $this->badAction();
}
$this->getResponse()->setHeader('Content-Type',$mime);

readfile($fullPath);


It decides the filename, decides the mime-type based on extension, and then readfile passes on the binary data.

In the above code we use the $type directly to decide the image path. You could instead map type to different directories. E.g.
switch($type){
case 'www':$path='/var/www/main_images/';break;
case 'www/articles':$path='/var/www/html/special/articles/';break;
case 'family':$path='/home/user/images/family/';break;
default:return $this->badAction();
}


Another way you might use $type is if certain users can only see certain types of images. You could do different validation checks in each case statement above.

Got any suggestions to make this code better? Let me know!

P.S. One special note about this technique. In the typical MVC Zend Framework setup, if you create public/img/ then apache will serve images from there; only if the image is missing in that location will Apache ask the Zend Framework to serve it. You might use this to your advantage to speed up delivery of certain common images. But it also opens the way to accidentally serving images that are supposed to be protected.

6 comments:

Eloar said...

Great! I've been looking for that for a long time! Thx for that good art.

darren said...

By the way, Facebook does not protect images in this way. Try making an image that only you are allowed to see. View it, save the image URL, then log off and you can still view it. Or pass the URL to your friends.

Of course they have quite a few users (*), and use a static content delivery server. But doing that while maintaining privacy is still not impossible. Hard work, but not impossible.

*: British understatement ;-)

Nick said...

Hey Darren,

I recently integrated this same concept into a project. My only thought is, isnt this alot of overhead when looking at a page full of protected images? Can you think of a way to avert that while still protecting the content?

darren said...

> isn't this a lot of overhead when looking
> at a page full of protected images?

Yes. What you could do is cut out Zend, and go to raw PHP to just do the DB validation. But then you have to make future changes to the DB validation in two places.

Or, if cutting out Zend, you could rely on a cookie (set when the user logs in), and to only serve the image if the cookie is present (i.e. not do the DB validation for the images).

Anonymous said...

Hello Darren, I am new to zend, may i know how to retrieve images from file location? I have tried by using imagepng($image); or imagecreatefromstring(file_get_contents('..path/abc.png)). But both leads to image cannot be open cause it contains error. Can you help me?

darren said...

Hello Anon,
It isn't clear if you are seeing an error server-side or client-side. If client-side my guess would be the header is not set. That is what this line is doing:
$this->getResponse()->setHeader('Content-Type','image/jpeg');

Using wget to see exactly what you are receiving is good (but it takes a bit of fiddling with cookies if your website uses logins).

If server-side, my guess would be your current working directory is unexpected. Try explicitly using chdir().