Syntax Highlighting

    <?php
    namespace Sonic;

    /**
     * App singleton
     *
     * @category Sonic
     * @package App
     * @author Craig Campbell
     */
    final class App
    {
        /**
         * @var string
         */
        const WEB = 'www';

        /**
         * @var string
         */
        const COMMAND_LINE = 'cli';

        /**
         * @var float
         */
        const VERSION = '1.1.2';

        /**
         * @var App
         */
        protected static $_instance;

        /**
         * @var Request
         */
        protected $_request;

        /**
         * @var Delegate
         */
        protected $_delegate;

        /**
         * @var array
         */
        protected $_paths = array();

        /**
         * @var array
         */
        protected $_controllers = array();

        /**
         * @var array
         */
        protected $_queued = array();

        /**
         * @var bool
         */
        protected $_layout_processed = false;

        /**
         * @var array
         */
        protected $_configs = array();

        /**
         * @var array
         */
        protected $_included = array();

        /**
         * @var string
         */
        protected $_base_path;

        /**
         * constants for settings
         */
        const MODE = 0;
        const ENVIRONMENT = 1;
        const AUTOLOAD = 2;
        const CONFIG_FILE = 3;
        const DEVS = 4;
        const DISABLE_APC = 5;
        const TURBO = 6;
        const TURBO_PLACEHOLDER = 7;
        const DEFAULT_SCHEMA = 8;
        const EXTENSION_DATA = 9;
        const EXTENSIONS_LOADED = 10;
        const URI_PREFIX = 11;

        /**
         * @var array
         */
        protected $_settings = array(
            self::MODE => self::WEB,
            self::AUTOLOAD => false,
            self::CONFIG_FILE => 'ini',
            self::DEVS => array('dev', 'development'),
            self::DISABLE_APC => false,
            self::TURBO => false,
            self::EXTENSIONS_LOADED => array()
        );

        /**
         * constructor
         *
         * @return void
         */
        private function __construct() {}

        /**
         * magic call for methods added at runtime
         *
         * @param string $name
         * @param array $args
         */
        public function __call($name, $args)
        {
            return $this->callIfExists($name, $args, __CLASS__, get_class($this));
        }

        /**
         * magic static call for methods added at run time
         *
         * @param string $name
         * @param array $args
         */
        public static function __callStatic($name, $args)
        {
            return self::getInstance()->callIfExists($name, $args, __CLASS__, get_called_class(), true);
        }

        /**
         * calls method if it exists
         *
         * @param string $name
         * @param array $args
         * @param string $class
         * @param instance $class_name
         */
        public function callIfExists($name, $args, $class, $class_name, $static = false)
        {
            if (count($this->getSetting(self::EXTENSIONS_LOADED)) == 0) {
                return trigger_error('Call to undefined method ' . $class_name . '::' . $name . '()', E_USER_ERROR);
            }
            $this->includeFile('Sonic/Extension/Transformation.php');
            $method = $static ? 'callStatic' : 'call';
            return Extension\Transformation::$method($name, $args, $class, $class_name);
        }

        /**
         * gets instance of App class
         *
         * @return App
         */
        public static function getInstance()
        {
            if (self::$_instance === null) {
                self::$_instance = new App();
            }
            return self::$_instance;
        }

        /**
         * handles autoloading
         *
         * @param string $class_name
         * @return void
         */
        public function autoloader($class_name)
        {
            $path = str_replace('\\', '/', $class_name) . '.php';
            return $this->includeFile($path);
        }

        /**
         * includes a file at the given path
         *
         * @param string
         * @return bool
         */
        public function includeFile($path)
        {
            // replace / with directory separator for windows
            $path = str_replace('/', DIRECTORY_SEPARATOR, $path);

            if (isset($this->_included[$path])) {
                return false;
            }

            // if the path starts with / or C: then it is an absolute path
            // otherwise pull it from the libs directory
            include $path[0] == '/' || $path[1] == ':' ? $path : $this->getPath('libs') . DIRECTORY_SEPARATOR . $path;
            $this->_included[$path] = true;

            return true;
        }

        /**
         * initializes autoloader
         *
         * @return void
         */
        public function autoload()
        {
            spl_autoload_register(array($this, 'autoloader'));
        }

        /**
         * sets a setting
         *
         * @param string $key
         * @param mixed $value
         */
        public function addSetting($key, $value)
        {
            $this->_settings[$key] = $value;
        }

        /**
         * gets a setting
         *
         * @param string $name
         * @return mixed
         */
        public function getSetting($name)
        {
            if (!isset($this->_settings[$name])) {
                return null;
            }

            return $this->_settings[$name];
        }

        /**
         * returns the config
         *
         * first tries to grab it from APC then tries to grab it from instance cache
         * if neither of those succeed then it will instantiate the config object
         * and add it to instance cache and/or APC
         *
         * @param string $path path to config path
         * @param string $type (php || ini)
         * @return Config
         */
        public static function getConfig($path = null)
        {
            $app = self::getInstance();
            $environment = $app->getEnvironment();

            $type = $app->getSetting(self::CONFIG_FILE);

            // get the config path
            if ($path === null) {
                $path = $app->getPath('configs') . '/app.' . $type;
            }

            // cache key
            $cache_key =  'config_' . $path . '_' . $environment;

            // if the config is in instance cache return it
            if (isset($app->_configs[$cache_key])) {
                return $app->_configs[$cache_key];
            }

            // we need to load the util and config object before it fetches it from APC
            $app->includeFile('Sonic/Util.php');
            $app->includeFile('Sonic/Config.php');

            // if we are not dev let's try to grab it from APC
            if (!self::isDev() && !$app->getSetting(self::DISABLE_APC) && ($config = apc_fetch($cache_key))) {
                $app->_configs[$cache_key] = $config;
                return $config;
            }

            // if we have gotten here then that means the config exists so we
            // now need to get the environment name and load the config
            $config = new Config($path, $environment, $type);
            $app->_configs[$cache_key] = $config;

            if (!self::isDev() && !$app->getSetting(self::DISABLE_APC)) {
                apc_store($cache_key, $config, Util::toSeconds('24 hours'));
            }

            return $config;
        }

        /**
         * is this dev mode?
         *
         * @return bool
         */
        public static function isDev()
        {
            $app = self::getInstance();
            return in_array($app->getEnvironment(), $app->getSetting(self::DEVS));
        }

        /**
         * gets apache/unix environment name
         *
         * @return string
         */
        public function getEnvironment()
        {
            if ($env = $this->getSetting(self::ENVIRONMENT)) {
                return $env;
            }

            if ($env = getenv('ENVIRONMENT')) {
                $this->addSetting(self::ENVIRONMENT, $env);
                return $env;
            }

            throw new Exception('ENVIRONMENT variable is not set! check your apache config');
        }

        /**
         * gets the request object
         *
         * @return Request
         */
        public function getRequest()
        {
            if (!$this->_request) {
                $this->_request = new Request();
            }
            return $this->_request;
        }

        /**
         * overrides base path
         *
         * @param string $dir
         * @return void
         */
        public function setBasePath($path)
        {
            $this->_base_path = $path;
        }

        /**
         * gets base path of the app
         *
         * @return string
         */
        public function getBasePath()
        {
            if ($this->_base_path) {
                return $this->_base_path;
            }

            throw new \Exception('base path must be set before App::start() is called');
        }

        /**
         * overrides a default path
         *
         * @param string $dir
         * @param string $path
         * @return void
         */
        public function setPath($dir, $path)
        {
            $this->_paths['path_' . $dir] = $path;
        }

        /**
         * gets the absolute path to a directory
         *
         * @param string $dir (views || controllers || lib) etc
         * @return string
         */
        public function getPath($dir = null)
        {
            $cache_key =  'path_' . $dir;

            if (isset($this->_paths[$cache_key])) {
                return $this->_paths[$cache_key];
            }

            $base_path = $this->getBasePath();

            if ($dir !== null) {
                $base_path .= DIRECTORY_SEPARATOR . $dir;
            }

            $this->_paths[$cache_key] = $base_path;
            return $this->_paths[$cache_key];
        }

        /**
         * globally disables layout
         *
         * @return void
         */
        public function disableLayout()
        {
            $this->_layout_processed = true;
        }

        /**
         * gets a controller by name
         *
         * @param string $name
         * @return Controller
         */
        public function getController($name)
        {
            $name = strtolower($name);

            // controller has not been instantiated yet
            if (!isset($this->_controllers[$name])) {
                $path = $this->getPath('controllers') . '/' . str_replace('\\', DIRECTORY_SEPARATOR, $name) . '.php';
                $success = include $path;
                if (!$success) {
                    throw new Exception('controller does not exist at path: ' . $path);
                }
                $class_name = '\Controllers\\' . $name;
                $this->_controllers[$name] = new $class_name;
                $this->_controllers[$name]->name($name);
            }

            return $this->_controllers[$name];
        }

        /**
         * runs a controller and action combination
         *
         * @param string $controller_name controller to use
         * @param string $action method within controller to execute
         * @param array $args arguments to be added to the Request object and view
         * @param bool $json should we render json
         * @param string $id view id for if we are in turbo mode an exception is thrown
         * @return void
         */
        protected function _runController($controller_name, $action, $args = array(), $json = false, $id = null)
        {
            $this->getRequest()->addParams($args);

            $controller = $this->getController($controller_name);
            $controller->setView($action, false);

            // if we are requesting JSON that means this is being processed from the turbo queue
            // if we are not in turbo mode then we run the action normally
            $can_run = $json || !$this->getSetting(self::TURBO);

            if ($this->_delegate) {
                $this->_delegate->actionWasCalled($controller, $action, $args);
            }

            if ($can_run) {
                $this->_runAction($controller, $action, $args);
            }

            $view = null;
            if ($controller->hasView()) {
                $view = $controller->getView();
                $view->setAction($action);
                $view->addVars($args);
            }

            // process the layout if we can
            // this takes care of handling this view
            if ($this->_processLayout($controller, $view, $args)) {
                return;
            }

            if (!$view) {
                return;
            }

            if ($this->_delegate) {
                $this->_delegate->viewStartedRendering($view, $json);
            }

            // output the view contents
            $view->output($json, $id);

            if ($this->_delegate) {
                $this->_delegate->viewFinishedRendering($view, $json);
            }
        }

        /**
         * processes the layout if it needs to be processed
         *
         * @param Controller $controller
         * @param View $view
         * @param array $args
         * @return bool
         */
        protected function _processLayout(Controller $controller, View $view = null, $args)
        {
            // if the layout was already processed ignore this call
            if ($this->_layout_processed) {
                return false;
            }

            // if the controller doesn't have a layout ignore this call
            if (!$controller->hasLayout()) {
                return false;
            }

            // if this is not the first controller and not an exception, ignore
            if (count($this->_controllers) != 1 && !isset($args['exception'])) {
                return false;
            }

            // process the layout!
            $this->_layout_processed = true;
            $layout = $controller->getLayout();

            $layout->topView($view ?: new View);

            if ($this->_delegate) {
                $this->_delegate->layoutStartedRendering($layout);
            }

            $layout->output();

            if ($this->_delegate) {
                $this->_delegate->layoutFinishedRendering($layout);
            }

            return true;
        }

        /**
         * runs a specific action in a controller
         *
         * @param Controller $controller
         * @param string $action
         * @return void
         */
        protected function _runAction(Controller $controller, $action, array $args = array())
        {
            if ($this->_delegate) {
                $this->_delegate->actionStartedRunning($controller, $action, $args);
            }

            $controller->$action();
            $controller->actionComplete($action);

            if ($this->_delegate) {
                $this->_delegate->actionFinishedRunning($controller, $action, $args);
            }
        }

        /**
         * public access to run a controller (handles exceptions)
         *
         * @param string $controller_name controller to use
         * @param string $action method within controller to execute
         * @param array $args arguments to be added to the Request object and view
         * @param bool $json should we render json?
         * @param string $controller_name
         */
        public function runController($controller_name, $action, $args = array(), $json = false)
        {
            try {
                $this->_runController($controller_name, $action, $args, $json);
            } catch (\Exception $e) {
                $this->handleException($e, $controller_name, $action);
                return;
            }
        }

        /**
         * queues up a view for later processing
         *
         * only happens in turbo mode
         *
         * @param string
         * @param string
         * @return void
         */
        public function queueView($controller, $name)
        {
            $this->_queued[] = array($controller, $name);
        }

        /**
         * processes queued up views for turbo mode
         *
         * @return void
         */
        public function processViewQueue()
        {
            if (!$this->getSetting(self::TURBO)) {
                return;
            }

            while (count($this->_queued)) {
                foreach ($this->_queued as $key => $queue) {
                    $this->runController($queue[0], $queue[1], array(), true);
                    unset($this->_queued[$key]);
                }
            }
        }

        /**
         * handles an exception when loading a page
         *
         * @param Exception $e
         * @param string $controller name of controller
         * @param string $action name of action
         * @return void
         */
        public function handleException(\Exception $e, $controller = null, $action = null)
        {
            if ($this->_delegate) {
                $this->_delegate->appCaughtException($e, $controller, $action);
            }

            // turn other exceptions into sonic exceptions
            if (!$e instanceof Exception) {
                $e = new Exception($e->getMessage(), Exception::INTERNAL_SERVER_ERROR, $e);
            }

            // only set the http code if output hasn't started
            if (!headers_sent()) {
                header($e->getHttpCode());
            }

            $json = false;
            $id = null;

            // in turbo mode we have to write the exception markup out to the
            // same div created before the exception was triggered.  this means
            // we have to get the id based on the controller and action that the
            // exception came from
            if ($this->getSetting(self::TURBO) && $this->_layout_processed) {
                $json = true;
                $id = View::generateId($controller, $action);
            }

            $completed = false;

            // controller and action are only null if this is a page not found
            // because we were not able to match any routes.  in all other cases
            // we can get the initial controller and action to determine if it has
            // completed
            if ($controller !== null && $action !== null) {
                $req = $this->getRequest();
                $first_controller = $req->getControllerName();
                $first_action = $req->getAction();
                $completed = $this->getController($first_controller)->hasCompleted($first_action);
            }

            $args = array(
                'exception' => $e,
                'top_level_exception' => !$completed,
                'from_controller' => $controller,
                'from_action' => $action
            );

            return $this->_runController('main', 'error', $args, $json, $id);
        }

        /**
         * determines if we should turn off turbo mode
         *
         * @return bool
         */
        protected function _robotnikWins()
        {
            if ($this->getRequest()->isAjax() || isset($_COOKIE['noturbo']) || isset($_COOKIE['bot'])) {
                return true;
            }

            if (isset($_GET['noturbo'])) {
                setcookie('noturbo', true, time() + 86400);
                return true;
            }

            if (isset($_SERVER['HTTP_USER_AGENT']) && strpos($_SERVER['HTTP_USER_AGENT'], 'Googlebot') !== false) {
                setcookie('bot', true, time() + 86400);
                return true;
            }

            return false;
        }

        /**
         * sets a delegate class to receive events as the application runs
         *
         * @param string $delegate name of delegate class
         * @return \Sonic\App
         */
        public function setDelegate($delegate)
        {
            $this->includeFile('Sonic/App/Delegate.php');
            $this->autoloader($delegate);

            $delegate = new $delegate;

            if (!$delegate instanceof App\Delegate) {
                throw new \Exception('app delegate of class ' . get_class($delegate) . ' must be instance of \Sonic\App\Delegate');
            }

            $this->_delegate = $delegate;
            $this->_delegate->setApp($this);
            return $this;
        }

        /**
         * loads an extension by name
         *
         * @param string $name
         * @return App
         */
        public function loadExtension($name)
        {
            // if this is already loaded don't do anything
            if ($this->extensionLoaded($name)) {
                return $this;
            }

            $name = strtolower($name);

            // first grab the extension installation data
            $extensions = $this->getSetting(self::EXTENSION_DATA);
            if (!$extensions) {
                $path = $this->getPath('extensions/installed.json');

                if (file_exists($path)) {
                    $extensions = json_decode(file_get_contents($path), true);
                    $this->addSetting(self::EXTENSION_DATA, $extensions);
                }
            }

            if (!isset($extensions[$name])) {
                throw new Exception('trying to load extension "' . $name . '" which is not installed!');
            }

            // get the data related to this extension
            $data = $extensions[$name];

            // create a delegate object if this extension has one
            $delegate = null;
            if (isset($data['delegate_path']) && isset($data['delegate'])) {
                $this->includeFile('Sonic/Extension/Delegate.php');
                $this->includeFile($this->getPath($data['delegate_path']));
                $delegate = new $data['delegate'];
            }

            if ($delegate) {
                $delegate->extensionStartedLoading();
            }

            $base_path = $this->getPath();

            $core = 'extensions/' . $name . '/Core.php';
            $has_core = isset($data['has_core']) && $data['has_core'];
            $dev = isset($data['dev']) && $data['dev'];

            foreach ($data['files'] as $file) {

                // if the file is not in the extensions or libs directory then skip it
                // we don't want to load controllers/views/etc. here
                $lib_file = strpos($file, 'libs') === 0;

                // don't load libs files unless the extension says to explicitly
                if ($lib_file && !$data['load_libs']) {
                    continue;
                }

                if (strpos($file, 'extensions') !== 0 && !$lib_file) {
                    continue;
                }

                // if this is not a PHP file then skip it
                if (substr($file, -4) != '.php') {
                    continue;
                }

                // skip core in dev mode
                if ($dev && $file == $core) {
                    continue;
                }

                // if this is a file that is not in libs and not core then skip it
                if (!$dev && !$lib_file && $has_core && $file != $core) {
                    continue;
                }

                $this->includeFile($base_path . '/' . $file);

                if ($delegate) {
                    $delegate->extensionLoadedFile($file);
                }
            }

            $loaded = $this->getSetting(self::EXTENSIONS_LOADED);
            $loaded[] = $name;
            $this->addSetting(self::EXTENSIONS_LOADED, $loaded);

            if ($delegate) {
                $delegate->extensionFinishedLoading();
            }

            return $this;
        }

        /**
         * determines if an extension is loaded
         *
         * @param string $name
         * @return bool
         */
        public function extensionLoaded($name)
        {
            $loaded = $this->getSetting(self::EXTENSIONS_LOADED);
            return in_array(strtolower($name), $loaded);
        }

        /**
         * gets an extension helper for this extension
         *
         * @param string $name
         * @return \Sonic\Extension\Helper
         */
        public function extension($name)
        {
            $this->includeFile('Sonic/Extension/Helper.php');
            return Extension\Helper::forExtension($name);
        }

        /**
         * pushes over the first domino
         *
         * @param string $mode
         * @return void
         */
        public function start($mode = self::WEB)
        {
            $lib = $this->getPath('libs') . DIRECTORY_SEPARATOR . 'Sonic' . DIRECTORY_SEPARATOR;
            try {
                if ($this->_delegate) {
                    $this->_delegate->appStartedLoading($mode);
                }

                $this->addSetting(self::MODE, $mode);

                require_once $lib . 'Exception.php';
                require_once $lib . 'Request.php';
                require_once $lib . 'Router.php';
                require_once $lib . 'Controller.php';
                require_once $lib . 'View.php';
                require_once $lib . 'Layout.php';

                if ($this->getSetting(self::AUTOLOAD)) {
                    $this->autoload();
                }

                if ($this->_delegate) {
                    $this->_delegate->appFinishedLoading();
                }

                // if we are calling this app from command line then all we want to do
                // is load the core application files
                if ($mode != self::WEB) {
                    return;
                }

                if ($this->getSetting(self::TURBO) && $this->_robotnikWins()) {
                    $this->addSetting(self::TURBO, false);
                }

                // try to get the controller and action
                // if an exception is thrown that means the page requested does not exist
                $controller = $this->getRequest()->getControllerName();
                $action = $this->getRequest()->getAction();

                if ($this->_delegate) {
                    $this->_delegate->appStartedRunning();
                }

                $this->runController($controller, $action);

                if ($this->_delegate) {
                    $this->_delegate->appFinishedRunning();
                }
            } catch (\Exception $e) {
                $this->handleException($e);
            }
        }
    }