GemsTracker/gemstracker-library

View on GitHub
classes/Gems/Default/OpenrosaAction.php

Summary

Maintainability
D
1 day
Test Coverage
F
0%
<?php

/**
 *
 * @package    Gems
 * @subpackage Default
 * @author     Menno Dekker <menno.dekker@erasmusmc.nl>
 * @copyright  Copyright (c) 2014 Erasmus MC
 * @license    New BSD License
 */

/**
 * Handles call like an openRosa compliant server. Implements the api as described on
 * https://bitbucket.org/javarosa/javarosa/wiki/OpenRosaAPI
 *
 * To implement, place the controller in the right directory and allow access without login to the
 * following actions:
 *  formList        - Lists the forms available
 *  submission      - Handles receiving a submitted form
 *  download        - Download a form
 *
 * @package    Gems
 * @subpackage Default
 * @author     Menno Dekker <menno.dekker@erasmusmc.nl>
 * @copyright  Copyright (c) 2014 Erasmus MC
 * @license    New BSD License
 */
class Gems_Default_OpenrosaAction extends \Gems_Controller_ModelSnippetActionAbstract
{
    /**
     * This holds the path to the location where the form definitions will be stored.
     * Will be set on init to: GEMS_ROOT_DIR . '/var/uploads/openrosa/forms/';
     *
     * @var string
     */
    public $formDir;

    /**
     * This holds the path to the location where the uploaded responses and their
     * backups will be stored.
     *
     * Will be set on init to: GEMS_ROOT_DIR . '/var/uploads/openrosa/';
     *
     * @var string
     */
    public $responseDir;

    /**
     * @var \Zend_Auth
     */
    protected $auth;

    /**
     * This lists the actions that need http-auth. Only applies to the actions that
     * the openRosa application needs.
     *
     * ODK Collect: http://code.google.com/p/opendatakit/wiki/ODKCollect
     *
     * @var array Array of actions
     */
    protected $authActions = array('formlist', 'submission', 'download');

    /**
     * The id to the last processed received form (gof_id)
     *
     * @var int
     */
    protected $openrosaFormID = null;

    /**
     * This can be used to generate barcodes, use the action
     *
     * /openrosa/barcode/code/<tokenid>
     *
     * example:
     * /openrosa/barocde/code/22pq-grkq
     *
     * The image will be a png
     */
    public function barcodeAction()
    {
        $code = $this->getRequest()->getParam('code', 'empty');
        \Zend_Layout::getMvcInstance()->disableLayout();
        $this->_helper->viewRenderer->setNoRender();

        $barcodeOptions = array('text' => $code);
        $rendererOptions = array();
        $barcode = \Zend_Barcode::render('code128', 'image', $barcodeOptions, $rendererOptions);
        $barcode->render();
    }

    protected function createModel($detailed, $action)
    {
        $model = $this->loader->getModels()->getOpenRosaFormModel();

        $model->set('TABLE_ROWS', 'label', $this->_('Responses'), 'elementClass', 'Exhibitor');

        return $model;
    }

    /**
     * This action should serve the right form to the downloading application
     * it should also handle expiration / availability of forms
     */
    public function downloadAction()
    {
        $filename = $this->getRequest()->getParam('form');
        $filename = basename($filename);    //Strip paths

        $file = $this->formDir . $filename;

        if (!empty($filename) && file_exists($file)) {
            $this->getHelper('layout')->disableLayout();
            $this->getResponse()->setHeader('Content-Type', 'application/xml; charset=utf-8');
            header('Content-Description: File Transfer');
            header('Content-Type: application/octet-stream');
            header('Content-Disposition: attachment; filename="' . $filename . '"');
            header('Content-Transfer-Encoding: binary');
            header('Expires: 0');
            header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
            header('Pragma: public');
            header('Content-Length: ' . filesize($file));
            ob_clean();
            flush();
            readfile($file);
            exit;
        } else {
            $this->getResponse()->setHttpResponseCode(404);
            $this->html->div("form $filename not found");
        }
    }

    /**
     * Accessible via formList as defined in the menu and standard for openRosa clients
     */
    public function formlistAction()
    {
        //first create the baseurl of the form http(s)://projecturl/openrosa/download/form/
        $helper  = new \Zend_View_Helper_ServerUrl();
        $baseUrl = $helper->serverUrl() . \Zend_Controller_Front::getInstance()->getBaseUrl() . '/openrosa/download/form/';

        //As we don't have forms defined yet, we pass in an array, but ofcourse this should be dynamic
        //and come from a helper method
        $model = $this->getModel();
        $rawForms = $model->load(array('gof_form_active'=>1));
        foreach($rawForms as $form) {
            $forms[] = array(
                'formID'      => $form['gof_form_id'],
                'name'        => $form['gof_form_title'],
                'version'     => $form['gof_form_version'],
                'hash'        => md5($form['gof_form_id'].$form['gof_form_version']),
                'downloadUrl' => $baseUrl . $form['gof_form_xml']
            );
        }

        //Now make it a rosaresponse
        $this->makeRosaResponse();

        $xml = $this->getXml('xforms xmlns="http://openrosa.org/xforms/xformsList"');
        foreach ($forms as $form) {
            $xform = $xml->addChild('xform');
            foreach ($form as $key => $value) {
                $xform->addChild($key, $value);
            }
        }

        echo $xml->asXML();
    }

    public function getTopic($count = 1)
    {
        return 'OpenRosa Form';
    }

    public function getTopicTitle()
    {
        return 'OpenRosa Forms';
    }

    /**
     * Create an xml response
     *
     * @param string $rootNode
     * @return SimpleXMLElement
     */
    protected function getXml($rootNode)
    {
        $this->getResponse()->setHeader('Content-Type', 'text/xml; charset=utf-8');

        $xml = simplexml_load_string("<?xml version='1.0' encoding='utf-8'?><$rootNode />");

        return $xml;
    }

    public function imageAction() {
        $request = $this->getRequest();
        $formId      = $request->getParam('id');
        $formVersion = $request->getParam('version');
        $resp        = $request->getParam('resp');
        $field       = $request->getParam('field');
        $model       = $this->getModel();

        $filter = array(
            //'gof_form_active'       => 1,
            'gof_form_id'      => $formId,
            'gof_form_version' => $formVersion,
        );

        if ($formData = $model->loadFirst($filter)) {
            $this->openrosaFormID = $formData['gof_id'];
            // Now find the file in the response table and serve the file

            $file = $this->responseDir . 'forms/' . $this->openrosaFormID . '/' . $resp . '_' . str_replace('_', '.', $field);

            \Zend_Layout::getMvcInstance()->disableLayout();
            $this->_helper->viewRenderer->setNoRender();

            if (file_exists($file)) {
                $size = getimagesize($file);

                $fp = fopen($file, 'rb');

                if ($size and $fp) {
                    // Optional never cache
                    //  header('Cache-Control: no-cache, no-store, max-age=0, must-revalidate');
                    //  header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); // Date in the past
                    //  header('Pragma: no-cache');
                    // Optional cache if not changed
                    header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($file)) . ' GMT');
                    // Optional send not modified
                    if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) and
                            filemtime($file) == strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
                        header('HTTP/1.1 304 Not Modified');
                        exit;
                    }

                    header('Content-Type: ' . $size['mime']);
                    header('Content-Length: ' . filesize($file));

                    fpassthru($fp);

                    exit;
                }
            }
            header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
            exit;
        } else {
            return false;
        }
    }

    public function init()
    {
        parent::init();

        $this->responseDir = GEMS_ROOT_DIR . '/var/uploads/openrosa/';
        $this->formDir = $this->responseDir . 'forms/';
    }

    /**
     * Each rosa response should have the x-openrosa-version header and disable the layout to allow
     * for xml repsonses if needed. We don't need a menu etc. on the openrosa responses
     */
    protected function makeRosaResponse()
    {
        $this->getHelper('layout')->disableLayout();
        $this->getResponse()->setHeader('X-OpenRosa-Version', '1.0');
    }

    /**
     * Handles receiving and storing the data from a form, files are stored on actual upload process
     * this only handles storing form data and can be used for resubmission too.
     *
     * @param type $xmlFile
     * @return string ResultID or false on failure
     */
    private function processReceivedForm($answerXmlFile)
    {
        //Log what we received
        $log     = \Gems_Log::getLogger();
        //$log->log(print_r($xmlFile, true), \Zend_Log::ERR);

        $xml = simplexml_load_file($answerXmlFile);

        $formId      = $xml->attributes()->id;
        $formVersion = $xml->attributes()->version;
        //Lookup what form belongs to this formId and then save
        $model       = $this->getModel();
        $filter      = array(
            //'gof_form_active'       => 1,
            'gof_form_id'      => $formId,
            'gof_form_version' => $formVersion,
        );
        if ($formData          = $model->loadFirst($filter)) {
            $this->openrosaFormID = $formData['gof_id'];
            // Safeguard for when the form definition no longer exists
            try {
                $form = new OpenRosa_Tracker_Source_OpenRosa_Form($this->formDir . $formData['gof_form_xml']);
                $answers = $form->saveAnswer($answerXmlFile);
                return $answers['orf_id'];
            } catch (\Exception $exc) {
                return false;
            }
        } else {
            return false;
        }
    }


    /**
     * Implements HTTP Basic auth
     */
    public function preDispatch()
    {
        parent::preDispatch();

        $action = strtolower($this->getRequest()->getActionName());
        if (in_array($action, $this->authActions)) {
            $auth       = \Zend_Auth::getInstance();
            $this->auth = $auth;

            if (!$auth->hasIdentity()) {
                $config = array(
                    'accept_schemes' => 'basic',
                    'realm'          => GEMS_PROJECT_NAME,
                    'nonce_timeout'  => 3600,
                );
                $adapter         = new \Zend_Auth_Adapter_Http($config);
                $basicResolver   = new \Zend_Auth_Adapter_Http_Resolver_File();

                //This is a basic resolver, use username:realm:password
                //@@TODO: move to a better db stored authentication system

                $basicResolver->setFile(GEMS_ROOT_DIR . '/var/settings/pwd.txt');
                $adapter->setBasicResolver($basicResolver);
                $request  = $this->getRequest();
                $response = $this->getResponse();

                assert($request instanceof \Zend_Controller_Request_Http);
                assert($response instanceof \Zend_Controller_Response_Http);

                $adapter->setRequest($request);
                $adapter->setResponse($response);

                $result = $auth->authenticate($adapter);

                if (!$result->isValid()) {
                    $adapter->getResponse()->sendResponse();
                    print 'Unauthorized';
                    exit;
                }
            }
        }
    }

    public function scanresponsesAction()
    {
        $model = $this->getModel();

        //Perform a scan of the form directory, to update the database of forms
        $eDir = dir($this->responseDir);

        $formCnt  = 0;
        $addCnt   = 0;
        $files    = array();
        $rescan = $this->getRequest()->getParam('rescan', false);
        while (false !== ($filename = $eDir->read())) {
            $ext = substr($filename, -4);
            if ($ext == '.xml' || ($ext == '.bak' && $rescan)) {
                if ($rescan) {
                    $oldname = $filename;
                    $filename = substr($oldname, 0, -4) . '.xml';
                    rename($this->responseDir . $oldname, $this->responseDir . $filename);
                }
                $files[] = $filename;
                $formCnt++;
            }
        }

        foreach ($files as $filename) {
            $result = $this->processReceivedForm($this->responseDir . $filename);
            if ($result !== false) {
                $addCnt++;
            }
        }
        $cache = \GemsEscort::getInstance()->cache;
        $cache->clean();

        $this->html[] = sprintf('Checked %s responses and added %s responses', $formCnt, $addCnt);
    }

    /**
     * Accepts the form
     *
     * Takes two roundtrips:
     * - first we get a HEAD request that should be answerd with
     *   responsecode 204
     * - then we get a post that only submits $_FILES (so actual $_POST will be empty)
     *   this will be an xml file for the actuel response and optionally images and/or video
     *   proper responses are
     *      201 received and stored
     *      202 received ok, not stored
     */
    public function submissionAction()
    {
        $this->makeRosaResponse();

        if ($this->getRequest()->isHead()) {
            $this->getResponse()->setHttpResponseCode(204);
        } elseif ($this->getRequest()->isPost()) {
            //Post
            // We get $_FILES variable holding the formresults as xml and all possible
            // attachments like photo's and video's
            $upload = new \Zend_File_Transfer_Adapter_Http();

            // We should really add some validators here see http://framework.zend.com/manual/en/zend.file.transfer.validators.html
            // Returns all known internal file information
            $files = $upload->getFileInfo();

            foreach ($files as $file => $info) {
                // file uploaded ?
                if (!$upload->isUploaded($file)) {
                    print "Why haven't you uploaded the file ?";
                    continue;
                }

                // validators are ok ?
                if (!$upload->isValid($file)) {
                    print "Sorry but $file is not what we wanted";
                    continue;
                }
            }

            //Dit moet een filter worden (rename filter) http://framework.zend.com/manual/en/zend.file.transfer.filters.html
            $upload->setDestination($this->responseDir);

            //Hier moeten we denk ik eerst de xml_submission_file uitlezen, en daar
            //iets mee doen
            if ($upload->receive('xml_submission_file')) {
                $xmlFile = $upload->getFileInfo('xml_submission_file');
                $answerXmlFile = $xmlFile['xml_submission_file']['tmp_name'];
                $resultId = $this->processReceivedForm($answerXmlFile);
                if ($resultId === false) {
                    //form not accepted!
                    foreach ($xml->children() as $child) {
                        $log->log($child->getName() . ' -> ' . $child, \Zend_Log::ERR);
                    }
                } else {
                    //$log->log(print_r($files, true), \Zend_Log::ERR);
                    //$log->log($deviceId, \Zend_Log::ERR);
                    \MUtil_File::ensureDir($this->responseDir . 'forms/' . (int) $this->openrosaFormID . '/');
                    $upload->setDestination($this->responseDir . 'forms/' . (int) $this->openrosaFormID . '/');
                    foreach ($upload->getFileInfo() as $file => $info) {
                        if ($info['received'] != 1) {
                            //Rename to responseid_filename
                            //@@TODO: move to form subdir, for better separation
                            $upload->addFilter('Rename', $resultId . '_' . $info['name'], $file);
                        }
                    }

                    //Now receive the other files
                    if (!$upload->receive()) {
                        $messages = $upload->getMessages();
                        echo implode("\n", $messages);
                    }
                    $this->getResponse()->setHttpResponseCode(201); //Form received ok
                }
            }
        }
    }
}