Not so poor man’s cron jobs for CakePHP

A.k.a. “How to make AuthComponent think the user is someone else”

The other day I needed to add subscribable email reports to a CakePHP web application. The system should generate the reports automatically every few days and send them automatically to their owners. In a Unix setting that would be job for cron.

Unfortunately my hosting, while otherwise awesome, doesn’t offer cron. The usual solution in these cases is “poor man’s cron:” on every page load, check and execute any pending scheduled jobs. The tricky bit is this: How do you convince AuthComponent (and the rest of the application) that the job is executed by its owner, not by the visitor?

Background: Cron is a Unix daemon for scheduled job execution. Cron is often used for system maintenance, but normal users can use it too. The output of a job is emailed to its owner.

The model

Since users need to be able to define their own jobs I decided it makes sense to store the jobs in the database. The data we need are foreign key to the User model “owner_id,” a schedule, a subject for the emails that are sent, and the URL of the job for execution. The schedule can be defined as a “date of next execution” and the execution period in days since all I need is execute the action every N days.

CREATE TABLE periodic_jobs (
  id        int NOT NULL AUTO_INCREMENT,
  owner_id  int DEFAULT NULL,
  next_exec date DEFAULT NULL,
  period    int DEFAULT NULL,
  subject   tinytext DEFAULT NULL,
  url       tinytext DEFAULT NULL,
  PRIMARY KEY (id), INDEX (next_exec)
);

Then we’ll need a Cake model class to bind to the user model. Mine is called User, you might have to change this:

<?php
// app/models/periodic_job.php
class PeriodicJob extends AppModel {
    var $name = 'PeriodicJob';
    var $belongsTo = array (
        'Owner' => array (
            'className' => 'User', // change this if needed
            'foreignKey' => 'owner_id'
    ));
}
?>

The controller

Cake makes it very easy to execute arbitrary actions. The requestAction function generates a fully rendered view, ready to be sent to the users with EmailComponent. Great, so we can stick a bunch of requestAction calls in some AppController callback, and be done with it!

But remember that requestAction function will create a new instance of AuthComponent to use with the new request. How do we execute the job as its owner, considering the application uses AuthComponent?

Switching users turns out to be pretty easy. AuthComponent stores the information of the authenticated user in the session, with sessionKey as the key. All you need is one call to Session->write, and all instances of AuthComponent in Cake will assume the other user’s identity. But there’s still one small complication.

If the user hasn’t logged in yet AuthComponent::sessionKey will not be set. In this case we just have to assume the default value for sessionKey. Setting it won’t do any good because requestAction will create a new instance of AuthComponent.

The code for checking for and executing pending jobs will look like below. I’ve created a new controller to keep AppController as light as possible.

<?php
// app/controllers/periodic_jobs.php

define('EMAIL_FROM', 'periodicjobs@example.com');

class PeriodicJobsController extends AppController {

    var $scaffold; // quick and dirty admin interface
    var $components = array ('Email');

    function checkAndExecutePending() {
	$this->autoRender = false;
	$jobs = $this->PeriodicJob->find('all', array (
	    'conditions' => array (
		'next_exec < NOW()'
 	    )
 	));
        // Save user from original request
        $origSession = false;
        if ($this->Auth->sessionKey) {
            $sessionKey = $this->Auth->sessionKey;
            $origSession = $this->Session->read($sessionKey);
        } else {
            // Assume AuthComponent will use the default sessionKey
            $sessionKey = 'Auth.'.$this->Auth->userModel;
        }
	foreach ($jobs as $job) {
	    $email = $job['Owner']['email'];
	    $url = $job['PeriodicJob']['url'];
            $period = $job['PeriodicJob']['period'];
            // Assume owner's identity
	    $this->Session->write($sessionKey, $job['Owner']);
            // Run the job
	    $result =& $this->requestAction($url, array('return'));
            // Email the results, update date if successful
	    $this->Email->reset();
	    $this->Email->from = EMAIL_FROM;
	    $this->Email->to = $email;
	    $this->Email->subject = $job['PeriodicJob']['subject'];
	    $this->Email->sendAs = 'html';
	    if ($this->Email->send($result)) {
		$this->log("Emailed $url to $email", 'periodic-jobs');
		$this->PeriodicJob->id = $job['PeriodicJob']['id'];
		$this->PeriodicJob->saveField('next_exec',
                        strftime('%F', strtotime("+$period days")));
	    } else {
		$this->log("Can't email $url to $email", 'periodic-jobs');
	    }
	}
        // Restore user from original request, or delete if not logged in
        if ($origSession) {
	    $this->Session->write($sessionKey, $origSession);
        } else {
            $this->Session->delete($sessionKey);
        }
    }
}
?>

Integrating with AppController

We’ll use the AppController::afterFilter-hook to check and execute pending jobs on every page load. To save resources we can limit the checks to once per session. This also prevents an infinite recursive loop, where the afterFilter executed for a PeriodicJob would call checkAndExecutePending again.

<?php
// app/app_controller.php

class AppController extends Controller {

	var $components = array ('Auth', 'Session');

	function beforeFilter() {
		$this->Auth->loginAction = array(
                        'controller' => 'users', 'action' => 'login');
	}

	function afterFilter() {
		$key = 'PeriodicJob.checked';
		if (!$this->Session->check($key)) {
		    $this->Session->write($key, true);
		    if (!class_exists('PeriodicJobsController')) {
		        App::import('Controller', 'PeriodicJobs');
		    }
		    $crtl = new PeriodicJobsController();
		    $crtl->constructClasses();
		    $crtl->checkAndExecutePending();
		}
	}
}
?>

Update: Want to see it run? Here’s a minimal sample Cake app without the “cake” directory, with a database schema and some test data. I’ve tested it with Cake 1.2.7 and 1.3.3, but it should work with other versions too. Download periodic_jobs-20100822.zip.

I haven’t checked if this method works also with AuthComponent’s authorization mechanisms. Here I use Auth for user authentication only.