<?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);
}
}
}