diff options
Diffstat (limited to 'main/app/sprinkles/core/src')
60 files changed, 7914 insertions, 0 deletions
diff --git a/main/app/sprinkles/core/src/Alert/AlertStream.php b/main/app/sprinkles/core/src/Alert/AlertStream.php new file mode 100755 index 0000000..3946cbf --- /dev/null +++ b/main/app/sprinkles/core/src/Alert/AlertStream.php @@ -0,0 +1,144 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Alert; + +use UserFrosting\Fortress\ServerSideValidator; + +/** + * AlertStream Class + * + * Implements an alert stream for use between HTTP requests, with i18n support via the MessageTranslator class + * + * @author Alex Weissman (https://alexanderweissman.com) + * @see http://www.userfrosting.com/components/#messages + */ +abstract class AlertStream +{ + + /** + * @var string + */ + protected $messagesKey; + + /** + * @var UserFrosting\I18n\MessageTranslator|null + */ + protected $messageTranslator = null; + + /** + * Create a new message stream. + */ + public function __construct($messagesKey, $translator = null) + { + $this->messagesKey = $messagesKey; + + $this->setTranslator($translator); + } + + /** + * Set the translator to be used for all message streams. Must be done before `addMessageTranslated` can be used. + * + * @param UserFrosting\I18n\MessageTranslator $translator A MessageTranslator to be used to translate messages when added via `addMessageTranslated`. + */ + public function setTranslator($translator) + { + $this->messageTranslator = $translator; + return $this; + } + + /** + * Adds a raw text message to the cache message stream. + * + * @param string $type The type of message, indicating how it will be styled when outputted. Should be set to "success", "danger", "warning", or "info". + * @param string $message The message to be added to the message stream. + * @return MessageStream this MessageStream object. + */ + public function addMessage($type, $message) + { + $messages = $this->messages(); + $messages[] = array( + "type" => $type, + "message" => $message + ); + $this->saveMessages($messages); + return $this; + } + + /** + * Adds a text message to the cache message stream, translated into the currently selected language. + * + * @param string $type The type of message, indicating how it will be styled when outputted. Should be set to "success", "danger", "warning", or "info". + * @param string $messageId The message id for the message to be added to the message stream. + * @param array[string] $placeholders An optional hash of placeholder names => placeholder values to substitute into the translated message. + * @return MessageStream this MessageStream object. + */ + public function addMessageTranslated($type, $messageId, $placeholders = array()) + { + if (!$this->messageTranslator){ + throw new \RuntimeException("No translator has been set! Please call MessageStream::setTranslator first."); + } + + $message = $this->messageTranslator->translate($messageId, $placeholders); + return $this->addMessage($type, $message); + } + + /** + * Get the messages and then clear the message stream. + * This function does the same thing as `messages()`, except that it also clears all messages afterwards. + * This is useful, because typically we don't want to view the same messages more than once. + * + * @return array An array of messages, each of which is itself an array containing "type" and "message" fields. + */ + public function getAndClearMessages() + { + $messages = $this->messages(); + $this->resetMessageStream(); + return $messages; + } + + /** + * Add error messages from a ServerSideValidator object to the message stream. + * + * @param ServerSideValidator $validator + */ + public function addValidationErrors(ServerSideValidator $validator) + { + foreach ($validator->errors() as $idx => $field) { + foreach($field as $eidx => $error) { + $this->addMessage("danger", $error); + } + } + } + + /** + * Return the translator for this message stream. + * + * @return MessageTranslator The translator for this message stream. + */ + public function translator() + { + return $this->messageTranslator; + } + + /** + * Get the messages from this message stream. + * + * @return array An array of messages, each of which is itself an array containing "type" and "message" fields. + */ + abstract public function messages(); + + /** + * Clear all messages from this message stream. + */ + abstract public function resetMessageStream(); + + /** + * Save messages to the stream + */ + abstract protected function saveMessages($message); +} diff --git a/main/app/sprinkles/core/src/Alert/CacheAlertStream.php b/main/app/sprinkles/core/src/Alert/CacheAlertStream.php new file mode 100755 index 0000000..1fd5131 --- /dev/null +++ b/main/app/sprinkles/core/src/Alert/CacheAlertStream.php @@ -0,0 +1,84 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Alert; + +use Illuminate\Cache\Repository as Cache; +use UserFrosting\I18n\MessageTranslator; +use UserFrosting\Support\Repository\Repository; + +/** + * CacheAlertStream Class + * Implements a message stream for use between HTTP requests, with i18n + * support via the MessageTranslator class using the cache system to store + * the alerts. Note that the tags are added each time instead of the + * constructor since the session_id can change when the user logs in or out + * + * @author Louis Charette + */ +class CacheAlertStream extends AlertStream +{ + /** + * @var Cache Object We use the cache object so that added messages will automatically appear in the cache. + */ + protected $cache; + + /** + * @var Repository Object We use the cache object so that added messages will automatically appear in the cache. + */ + protected $config; + + /** + * Create a new message stream. + * + * @param string $messagesKey Store the messages under this key + * @param MessageTranslator|null $translator + * @param Cache $cache + * @param Repository $config + */ + public function __construct($messagesKey, MessageTranslator $translator = null, Cache $cache, Repository $config) + { + $this->cache = $cache; + $this->config = $config; + parent::__construct($messagesKey, $translator); + } + + /** + * Get the messages from this message stream. + * + * @return array An array of messages, each of which is itself an array containing 'type' and 'message' fields. + */ + public function messages() + { + if ($this->cache->tags('_s'.session_id())->has($this->messagesKey)) { + return $this->cache->tags('_s'.session_id())->get($this->messagesKey) ?: []; + } else { + return []; + } + } + + /** + * Clear all messages from this message stream. + * + * @return void + */ + public function resetMessageStream() + { + $this->cache->tags('_s'.session_id())->forget($this->messagesKey); + } + + /** + * Save messages to the stream + * + * @param string $messages The message + * @return void + */ + protected function saveMessages($messages) + { + $this->cache->tags('_s'.session_id())->forever($this->messagesKey, $messages); + } +} diff --git a/main/app/sprinkles/core/src/Alert/SessionAlertStream.php b/main/app/sprinkles/core/src/Alert/SessionAlertStream.php new file mode 100755 index 0000000..8b4604b --- /dev/null +++ b/main/app/sprinkles/core/src/Alert/SessionAlertStream.php @@ -0,0 +1,70 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Alert; + +use UserFrosting\I18n\MessageTranslator; +use UserFrosting\Session\Session; + +/** + * SessionAlertStream Class + * Implements a message stream for use between HTTP requests, with i18n support via the MessageTranslator class + * Using the session storage to store the alerts + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class SessionAlertStream extends AlertStream +{ + /** + * @var Session We use the session object so that added messages will automatically appear in the session. + */ + protected $session; + + /** + * Create a new message stream. + * + * @param string $messagesKey Store the messages under this key + * @param MessageTranslator|null $translator + * @param Session $session + */ + public function __construct($messagesKey, MessageTranslator $translator = null, Session $session) + { + $this->session = $session; + parent::__construct($messagesKey, $translator); + } + + /** + * Get the messages from this message stream. + * + * @return array An array of messages, each of which is itself an array containing "type" and "message" fields. + */ + public function messages() + { + return $this->session[$this->messagesKey] ?: []; + } + + /** + * Clear all messages from this message stream. + * + * @return void + */ + public function resetMessageStream() + { + $this->session[$this->messagesKey] = []; + } + + /** + * Save messages to the stream + * + * @param string $messages The message + * @return void + */ + protected function saveMessages($messages) + { + $this->session[$this->messagesKey] = $messages; + } +} diff --git a/main/app/sprinkles/core/src/Controller/CoreController.php b/main/app/sprinkles/core/src/Controller/CoreController.php new file mode 100755 index 0000000..b5f6e3c --- /dev/null +++ b/main/app/sprinkles/core/src/Controller/CoreController.php @@ -0,0 +1,90 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ + +namespace UserFrosting\Sprinkle\Core\Controller; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Slim\Exception\NotFoundException as NotFoundException; + +/** + * CoreController Class + * + * Implements some common sitewide routes. + * @author Alex Weissman (https://alexanderweissman.com) + * @see http://www.userfrosting.com/navigating/#structure + */ +class CoreController extends SimpleController +{ + /** + * Renders the default home page for UserFrosting. + * + * By default, this is the page that non-authenticated users will first see when they navigate to your website's root. + * Request type: GET + */ + public function pageIndex($request, $response, $args) { + return $this->ci->view->render($response, 'pages/index.html.twig'); + } + + /** + * Renders a sample "about" page for UserFrosting. + * + * Request type: GET + */ + public function pageAbout($request, $response, $args) { + return $this->ci->view->render($response, 'pages/about.html.twig'); + } + + /** + * Renders terms of service page. + * + * Request type: GET + */ + public function pageLegal($request, $response, $args) { + return $this->ci->view->render($response, 'pages/legal.html.twig'); + } + + /** + * Renders privacy page. + * + * Request type: GET + */ + public function pagePrivacy($request, $response, $args) { + return $this->ci->view->render($response, 'pages/privacy.html.twig'); + } + + /** + * Render the alert stream as a JSON object. + * + * The alert stream contains messages which have been generated by calls to `MessageStream::addMessage` and `MessageStream::addMessageTranslated`. + * Request type: GET + */ + public function jsonAlerts($request, $response, $args) { + return $response->withJson($this->ci->alerts->getAndClearMessages()); + } + + /** + * Handle all requests for raw assets. + * Request type: GET + */ + public function getAsset($request, $response, $args) { + // By starting this service, we ensure that the timezone gets set. + $config = $this->ci->config; + + $assetLoader = $this->ci->assetLoader; + + if (!$assetLoader->loadAsset($args['url'])) { + throw new NotFoundException($request, $response); + } + + return $response + ->withHeader('Content-Type', $assetLoader->getType()) + ->withHeader('Content-Length', $assetLoader->getLength()) + ->write($assetLoader->getContent()); + } +} diff --git a/main/app/sprinkles/core/src/Controller/SimpleController.php b/main/app/sprinkles/core/src/Controller/SimpleController.php new file mode 100755 index 0000000..b0fc152 --- /dev/null +++ b/main/app/sprinkles/core/src/Controller/SimpleController.php @@ -0,0 +1,36 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Controller; + +use Interop\Container\ContainerInterface; + +/** + * SimpleController Class + * + * Basic controller class, that imports the entire DI container for easy access to services. + * Your controller classes may extend this controller class. + * @author Alex Weissman (https://alexanderweissman.com) + * @see http://www.userfrosting.com/navigating/#structure + */ +class SimpleController +{ + /** + * @var ContainerInterface The global container object, which holds all your services. + */ + protected $ci; + + /** + * Constructor. + * + * @param ContainerInterface $ci The global container object, which holds all your services. + */ + public function __construct(ContainerInterface $ci) + { + $this->ci = $ci; + } +} diff --git a/main/app/sprinkles/core/src/Core.php b/main/app/sprinkles/core/src/Core.php new file mode 100755 index 0000000..d7e1dcb --- /dev/null +++ b/main/app/sprinkles/core/src/Core.php @@ -0,0 +1,121 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core; + +use RocketTheme\Toolbox\Event\Event; +use UserFrosting\Sprinkle\Core\Database\Models\Model; +use UserFrosting\Sprinkle\Core\Util\EnvironmentInfo; +use UserFrosting\Sprinkle\Core\Util\ShutdownHandler; +use UserFrosting\System\Sprinkle\Sprinkle; + +/** + * Bootstrapper class for the core sprinkle. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Core extends Sprinkle +{ + /** + * Defines which events in the UF lifecycle our Sprinkle should hook into. + */ + public static function getSubscribedEvents() + { + return [ + 'onSprinklesInitialized' => ['onSprinklesInitialized', 0], + 'onSprinklesRegisterServices' => ['onSprinklesRegisterServices', 0], + 'onAddGlobalMiddleware' => ['onAddGlobalMiddleware', 0] + ]; + } + + /** + * Set static references to DI container in necessary classes. + */ + public function onSprinklesInitialized() + { + // Set container for data model + Model::$ci = $this->ci; + + // Set container for environment info class + EnvironmentInfo::$ci = $this->ci; + } + + /** + * Get shutdownHandler set up. This needs to be constructed explicitly because it's invoked natively by PHP. + */ + public function onSprinklesRegisterServices() + { + // Set up any global PHP settings from the config service. + $config = $this->ci->config; + + // Display PHP fatal errors natively. + if (isset($config['php.display_errors_native'])) { + ini_set('display_errors', $config['php.display_errors_native']); + } + + // Log PHP fatal errors + if (isset($config['php.log_errors'])) { + ini_set('log_errors', $config['php.log_errors']); + } + + // Configure error-reporting level + if (isset($config['php.error_reporting'])) { + error_reporting($config['php.error_reporting']); + } + + // Configure time zone + if (isset($config['php.timezone'])) { + date_default_timezone_set($config['php.timezone']); + } + + // Determine if error display is enabled in the shutdown handler. + $displayErrors = false; + if (in_array(strtolower($config['php.display_errors']), [ + '1', + 'on', + 'true', + 'yes' + ])) { + $displayErrors = true; + } + + $sh = new ShutdownHandler($this->ci, $displayErrors); + $sh->register(); + } + + /** + * Add CSRF middleware. + */ + public function onAddGlobalMiddleware(Event $event) + { + $request = $this->ci->request; + $path = $request->getUri()->getPath(); + $method = $request->getMethod(); + + // Normalize path to always have a leading slash + $path = '/' . ltrim($path, '/'); + // Normalize method to uppercase + $method = strtoupper($method); + + $csrfBlacklist = $this->ci->config['csrf.blacklist']; + $isBlacklisted = false; + + // Go through the blacklist and determine if the path and method match any of the blacklist entries. + foreach ($csrfBlacklist as $pattern => $methods) { + $methods = array_map('strtoupper', (array) $methods); + if (in_array($method, $methods) && $pattern != '' && preg_match('~' . $pattern . '~', $path)) { + $isBlacklisted = true; + break; + } + } + + if (!$path || !$isBlacklisted) { + $app = $event->getApp(); + $app->add($this->ci->csrf); + } + } +} diff --git a/main/app/sprinkles/core/src/Database/Builder.php b/main/app/sprinkles/core/src/Database/Builder.php new file mode 100755 index 0000000..8e27b7c --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Builder.php @@ -0,0 +1,210 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database; + +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Query\Builder as LaravelBuilder; + +/** + * UFBuilder Class + * + * The base Eloquent data model, from which all UserFrosting data classes extend. + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Builder extends LaravelBuilder +{ + protected $excludedColumns = null; + + /** + * Perform a "begins with" pattern match on a specified column in a query. + * + * @param $query + * @param $field string The column to match + * @param $value string The value to match + */ + public function beginsWith($field, $value) + { + return $this->where($field, 'LIKE', "$value%"); + } + + /** + * Perform an "ends with" pattern match on a specified column in a query. + * + * @param $query + * @param $field string The column to match + * @param $value string The value to match + */ + public function endsWith($field, $value) + { + return $this->where($field, 'LIKE', "%$value"); + } + + /** + * Add columns to be excluded from the query. + * + * @param $value array|string The column(s) to exclude + * @return $this + */ + public function exclude($column) + { + $column = is_array($column) ? $column : func_get_args(); + + $this->excludedColumns = array_merge((array) $this->excludedColumns, $column); + + return $this; + } + + /** + * Perform a pattern match on a specified column in a query. + * @param $query + * @param $field string The column to match + * @param $value string The value to match + */ + public function like($field, $value) + { + return $this->where($field, 'LIKE', "%$value%"); + } + + /** + * Perform a pattern match on a specified column in a query. + * @param $query + * @param $field string The column to match + * @param $value string The value to match + */ + public function orLike($field, $value) + { + return $this->orWhere($field, 'LIKE', "%$value%"); + } + + /** + * Execute the query as a "select" statement. + * + * @param array $columns + * @return \Illuminate\Support\Collection + */ + public function get($columns = ['*']) + { + $original = $this->columns; + + if (is_null($original)) { + $this->columns = $columns; + } + + // Exclude any explicitly excluded columns + if (!is_null($this->excludedColumns)) { + $this->removeExcludedSelectColumns(); + } + + $results = $this->processor->processSelect($this, $this->runSelect()); + + $this->columns = $original; + + return collect($results); + } + + /** + * Remove excluded columns from the select column list. + */ + protected function removeExcludedSelectColumns() + { + // Convert current column list and excluded column list to fully-qualified list + $this->columns = $this->convertColumnsToFullyQualified($this->columns); + $excludedColumns = $this->convertColumnsToFullyQualified($this->excludedColumns); + + // Remove any explicitly referenced excludable columns + $this->columns = array_diff($this->columns, $excludedColumns); + + // Replace any remaining wildcard columns (*, table.*, etc) with a list + // of fully-qualified column names + $this->columns = $this->replaceWildcardColumns($this->columns); + + $this->columns = array_diff($this->columns, $excludedColumns); + } + + /** + * Find any wildcard columns ('*'), remove it from the column list and replace with an explicit list of columns. + * + * @param array $columns + * @return array + */ + protected function replaceWildcardColumns(array $columns) + { + $wildcardTables = $this->findWildcardTables($columns); + + foreach ($wildcardTables as $wildColumn => $table) { + $schemaColumns = $this->getQualifiedColumnNames($table); + + // Remove the `*` or `.*` column and replace with the individual schema columns + $columns = array_diff($columns, [$wildColumn]); + $columns = array_merge($columns, $schemaColumns); + } + + return $columns; + } + + /** + * Return a list of wildcard columns from the list of columns, mapping columns to their corresponding tables. + * + * @param array $columns + * @return array + */ + protected function findWildcardTables(array $columns) + { + $tables = []; + + foreach ($columns as $column) { + if ($column == '*') { + $tables[$column] = $this->from; + continue; + } + + if (substr($column, -1) == '*') { + $tableName = explode('.', $column)[0]; + if ($tableName) { + $tables[$column] = $tableName; + } + } + } + + return $tables; + } + + /** + * Gets the fully qualified column names for a specified table. + * + * @param string $table + * @return array + */ + protected function getQualifiedColumnNames($table = null) + { + $schema = $this->getConnection()->getSchemaBuilder(); + + return $this->convertColumnsToFullyQualified($schema->getColumnListing($table), $table); + } + + /** + * Fully qualify any unqualified columns in a list with this builder's table name. + * + * @param array $columns + * @return array + */ + protected function convertColumnsToFullyQualified($columns, $table = null) + { + if (is_null($table)) { + $table = $this->from; + } + + array_walk($columns, function (&$item, $key) use ($table) { + if (strpos($item, '.') === false) { + $item = "$table.$item"; + } + }); + + return $columns; + } +} diff --git a/main/app/sprinkles/core/src/Database/DatabaseInvalidException.php b/main/app/sprinkles/core/src/Database/DatabaseInvalidException.php new file mode 100755 index 0000000..08f8a31 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/DatabaseInvalidException.php @@ -0,0 +1,20 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database; + +use UserFrosting\Support\Exception\ForbiddenException; + +/** + * Invalid database exception. Used when the database cannot be accessed. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class DatabaseInvalidException extends ForbiddenException +{ + protected $defaultMessage = 'DB_INVALID'; +} diff --git a/main/app/sprinkles/core/src/Database/Migrations/v400/SessionsTable.php b/main/app/sprinkles/core/src/Database/Migrations/v400/SessionsTable.php new file mode 100755 index 0000000..ac86ceb --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Migrations/v400/SessionsTable.php @@ -0,0 +1,48 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Migrations\v400; + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\Builder; +use UserFrosting\System\Bakery\Migration; + +/** + * Sessions table migration + * Version 4.0.0 + * + * See https://laravel.com/docs/5.4/migrations#tables + * @extends Migration + * @author Alex Weissman (https://alexanderweissman.com) + */ +class SessionsTable extends Migration +{ + /** + * {@inheritDoc} + */ + public function up() + { + if (!$this->schema->hasTable('sessions')) { + $this->schema->create('sessions', function (Blueprint $table) { + $table->string('id')->unique(); + $table->integer('user_id')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->text('payload'); + $table->integer('last_activity'); + }); + } + } + + /** + * {@inheritDoc} + */ + public function down() + { + $this->schema->drop('sessions'); + } +} diff --git a/main/app/sprinkles/core/src/Database/Migrations/v400/ThrottlesTable.php b/main/app/sprinkles/core/src/Database/Migrations/v400/ThrottlesTable.php new file mode 100755 index 0000000..1c742f7 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Migrations/v400/ThrottlesTable.php @@ -0,0 +1,52 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Migrations\v400; + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\Builder; +use UserFrosting\System\Bakery\Migration; + +/** + * Throttles table migration + * Version 4.0.0 + * + * @extends Migration + * @author Alex Weissman (https://alexanderweissman.com) + */ +class ThrottlesTable extends Migration +{ + /** + * {@inheritDoc} + */ + public function up() + { + if (!$this->schema->hasTable('throttles')) { + $this->schema->create('throttles', function (Blueprint $table) { + $table->increments('id'); + $table->string('type'); + $table->string('ip')->nullable(); + $table->text('request_data')->nullable(); + $table->timestamps(); + + $table->engine = 'InnoDB'; + $table->collation = 'utf8_unicode_ci'; + $table->charset = 'utf8'; + $table->index('type'); + $table->index('ip'); + }); + } + } + + /** + * {@inheritDoc} + */ + public function down() + { + $this->schema->drop('throttles'); + } +} diff --git a/main/app/sprinkles/core/src/Database/Models/Concerns/HasRelationships.php b/main/app/sprinkles/core/src/Database/Models/Concerns/HasRelationships.php new file mode 100755 index 0000000..4fe9a30 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Models/Concerns/HasRelationships.php @@ -0,0 +1,278 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Models\Concerns; + +use Illuminate\Support\Arr; +use Illuminate\Support\Str; + +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphToMany; + +use UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyConstrained; +use UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyThrough; +use UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyUnique; +use UserFrosting\Sprinkle\Core\Database\Relations\HasManySyncable; +use UserFrosting\Sprinkle\Core\Database\Relations\MorphManySyncable; +use UserFrosting\Sprinkle\Core\Database\Relations\MorphToManyUnique; + +/** + * HasRelationships trait + * + * Extends Laravel's Model class to add some additional relationships. + * @author Alex Weissman (https://alexanderweissman.com) + */ +trait HasRelationships +{ + /** + * The many to many relationship methods. + * + * @var array + */ + public static $manyMethodsExtended = ['belongsToMany', 'morphToMany', 'morphedByMany', 'morphToManyUnique']; + + /** + * Overrides the default Eloquent hasMany relationship to return a HasManySyncable. + * + * {@inheritDoc} + * @return \UserFrosting\Sprinkle\Core\Database\Relations\HasManySyncable + */ + public function hasMany($related, $foreignKey = null, $localKey = null) + { + $instance = $this->newRelatedInstance($related); + + $foreignKey = $foreignKey ?: $this->getForeignKey(); + + $localKey = $localKey ?: $this->getKeyName(); + + return new HasManySyncable( + $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey + ); + } + + /** + * Overrides the default Eloquent morphMany relationship to return a MorphManySyncable. + * + * {@inheritDoc} + * @return \UserFrosting\Sprinkle\Core\Database\Relations\MorphManySyncable + */ + public function morphMany($related, $name, $type = null, $id = null, $localKey = null) + { + $instance = $this->newRelatedInstance($related); + + // Here we will gather up the morph type and ID for the relationship so that we + // can properly query the intermediate table of a relation. Finally, we will + // get the table and create the relationship instances for the developers. + list($type, $id) = $this->getMorphs($name, $type, $id); + $table = $instance->getTable(); + $localKey = $localKey ?: $this->getKeyName(); + + return new MorphManySyncable($instance->newQuery(), $this, $table.'.'.$type, $table.'.'.$id, $localKey); + } + + /** + * Define a many-to-many 'through' relationship. + * This is basically hasManyThrough for many-to-many relationships. + * + * @param string $related + * @param string $through + * @param string $firstJoiningTable + * @param string $firstForeignKey + * @param string $firstRelatedKey + * @param string $secondJoiningTable + * @param string $secondForeignKey + * @param string $secondRelatedKey + * @param string $throughRelation + * @param string $relation + * @return \UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyThrough + */ + public function belongsToManyThrough( + $related, + $through, + $firstJoiningTable = null, + $firstForeignKey = null, + $firstRelatedKey = null, + $secondJoiningTable = null, + $secondForeignKey = null, + $secondRelatedKey = null, + $throughRelation = null, + $relation = null + ) + { + // If no relationship name was passed, we will pull backtraces to get the + // name of the calling function. We will use that function name as the + // title of this relation since that is a great convention to apply. + if (is_null($relation)) { + $relation = $this->guessBelongsToManyRelation(); + } + + // Create models for through and related + $through = new $through; + $related = $this->newRelatedInstance($related); + + if (is_null($throughRelation)) { + $throughRelation = $through->getTable(); + } + + // If no table names were provided, we can guess it by concatenating the parent + // and through table names. The two model names are transformed to snake case + // from their default CamelCase also. + if (is_null($firstJoiningTable)) { + $firstJoiningTable = $this->joiningTable($through); + } + + if (is_null($secondJoiningTable)) { + $secondJoiningTable = $through->joiningTable($related); + } + + $firstForeignKey = $firstForeignKey ?: $this->getForeignKey(); + $firstRelatedKey = $firstRelatedKey ?: $through->getForeignKey(); + $secondForeignKey = $secondForeignKey ?: $through->getForeignKey(); + $secondRelatedKey = $secondRelatedKey ?: $related->getForeignKey(); + + // This relationship maps the top model (this) to the through model. + $intermediateRelationship = $this->belongsToMany($through, $firstJoiningTable, $firstForeignKey, $firstRelatedKey, $throughRelation) + ->withPivot($firstForeignKey); + + // Now we set up the relationship with the related model. + $query = new BelongsToManyThrough( + $related->newQuery(), $this, $intermediateRelationship, $secondJoiningTable, $secondForeignKey, $secondRelatedKey, $relation + ); + + return $query; + } + + /** + * Define a unique many-to-many relationship. Similar to a regular many-to-many relationship, but removes duplicate child objects. + * Can also be used to implement ternary relationships. + * + * {@inheritDoc} + * @return \UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyUnique + */ + public function belongsToManyUnique($related, $table = null, $foreignKey = null, $relatedKey = null, $relation = null) + { + // If no relationship name was passed, we will pull backtraces to get the + // name of the calling function. We will use that function name as the + // title of this relation since that is a great convention to apply. + if (is_null($relation)) { + $relation = $this->guessBelongsToManyRelation(); + } + + // First, we'll need to determine the foreign key and "other key" for the + // relationship. Once we have determined the keys we'll make the query + // instances as well as the relationship instances we need for this. + $instance = $this->newRelatedInstance($related); + + $foreignKey = $foreignKey ?: $this->getForeignKey(); + + $relatedKey = $relatedKey ?: $instance->getForeignKey(); + + // If no table name was provided, we can guess it by concatenating the two + // models using underscores in alphabetical order. The two model names + // are transformed to snake case from their default CamelCase also. + if (is_null($table)) { + $table = $this->joiningTable($related); + } + + return new BelongsToManyUnique( + $instance->newQuery(), $this, $table, $foreignKey, $relatedKey, $relation + ); + } + + /** + * Define a unique morphs-to-many relationship. Similar to a regular morphs-to-many relationship, but removes duplicate child objects. + * + * {@inheritDoc} + * @return \UserFrosting\Sprinkle\Core\Database\Relations\MorphToManyUnique + */ + public function morphToManyUnique($related, $name, $table = null, $foreignKey = null, $otherKey = null, $inverse = false) + { + $caller = $this->getBelongsToManyCaller(); + + // First, we will need to determine the foreign key and "other key" for the + // relationship. Once we have determined the keys we will make the query + // instances, as well as the relationship instances we need for these. + $foreignKey = $foreignKey ?: $name.'_id'; + + $instance = new $related; + + $otherKey = $otherKey ?: $instance->getForeignKey(); + + // Now we're ready to create a new query builder for this related model and + // the relationship instances for this relation. This relations will set + // appropriate query constraints then entirely manages the hydrations. + $query = $instance->newQuery(); + + $table = $table ?: Str::plural($name); + + return new MorphToManyUnique( + $query, $this, $name, $table, $foreignKey, + $otherKey, $caller, $inverse + ); + } + + /** + * Define a constrained many-to-many relationship. + * This is similar to a regular many-to-many, but constrains the child results to match an additional constraint key in the parent object. + * This has been superseded by the belongsToManyUnique relationship's `withTernary` method since 4.1.7. + * + * @deprecated since 4.1.6 + * @param string $related + * @param string $constraintKey + * @param string $table + * @param string $foreignKey + * @param string $relatedKey + * @param string $relation + * @return \UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyConstrained + */ + public function belongsToManyConstrained($related, $constraintKey, $table = null, $foreignKey = null, $relatedKey = null, $relation = null) + { + // If no relationship name was passed, we will pull backtraces to get the + // name of the calling function. We will use that function name as the + // title of this relation since that is a great convention to apply. + if (is_null($relation)) { + $relation = $this->guessBelongsToManyRelation(); + } + + // First, we'll need to determine the foreign key and "other key" for the + // relationship. Once we have determined the keys we'll make the query + // instances as well as the relationship instances we need for this. + $instance = $this->newRelatedInstance($related); + + $foreignKey = $foreignKey ?: $this->getForeignKey(); + + $relatedKey = $relatedKey ?: $instance->getForeignKey(); + + // If no table name was provided, we can guess it by concatenating the two + // models using underscores in alphabetical order. The two model names + // are transformed to snake case from their default CamelCase also. + if (is_null($table)) { + $table = $this->joiningTable($related); + } + + return new BelongsToManyConstrained( + $instance->newQuery(), $this, $constraintKey, $table, $foreignKey, $relatedKey, $relation + ); + } + + /** + * Get the relationship name of the belongs to many. + * + * @return string + */ + protected function getBelongsToManyCaller() + { + $self = __FUNCTION__; + + $caller = Arr::first(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS), function ($key, $trace) use ($self) { + $caller = $trace['function']; + return ! in_array($caller, HasRelationships::$manyMethodsExtended) && $caller != $self; + }); + + return ! is_null($caller) ? $caller['function'] : null; + } +} diff --git a/main/app/sprinkles/core/src/Database/Models/Model.php b/main/app/sprinkles/core/src/Database/Models/Model.php new file mode 100755 index 0000000..1c18c2c --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Models/Model.php @@ -0,0 +1,140 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Models; + +use Illuminate\Database\Capsule\Manager as DB; +use Illuminate\Database\Eloquent\Model as LaravelModel; +use UserFrosting\Sprinkle\Core\Database\Models\Concerns\HasRelationships; + +/** + * Model Class + * + * UserFrosting's base data model, from which all UserFrosting data classes extend. + * @author Alex Weissman (https://alexanderweissman.com) + */ +abstract class Model extends LaravelModel +{ + use HasRelationships; + + /** + * @var ContainerInterface The DI container for your application. + */ + public static $ci; + + /** + * @var bool Disable timestamps for now. + */ + public $timestamps = false; + + public function __construct(array $attributes = []) + { + // Hacky way to force the DB service to load before attempting to use the model + static::$ci['db']; + + parent::__construct($attributes); + } + + /** + * Determine if an attribute exists on the model - even if it is null. + * + * @param string $key + * @return bool + */ + public function attributeExists($key) + { + return array_key_exists($key, $this->attributes); + } + + /** + * Determines whether a model exists by checking a unique column, including checking soft-deleted records + * + * @param mixed $value + * @param string $identifier + * @param bool $checkDeleted set to true to include soft-deleted records + * @return \UserFrosting\Sprinkle\Core\Database\Models\Model|null + */ + public static function findUnique($value, $identifier, $checkDeleted = true) + { + $query = static::where($identifier, $value); + + if ($checkDeleted) { + $query = $query->withTrashed(); + } + + return $query->first(); + } + + /** + * Determine if an relation exists on the model - even if it is null. + * + * @param string $key + * @return bool + */ + public function relationExists($key) + { + return array_key_exists($key, $this->relations); + } + + /** + * Store the object in the DB, creating a new row if one doesn't already exist. + * + * Calls save(), then returns the id of the new record in the database. + * @return int the id of this object. + */ + public function store() + { + $this->save(); + + // Store function should always return the id of the object + return $this->id; + } + + /** + * Overrides Laravel's base Model to return our custom query builder object. + * + * @return \UserFrosting\Sprinkles\Core\Database\Builder + */ + protected function newBaseQueryBuilder() + { + /** @var UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = static::$ci->classMapper; + + $connection = $this->getConnection(); + + return $classMapper->createInstance( + 'query_builder', + $connection, + $connection->getQueryGrammar(), + $connection->getPostProcessor() + ); + } + + /** + * Get the properties of this object as an associative array. Alias for toArray(). + * + * @deprecated since 4.1.8 There is no point in having this alias. + * @return array + */ + public function export() + { + return $this->toArray(); + } + + /** + * For raw array fetching. Must be static, otherwise PHP gets confused about where to find $table. + * + * @deprecated since 4.1.8 setFetchMode is no longer available as of Laravel 5.4. + * @link https://github.com/laravel/framework/issues/17728 + */ + public static function queryBuilder() + { + // Set query builder to fetch result sets as associative arrays (instead of creating stdClass objects) + DB::connection()->setFetchMode(\PDO::FETCH_ASSOC); + return DB::table(static::$table); + } +} diff --git a/main/app/sprinkles/core/src/Database/Models/Throttle.php b/main/app/sprinkles/core/src/Database/Models/Throttle.php new file mode 100755 index 0000000..d13a7c1 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Models/Throttle.php @@ -0,0 +1,36 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Models; + +/** + * Throttle Class + * + * Represents a throttleable request from a user agent. + * @author Alex Weissman (https://alexanderweissman.com) + * @property string type + * @property string ip + * @property string request_data + */ +class Throttle extends Model +{ + /** + * @var string The name of the table for the current model. + */ + protected $table = "throttles"; + + protected $fillable = [ + "type", + "ip", + "request_data" + ]; + + /** + * @var bool Enable timestamps for Throttles. + */ + public $timestamps = true; +} diff --git a/main/app/sprinkles/core/src/Database/Relations/BelongsToManyConstrained.php b/main/app/sprinkles/core/src/Database/Relations/BelongsToManyConstrained.php new file mode 100755 index 0000000..d652b56 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Relations/BelongsToManyConstrained.php @@ -0,0 +1,122 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Relations; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; + +/** + * A BelongsToMany relationship that constrains on the value of an additional foreign key in the pivot table. + * This has been superseded by the BelongsToTernary relationship since 4.1.6. + * + * @deprecated since 4.1.6 + * @author Alex Weissman (https://alexanderweissman.com) + * @link https://github.com/laravel/framework/blob/5.4/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php + */ +class BelongsToManyConstrained extends BelongsToMany +{ + /** + * @var The pivot foreign key on which to constrain the result sets for this relation. + */ + protected $constraintKey; + + /** + * Create a new belongs to many constrained relationship instance. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Model $parent + * @param string $constraintKey + * @param string $table + * @param string $foreignKey + * @param string $relatedKey + * @param string $relationName + * @return void + */ + public function __construct(Builder $query, Model $parent, $constraintKey, $table, $foreignKey, $relatedKey, $relationName = null) + { + $this->constraintKey = $constraintKey; + parent::__construct($query, $parent, $table, $foreignKey, $relatedKey, $relationName); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + public function addEagerConstraints(array $models) + { + // To make the query more efficient, we only bother querying related models if their pivot key value + // matches the pivot key value of one of the parent models. + $pivotKeys = $this->getPivotKeys($models, $this->constraintKey); + $this->query->whereIn($this->getQualifiedForeignKeyName(), $this->getKeys($models)) + ->whereIn($this->constraintKey, $pivotKeys); + } + + /** + * Gets a list of unique pivot key values from an array of models. + */ + protected function getPivotKeys(array $models, $pivotKey) + { + $pivotKeys = []; + foreach ($models as $model) { + $pivotKeys[] = $model->getRelation('pivot')->{$pivotKey}; + } + return array_unique($pivotKeys); + } + + /** + * Match the eagerly loaded results to their parents, constraining the results by matching the values of $constraintKey + * in the parent object to the child objects. + * + * @link Called in https://github.com/laravel/framework/blob/2f4135d8db5ded851d1f4f611124c53b768a3c08/src/Illuminate/Database/Eloquent/Builder.php + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results + * @param string $relation + * @return array + */ + public function match(array $models, Collection $results, $relation) + { + $dictionary = $this->buildDictionary($results); + + // Once we have an array dictionary of child objects we can easily match the + // children back to their parent using the dictionary and the keys on the + // the parent models. Then we will return the hydrated models back out. + foreach ($models as $model) { + $pivotValue = $model->getRelation('pivot')->{$this->constraintKey}; + if (isset($dictionary[$key = $model->getKey()])) { + // Only match children if their pivot key value matches that of the parent model + $items = $this->findMatchingPivots($dictionary[$key], $pivotValue); + $model->setRelation( + $relation, $this->related->newCollection($items) + ); + } + } + + return $models; + } + + /** + * Filter an array of models, only taking models whose $constraintKey value matches $pivotValue. + * + * @param mixed $pivotValue + * @return array + */ + protected function findMatchingPivots($items, $pivotValue) + { + $result = []; + foreach ($items as $item) { + if ($item->getRelation('pivot')->{$this->constraintKey} == $pivotValue) { + $result[] = $item; + } + } + return $result; + } +} diff --git a/main/app/sprinkles/core/src/Database/Relations/BelongsToManyThrough.php b/main/app/sprinkles/core/src/Database/Relations/BelongsToManyThrough.php new file mode 100755 index 0000000..33be507 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Relations/BelongsToManyThrough.php @@ -0,0 +1,232 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Relations; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\Relation; +use UserFrosting\Sprinkle\Core\Database\Relations\Concerns\Unique; + +/** + * A BelongsToMany relationship that queries through an additional intermediate model. + * + * @author Alex Weissman (https://alexanderweissman.com) + * @link https://github.com/laravel/framework/blob/5.4/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php + */ +class BelongsToManyThrough extends BelongsToMany +{ + use Unique; + + /** + * The relation through which we are joining. + * + * @var Relation + */ + protected $intermediateRelation; + + /** + * Create a new belongs to many relationship instance. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Model $parent + * @param \Illuminate\Database\Eloquent\Relations\Relation $intermediateRelation + * @param string $table + * @param string $foreignKey + * @param string $relatedKey + * @param string $relationName + * @return void + */ + public function __construct(Builder $query, Model $parent, Relation $intermediateRelation, $table, $foreignKey, $relatedKey, $relationName = null) + { + $this->intermediateRelation = $intermediateRelation; + + parent::__construct($query, $parent, $table, $foreignKey, $relatedKey, $relationName); + } + + /** + * Use the intermediate relationship to determine the "parent" pivot key name + * + * This is a crazy roundabout way to get the name of the intermediate relation's foreign key. + * It would be better if BelongsToMany had a simple accessor for its foreign key. + * @return string + */ + public function getParentKeyName() + { + return $this->intermediateRelation->newExistingPivot()->getForeignKey(); + } + + /** + * Get the key for comparing against the parent key in "has" query. + * + * @see \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return string + */ + public function getExistenceCompareKey() + { + return $this->intermediateRelation->getQualifiedForeignKeyName(); + } + + /** + * Add a "via" query to load the intermediate models through which the child models are related. + * + * @param string $viaRelationName + * @param callable $viaCallback + * @return $this + */ + public function withVia($viaRelationName = null, $viaCallback = null) + { + $this->tertiaryRelated = $this->intermediateRelation->getRelated(); + + // Set tertiary key and related model + $this->tertiaryKey = $this->foreignKey; + + $this->tertiaryRelationName = is_null($viaRelationName) ? $this->intermediateRelation->getRelationName() . '_via' : $viaRelationName; + + $this->tertiaryCallback = is_null($viaCallback) + ? function () { + // + } + : $viaCallback; + + return $this; + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + public function addEagerConstraints(array $models) + { + // Constraint to only load models where the intermediate relation's foreign key matches the parent model + $intermediateForeignKeyName = $this->intermediateRelation->getQualifiedForeignKeyName(); + + return $this->query->whereIn($intermediateForeignKeyName, $this->getKeys($models)); + } + + /** + * Set the where clause for the relation query. + * + * @return $this + */ + protected function addWhereConstraints() + { + $parentKeyName = $this->getParentKeyName(); + + $this->query->where( + $parentKeyName, '=', $this->parent->getKey() + ); + + return $this; + } + + /** + * Match the eagerly loaded results to their parents + * + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results + * @param string $relation + * @return array + */ + public function match(array $models, Collection $results, $relation) + { + // Build dictionary of parent (e.g. user) to related (e.g. permission) models + list($dictionary, $nestedViaDictionary) = $this->buildDictionary($results, $this->getParentKeyName()); + + // Once we have an array dictionary of child objects we can easily match the + // children back to their parent using the dictionary and the keys on the + // the parent models. Then we will return the hydrated models back out. + foreach ($models as $model) { + if (isset($dictionary[$key = $model->getKey()])) { + /** @var array */ + $items = $dictionary[$key]; + + // Eliminate any duplicates + $items = $this->related->newCollection($items)->unique(); + + // If set, match up the via models to the models in the related collection + if (!is_null($nestedViaDictionary)) { + $this->matchTertiaryModels($nestedViaDictionary[$key], $items); + } + + // Remove the tertiary pivot key from the condensed models + foreach ($items as $relatedModel) { + unset($relatedModel->pivot->{$this->foreignKey}); + } + + $model->setRelation( + $relation, $items + ); + } + } + + return $models; + } + + /** + * Unset tertiary pivots on a collection or array of models. + * + * @param \Illuminate\Database\Eloquent\Collection $models + * @return void + */ + protected function unsetTertiaryPivots(Collection $models) + { + foreach ($models as $model) { + unset($model->pivot->{$this->foreignKey}); + } + } + + /** + * Set the join clause for the relation query. + * + * @param \Illuminate\Database\Eloquent\Builder|null $query + * @return $this + */ + protected function performJoin($query = null) + { + $query = $query ?: $this->query; + + parent::performJoin($query); + + // We need to join to the intermediate table on the related model's primary + // key column with the intermediate table's foreign key for the related + // model instance. Then we can set the "where" for the parent models. + $intermediateTable = $this->intermediateRelation->getTable(); + + $key = $this->intermediateRelation->getQualifiedRelatedKeyName(); + + $query->join($intermediateTable, $key, '=', $this->getQualifiedForeignKeyName()); + + return $this; + } + + /** + * Get the pivot columns for the relation. + * + * "pivot_" is prefixed to each column for easy removal later. + * + * @return array + */ + protected function aliasedPivotColumns() + { + $defaults = [$this->foreignKey, $this->relatedKey]; + $aliasedPivotColumns = collect(array_merge($defaults, $this->pivotColumns))->map(function ($column) { + return $this->table.'.'.$column.' as pivot_'.$column; + }); + + $parentKeyName = $this->getParentKeyName(); + + // Add pivot column for the intermediate relation + $aliasedPivotColumns[] = "{$this->intermediateRelation->getQualifiedForeignKeyName()} as pivot_$parentKeyName"; + + return $aliasedPivotColumns->unique()->all(); + } +} diff --git a/main/app/sprinkles/core/src/Database/Relations/BelongsToManyUnique.php b/main/app/sprinkles/core/src/Database/Relations/BelongsToManyUnique.php new file mode 100755 index 0000000..f256f17 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Relations/BelongsToManyUnique.php @@ -0,0 +1,22 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Relations; + +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use UserFrosting\Sprinkle\Core\Database\Relations\Concerns\Unique; + +/** + * A BelongsToMany relationship that reduces the related members to a unique (by primary key) set. + * + * @author Alex Weissman (https://alexanderweissman.com) + * @link https://github.com/laravel/framework/blob/5.4/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php + */ +class BelongsToManyUnique extends BelongsToMany +{ + use Unique; +} diff --git a/main/app/sprinkles/core/src/Database/Relations/Concerns/Syncable.php b/main/app/sprinkles/core/src/Database/Relations/Concerns/Syncable.php new file mode 100755 index 0000000..278b762 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Relations/Concerns/Syncable.php @@ -0,0 +1,132 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Relations\Concerns; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; + +/** + * Implements the `sync` method for HasMany relationships. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +trait Syncable +{ + /** + * Synchronizes an array of data for related models with a parent model. + * + * @param mixed[] $data + * @param bool $deleting Delete models from the database that are not represented in the input data. + * @param bool $forceCreate Ignore mass assignment restrictions on child models. + * @param string $relatedKeyName The primary key used to determine which child models are new, updated, or deleted. + */ + public function sync($data, $deleting = true, $forceCreate = false, $relatedKeyName = null) + { + $changes = [ + 'created' => [], 'deleted' => [], 'updated' => [], + ]; + + if (is_null($relatedKeyName)) { + $relatedKeyName = $this->related->getKeyName(); + } + + // First we need to attach any of the associated models that are not currently + // in the child entity table. We'll spin through the given IDs, checking to see + // if they exist in the array of current ones, and if not we will insert. + $current = $this->newQuery()->pluck( + $relatedKeyName + )->all(); + + // Separate the submitted data into "update" and "new" + $updateRows = []; + $newRows = []; + foreach ($data as $row) { + // We determine "updateable" rows as those whose $relatedKeyName (usually 'id') is set, not empty, and + // match a related row in the database. + if (isset($row[$relatedKeyName]) && !empty($row[$relatedKeyName]) && in_array($row[$relatedKeyName], $current)) { + $id = $row[$relatedKeyName]; + $updateRows[$id] = $row; + } else { + $newRows[] = $row; + } + } + + // Next, we'll determine the rows in the database that aren't in the "update" list. + // These rows will be scheduled for deletion. Again, we determine based on the relatedKeyName (typically 'id'). + $updateIds = array_keys($updateRows); + $deleteIds = []; + foreach ($current as $currentId) { + if (!in_array($currentId, $updateIds)) { + $deleteIds[] = $currentId; + } + } + + // Delete any non-matching rows + if ($deleting && count($deleteIds) > 0) { + // Remove global scopes to avoid ambiguous keys + $this->getRelated() + ->withoutGlobalScopes() + ->whereIn($relatedKeyName, $deleteIds) + ->delete(); + + $changes['deleted'] = $this->castKeys($deleteIds); + } + + // Update the updatable rows + foreach ($updateRows as $id => $row) { + // Remove global scopes to avoid ambiguous keys + $this->getRelated() + ->withoutGlobalScopes() + ->where($relatedKeyName, $id) + ->update($row); + } + + $changes['updated'] = $this->castKeys($updateIds); + + // Insert the new rows + $newIds = []; + foreach ($newRows as $row) { + if ($forceCreate) { + $newModel = $this->forceCreate($row); + } else { + $newModel = $this->create($row); + } + $newIds[] = $newModel->$relatedKeyName; + } + + $changes['created'] = $this->castKeys($newIds); + + return $changes; + } + + + /** + * Cast the given keys to integers if they are numeric and string otherwise. + * + * @param array $keys + * @return array + */ + protected function castKeys(array $keys) + { + return (array) array_map(function ($v) { + return $this->castKey($v); + }, $keys); + } + + /** + * Cast the given key to an integer if it is numeric. + * + * @param mixed $key + * @return mixed + */ + protected function castKey($key) + { + return is_numeric($key) ? (int) $key : (string) $key; + } +} diff --git a/main/app/sprinkles/core/src/Database/Relations/Concerns/Unique.php b/main/app/sprinkles/core/src/Database/Relations/Concerns/Unique.php new file mode 100755 index 0000000..4b529bb --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Relations/Concerns/Unique.php @@ -0,0 +1,563 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Relations\Concerns; + +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Query\Expression; + +/** + * Enforce uniqueness for BelongsToManyUnique, MorphToManyUnique, and BelongsToManyThrough. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +trait Unique +{ + /** + * The related tertiary model instance. + * + * @var \Illuminate\Database\Eloquent\Model + */ + protected $tertiaryRelated = null; + + /** + * The name to use for the tertiary relation (e.g. 'roles_via', etc) + * + * @var string + */ + protected $tertiaryRelationName = null; + + /** + * The foreign key to the related tertiary model instance. + * + * @var string + */ + protected $tertiaryKey; + + /** + * A callback to apply to the tertiary query. + * + * @var callable|null + */ + protected $tertiaryCallback = null; + + /** + * The limit to apply on the number of related models retrieved. + * + * @var int|null + */ + protected $limit = null; + + /** + * The offset to apply on the related models retrieved. + * + * @var int|null + */ + protected $offset = null; + + /** + * Alias to set the "offset" value of the query. + * + * @param int $value + * @return $this + */ + public function skip($value) + { + return $this->offset($value); + } + + /** + * Set the "offset" value of the query. + * + * @todo Implement for 'unionOffset' as well? (By checking the value of $this->query->getQuery()->unions) + * @see \Illuminate\Database\Query\Builder + * @param int $value + * @return $this + */ + public function offset($value) + { + $this->offset = max(0, $value); + + return $this; + } + + /** + * Alias to set the "limit" value of the query. + * + * @param int $value + * @return $this + */ + public function take($value) + { + return $this->limit($value); + } + + /** + * Set the "limit" value of the query. + * + * @todo Implement for 'unionLimit' as well? (By checking the value of $this->query->getQuery()->unions) + * @see \Illuminate\Database\Query\Builder + * @param int $value + * @return $this + */ + public function limit($value) + { + if ($value >= 0) { + $this->limit = $value; + } + + return $this; + } + + /** + * Set the limit on the number of intermediate models to load. + * + * @deprecated since 4.1.7 + * @param int $value + * @return $this + */ + public function withLimit($value) + { + return $this->limit($value); + } + + /** + * Set the offset when loading the intermediate models. + * + * @deprecated since 4.1.7 + * @param int $value + * @return $this + */ + public function withOffset($value) + { + return $this->offset($value); + } + + /** + * Add a query to load the nested tertiary models for this relationship. + * + * @param \Illuminate\Database\Eloquent\Model $tertiaryRelated + * @param string $tertiaryRelationName + * @param string $tertiaryKey + * @param callable $tertiaryCallback + * @return $this + */ + public function withTertiary($tertiaryRelated, $tertiaryRelationName = null, $tertiaryKey = null, $tertiaryCallback = null) + { + $this->tertiaryRelated = new $tertiaryRelated; + + // Try to guess the tertiary related key from the tertiaryRelated model. + $this->tertiaryKey = $tertiaryKey ?: $this->tertiaryRelated->getForeignKey(); + + // Also add the tertiary key as a pivot + $this->withPivot($this->tertiaryKey); + + $this->tertiaryRelationName = is_null($tertiaryRelationName) ? $this->tertiaryRelated->getTable() : $tertiaryRelationName; + + $this->tertiaryCallback = is_null($tertiaryCallback) + ? function () { + // + } + : $tertiaryCallback; + + return $this; + } + + /** + * Return the count of child models for this relationship. + * + * @see http://stackoverflow.com/a/29728129/2970321 + * @return int + */ + public function count() + { + $constrainedBuilder = clone $this->query; + + $constrainedBuilder = $constrainedBuilder->distinct(); + + return $constrainedBuilder->count($this->relatedKey); + } + + /** + * Add the constraints for a relationship count query. + * + * @see \Illuminate\Database\Eloquent\Relations\Relation + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery) + { + return $this->getRelationExistenceQuery( + $query, $parentQuery, new Expression("count(distinct {$this->relatedKey})") + ); + } + + /** + * Match the eagerly loaded results to their parents + * + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results + * @param string $relation + * @return array + */ + public function match(array $models, Collection $results, $relation) + { + // Build dictionary of parent (e.g. user) to related (e.g. permission) models + list($dictionary, $nestedTertiaryDictionary) = $this->buildDictionary($results, $this->foreignKey); + + // Once we have an array dictionary of child objects we can easily match the + // children back to their parent using the dictionary and the keys on the + // the parent models. Then we will return the hydrated models back out. + foreach ($models as $model) { + if (isset($dictionary[$key = $model->getKey()])) { + /** @var array */ + $items = $dictionary[$key]; + + // Eliminate any duplicates + $items = $this->related->newCollection($items)->unique(); + + // If set, match up the tertiary models to the models in the related collection + if (!is_null($nestedTertiaryDictionary)) { + $this->matchTertiaryModels($nestedTertiaryDictionary[$key], $items); + } + + $model->setRelation( + $relation, $items + ); + } + } + + return $models; + } + + /** + * Execute the query as a "select" statement, getting all requested models + * and matching up any tertiary models. + * + * @param array $columns + * @return \Illuminate\Database\Eloquent\Collection + */ + public function get($columns = ['*']) + { + // Get models and condense the result set + $models = $this->getModels($columns, true); + + // Remove the tertiary pivot key from the condensed models + $this->unsetTertiaryPivots($models); + + return $models; + } + + /** + * If we are applying either a limit or offset, we'll first determine a limited/offset list of model ids + * to select from in the final query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param int $limit + * @param int $offset + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getPaginatedQuery(Builder $query, $limit = null, $offset = null) + { + $constrainedBuilder = clone $query; + + // Since some unique models will be represented by more than one row in the database, + // we cannot apply limit/offset directly to the query. If we did that, we'd miss + // some of the records that are to be coalesced into the final set of models. + // Instead, we perform an additional query with grouping and limit/offset to determine + // the desired set of unique model _ids_, and then constrain our final query + // to these models with a whereIn clause. + $relatedKeyName = $this->related->getQualifiedKeyName(); + + // Apply an additional scope to override any selected columns in other global scopes + $uniqueIdScope = function ($subQuery) use ($relatedKeyName) { + $subQuery->select($relatedKeyName) + ->groupBy($relatedKeyName); + }; + + $identifier = spl_object_hash($uniqueIdScope); + + $constrainedBuilder->withGlobalScope($identifier, $uniqueIdScope); + + if ($limit) { + $constrainedBuilder->limit($limit); + } + + if ($offset) { + $constrainedBuilder->offset($offset); + } + + $primaryKeyName = $this->getParent()->getKeyName(); + $modelIds = $constrainedBuilder->get()->pluck($primaryKeyName)->toArray(); + + // Modify the unconstrained query to limit to these models + return $query->whereIn($relatedKeyName, $modelIds); + } + + /** + * Get the full join results for this query, overriding the default getEager() method. + * The default getEager() method would normally just call get() on this relationship. + * This is not what we want here though, because our get() method removes records before + * `match` has a chance to build out the substructures. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getEager() + { + return $this->getModels(['*'], false); + } + + /** + * Get the hydrated models and eager load their relations, optionally + * condensing the set of models before performing the eager loads. + * + * @param array $columns + * @param bool $condenseModels + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getModels($columns = ['*'], $condenseModels = true) + { + // First we'll add the proper select columns onto the query so it is run with + // the proper columns. Then, we will get the results and hydrate out pivot + // models with the result of those columns as a separate model relation. + $columns = $this->query->getQuery()->columns ? [] : $columns; + + // Add any necessary pagination on the related models + if ($this->limit || $this->offset) { + $this->getPaginatedQuery($this->query, $this->limit, $this->offset); + } + + // Apply scopes to the Eloquent\Builder instance. + $builder = $this->query->applyScopes(); + + $builder = $builder->addSelect( + $this->shouldSelect($columns) + ); + + $models = $builder->getModels(); + + // Hydrate the pivot models so we can load the via models + $this->hydratePivotRelation($models); + + if ($condenseModels) { + $models = $this->condenseModels($models); + } + + // If we actually found models we will also eager load any relationships that + // have been specified as needing to be eager loaded. This will solve the + // n + 1 query problem for the developer and also increase performance. + if (count($models) > 0) { + $models = $builder->eagerLoadRelations($models); + } + + return $this->related->newCollection($models); + } + + /** + * Condense the raw join query results into a set of unique models. + * + * Before doing this, we may optionally find any tertiary models that should be + * set as sub-relations on these models. + * @param array $models + * @return array + */ + protected function condenseModels(array $models) + { + // Build dictionary of tertiary models, if `withTertiary` was called + $dictionary = null; + if ($this->tertiaryRelated) { + $dictionary = $this->buildTertiaryDictionary($models); + } + + // Remove duplicate models from collection + $models = $this->related->newCollection($models)->unique(); + + // If using withTertiary, use the dictionary to set the tertiary relation on each model. + if (!is_null($dictionary)) { + $this->matchTertiaryModels($dictionary, $models); + } + + return $models->all(); + } + + /** + * Build dictionary of related models keyed by the top-level "parent" id. + * If there is a tertiary query set as well, then also build a two-level dictionary + * that maps parent ids to arrays of related ids, which in turn map to arrays + * of tertiary models corresponding to each relationship. + * + * @param \Illuminate\Database\Eloquent\Collection $results + * @param string $parentKey + * @return array + */ + protected function buildDictionary(Collection $results, $parentKey = null) + { + // First we will build a dictionary of child models keyed by the "parent key" (foreign key + // of the intermediate relation) so that we will easily and quickly match them to their + // parents without having a possibly slow inner loops for every models. + $dictionary = []; + + //Example nested dictionary: + //[ + // // User 1 + // '1' => [ + // // Permission 3 + // '3' => [ + // Role1, + // Role2 + // ], + // ... + // ], + // ... + //] + $nestedTertiaryDictionary = null; + $tertiaryModels = null; + + if ($this->tertiaryRelationName) { + // Get all tertiary models from the result set matching any of the parent models. + $tertiaryModels = $this->getTertiaryModels($results->all()); + } + + foreach ($results as $result) { + $parentKeyValue = $result->pivot->$parentKey; + + // Set the related model in the main dictionary. + // Note that this can end up adding duplicate models. It's cheaper to simply + // go back and remove the duplicates when we actually use the dictionary, + // rather than check for duplicates on each insert. + $dictionary[$parentKeyValue][] = $result; + + // If we're loading tertiary models, then set the keys in the nested dictionary as well. + if (!is_null($tertiaryModels)) { + $tertiaryKeyValue = $result->pivot->{$this->tertiaryKey}; + + if (!is_null($tertiaryKeyValue)) { + $tertiaryModel = clone $tertiaryModels[$tertiaryKeyValue]; + + // We also transfer the pivot relation at this point, since we have already coalesced + // any tertiary models into the nested dictionary. + $this->transferPivotsToTertiary($result, $tertiaryModel); + + $nestedTertiaryDictionary[$parentKeyValue][$result->getKey()][] = $tertiaryModel; + } + } + } + + return [$dictionary, $nestedTertiaryDictionary]; + } + + /** + * Build dictionary of tertiary models keyed by the corresponding related model keys. + * + * @param array $models + * @return array + */ + protected function buildTertiaryDictionary(array $models) + { + $dictionary = []; + + // Find the related tertiary entities (e.g. tasks) for all related models (e.g. locations) + $tertiaryModels = $this->getTertiaryModels($models); + + // Now for each related model (e.g. location), we will build out a dictionary of their tertiary models (e.g. tasks) + foreach ($models as $model) { + $tertiaryKeyValue = $model->pivot->{$this->tertiaryKey}; + + $tertiaryModel = clone $tertiaryModels[$tertiaryKeyValue]; + + $this->transferPivotsToTertiary($model, $tertiaryModel); + + $dictionary[$model->getKey()][] = $tertiaryModel; + } + + return $dictionary; + } + + protected function transferPivotsToTertiary($model, $tertiaryModel) + { + $pivotAttributes = []; + foreach ($this->pivotColumns as $column) { + $pivotAttributes[$column] = $model->pivot->$column; + unset($model->pivot->$column); + } + // Copy the related key pivot as well, but don't unset on the related model + $pivotAttributes[$this->relatedKey] = $model->pivot->{$this->relatedKey}; + + // Set the tertiary key pivot as well + $pivotAttributes[$this->tertiaryKey] = $tertiaryModel->getKey(); + + $pivot = $this->newExistingPivot($pivotAttributes); + $tertiaryModel->setRelation('pivot', $pivot); + } + + /** + * Get the tertiary models for the relationship. + * + * @param array $models + * @return \Illuminate\Database\Eloquent\Collection + */ + protected function getTertiaryModels(array $models) + { + $tertiaryClass = $this->tertiaryRelated; + + $keys = []; + foreach ($models as $model) { + $keys[] = $model->getRelation('pivot')->{$this->tertiaryKey}; + } + $keys = array_unique($keys); + + $query = $tertiaryClass->whereIn($tertiaryClass->getQualifiedKeyName(), $keys); + + // Add any additional constraints/eager loads to the tertiary query + $callback = $this->tertiaryCallback; + $callback($query); + + $tertiaryModels = $query + ->get() + ->keyBy($tertiaryClass->getKeyName()); + + return $tertiaryModels; + } + + /** + * Match a collection of child models into a collection of parent models using a dictionary. + * + * @param array $dictionary + * @param \Illuminate\Database\Eloquent\Collection $results + * @return void + */ + protected function matchTertiaryModels(array $dictionary, Collection $results) + { + // Now go through and set the tertiary relation on each child model + foreach ($results as $model) { + if (isset($dictionary[$key = $model->getKey()])) { + $tertiaryModels = $dictionary[$key]; + + $model->setRelation( + $this->tertiaryRelationName, $this->tertiaryRelated->newCollection($tertiaryModels) + ); + } + } + } + + /** + * Unset tertiary pivots on a collection or array of models. + * + * @param \Illuminate\Database\Eloquent\Collection $models + * @return void + */ + protected function unsetTertiaryPivots(Collection $models) + { + foreach ($models as $model) { + foreach ($this->pivotColumns as $column) { + unset($model->pivot->$column); + } + } + } +} diff --git a/main/app/sprinkles/core/src/Database/Relations/HasManySyncable.php b/main/app/sprinkles/core/src/Database/Relations/HasManySyncable.php new file mode 100755 index 0000000..bcf2a9d --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Relations/HasManySyncable.php @@ -0,0 +1,22 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Relations; + +use Illuminate\Database\Eloquent\Relations\HasMany; +use UserFrosting\Sprinkle\Core\Database\Relations\Concerns\Syncable; + +/** + * A HasMany relationship that supports a `sync` method. + * + * @author Alex Weissman (https://alexanderweissman.com) + * @link https://github.com/laravel/framework/blob/5.4/src/Illuminate/Database/Eloquent/Relations/HasMany.php + */ +class HasManySyncable extends HasMany +{ + use Syncable; +} diff --git a/main/app/sprinkles/core/src/Database/Relations/MorphManySyncable.php b/main/app/sprinkles/core/src/Database/Relations/MorphManySyncable.php new file mode 100755 index 0000000..2786193 --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Relations/MorphManySyncable.php @@ -0,0 +1,22 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Relations; + +use Illuminate\Database\Eloquent\Relations\MorphMany; +use UserFrosting\Sprinkle\Core\Database\Relations\Concerns\Syncable; + +/** + * A MorphMany relationship that constrains on the value of an additional foreign key in the pivot table. + * + * @author Alex Weissman (https://alexanderweissman.com) + * @link https://github.com/laravel/framework/blob/5.4/src/Illuminate/Database/Eloquent/Relations/MorphMany.php + */ +class MorphManySyncable extends MorphMany +{ + use Syncable; +} diff --git a/main/app/sprinkles/core/src/Database/Relations/MorphToManyUnique.php b/main/app/sprinkles/core/src/Database/Relations/MorphToManyUnique.php new file mode 100755 index 0000000..cc9a03f --- /dev/null +++ b/main/app/sprinkles/core/src/Database/Relations/MorphToManyUnique.php @@ -0,0 +1,22 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Database\Relations; + +use Illuminate\Database\Eloquent\Relations\MorphToMany; +use UserFrosting\Sprinkle\Core\Database\Relations\Concerns\Unique; + +/** + * A MorphToMany relationship that reduces the related members to a unique (by primary key) set. + * + * @author Alex Weissman (https://alexanderweissman.com) + * @link https://github.com/laravel/framework/blob/5.4/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php + */ +class MorphToManyUnique extends MorphToMany +{ + use Unique; +} diff --git a/main/app/sprinkles/core/src/Error/ExceptionHandlerManager.php b/main/app/sprinkles/core/src/Error/ExceptionHandlerManager.php new file mode 100755 index 0000000..4680da5 --- /dev/null +++ b/main/app/sprinkles/core/src/Error/ExceptionHandlerManager.php @@ -0,0 +1,93 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error; + +use Interop\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use UserFrosting\Sprinkle\Core\Handler\ExceptionHandlerInterface; + +/** + * Default UserFrosting application error handler + * + * It outputs the error message and diagnostic information in either JSON, XML, or HTML based on the Accept header. + * @author Alex Weissman (https://alexanderweissman.com) + */ +class ExceptionHandlerManager +{ + /** + * @var ContainerInterface The global container object, which holds all your services. + */ + protected $ci; + + /** + * @var array[string] An array that maps Exception types to callbacks, for special processing of certain types of errors. + */ + protected $exceptionHandlers = []; + + /** + * @var bool + */ + protected $displayErrorDetails; + + /** + * Constructor + * + * @param ContainerInterface $ci The global container object, which holds all your services. + * @param boolean $displayErrorDetails Set to true to display full details + */ + public function __construct(ContainerInterface $ci, $displayErrorDetails = false) + { + $this->ci = $ci; + $this->displayErrorDetails = (bool)$displayErrorDetails; + } + + /** + * Invoke error handler + * + * @param ServerRequestInterface $request The most recent Request object + * @param ResponseInterface $response The most recent Response object + * @param Throwable $exception The caught Exception object + * + * @return ResponseInterface + */ + public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $exception) + { + // Default exception handler class + $handlerClass = '\UserFrosting\Sprinkle\Core\Error\Handler\ExceptionHandler'; + + // Get the last matching registered handler class, and instantiate it + foreach ($this->exceptionHandlers as $exceptionClass => $matchedHandlerClass) { + if ($exception instanceof $exceptionClass) { + $handlerClass = $matchedHandlerClass; + } + } + + $handler = new $handlerClass($this->ci, $request, $response, $exception, $this->displayErrorDetails); + + return $handler->handle(); + } + + /** + * Register an exception handler for a specified exception class. + * + * The exception handler must implement \UserFrosting\Sprinkle\Core\Handler\ExceptionHandlerInterface. + * + * @param string $exceptionClass The fully qualified class name of the exception to handle. + * @param string $handlerClass The fully qualified class name of the assigned handler. + * @throws InvalidArgumentException If the registered handler fails to implement ExceptionHandlerInterface + */ + public function registerHandler($exceptionClass, $handlerClass) + { + if (!is_a($handlerClass, '\UserFrosting\Sprinkle\Core\Error\Handler\ExceptionHandlerInterface', true)) { + throw new \InvalidArgumentException("Registered exception handler must implement ExceptionHandlerInterface!"); + } + + $this->exceptionHandlers[$exceptionClass] = $handlerClass; + } +} diff --git a/main/app/sprinkles/core/src/Error/Handler/ExceptionHandler.php b/main/app/sprinkles/core/src/Error/Handler/ExceptionHandler.php new file mode 100755 index 0000000..4fdc51d --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Handler/ExceptionHandler.php @@ -0,0 +1,275 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Handler; + +use Interop\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use UserFrosting\Sprinkle\Core\Error\Renderer\HtmlRenderer; +use UserFrosting\Sprinkle\Core\Error\Renderer\JsonRenderer; +use UserFrosting\Sprinkle\Core\Error\Renderer\PlainTextRenderer; +use UserFrosting\Sprinkle\Core\Error\Renderer\WhoopsRenderer; +use UserFrosting\Sprinkle\Core\Error\Renderer\XmlRenderer; +use UserFrosting\Sprinkle\Core\Http\Concerns\DeterminesContentType; +use UserFrosting\Support\Message\UserMessage; + +/** + * Generic handler for exceptions. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class ExceptionHandler implements ExceptionHandlerInterface +{ + use DeterminesContentType; + + /** + * @var ContainerInterface The global container object, which holds all your services. + */ + protected $ci; + + /** + * @var ServerRequestInterface + */ + protected $request; + + /** + * @var ResponseInterface + */ + protected $response; + + /** + * @var Throwable + */ + protected $exception; + + /** + * @var ErrorRendererInterface + */ + protected $renderer = null; + + /** + * @var string + */ + protected $contentType; + + /** + * @var int + */ + protected $statusCode; + + /** + * Tells the handler whether or not to output detailed error information to the client. + * Each handler may choose if and how to implement this. + * + * @var bool + */ + protected $displayErrorDetails; + + /** + * Create a new ExceptionHandler object. + * + * @param ContainerInterface $ci + * @param ServerRequestInterface $request The most recent Request object + * @param ResponseInterface $response The most recent Response object + * @param Throwable $exception The caught Exception object + * @param bool $displayErrorDetails + */ + public function __construct( + ContainerInterface $ci, + ServerRequestInterface $request, + ResponseInterface $response, + $exception, + $displayErrorDetails = false + ) { + $this->ci = $ci; + $this->request = $request; + $this->response = $response; + $this->exception = $exception; + $this->displayErrorDetails = $displayErrorDetails; + $this->statusCode = $this->determineStatusCode(); + $this->contentType = $this->determineContentType($request, $this->ci->config['site.debug.ajax']); + $this->renderer = $this->determineRenderer(); + } + + /** + * Handle the caught exception. + * The handler may render a detailed debugging error page, a generic error page, write to logs, and/or add messages to the alert stream. + * + * @return ResponseInterface + */ + public function handle() + { + // If displayErrorDetails is set to true, we'll halt and immediately respond with a detailed debugging page. + // We do not log errors in this case. + if ($this->displayErrorDetails) { + $response = $this->renderDebugResponse(); + } else { + // Write exception to log + $this->writeToErrorLog(); + + // Render generic error page + $response = $this->renderGenericResponse(); + } + + // If this is an AJAX request and AJAX debugging is turned off, write messages to the alert stream + if ($this->request->isXhr() && !$this->ci->config['site.debug.ajax']) { + $this->writeAlerts(); + } + + return $response; + } + + /** + * Render a detailed response with debugging information. + * + * @return ResponseInterface + */ + public function renderDebugResponse() + { + $body = $this->renderer->renderWithBody(); + + return $this->response + ->withStatus($this->statusCode) + ->withHeader('Content-type', $this->contentType) + ->withBody($body); + } + + /** + * Render a generic, user-friendly response without sensitive debugging information. + * + * @return ResponseInterface + */ + public function renderGenericResponse() + { + $messages = $this->determineUserMessages(); + $httpCode = $this->statusCode; + + try { + $template = $this->ci->view->getEnvironment()->loadTemplate("pages/error/$httpCode.html.twig"); + } catch (\Twig_Error_Loader $e) { + $template = $this->ci->view->getEnvironment()->loadTemplate("pages/abstract/error.html.twig"); + } + + return $this->response + ->withStatus($httpCode) + ->withHeader('Content-type', $this->contentType) + ->write($template->render([ + 'messages' => $messages + ])); + } + + /** + * Write to the error log + * + * @return void + */ + public function writeToErrorLog() + { + $renderer = new PlainTextRenderer($this->request, $this->response, $this->exception, true); + $error = $renderer->render(); + $error .= PHP_EOL . 'View in rendered output by enabling the "displayErrorDetails" setting.' . PHP_EOL; + $this->logError($error); + } + + /** + * Write user-friendly error messages to the alert message stream. + * + * @return void + */ + public function writeAlerts() + { + $messages = $this->determineUserMessages(); + + foreach ($messages as $message) { + $this->ci->alerts->addMessageTranslated('danger', $message->message, $message->parameters); + } + } + + /** + * Determine which renderer to use based on content type + * Overloaded $renderer from calling class takes precedence over all + * + * @return ErrorRendererInterface + * + * @throws \RuntimeException + */ + protected function determineRenderer() + { + $renderer = $this->renderer; + + if ((!is_null($renderer) && !class_exists($renderer)) + || (!is_null($renderer) && !in_array('UserFrosting\Sprinkle\Core\Error\Renderer\ErrorRendererInterface', class_implements($renderer))) + ) { + throw new \RuntimeException(sprintf( + 'Non compliant error renderer provided (%s). ' . + 'Renderer must implement the ErrorRendererInterface', + $renderer + )); + } + + if (is_null($renderer)) { + switch ($this->contentType) { + case 'application/json': + $renderer = JsonRenderer::class; + break; + + case 'text/xml': + case 'application/xml': + $renderer = XmlRenderer::class; + break; + + case 'text/plain': + $renderer = PlainTextRenderer::class; + break; + + default: + case 'text/html': + $renderer = WhoopsRenderer::class; + break; + } + } + + return new $renderer($this->request, $this->response, $this->exception, $this->displayErrorDetails); + } + + /** + * Resolve the status code to return in the response from this handler. + * + * @return int + */ + protected function determineStatusCode() + { + if ($this->request->getMethod() === 'OPTIONS') { + return 200; + } + return 500; + } + + /** + * Resolve a list of error messages to present to the end user. + * + * @return array + */ + protected function determineUserMessages() + { + return [ + new UserMessage("ERROR.SERVER") + ]; + } + + /** + * Monolog logging for errors + * + * @param $message + * @return void + */ + protected function logError($message) + { + $this->ci->errorLogger->error($message); + } +} diff --git a/main/app/sprinkles/core/src/Error/Handler/ExceptionHandlerInterface.php b/main/app/sprinkles/core/src/Error/Handler/ExceptionHandlerInterface.php new file mode 100755 index 0000000..a928b69 --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Handler/ExceptionHandlerInterface.php @@ -0,0 +1,32 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Handler; + +use Interop\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * All exception handlers must implement this interface. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +interface ExceptionHandlerInterface +{ + public function __construct(ContainerInterface $ci, ServerRequestInterface $request, ResponseInterface $response, $exception, $displayErrorDetails = false); + + public function handle(); + + public function renderDebugResponse(); + + public function renderGenericResponse(); + + public function writeToErrorLog(); + + public function writeAlerts(); +} diff --git a/main/app/sprinkles/core/src/Error/Handler/HttpExceptionHandler.php b/main/app/sprinkles/core/src/Error/Handler/HttpExceptionHandler.php new file mode 100755 index 0000000..946bda7 --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Handler/HttpExceptionHandler.php @@ -0,0 +1,64 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Handler; + +use UserFrosting\Support\Exception\HttpException; + +/** + * Handler for HttpExceptions. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class HttpExceptionHandler extends ExceptionHandler +{ + /** + * For HttpExceptions, only write to the error log if the status code is 500 + * + * @return void + */ + public function writeToErrorLog() + { + if ($this->statusCode != 500) { + return; + } + + parent::writeToErrorLog(); + } + + /** + * Resolve the status code to return in the response from this handler. + * + * @return int + */ + protected function determineStatusCode() + { + if ($this->request->getMethod() === 'OPTIONS') { + return 200; + } elseif ($this->exception instanceof HttpException) { + return $this->exception->getHttpErrorCode(); + } + return 500; + } + + /** + * Resolve a list of error messages to present to the end user. + * + * @return array + */ + protected function determineUserMessages() + { + if ($this->exception instanceof HttpException) { + return $this->exception->getUserMessages(); + } + + // Fallback + return [ + new UserMessage("ERROR.SERVER") + ]; + } +} diff --git a/main/app/sprinkles/core/src/Error/Handler/NotFoundExceptionHandler.php b/main/app/sprinkles/core/src/Error/Handler/NotFoundExceptionHandler.php new file mode 100755 index 0000000..306feed --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Handler/NotFoundExceptionHandler.php @@ -0,0 +1,38 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Handler; + +use UserFrosting\Sprinkle\Core\Error\Handler\HttpExceptionHandler; +use UserFrosting\Support\Exception\HttpException; +use UserFrosting\Support\Message\UserMessage; + +/** + * Handler for NotFoundExceptions. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class NotFoundExceptionHandler extends HttpExceptionHandler +{ + /** + * Custom handling for NotFoundExceptions. Always render a generic response! + * + * @return Response + */ + public function handle() + { + // Render generic error page + $response = $this->renderGenericResponse(); + + // If this is an AJAX request and AJAX debugging is turned off, write messages to the alert stream + if ($this->request->isXhr() && !$this->ci->config['site.debug.ajax']) { + $this->writeAlerts(); + } + + return $response; + } +} diff --git a/main/app/sprinkles/core/src/Error/Handler/PhpMailerExceptionHandler.php b/main/app/sprinkles/core/src/Error/Handler/PhpMailerExceptionHandler.php new file mode 100755 index 0000000..45f0e8d --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Handler/PhpMailerExceptionHandler.php @@ -0,0 +1,30 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Handler; + +use UserFrosting\Support\Message\UserMessage; + +/** + * Handler for phpMailer exceptions. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class PhpMailerExceptionHandler extends ExceptionHandler +{ + /** + * Resolve a list of error messages to present to the end user. + * + * @return array + */ + protected function determineUserMessages() + { + return [ + new UserMessage("ERROR.MAIL") + ]; + } +} diff --git a/main/app/sprinkles/core/src/Error/Renderer/ErrorRenderer.php b/main/app/sprinkles/core/src/Error/Renderer/ErrorRenderer.php new file mode 100755 index 0000000..f065af0 --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Renderer/ErrorRenderer.php @@ -0,0 +1,64 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Renderer; + +use Slim\Http\Body; + +abstract class ErrorRenderer implements ErrorRendererInterface +{ + /** + * @var ServerRequestInterface + */ + protected $request; + + /** + * @var ResponseInterface + */ + protected $response; + + /** + * @var Exception + */ + protected $exception; + + /** + * Tells the renderer whether or not to output detailed error information to the client. + * Each renderer may choose if and how to implement this. + * + * @var bool + */ + protected $displayErrorDetails; + + /** + * Create a new ErrorRenderer object. + * + * @param ServerRequestInterface $request The most recent Request object + * @param ResponseInterface $response The most recent Response object + * @param Exception $exception The caught Exception object + * @param bool $displayErrorDetails + */ + public function __construct($request, $response, $exception, $displayErrorDetails = false) + { + $this->request = $request; + $this->response = $response; + $this->exception = $exception; + $this->displayErrorDetails = $displayErrorDetails; + } + + abstract public function render(); + + /** + * @return Body + */ + public function renderWithBody() + { + $body = new Body(fopen('php://temp', 'r+')); + $body->write($this->render()); + return $body; + } +} diff --git a/main/app/sprinkles/core/src/Error/Renderer/ErrorRendererInterface.php b/main/app/sprinkles/core/src/Error/Renderer/ErrorRendererInterface.php new file mode 100755 index 0000000..7af269a --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Renderer/ErrorRendererInterface.php @@ -0,0 +1,29 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Renderer; + +interface ErrorRendererInterface +{ + /** + * @param ServerRequestInterface $request The most recent Request object + * @param ResponseInterface $response The most recent Response object + * @param Exception $exception The caught Exception object + * @param bool $displayErrorDetails + */ + public function __construct($request, $response, $exception, $displayErrorDetails = false); + + /** + * @return string + */ + public function render(); + + /** + * @return \Slim\Http\Body + */ + public function renderWithBody(); +} diff --git a/main/app/sprinkles/core/src/Error/Renderer/HtmlRenderer.php b/main/app/sprinkles/core/src/Error/Renderer/HtmlRenderer.php new file mode 100755 index 0000000..1f32675 --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Renderer/HtmlRenderer.php @@ -0,0 +1,151 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Renderer; + +class HtmlRenderer extends ErrorRenderer +{ + /** + * Render HTML error report. + * + * @return string + */ + public function render() + { + $title = 'UserFrosting Application Error'; + + if ($this->displayErrorDetails) { + $html = '<p>The application could not run because of the following error:</p>'; + $html .= '<h2>Details</h2>'; + $html .= $this->renderException($this->exception); + + $html .= '<h2>Your request</h2>'; + $html .= $this->renderRequest(); + + $html .= '<h2>Response headers</h2>'; + $html .= $this->renderResponseHeaders(); + + $exception = $this->exception; + while ($exception = $exception->getPrevious()) { + $html .= '<h2>Previous exception</h2>'; + $html .= $this->renderException($exception); + } + } else { + $html = '<p>A website error has occurred. Sorry for the temporary inconvenience.</p>'; + } + + $output = sprintf( + "<html><head><meta http-equiv='Content-Type' content='text/html; charset=utf-8'>" . + "<title>%s</title><style>body{margin:0;padding:30px;font:12px/1.5 Helvetica,Arial,Verdana," . + "sans-serif;}h1{margin:0;font-size:48px;font-weight:normal;line-height:48px;}strong{" . + "display:inline-block;width:65px;}table,th,td{font:12px Helvetica,Arial,Verdana," . + "sans-serif;border:1px solid black;border-collapse:collapse;padding:5px;text-align: left;}" . + "th{font-weight:600;}" . + "</style></head><body><h1>%s</h1>%s</body></html>", + $title, + $title, + $html + ); + + return $output; + } + + /** + * Render a summary of the exception. + * + * @param Exception $exception + * @return string + */ + public function renderException($exception) + { + $html = sprintf('<div><strong>Type:</strong> %s</div>', get_class($exception)); + + if (($code = $exception->getCode())) { + $html .= sprintf('<div><strong>Code:</strong> %s</div>', $code); + } + + if (($message = $exception->getMessage())) { + $html .= sprintf('<div><strong>Message:</strong> %s</div>', htmlentities($message)); + } + + if (($file = $exception->getFile())) { + $html .= sprintf('<div><strong>File:</strong> %s</div>', $file); + } + + if (($line = $exception->getLine())) { + $html .= sprintf('<div><strong>Line:</strong> %s</div>', $line); + } + + if (($trace = $exception->getTraceAsString())) { + $html .= '<h2>Trace</h2>'; + $html .= sprintf('<pre>%s</pre>', htmlentities($trace)); + } + + return $html; + } + + /** + * Render HTML representation of original request. + * + * @return string + */ + public function renderRequest() + { + $method = $this->request->getMethod(); + $uri = $this->request->getUri(); + $params = $this->request->getParams(); + $requestHeaders = $this->request->getHeaders(); + + $html = '<h3>Request URI:</h3>'; + + $html .= sprintf('<div><strong>%s</strong> %s</div>', $method, $uri); + + $html .= '<h3>Request parameters:</h3>'; + + $html .= $this->renderTable($params); + + $html .= '<h3>Request headers:</h3>'; + + $html .= $this->renderTable($requestHeaders); + + return $html; + } + + /** + * Render HTML representation of response headers. + * + * @return string + */ + public function renderResponseHeaders() + { + $html = '<h3>Response headers:</h3>'; + $html .= '<em>Additional response headers may have been set by Slim after the error handling routine. Please check your browser console for a complete list.</em><br>'; + + $html .= $this->renderTable($this->response->getHeaders()); + + return $html; + } + + /** + * Render HTML representation of a table of data. + * + * @param mixed[] $data the array of data to render. + * + * @return string + */ + protected function renderTable($data) + { + $html = '<table><tr><th>Name</th><th>Value</th></tr>'; + foreach ($data as $name => $value) { + $value = print_r($value, true); + $html .= "<tr><td>$name</td><td>$value</td></tr>"; + } + $html .= '</table>'; + + return $html; + } +} diff --git a/main/app/sprinkles/core/src/Error/Renderer/JsonRenderer.php b/main/app/sprinkles/core/src/Error/Renderer/JsonRenderer.php new file mode 100755 index 0000000..3adfd45 --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Renderer/JsonRenderer.php @@ -0,0 +1,57 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Renderer; + +/** + * Default JSON Error Renderer + */ +class JsonRenderer extends ErrorRenderer +{ + /** + * @return string + */ + public function render() + { + $message = $this->exception->getMessage(); + return $this->formatExceptionPayload($message); + } + + /** + * @param $message + * @return string + */ + public function formatExceptionPayload($message) + { + $e = $this->exception; + $error = ['message' => $message]; + + if ($this->displayErrorDetails) { + $error['exception'] = []; + do { + $error['exception'][] = $this->formatExceptionFragment($e); + } while ($e = $e->getPrevious()); + } + + return json_encode($error, JSON_PRETTY_PRINT); + } + + /** + * @param \Exception|\Throwable $e + * @return array + */ + public function formatExceptionFragment($e) + { + return [ + 'type' => get_class($e), + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]; + } +} diff --git a/main/app/sprinkles/core/src/Error/Renderer/PlainTextRenderer.php b/main/app/sprinkles/core/src/Error/Renderer/PlainTextRenderer.php new file mode 100755 index 0000000..a4984fc --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Renderer/PlainTextRenderer.php @@ -0,0 +1,65 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Renderer; + +/** + * Plain Text Error Renderer + */ +class PlainTextRenderer extends ErrorRenderer +{ + public function render() + { + if ($this->displayErrorDetails) { + return $this->formatExceptionBody(); + } + + return $this->exception->getMessage(); + } + + public function formatExceptionBody() + { + $e = $this->exception; + + $text = 'UserFrosting Application Error:' . PHP_EOL; + $text .= $this->formatExceptionFragment($e); + + while ($e = $e->getPrevious()) { + $text .= PHP_EOL . 'Previous Error:' . PHP_EOL; + $text .= $this->formatExceptionFragment($e); + } + + return $text; + } + + /** + * @param \Exception|\Throwable $e + * @return string + */ + public function formatExceptionFragment($e) + { + $text = sprintf('Type: %s' . PHP_EOL, get_class($e)); + + if ($code = $e->getCode()) { + $text .= sprintf('Code: %s' . PHP_EOL, $code); + } + if ($message = $e->getMessage()) { + $text .= sprintf('Message: %s' . PHP_EOL, htmlentities($message)); + } + if ($file = $e->getFile()) { + $text .= sprintf('File: %s' . PHP_EOL, $file); + } + if ($line = $e->getLine()) { + $text .= sprintf('Line: %s' . PHP_EOL, $line); + } + if ($trace = $e->getTraceAsString()) { + $text .= sprintf('Trace: %s', $trace); + } + + return $text; + } +} diff --git a/main/app/sprinkles/core/src/Error/Renderer/WhoopsRenderer.php b/main/app/sprinkles/core/src/Error/Renderer/WhoopsRenderer.php new file mode 100755 index 0000000..767ce1b --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Renderer/WhoopsRenderer.php @@ -0,0 +1,712 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Renderer; + +use InvalidArgumentException; +use RuntimeException; +use Symfony\Component\VarDumper\Cloner\AbstractCloner; +use Symfony\Component\VarDumper\Cloner\VarCloner; +use UnexpectedValueException; +use UserFrosting\Sprinkle\Core\Util\Util; +use Whoops\Exception\Formatter; +use Whoops\Exception\Inspector; +use Whoops\Handler\PlainTextHandler; +use Whoops\Util\Misc; +use Whoops\Util\TemplateHelper; + +class WhoopsRenderer extends ErrorRenderer +{ + /** + * Search paths to be scanned for resources, in the reverse + * order they're declared. + * + * @var array + */ + private $searchPaths = []; + + /** + * Fast lookup cache for known resource locations. + * + * @var array + */ + private $resourceCache = []; + + /** + * The name of the custom css file. + * + * @var string + */ + private $customCss = null; + + /** + * @var array[] + */ + private $extraTables = []; + + /** + * @var bool + */ + private $handleUnconditionally = false; + + /** + * @var string + */ + private $pageTitle = 'Whoops! There was an error.'; + + /** + * @var array[] + */ + private $applicationPaths; + + /** + * @var array[] + */ + private $blacklist = [ + '_GET' => [], + '_POST' => [], + '_FILES' => [], + '_COOKIE' => [], + '_SESSION' => [], + '_SERVER' => ['DB_PASSWORD', 'SMTP_PASSWORD'], + '_ENV' => ['DB_PASSWORD', 'SMTP_PASSWORD'], + ]; + + /** + * A string identifier for a known IDE/text editor, or a closure + * that resolves a string that can be used to open a given file + * in an editor. If the string contains the special substrings + * %file or %line, they will be replaced with the correct data. + * + * @example + * "txmt://open?url=%file&line=%line" + * @var mixed $editor + */ + protected $editor; + + /** + * A list of known editor strings + * @var array + */ + protected $editors = [ + "sublime" => "subl://open?url=file://%file&line=%line", + "textmate" => "txmt://open?url=file://%file&line=%line", + "emacs" => "emacs://open?url=file://%file&line=%line", + "macvim" => "mvim://open/?url=file://%file&line=%line", + "phpstorm" => "phpstorm://open?file=%file&line=%line", + "idea" => "idea://open?file=%file&line=%line", + ]; + + /** + * @var Inspector + */ + protected $inspector; + + /** + * @var TemplateHelper + */ + private $templateHelper; + + /** + * {@inheritDoc} + */ + public function __construct($request, $response, $exception, $displayErrorDetails = false) + { + $this->request = $request; + $this->response = $response; + $this->exception = $exception; + $this->displayErrorDetails = $displayErrorDetails; + + if (ini_get('xdebug.file_link_format') || extension_loaded('xdebug')) { + // Register editor using xdebug's file_link_format option. + $this->editors['xdebug'] = function ($file, $line) { + return str_replace(['%f', '%l'], [$file, $line], ini_get('xdebug.file_link_format')); + }; + } + + // Add the default, local resource search path: + $this->searchPaths[] = \UserFrosting\VENDOR_DIR . '/filp/whoops/src/Whoops/Resources'; + + // blacklist php provided auth based values + $this->blacklist('_SERVER', 'PHP_AUTH_PW'); + + $this->templateHelper = new TemplateHelper(); + + // Set up dummy inspector + $this->inspector = new Inspector($exception); + + if (class_exists('Symfony\Component\VarDumper\Cloner\VarCloner')) { + $cloner = new VarCloner(); + // Only dump object internals if a custom caster exists. + $cloner->addCasters(['*' => function ($obj, $a, $stub, $isNested, $filter = 0) { + $class = $stub->class; + $classes = [$class => $class] + class_parents($class) + class_implements($class); + + foreach ($classes as $class) { + if (isset(AbstractCloner::$defaultCasters[$class])) { + return $a; + } + } + + // Remove all internals + return []; + }]); + $this->templateHelper->setCloner($cloner); + } + } + + /** + * {@inheritDoc} + */ + public function render() + { + if (!$this->handleUnconditionally()) { + // Check conditions for outputting HTML: + // @todo: Make this more robust + if (php_sapi_name() === 'cli') { + // Help users who have been relying on an internal test value + // fix their code to the proper method + if (isset($_ENV['whoops-test'])) { + throw new \Exception( + 'Use handleUnconditionally instead of whoops-test' + .' environment variable' + ); + } + + return Handler::DONE; + } + } + + $templateFile = $this->getResource("views/layout.html.php"); + $cssFile = $this->getResource("css/whoops.base.css"); + $zeptoFile = $this->getResource("js/zepto.min.js"); + $clipboard = $this->getResource("js/clipboard.min.js"); + $jsFile = $this->getResource("js/whoops.base.js"); + + if ($this->customCss) { + $customCssFile = $this->getResource($this->customCss); + } + + $inspector = $this->getInspector(); + $frames = $inspector->getFrames(); + + $code = $inspector->getException()->getCode(); + + if ($inspector->getException() instanceof \ErrorException) { + // ErrorExceptions wrap the php-error types within the "severity" property + $code = Misc::translateErrorCode($inspector->getException()->getSeverity()); + } + + // Detect frames that belong to the application. + if ($this->applicationPaths) { + /* @var \Whoops\Exception\Frame $frame */ + foreach ($frames as $frame) { + foreach ($this->applicationPaths as $path) { + if (substr($frame->getFile(), 0, strlen($path)) === $path) { + $frame->setApplication(true); + break; + } + } + } + } + + // Nicely format the session object + $session = isset($_SESSION) ? $this->masked($_SESSION, '_SESSION') : []; + $session = ['session' => Util::prettyPrintArray($session)]; + + // List of variables that will be passed to the layout template. + $vars = [ + "page_title" => $this->getPageTitle(), + + // @todo: Asset compiler + "stylesheet" => file_get_contents($cssFile), + "zepto" => file_get_contents($zeptoFile), + "clipboard" => file_get_contents($clipboard), + "javascript" => file_get_contents($jsFile), + + // Template paths: + "header" => $this->getResource("views/header.html.php"), + "header_outer" => $this->getResource("views/header_outer.html.php"), + "frame_list" => $this->getResource("views/frame_list.html.php"), + "frames_description" => $this->getResource("views/frames_description.html.php"), + "frames_container" => $this->getResource("views/frames_container.html.php"), + "panel_details" => $this->getResource("views/panel_details.html.php"), + "panel_details_outer" => $this->getResource("views/panel_details_outer.html.php"), + "panel_left" => $this->getResource("views/panel_left.html.php"), + "panel_left_outer" => $this->getResource("views/panel_left_outer.html.php"), + "frame_code" => $this->getResource("views/frame_code.html.php"), + "env_details" => $this->getResource("views/env_details.html.php"), + + "title" => $this->getPageTitle(), + "name" => explode("\\", $inspector->getExceptionName()), + "message" => $inspector->getException()->getMessage(), + "code" => $code, + "plain_exception" => Formatter::formatExceptionPlain($inspector), + "frames" => $frames, + "has_frames" => !!count($frames), + "handler" => $this, + "handlers" => [$this], + + "active_frames_tab" => count($frames) && $frames->offsetGet(0)->isApplication() ? 'application' : 'all', + "has_frames_tabs" => $this->getApplicationPaths(), + + "tables" => [ + "GET Data" => $this->masked($_GET, '_GET'), + "POST Data" => $this->masked($_POST, '_POST'), + "Files" => isset($_FILES) ? $this->masked($_FILES, '_FILES') : [], + "Cookies" => $this->masked($_COOKIE, '_COOKIE'), + "Session" => $session, + "Server/Request Data" => $this->masked($_SERVER, '_SERVER'), + "Environment Variables" => $this->masked($_ENV, '_ENV'), + ], + ]; + + if (isset($customCssFile)) { + $vars["stylesheet"] .= file_get_contents($customCssFile); + } + + // Add extra entries list of data tables: + // @todo: Consolidate addDataTable and addDataTableCallback + $extraTables = array_map(function ($table) use ($inspector) { + return $table instanceof \Closure ? $table($inspector) : $table; + }, $this->getDataTables()); + $vars["tables"] = array_merge($extraTables, $vars["tables"]); + + $plainTextHandler = new PlainTextHandler(); + $plainTextHandler->setException($this->getException()); + $plainTextHandler->setInspector($this->getInspector()); + $vars["preface"] = "<!--\n\n\n" . $plainTextHandler->generateResponse() . "\n\n\n\n\n\n\n\n\n\n\n-->"; + + $this->templateHelper->setVariables($vars); + + ob_start(); + $this->templateHelper->render($templateFile); + + $result = ob_get_clean(); + return $result; + } + + /** + * Adds an entry to the list of tables displayed in the template. + * The expected data is a simple associative array. Any nested arrays + * will be flattened with print_r + * @param string $label + * @param array $data + */ + public function addDataTable($label, array $data) + { + $this->extraTables[$label] = $data; + } + + /** + * Lazily adds an entry to the list of tables displayed in the table. + * The supplied callback argument will be called when the error is rendered, + * it should produce a simple associative array. Any nested arrays will + * be flattened with print_r. + * + * @throws InvalidArgumentException If $callback is not callable + * @param string $label + * @param callable $callback Callable returning an associative array + */ + public function addDataTableCallback($label, /* callable */ $callback) + { + if (!is_callable($callback)) { + throw new InvalidArgumentException('Expecting callback argument to be callable'); + } + + $this->extraTables[$label] = function (\Whoops\Exception\Inspector $inspector = null) use ($callback) { + try { + $result = call_user_func($callback, $inspector); + + // Only return the result if it can be iterated over by foreach(). + return is_array($result) || $result instanceof \Traversable ? $result : []; + } catch (\Exception $e) { + // Don't allow failure to break the rendering of the original exception. + return []; + } + }; + } + + /** + * blacklist a sensitive value within one of the superglobal arrays. + * + * @param $superGlobalName string the name of the superglobal array, e.g. '_GET' + * @param $key string the key within the superglobal + */ + public function blacklist($superGlobalName, $key) + { + $this->blacklist[$superGlobalName][] = $key; + } + + /** + * Returns all the extra data tables registered with this handler. + * Optionally accepts a 'label' parameter, to only return the data + * table under that label. + * @param string|null $label + * @return array[]|callable + */ + public function getDataTables($label = null) + { + if ($label !== null) { + return isset($this->extraTables[$label]) ? + $this->extraTables[$label] : []; + } + + return $this->extraTables; + } + + /** + * Allows to disable all attempts to dynamically decide whether to + * handle or return prematurely. + * Set this to ensure that the handler will perform no matter what. + * @param bool|null $value + * @return bool|null + */ + public function handleUnconditionally($value = null) + { + if (func_num_args() == 0) { + return $this->handleUnconditionally; + } + + $this->handleUnconditionally = (bool) $value; + } + + /** + * Adds an editor resolver, identified by a string + * name, and that may be a string path, or a callable + * resolver. If the callable returns a string, it will + * be set as the file reference's href attribute. + * + * @example + * $run->addEditor('macvim', "mvim://open?url=file://%file&line=%line") + * @example + * $run->addEditor('remove-it', function($file, $line) { + * unlink($file); + * return "http://stackoverflow.com"; + * }); + * @param string $identifier + * @param string $resolver + */ + public function addEditor($identifier, $resolver) + { + $this->editors[$identifier] = $resolver; + } + + /** + * Set the editor to use to open referenced files, by a string + * identifier, or a callable that will be executed for every + * file reference, with a $file and $line argument, and should + * return a string. + * + * @example + * $run->setEditor(function($file, $line) { return "file:///{$file}"; }); + * @example + * $run->setEditor('sublime'); + * + * @throws InvalidArgumentException If invalid argument identifier provided + * @param string|callable $editor + */ + public function setEditor($editor) + { + if (!is_callable($editor) && !isset($this->editors[$editor])) { + throw new InvalidArgumentException( + "Unknown editor identifier: $editor. Known editors:" . + implode(",", array_keys($this->editors)) + ); + } + + $this->editor = $editor; + } + + /** + * Given a string file path, and an integer file line, + * executes the editor resolver and returns, if available, + * a string that may be used as the href property for that + * file reference. + * + * @throws InvalidArgumentException If editor resolver does not return a string + * @param string $filePath + * @param int $line + * @return string|bool + */ + public function getEditorHref($filePath, $line) + { + $editor = $this->getEditor($filePath, $line); + + if (empty($editor)) { + return false; + } + + // Check that the editor is a string, and replace the + // %line and %file placeholders: + if (!isset($editor['url']) || !is_string($editor['url'])) { + throw new UnexpectedValueException( + __METHOD__ . " should always resolve to a string or a valid editor array; got something else instead." + ); + } + + $editor['url'] = str_replace("%line", rawurlencode($line), $editor['url']); + $editor['url'] = str_replace("%file", rawurlencode($filePath), $editor['url']); + + return $editor['url']; + } + + /** + * Given a boolean if the editor link should + * act as an Ajax request. The editor must be a + * valid callable function/closure + * + * @throws UnexpectedValueException If editor resolver does not return a boolean + * @param string $filePath + * @param int $line + * @return bool + */ + public function getEditorAjax($filePath, $line) + { + $editor = $this->getEditor($filePath, $line); + + // Check that the ajax is a bool + if (!isset($editor['ajax']) || !is_bool($editor['ajax'])) { + throw new UnexpectedValueException( + __METHOD__ . " should always resolve to a bool; got something else instead." + ); + } + return $editor['ajax']; + } + + /** + * @param string $title + * @return void + */ + public function setPageTitle($title) + { + $this->pageTitle = (string) $title; + } + + /** + * @return string + */ + public function getPageTitle() + { + return $this->pageTitle; + } + + /** + * Adds a path to the list of paths to be searched for + * resources. + * + * @throws InvalidArgumentException If $path is not a valid directory + * + * @param string $path + * @return void + */ + public function addResourcePath($path) + { + if (!is_dir($path)) { + throw new InvalidArgumentException( + "'$path' is not a valid directory" + ); + } + + array_unshift($this->searchPaths, $path); + } + + /** + * Adds a custom css file to be loaded. + * + * @param string $name + * @return void + */ + public function addCustomCss($name) + { + $this->customCss = $name; + } + + /** + * @return array + */ + public function getResourcePaths() + { + return $this->searchPaths; + } + + /** + * @deprecated + * + * @return string + */ + public function getResourcesPath() + { + $allPaths = $this->getResourcePaths(); + + // Compat: return only the first path added + return end($allPaths) ?: null; + } + + /** + * @deprecated + * + * @param string $resourcesPath + * @return void + */ + public function setResourcesPath($resourcesPath) + { + $this->addResourcePath($resourcesPath); + } + + /** + * Return the application paths. + * + * @return array + */ + public function getApplicationPaths() + { + return $this->applicationPaths; + } + + /** + * Set the application paths. + * + * @param array $applicationPaths + */ + public function setApplicationPaths($applicationPaths) + { + $this->applicationPaths = $applicationPaths; + } + + /** + * Set the application root path. + * + * @param string $applicationRootPath + */ + public function setApplicationRootPath($applicationRootPath) + { + $this->templateHelper->setApplicationRootPath($applicationRootPath); + } + + /** + * Given a boolean if the editor link should + * act as an Ajax request. The editor must be a + * valid callable function/closure + * + * @param string $filePath + * @param int $line + * @return array + */ + protected function getEditor($filePath, $line) + { + if (!$this->editor || (!is_string($this->editor) && !is_callable($this->editor))) { + return []; + } + + if (is_string($this->editor) && isset($this->editors[$this->editor]) && !is_callable($this->editors[$this->editor])) { + return [ + 'ajax' => false, + 'url' => $this->editors[$this->editor], + ]; + } + + if (is_callable($this->editor) || (isset($this->editors[$this->editor]) && is_callable($this->editors[$this->editor]))) { + if (is_callable($this->editor)) { + $callback = call_user_func($this->editor, $filePath, $line); + } else { + $callback = call_user_func($this->editors[$this->editor], $filePath, $line); + } + + if (is_string($callback)) { + return [ + 'ajax' => false, + 'url' => $callback, + ]; + } + + return [ + 'ajax' => isset($callback['ajax']) ? $callback['ajax'] : false, + 'url' => isset($callback['url']) ? $callback['url'] : $callback, + ]; + } + + return []; + } + + /** + * @return \Throwable + */ + protected function getException() + { + return $this->exception; + } + + /** + * @return Inspector + */ + protected function getInspector() + { + return $this->inspector; + } + + /** + * Finds a resource, by its relative path, in all available search paths. + * The search is performed starting at the last search path, and all the + * way back to the first, enabling a cascading-type system of overrides + * for all resources. + * + * @throws RuntimeException If resource cannot be found in any of the available paths + * + * @param string $resource + * @return string + */ + protected function getResource($resource) + { + // If the resource was found before, we can speed things up + // by caching its absolute, resolved path: + if (isset($this->resourceCache[$resource])) { + return $this->resourceCache[$resource]; + } + + // Search through available search paths, until we find the + // resource we're after: + foreach ($this->searchPaths as $path) { + $fullPath = $path . "/$resource"; + + if (is_file($fullPath)) { + // Cache the result: + $this->resourceCache[$resource] = $fullPath; + return $fullPath; + } + } + + // If we got this far, nothing was found. + throw new RuntimeException( + "Could not find resource '$resource' in any resource paths." + . "(searched: " . join(", ", $this->searchPaths). ")" + ); + } + + /** + * Checks all values within the given superGlobal array. + * Blacklisted values will be replaced by a equal length string cointaining only '*' characters. + * + * We intentionally dont rely on $GLOBALS as it depends on 'auto_globals_jit' php.ini setting. + * + * @param array $superGlobal One of the superglobal arrays + * @param string $superGlobalName the name of the superglobal array, e.g. '_GET' + * @return array $values without sensitive data + */ + private function masked(array $superGlobal, $superGlobalName) + { + $blacklisted = $this->blacklist[$superGlobalName]; + + $values = $superGlobal; + foreach($blacklisted as $key) { + if (isset($superGlobal[$key])) { + $values[$key] = str_repeat('*', strlen($superGlobal[$key])); + } + } + return $values; + } +} diff --git a/main/app/sprinkles/core/src/Error/Renderer/XmlRenderer.php b/main/app/sprinkles/core/src/Error/Renderer/XmlRenderer.php new file mode 100755 index 0000000..52e71cf --- /dev/null +++ b/main/app/sprinkles/core/src/Error/Renderer/XmlRenderer.php @@ -0,0 +1,48 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Error\Renderer; + +/** + * Default XML Error Renderer + */ +class XmlRenderer extends ErrorRenderer +{ + /** + * @return string + */ + public function render() + { + $e = $this->exception; + $xml = "<error>\n <message>UserFrosting Application Error</message>\n"; + if ($this->displayErrorDetails) { + do { + $xml .= " <exception>\n"; + $xml .= " <type>" . get_class($e) . "</type>\n"; + $xml .= " <code>" . $e->getCode() . "</code>\n"; + $xml .= " <message>" . $this->createCdataSection($e->getMessage()) . "</message>\n"; + $xml .= " <file>" . $e->getFile() . "</file>\n"; + $xml .= " <line>" . $e->getLine() . "</line>\n"; + $xml .= " </exception>\n"; + } while ($e = $e->getPrevious()); + } + $xml .= "</error>"; + + return $xml; + } + + /** + * Returns a CDATA section with the given content. + * + * @param string $content + * @return string + */ + private function createCdataSection($content) + { + return sprintf('<![CDATA[%s]]>', str_replace(']]>', ']]]]><![CDATA[>', $content)); + } +} diff --git a/main/app/sprinkles/core/src/Facades/Debug.php b/main/app/sprinkles/core/src/Facades/Debug.php new file mode 100755 index 0000000..86ef450 --- /dev/null +++ b/main/app/sprinkles/core/src/Facades/Debug.php @@ -0,0 +1,28 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Facades; + +use UserFrosting\System\Facade; + +/** + * Implements facade for the "debugLogger" service + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Debug extends Facade +{ + /** + * Get the registered name of the component. + * + * @return string + */ + protected static function getFacadeAccessor() + { + return 'debugLogger'; + } +} diff --git a/main/app/sprinkles/core/src/Facades/Translator.php b/main/app/sprinkles/core/src/Facades/Translator.php new file mode 100755 index 0000000..e6fcccc --- /dev/null +++ b/main/app/sprinkles/core/src/Facades/Translator.php @@ -0,0 +1,28 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Facades; + +use UserFrosting\System\Facade; + +/** + * Implements facade for the "translator" service + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Translator extends Facade +{ + /** + * Get the registered name of the component. + * + * @return string + */ + protected static function getFacadeAccessor() + { + return 'translator'; + } +} diff --git a/main/app/sprinkles/core/src/Http/Concerns/DeterminesContentType.php b/main/app/sprinkles/core/src/Http/Concerns/DeterminesContentType.php new file mode 100755 index 0000000..e963afa --- /dev/null +++ b/main/app/sprinkles/core/src/Http/Concerns/DeterminesContentType.php @@ -0,0 +1,76 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Http\Concerns; + +use Psr\Http\Message\ServerRequestInterface; + +/** + * Trait for classes that need to determine a request's accepted content type(s). + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +trait DeterminesContentType +{ + /** + * Known handled content types + * + * @var array + */ + protected $knownContentTypes = [ + 'application/json', + 'application/xml', + 'text/xml', + 'text/html', + 'text/plain' + ]; + + /** + * Determine which content type we know about is wanted using Accept header + * + * Note: This method is a bare-bones implementation designed specifically for + * Slim's error handling requirements. Consider a fully-feature solution such + * as willdurand/negotiation for any other situation. + * + * @param ServerRequestInterface $request + * @return string + */ + protected function determineContentType(ServerRequestInterface $request, $ajaxDebug = false) + { + // For AJAX requests, if AJAX debugging is turned on, always return html + if ($ajaxDebug && $request->isXhr()) { + return 'text/html'; + } + + $acceptHeader = $request->getHeaderLine('Accept'); + $selectedContentTypes = array_intersect(explode(',', $acceptHeader), $this->knownContentTypes); + $count = count($selectedContentTypes); + + if ($count) { + $current = current($selectedContentTypes); + + /** + * Ensure other supported content types take precedence over text/plain + * when multiple content types are provided via Accept header. + */ + if ($current === 'text/plain' && $count > 1) { + return next($selectedContentTypes); + } + + return $current; + } + + if (preg_match('/\+(json|xml)/', $acceptHeader, $matches)) { + $mediaType = 'application/' . $matches[1]; + if (in_array($mediaType, $this->knownContentTypes)) { + return $mediaType; + } + } + + return 'text/html'; + } +} diff --git a/main/app/sprinkles/core/src/Log/DatabaseHandler.php b/main/app/sprinkles/core/src/Log/DatabaseHandler.php new file mode 100755 index 0000000..c78308c --- /dev/null +++ b/main/app/sprinkles/core/src/Log/DatabaseHandler.php @@ -0,0 +1,53 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Log; + +use Monolog\Logger; +use Monolog\Handler\AbstractProcessingHandler; + +/** + * Monolog handler for storing the record to a database. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class DatabaseHandler extends AbstractProcessingHandler +{ + /** + * @var UserFrosting\Sprinkle\Core\Util\ClassMapper + */ + protected $classMapper; + + /** + * @var string + */ + protected $modelIdentifier; + + /** + * Create a new DatabaseHandler object. + * + * @param ClassMapper $classMapper Maps the modelIdentifier to the specific Eloquent model. + * @param string $modelIdentifier + * @param int $level The minimum logging level at which this handler will be triggered + * @param Boolean $bubble Whether the messages that are handled can bubble up the stack or not + */ + public function __construct($classMapper, $modelIdentifier, $level = Logger::DEBUG, $bubble = true) + { + $this->classMapper = $classMapper; + $this->modelName = $modelIdentifier; + parent::__construct($level, $bubble); + } + + /** + * {@inheritDoc} + */ + protected function write(array $record) + { + $log = $this->classMapper->createInstance($this->modelName, $record['extra']); + $log->save(); + } +} diff --git a/main/app/sprinkles/core/src/Log/MixedFormatter.php b/main/app/sprinkles/core/src/Log/MixedFormatter.php new file mode 100755 index 0000000..beae788 --- /dev/null +++ b/main/app/sprinkles/core/src/Log/MixedFormatter.php @@ -0,0 +1,59 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Log; + +use Monolog\Formatter\LineFormatter; + +/** + * Monolog formatter for pretty-printing arrays and objects. + * + * This class extends the basic Monolog LineFormatter class, and provides basically the same functionality but with one exception: + * if the second parameter of any logging method (debug, error, info, etc) is an array, it will print it as a nicely formatted, + * multi-line JSON object instead of all on a single line. + * @author Alex Weissman (https://alexanderweissman.com) + */ +class MixedFormatter extends LineFormatter +{ + + /** + * Return the JSON representation of a value + * + * @param mixed $data + * @param bool $ignoreErrors + * @throws \RuntimeException if encoding fails and errors are not ignored + * @return string + */ + protected function toJson($data, $ignoreErrors = false) + { + // suppress json_encode errors since it's twitchy with some inputs + if ($ignoreErrors) { + return @$this->jsonEncodePretty($data); + } + + $json = $this->jsonEncodePretty($data); + + if ($json === false) { + $json = $this->handleJsonError(json_last_error(), $data); + } + + return $json; + } + + /** + * @param mixed $data + * @return string JSON encoded data or null on failure + */ + private function jsonEncodePretty($data) + { + if (version_compare(PHP_VERSION, '5.4.0', '>=')) { + return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + } + + return json_encode($data); + } +} diff --git a/main/app/sprinkles/core/src/Mail/EmailRecipient.php b/main/app/sprinkles/core/src/Mail/EmailRecipient.php new file mode 100755 index 0000000..0b9381a --- /dev/null +++ b/main/app/sprinkles/core/src/Mail/EmailRecipient.php @@ -0,0 +1,136 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Mail; + +/** + * EmailRecipient Class + * + * A class representing a recipient for a MailMessage, with associated parameters. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class EmailRecipient +{ + + /** + * @var string The email address for this recipient. + */ + protected $email; + + /** + * @var string The name for this recipient. + */ + protected $name; + + /** + * @var array Any additional parameters (name => value) to use when rendering an email template for this recipient. + */ + protected $params = []; + + /** + * @var array A list of CCs for this recipient. Each CC is an associative array with `email` and `name` properties. + */ + protected $cc = []; + + /** + * @var array A list of BCCs for this recipient. Each BCC is an associative array with `email` and `name` properties. + */ + protected $bcc = []; + + /** + * Create a new EmailRecipient instance. + * + * @param string $email The primary recipient email address. + * @param string $name The primary recipient name. + * @param array $params An array of template parameters to render the email message with for this particular recipient. + */ + public function __construct($email, $name = "", $params = []) + { + $this->email = $email; + $this->name = $name; + $this->params = $params; + } + + /** + * Add a CC for this primary recipient. + * + * @param string $email The CC recipient email address. + * @param string $name The CC recipient name. + */ + public function cc($email, $name = "") + { + $this->cc[] = [ + "email" => $email, + "name" => $name + ]; + } + + /** + * Add a BCC for this primary recipient. + * + * @param string $email The BCC recipient email address. + * @param string $name The BCC recipient name. + */ + public function bcc($email, $name = "") + { + $this->bcc[] = [ + "email" => $email, + "name" => $name + ]; + } + + /** + * Get the primary recipient email address. + * + * @return string the primary recipient email address. + */ + public function getEmail() + { + return $this->email; + } + + /** + * Get the primary recipient name. + * + * @return string the primary recipient name. + */ + public function getName() + { + return $this->name; + } + + /** + * Get the parameters to use when rendering the template this recipient. + * + * @return array The parameters (name => value) to use when rendering an email template for this recipient. + */ + public function getParams() + { + return $this->params; + } + + /** + * Get the list of CCs for this recipient. + * + * @return array A list of CCs for this recipient. Each CC is an associative array with `email` and `name` properties. + */ + public function getCCs() + { + return $this->cc; + } + + /** + * Get the list of BCCs for this recipient. + * + * @return array A list of BCCs for this recipient. Each BCC is an associative array with `email` and `name` properties. + */ + public function getBCCs() + { + return $this->bcc; + } +} diff --git a/main/app/sprinkles/core/src/Mail/MailMessage.php b/main/app/sprinkles/core/src/Mail/MailMessage.php new file mode 100755 index 0000000..29bcf15 --- /dev/null +++ b/main/app/sprinkles/core/src/Mail/MailMessage.php @@ -0,0 +1,186 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Mail; + +/** + * MailMessage Class + * + * Represents a basic mail message, containing a static subject and body. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +abstract class MailMessage +{ + /** + * @var string The current sender email address. + */ + protected $fromEmail = ""; + + /** + * @var string The current sender name. + */ + protected $fromName = null; + + /** + * @var EmailRecipient[] A list of recipients for this message. + */ + protected $recipients = []; + + /** + * @var string The current reply-to email. + */ + protected $replyEmail = null; + + /** + * @var string The current reply-to name. + */ + protected $replyName = null; + + /** + * Gets the fully rendered text of the message body. + * + * @return string + */ + abstract public function renderBody($params = []); + + /** + * Gets the fully rendered text of the message subject. + * + * @return string + */ + abstract public function renderSubject($params = []); + + /** + * Add an email recipient. + * + * @param EmailRecipient $recipient + */ + public function addEmailRecipient(EmailRecipient $recipient) + { + $this->recipients[] = $recipient; + return $this; + } + + /** + * Clears out all recipients for this message. + */ + public function clearRecipients() + { + $this->recipients = array(); + } + + /** + * Set sender information for this message. + * + * This is a shortcut for calling setFromEmail, setFromName, setReplyEmail, and setReplyName. + * @param string $fromInfo An array containing 'email', 'name', 'reply_email', and 'reply_name'. + */ + public function from($fromInfo = []) + { + $this->setFromEmail(isset($fromInfo['email']) ? $fromInfo['email'] : ""); + $this->setFromName(isset($fromInfo['name']) ? $fromInfo['name'] : null); + $this->setReplyEmail(isset($fromInfo['reply_email']) ? $fromInfo['reply_email'] : null); + $this->setReplyName(isset($fromInfo['reply_name']) ? $fromInfo['reply_name'] : null); + + return $this; + } + + /** + * Get the sender email address. + * + * @return string + */ + public function getFromEmail() + { + return $this->fromEmail; + } + + /** + * Get the sender name. Defaults to the email address if name is not set. + * + * @return string + */ + public function getFromName() + { + return isset($this->fromName) ? $this->fromName : $this->getFromEmail(); + } + + /** + * Get the list of recipients for this message. + * + * @return EmailRecipient[] + */ + public function getRecipients() + { + return $this->recipients; + } + + /** + * Get the 'reply-to' address for this message. Defaults to the sender email. + * + * @return string + */ + public function getReplyEmail() + { + return isset($this->replyEmail) ? $this->replyEmail : $this->getFromEmail(); + } + + /** + * Get the 'reply-to' name for this message. Defaults to the sender name. + * + * @return string + */ + public function getReplyName() + { + return isset($this->replyName) ? $this->replyName : $this->getFromName(); + } + + /** + * Set the sender email address. + * + * @param string $fromEmail + */ + public function setFromEmail($fromEmail) + { + $this->fromEmail = $fromEmail; + return $this; + } + + /** + * Set the sender name. + * + * @param string $fromName + */ + public function setFromName($fromName) + { + $this->fromName = $fromName; + return $this; + } + + /** + * Set the sender 'reply-to' address. + * + * @param string $replyEmail + */ + public function setReplyEmail($replyEmail) + { + $this->replyEmail = $replyEmail; + return $this; + } + + /** + * Set the sender 'reply-to' name. + * + * @param string $replyName + */ + public function setReplyName($replyName) + { + $this->replyName = $replyName; + return $this; + } +} diff --git a/main/app/sprinkles/core/src/Mail/Mailer.php b/main/app/sprinkles/core/src/Mail/Mailer.php new file mode 100755 index 0000000..5b346b4 --- /dev/null +++ b/main/app/sprinkles/core/src/Mail/Mailer.php @@ -0,0 +1,204 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Mail; + +use Monolog\Logger; + +/** + * Mailer Class + * + * A basic wrapper for sending template-based emails. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Mailer +{ + /** + * @var Logger + */ + protected $logger; + + /** + * @var \PHPMailer + */ + protected $phpMailer; + + /** + * Create a new Mailer instance. + * + * @param Logger $logger A Monolog logger, used to dump debugging info for SMTP server transactions. + * @param mixed[] $config An array of configuration parameters for phpMailer. + * @throws \phpmailerException Wrong mailer config value given. + */ + public function __construct($logger, $config = []) + { + $this->logger = $logger; + + // 'true' tells PHPMailer to use exceptions instead of error codes + $this->phpMailer = new \PHPMailer(true); + + // Configuration options + if (isset($config['mailer'])) { + if (!in_array($config['mailer'], ['smtp', 'mail', 'qmail', 'sendmail'])) { + throw new \phpmailerException("'mailer' must be one of 'smtp', 'mail', 'qmail', or 'sendmail'."); + } + + if ($config['mailer'] == 'smtp') { + $this->phpMailer->isSMTP(true); + $this->phpMailer->Host = $config['host']; + $this->phpMailer->Port = $config['port']; + $this->phpMailer->SMTPAuth = $config['auth']; + $this->phpMailer->SMTPSecure = $config['secure']; + $this->phpMailer->Username = $config['username']; + $this->phpMailer->Password = $config['password']; + $this->phpMailer->SMTPDebug = $config['smtp_debug']; + + if (isset($config['smtp_options'])) { + $this->phpMailer->SMTPOptions = $config['smtp_options']; + } + } + + // Set any additional message-specific options + // TODO: enforce which options can be set through this subarray + if (isset($config['message_options'])) { + $this->setOptions($config['message_options']); + } + } + + // Pass logger into phpMailer object + $this->phpMailer->Debugoutput = function($message, $level) { + $this->logger->debug($message); + }; + } + + /** + * Get the underlying PHPMailer object. + * + * @return \PHPMailer + */ + public function getPhpMailer() + { + return $this->phpMailer; + } + + /** + * Send a MailMessage message. + * + * Sends a single email to all recipients, as well as their CCs and BCCs. + * Since it is a single-header message, recipient-specific template data will not be included. + * @param MailMessage $message + * @param bool $clearRecipients Set to true to clear the list of recipients in the message after calling send(). This helps avoid accidentally sending a message multiple times. + * @throws \phpmailerException The message could not be sent. + */ + public function send(MailMessage $message, $clearRecipients = true) + { + $this->phpMailer->From = $message->getFromEmail(); + $this->phpMailer->FromName = $message->getFromName(); + $this->phpMailer->addReplyTo($message->getReplyEmail(), $message->getReplyName()); + + // Add all email recipients, as well as their CCs and BCCs + foreach ($message->getRecipients() as $recipient) { + $this->phpMailer->addAddress($recipient->getEmail(), $recipient->getName()); + + // Add any CCs and BCCs + if ($recipient->getCCs()) { + foreach($recipient->getCCs() as $cc) { + $this->phpMailer->addCC($cc['email'], $cc['name']); + } + } + + if ($recipient->getBCCs()) { + foreach($recipient->getBCCs() as $bcc) { + $this->phpMailer->addBCC($bcc['email'], $bcc['name']); + } + } + } + + $this->phpMailer->Subject = $message->renderSubject(); + $this->phpMailer->Body = $message->renderBody(); + + // Try to send the mail. Will throw an exception on failure. + $this->phpMailer->send(); + + // Clear recipients from the PHPMailer object for this iteration, + // so that we can use the same object for other emails. + $this->phpMailer->clearAllRecipients(); + + // Clear out the MailMessage's internal recipient list + if ($clearRecipients) { + $message->clearRecipients(); + } + } + + /** + * Send a MailMessage message, sending a separate email to each recipient. + * + * If the message object supports message templates, this will render the template with the corresponding placeholder values for each recipient. + * @param MailMessage $message + * @param bool $clearRecipients Set to true to clear the list of recipients in the message after calling send(). This helps avoid accidentally sending a message multiple times. + * @throws \phpmailerException The message could not be sent. + */ + public function sendDistinct(MailMessage $message, $clearRecipients = true) + { + $this->phpMailer->From = $message->getFromEmail(); + $this->phpMailer->FromName = $message->getFromName(); + $this->phpMailer->addReplyTo($message->getReplyEmail(), $message->getReplyName()); + + // Loop through email recipients, sending customized content to each one + foreach ($message->getRecipients() as $recipient) { + $this->phpMailer->addAddress($recipient->getEmail(), $recipient->getName()); + + // Add any CCs and BCCs + if ($recipient->getCCs()) { + foreach($recipient->getCCs() as $cc) { + $this->phpMailer->addCC($cc['email'], $cc['name']); + } + } + + if ($recipient->getBCCs()) { + foreach($recipient->getBCCs() as $bcc) { + $this->phpMailer->addBCC($bcc['email'], $bcc['name']); + } + } + + $this->phpMailer->Subject = $message->renderSubject($recipient->getParams()); + $this->phpMailer->Body = $message->renderBody($recipient->getParams()); + + // Try to send the mail. Will throw an exception on failure. + $this->phpMailer->send(); + + // Clear recipients from the PHPMailer object for this iteration, + // so that we can send a separate email to the next recipient. + $this->phpMailer->clearAllRecipients(); + } + + // Clear out the MailMessage's internal recipient list + if ($clearRecipients) { + $message->clearRecipients(); + } + } + + /** + * Set option(s) on the underlying phpMailer object. + * + * @param mixed[] $options + * @return Mailer + */ + public function setOptions($options) + { + if (isset($options['isHtml'])) { + $this->phpMailer->isHTML($options['isHtml']); + } + + foreach ($options as $name => $value) { + $this->phpMailer->set($name, $value); + } + + return $this; + } +} diff --git a/main/app/sprinkles/core/src/Mail/StaticMailMessage.php b/main/app/sprinkles/core/src/Mail/StaticMailMessage.php new file mode 100755 index 0000000..098bbfc --- /dev/null +++ b/main/app/sprinkles/core/src/Mail/StaticMailMessage.php @@ -0,0 +1,78 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Mail; + +/** + * StaticMailMessage Class + * + * Represents a basic mail message, containing a static subject and body. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class StaticMailMessage extends MailMessage +{ + /** + * @var string The default body for this message. + */ + protected $body; + + /** + * @var string The default subject for this message. + */ + protected $subject; + + /** + * Create a new MailMessage instance. + * + * @param string $subject + * @param string $body + */ + public function __construct($subject = "", $body = "") + { + $this->subject = $subject; + $this->body = $body; + } + + /** + * {@inheritDoc} + */ + public function renderBody($params = []) + { + return $this->body; + } + + /** + * {@inheritDoc} + */ + public function renderSubject($params = []) + { + return $this->subject; + } + + /** + * Set the text of the message subject. + * + * @param string $subject + */ + public function setSubject($subject) + { + $this->subject = $subject; + return $this; + } + + /** + * Set the text of the message body. + * + * @param string $body + */ + public function setBody($body) + { + $this->body = $body; + return $this; + } +} diff --git a/main/app/sprinkles/core/src/Mail/TwigMailMessage.php b/main/app/sprinkles/core/src/Mail/TwigMailMessage.php new file mode 100755 index 0000000..aa65240 --- /dev/null +++ b/main/app/sprinkles/core/src/Mail/TwigMailMessage.php @@ -0,0 +1,93 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Mail; + +/** + * MailMessage Class + * + * Represents a basic mail message, containing a static subject and body. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class TwigMailMessage extends MailMessage +{ + /** + * @var mixed[] A list of Twig placeholder values to use when rendering this message. + */ + protected $params; + + /** + * @var Twig_Template The Twig template object, to source the content for this message. + */ + protected $template; + + /** + * @var \Slim\Views\Twig The view object, used to render mail templates. + */ + protected $view; + + /** + * Create a new TwigMailMessage instance. + * + * @param Slim\Views\Twig $view The Twig view object used to render mail templates. + * @param string $filename optional Set the Twig template to use for this message. + */ + public function __construct($view, $filename = null) + { + $this->view = $view; + + $twig = $this->view->getEnvironment(); + // Must manually merge in global variables for block rendering + // TODO: should we keep this separate from the local parameters? + $this->params = $twig->getGlobals(); + + if ($filename !== null) { + $this->template = $twig->loadTemplate($filename); + } + } + + /** + * Merge in any additional global Twig variables to use when rendering this message. + * + * @param mixed[] $params + */ + public function addParams($params = []) + { + $this->params = array_replace_recursive($this->params, $params); + return $this; + } + + /** + * {@inheritDoc} + */ + public function renderSubject($params = []) + { + $params = array_replace_recursive($this->params, $params); + return $this->template->renderBlock('subject', $params); + } + + /** + * {@inheritDoc} + */ + public function renderBody($params = []) + { + $params = array_replace_recursive($this->params, $params); + return $this->template->renderBlock('body', $params); + } + + /** + * Sets the Twig template object for this message. + * + * @param Twig_Template $template The Twig template object, to source the content for this message. + */ + public function setTemplate($template) + { + $this->template = $template; + return $this; + } +} diff --git a/main/app/sprinkles/core/src/Model/UFModel.php b/main/app/sprinkles/core/src/Model/UFModel.php new file mode 100755 index 0000000..0d9feff --- /dev/null +++ b/main/app/sprinkles/core/src/Model/UFModel.php @@ -0,0 +1,27 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Model; + +use UserFrosting\Sprinkle\Core\Database\Models\Model; +use UserFrosting\Sprinkle\Core\Facades\Debug; + +/** + * UFModel Class + * + * @deprecated since 4.1 + * @author Alex Weissman (https://alexanderweissman.com) + */ +abstract class UFModel extends Model +{ + public function __construct(array $attributes = []) + { + Debug::debug("UFModel has been deprecated and will be removed in future versions. Please move your model " . static::class . " to Database/Models/ and have it extend the base Database/Models/Model class."); + + parent::__construct($attributes); + } +} diff --git a/main/app/sprinkles/core/src/Router.php b/main/app/sprinkles/core/src/Router.php new file mode 100755 index 0000000..8a10c85 --- /dev/null +++ b/main/app/sprinkles/core/src/Router.php @@ -0,0 +1,101 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core; + +use FastRoute\Dispatcher; +use Illuminate\Filesystem\Filesystem; +use InvalidArgumentException; +use RuntimeException; +use Psr\Http\Message\ServerRequestInterface; +use FastRoute\RouteCollector; +use FastRoute\RouteParser; +use FastRoute\RouteParser\Std as StdParser; +use FastRoute\DataGenerator; +use Slim\Interfaces\RouteGroupInterface; +use Slim\Interfaces\RouterInterface; +use Slim\Interfaces\RouteInterface; + +/** + * Router + * + * This class extends Slim's router, to permit overriding of routes with the same signature. + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Router extends \Slim\Router implements RouterInterface +{ + + /* + * @var string[] a reverse lookup of route identifiers, indexed by route signature + */ + protected $identifiers; + + /** + * Add route + * + * @param string[] $methods Array of HTTP methods + * @param string $pattern The route pattern + * @param callable $handler The route callable + * + * @return RouteInterface + * + * @throws InvalidArgumentException if the route pattern isn't a string + */ + public function map($methods, $pattern, $handler) + { + if (!is_string($pattern)) { + throw new InvalidArgumentException('Route pattern must be a string'); + } + + // Prepend parent group pattern(s) + if ($this->routeGroups) { + $pattern = $this->processGroups() . $pattern; + } + + // According to RFC methods are defined in uppercase (See RFC 7231) + $methods = array_map("strtoupper", $methods); + + // Determine route signature + $signature = implode('-', $methods) . '-' . $pattern; + + // If a route with the same signature already exists, then we must replace it + if (isset($this->identifiers[$signature])) { + $route = new \Slim\Route($methods, $pattern, $handler, $this->routeGroups, str_replace('route', '', $this->identifiers[$signature])); + } else { + $route = new \Slim\Route($methods, $pattern, $handler, $this->routeGroups, $this->routeCounter); + } + + $this->routes[$route->getIdentifier()] = $route; + + // Record identifier in reverse lookup array + $this->identifiers[$signature] = $route->getIdentifier(); + + $this->routeCounter++; + + return $route; + } + + /** + * Delete the cache file + * + * @access public + * @return bool true/false if operation is successfull + */ + public function clearCache() + { + // Get Filesystem instance + $fs = new FileSystem; + + // Make sure file exist and delete it + if ($fs->exists($this->cacheFile)) { + return $fs->delete($this->cacheFile); + } + + // It's still considered a success if file doesn't exist + return true; + } +} diff --git a/main/app/sprinkles/core/src/ServicesProvider/ServicesProvider.php b/main/app/sprinkles/core/src/ServicesProvider/ServicesProvider.php new file mode 100755 index 0000000..c67b886 --- /dev/null +++ b/main/app/sprinkles/core/src/ServicesProvider/ServicesProvider.php @@ -0,0 +1,618 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ + +namespace UserFrosting\Sprinkle\Core\ServicesProvider; + +use Dotenv\Dotenv; +use Dotenv\Exception\InvalidPathException; +use Illuminate\Container\Container; +use Illuminate\Database\Capsule\Manager as Capsule; +use Illuminate\Database\Events\QueryExecuted; +use Illuminate\Events\Dispatcher; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Session\DatabaseSessionHandler; +use Illuminate\Session\FileSessionHandler; +use Interop\Container\ContainerInterface; +use League\FactoryMuffin\FactoryMuffin; +use League\FactoryMuffin\Faker\Facade as Faker; +use Monolog\Formatter\LineFormatter; +use Monolog\Handler\ErrorLogHandler; +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +use Slim\Csrf\Guard; +use Slim\Http\Uri; +use Slim\Views\Twig; +use Slim\Views\TwigExtension; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\NullSessionHandler; +use UserFrosting\Assets\AssetBundleSchema; +use UserFrosting\Assets\AssetLoader; +use UserFrosting\Assets\AssetManager; +use UserFrosting\Assets\UrlBuilder\AssetUrlBuilder; +use UserFrosting\Assets\UrlBuilder\CompiledAssetUrlBuilder; +use UserFrosting\Cache\TaggableFileStore; +use UserFrosting\Cache\MemcachedStore; +use UserFrosting\Cache\RedisStore; +use UserFrosting\Config\ConfigPathBuilder; +use UserFrosting\I18n\LocalePathBuilder; +use UserFrosting\I18n\MessageTranslator; +use UserFrosting\Session\Session; +use UserFrosting\Sprinkle\Core\Error\ExceptionHandlerManager; +use UserFrosting\Sprinkle\Core\Error\Handler\NotFoundExceptionHandler; +use UserFrosting\Sprinkle\Core\Log\MixedFormatter; +use UserFrosting\Sprinkle\Core\Mail\Mailer; +use UserFrosting\Sprinkle\Core\Alert\CacheAlertStream; +use UserFrosting\Sprinkle\Core\Alert\SessionAlertStream; +use UserFrosting\Sprinkle\Core\Router; +use UserFrosting\Sprinkle\Core\Throttle\Throttler; +use UserFrosting\Sprinkle\Core\Throttle\ThrottleRule; +use UserFrosting\Sprinkle\Core\Twig\CoreExtension; +use UserFrosting\Sprinkle\Core\Util\CheckEnvironment; +use UserFrosting\Sprinkle\Core\Util\ClassMapper; +use UserFrosting\Support\Exception\BadRequestException; +use UserFrosting\Support\Exception\NotFoundException; +use UserFrosting\Support\Repository\Loader\ArrayFileLoader; +use UserFrosting\Support\Repository\Repository; + +/** + * UserFrosting core services provider. + * + * Registers core services for UserFrosting, such as config, database, asset manager, translator, etc. + * @author Alex Weissman (https://alexanderweissman.com) + */ +class ServicesProvider +{ + /** + * Register UserFrosting's core services. + * + * @param ContainerInterface $container A DI container implementing ArrayAccess and container-interop. + */ + public function register(ContainerInterface $container) { + /** + * Flash messaging service. + * + * Persists error/success messages between requests in the session. + */ + $container['alerts'] = function ($c) { + $config = $c->config; + + if ($config['alert.storage'] == 'cache') { + return new CacheAlertStream($config['alert.key'], $c->translator, $c->cache, $c->config); + } elseif ($config['alert.storage'] == 'session') { + return new SessionAlertStream($config['alert.key'], $c->translator, $c->session); + } else { + throw new \Exception("Bad alert storage handler type '{$config['alert.storage']}' specified in configuration file."); + } + }; + + /** + * Asset loader service. + * + * Loads assets from a specified relative location. + * Assets are Javascript, CSS, image, and other files used by your site. + */ + $container['assetLoader'] = function ($c) { + $basePath = \UserFrosting\SPRINKLES_DIR; + $pattern = "/^[A-Za-z0-9_\-]+\/assets\//"; + + $al = new AssetLoader($basePath, $pattern); + return $al; + }; + + /** + * Asset manager service. + * + * Loads raw or compiled asset information from your bundle.config.json schema file. + * Assets are Javascript, CSS, image, and other files used by your site. + */ + $container['assets'] = function ($c) { + $config = $c->config; + $locator = $c->locator; + + // Load asset schema + if ($config['assets.use_raw']) { + $baseUrl = $config['site.uri.public'] . '/' . $config['assets.raw.path']; + $removePrefix = \UserFrosting\APP_DIR_NAME . \UserFrosting\DS . \UserFrosting\SPRINKLES_DIR_NAME; + $aub = new AssetUrlBuilder($locator, $baseUrl, $removePrefix, 'assets'); + + $as = new AssetBundleSchema($aub); + + // Load Sprinkle assets + $sprinkles = $c->sprinkleManager->getSprinkleNames(); + + // TODO: move this out into PathBuilder and Loader classes in userfrosting/assets + // This would also allow us to define and load bundles in themes + $bundleSchemas = array_reverse($locator->findResources('sprinkles://' . $config['assets.raw.schema'], TRUE, TRUE)); + + foreach ($bundleSchemas as $schema) { + if (file_exists($schema)) { + $as->loadRawSchemaFile($schema); + } + } + } else { + $baseUrl = $config['site.uri.public'] . '/' . $config['assets.compiled.path']; + $aub = new CompiledAssetUrlBuilder($baseUrl); + + $as = new AssetBundleSchema($aub); + $as->loadCompiledSchemaFile($locator->findResource("build://" . $config['assets.compiled.schema'], TRUE, TRUE)); + } + + $am = new AssetManager($aub, $as); + + return $am; + }; + + /** + * Cache service. + * + * @return \Illuminate\Cache\Repository + */ + $container['cache'] = function ($c) { + + $config = $c->config; + + if ($config['cache.driver'] == 'file') { + $path = $c->locator->findResource('cache://', TRUE, TRUE); + $cacheStore = new TaggableFileStore($path); + } elseif ($config['cache.driver'] == 'memcached') { + // We need to inject the prefix in the memcached config + $config = array_merge($config['cache.memcached'], ['prefix' => $config['cache.prefix']]); + $cacheStore = new MemcachedStore($config); + } elseif ($config['cache.driver'] == 'redis') { + // We need to inject the prefix in the redis config + $config = array_merge($config['cache.redis'], ['prefix' => $config['cache.prefix']]); + $cacheStore = new RedisStore($config); + } else { + throw new \Exception("Bad cache store type '{$config['cache.driver']}' specified in configuration file."); + } + + return $cacheStore->instance(); + }; + + /** + * Middleware to check environment. + * + * @todo We should cache the results of this, the first time that it succeeds. + */ + $container['checkEnvironment'] = function ($c) { + $checkEnvironment = new CheckEnvironment($c->view, $c->locator, $c->cache); + return $checkEnvironment; + }; + + /** + * Class mapper. + * + * Creates an abstraction on top of class names to allow extending them in sprinkles. + */ + $container['classMapper'] = function ($c) { + $classMapper = new ClassMapper(); + $classMapper->setClassMapping('query_builder', 'UserFrosting\Sprinkle\Core\Database\Builder'); + $classMapper->setClassMapping('throttle', 'UserFrosting\Sprinkle\Core\Database\Models\Throttle'); + return $classMapper; + }; + + /** + * Site config service (separate from Slim settings). + * + * Will attempt to automatically determine which config file(s) to use based on the value of the UF_MODE environment variable. + */ + $container['config'] = function ($c) { + // Grab any relevant dotenv variables from the .env file + try { + $dotenv = new Dotenv(\UserFrosting\APP_DIR); + $dotenv->load(); + } catch (InvalidPathException $e) { + // Skip loading the environment config file if it doesn't exist. + } + + // Get configuration mode from environment + $mode = getenv('UF_MODE') ?: ''; + + // Construct and load config repository + $builder = new ConfigPathBuilder($c->locator, 'config://'); + $loader = new ArrayFileLoader($builder->buildPaths($mode)); + $config = new Repository($loader->load()); + + // Construct base url from components, if not explicitly specified + if (!isset($config['site.uri.public'])) { + $base_uri = $config['site.uri.base']; + + $public = new Uri( + $base_uri['scheme'], + $base_uri['host'], + $base_uri['port'], + $base_uri['path'] + ); + + // Slim\Http\Uri likes to add trailing slashes when the path is empty, so this fixes that. + $config['site.uri.public'] = trim($public, '/'); + } + + // Hacky fix to prevent sessions from being hit too much: ignore CSRF middleware for requests for raw assets ;-) + // See https://github.com/laravel/framework/issues/8172#issuecomment-99112012 for more information on why it's bad to hit Laravel sessions multiple times in rapid succession. + $csrfBlacklist = $config['csrf.blacklist']; + $csrfBlacklist['^/' . $config['assets.raw.path']] = [ + 'GET' + ]; + + $config->set('csrf.blacklist', $csrfBlacklist); + + return $config; + }; + + /** + * Initialize CSRF guard middleware. + * + * @see https://github.com/slimphp/Slim-Csrf + */ + $container['csrf'] = function ($c) { + $csrfKey = $c->config['session.keys.csrf']; + + // Workaround so that we can pass storage into CSRF guard. + // If we tried to directly pass the indexed portion of `session` (for example, $c->session['site.csrf']), + // we would get an 'Indirect modification of overloaded element of UserFrosting\Session\Session' error. + // If we tried to assign an array and use that, PHP would only modify the local variable, and not the session. + // Since ArrayObject is an object, PHP will modify the object itself, allowing it to persist in the session. + if (!$c->session->has($csrfKey)) { + $c->session[$csrfKey] = new \ArrayObject(); + } + $csrfStorage = $c->session[$csrfKey]; + + $onFailure = function ($request, $response, $next) { + $e = new BadRequestException("The CSRF code was invalid or not provided."); + $e->addUserMessage('CSRF_MISSING'); + throw $e; + + return $next($request, $response); + }; + + return new Guard($c->config['csrf.name'], $csrfStorage, $onFailure, $c->config['csrf.storage_limit'], $c->config['csrf.strength'], $c->config['csrf.persistent_token']); + }; + + /** + * Initialize Eloquent Capsule, which provides the database layer for UF. + * + * @todo construct the individual objects rather than using the facade + */ + $container['db'] = function ($c) { + $config = $c->config; + + $capsule = new Capsule; + + foreach ($config['db'] as $name => $dbConfig) { + $capsule->addConnection($dbConfig, $name); + } + + $queryEventDispatcher = new Dispatcher(new Container); + + $capsule->setEventDispatcher($queryEventDispatcher); + + // Register as global connection + $capsule->setAsGlobal(); + + // Start Eloquent + $capsule->bootEloquent(); + + if ($config['debug.queries']) { + $logger = $c->queryLogger; + + foreach ($config['db'] as $name => $dbConfig) { + $capsule->connection($name)->enableQueryLog(); + } + + // Register listener + $queryEventDispatcher->listen(QueryExecuted::class, function ($query) use ($logger) { + $logger->debug("Query executed on database [{$query->connectionName}]:", [ + 'query' => $query->sql, + 'bindings' => $query->bindings, + 'time' => $query->time . ' ms' + ]); + }); + } + + return $capsule; + }; + + /** + * Debug logging with Monolog. + * + * Extend this service to push additional handlers onto the 'debug' log stack. + */ + $container['debugLogger'] = function ($c) { + $logger = new Logger('debug'); + + $logFile = $c->locator->findResource('log://userfrosting.log', TRUE, TRUE); + + $handler = new StreamHandler($logFile); + + $formatter = new MixedFormatter(NULL, NULL, TRUE); + + $handler->setFormatter($formatter); + $logger->pushHandler($handler); + + return $logger; + }; + + /** + * Custom error-handler for recoverable errors. + */ + $container['errorHandler'] = function ($c) { + $settings = $c->settings; + + $handler = new ExceptionHandlerManager($c, $settings['displayErrorDetails']); + + // Register the base HttpExceptionHandler. + $handler->registerHandler('\UserFrosting\Support\Exception\HttpException', '\UserFrosting\Sprinkle\Core\Error\Handler\HttpExceptionHandler'); + + // Register the NotFoundExceptionHandler. + $handler->registerHandler('\UserFrosting\Support\Exception\NotFoundException', '\UserFrosting\Sprinkle\Core\Error\Handler\NotFoundExceptionHandler'); + + // Register the PhpMailerExceptionHandler. + $handler->registerHandler('\phpmailerException', '\UserFrosting\Sprinkle\Core\Error\Handler\PhpMailerExceptionHandler'); + + return $handler; + }; + + /** + * Error logging with Monolog. + * + * Extend this service to push additional handlers onto the 'error' log stack. + */ + $container['errorLogger'] = function ($c) { + $log = new Logger('errors'); + + $logFile = $c->locator->findResource('log://userfrosting.log', TRUE, TRUE); + + $handler = new StreamHandler($logFile, Logger::WARNING); + + $formatter = new LineFormatter(NULL, NULL, TRUE); + + $handler->setFormatter($formatter); + $log->pushHandler($handler); + + return $log; + }; + + /** + * Factory service with FactoryMuffin. + * + * Provide access to factories for the rapid creation of objects for the purpose of testing + */ + $container['factory'] = function ($c) { + + // Get the path of all of the sprinkle's factories + $factoriesPath = $c->locator->findResources('factories://', TRUE, TRUE); + + // Create a new Factory Muffin instance + $fm = new FactoryMuffin(); + + // Load all of the model definitions + $fm->loadFactories($factoriesPath); + + // Set the locale. Could be the config one, but for testing English should do + Faker::setLocale('en_EN'); + + return $fm; + }; + + /** + * Builds search paths for locales in all Sprinkles. + */ + $container['localePathBuilder'] = function ($c) { + $config = $c->config; + + // Make sure the locale config is a valid string + if (!is_string($config['site.locales.default']) || $config['site.locales.default'] == '') { + throw new \UnexpectedValueException('The locale config is not a valid string.'); + } + + // Load the base locale file(s) as specified in the configuration + $locales = explode(',', $config['site.locales.default']); + + return new LocalePathBuilder($c->locator, 'locale://', $locales); + }; + + /** + * Mail service. + */ + $container['mailer'] = function ($c) { + $mailer = new Mailer($c->mailLogger, $c->config['mail']); + + // Use UF debug settings to override any service-specific log settings. + if (!$c->config['debug.smtp']) { + $mailer->getPhpMailer()->SMTPDebug = 0; + } + + return $mailer; + }; + + /** + * Mail logging service. + * + * PHPMailer will use this to log SMTP activity. + * Extend this service to push additional handlers onto the 'mail' log stack. + */ + $container['mailLogger'] = function ($c) { + $log = new Logger('mail'); + + $logFile = $c->locator->findResource('log://userfrosting.log', TRUE, TRUE); + + $handler = new StreamHandler($logFile); + $formatter = new LineFormatter(NULL, NULL, TRUE); + + $handler->setFormatter($formatter); + $log->pushHandler($handler); + + return $log; + }; + + /** + * Error-handler for 404 errors. Notice that we manually create a UserFrosting NotFoundException, + * and a NotFoundExceptionHandler. This lets us pass through to the UF error handling system. + */ + $container['notFoundHandler'] = function ($c) { + return function ($request, $response) use ($c) { + $exception = new NotFoundException; + $handler = new NotFoundExceptionHandler($c, $request, $response, $exception, $c->settings['displayErrorDetails']); + return $handler->handle(); + }; + }; + + /** + * Error-handler for PHP runtime errors. Notice that we just pass this through to our general-purpose + * error-handling service. + */ + $container['phpErrorHandler'] = function ($c) { + return $c->errorHandler; + }; + + /** + * Laravel query logging with Monolog. + * + * Extend this service to push additional handlers onto the 'query' log stack. + */ + $container['queryLogger'] = function ($c) { + $logger = new Logger('query'); + + $logFile = $c->locator->findResource('log://userfrosting.log', TRUE, TRUE); + + $handler = new StreamHandler($logFile); + + $formatter = new MixedFormatter(NULL, NULL, TRUE); + + $handler->setFormatter($formatter); + $logger->pushHandler($handler); + + return $logger; + }; + + /** + * Override Slim's default router with the UF router. + */ + $container['router'] = function ($c) { + $routerCacheFile = FALSE; + if (isset($c->config['settings.routerCacheFile'])) { + $routerCacheFile = $c->config['settings.routerCacheFile']; + } + + return (new Router)->setCacheFile($routerCacheFile); + }; + + /** + * Start the PHP session, with the name and parameters specified in the configuration file. + */ + $container['session'] = function ($c) { + $config = $c->config; + + // Create appropriate handler based on config + if ($config['session.handler'] == 'file') { + $fs = new FileSystem; + $handler = new FileSessionHandler($fs, $c->locator->findResource('session://'), $config['session.minutes']); + } elseif ($config['session.handler'] == 'database') { + $connection = $c->db->connection(); + // Table must exist, otherwise an exception will be thrown + $handler = new DatabaseSessionHandler($connection, $config['session.database.table'], $config['session.minutes']); + } else { + throw new \Exception("Bad session handler type '{$config['session.handler']}' specified in configuration file."); + } + + // Create, start and return a new wrapper for $_SESSION + $session = new Session($handler, $config['session']); + $session->start(); + + return $session; + }; + + /** + * Request throttler. + * + * Throttles (rate-limits) requests of a predefined type, with rules defined in site config. + */ + $container['throttler'] = function ($c) { + $throttler = new Throttler($c->classMapper); + + $config = $c->config; + + if ($config->has('throttles') && ($config['throttles'] !== NULL)) { + foreach ($config['throttles'] as $type => $rule) { + if ($rule) { + $throttleRule = new ThrottleRule($rule['method'], $rule['interval'], $rule['delays']); + $throttler->addThrottleRule($type, $throttleRule); + } else { + $throttler->addThrottleRule($type, NULL); + } + } + } + + return $throttler; + }; + + /** + * Translation service, for translating message tokens. + */ + $container['translator'] = function ($c) { + // Load the translations + $paths = $c->localePathBuilder->buildPaths(); + $loader = new ArrayFileLoader($paths); + + // Create the $translator object + $translator = new MessageTranslator($loader->load()); + + return $translator; + }; + + /** + * Set up Twig as the view, adding template paths for all sprinkles and the Slim Twig extension. + * + * Also adds the UserFrosting core Twig extension, which provides additional functions, filters, global variables, etc. + */ + $container['view'] = function ($c) { + $templatePaths = $c->locator->findResources('templates://', TRUE, TRUE); + + $view = new Twig($templatePaths); + + $loader = $view->getLoader(); + + $sprinkles = $c->sprinkleManager->getSprinkleNames(); + + // Add Sprinkles' templates namespaces + foreach ($sprinkles as $sprinkle) { + $path = \UserFrosting\SPRINKLES_DIR . \UserFrosting\DS . + $sprinkle . \UserFrosting\DS . + \UserFrosting\TEMPLATE_DIR_NAME . \UserFrosting\DS; + + if (is_dir($path)) { + $loader->addPath($path, $sprinkle); + } + } + + $twig = $view->getEnvironment(); + + if ($c->config['cache.twig']) { + $twig->setCache($c->locator->findResource('cache://twig', TRUE, TRUE)); + } + + if ($c->config['debug.twig']) { + $twig->enableDebug(); + $view->addExtension(new \Twig_Extension_Debug()); + } + + // Register the Slim extension with Twig + $slimExtension = new TwigExtension( + $c->router, + $c->request->getUri() + ); + $view->addExtension($slimExtension); + + // Register the core UF extension with Twig + $coreExtension = new CoreExtension($c); + $view->addExtension($coreExtension); + + return $view; + }; + } +} diff --git a/main/app/sprinkles/core/src/Sprunje/Sprunje.php b/main/app/sprinkles/core/src/Sprunje/Sprunje.php new file mode 100755 index 0000000..5525dc4 --- /dev/null +++ b/main/app/sprinkles/core/src/Sprunje/Sprunje.php @@ -0,0 +1,566 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Sprunje; + +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; +use League\Csv\Writer; +use Psr\Http\Message\ResponseInterface as Response; +use UserFrosting\Sprinkle\Core\Facades\Debug; +use UserFrosting\Sprinkle\Core\Util\ClassMapper; +use UserFrosting\Support\Exception\BadRequestException; +use Valitron\Validator; + +/** + * Sprunje + * + * Implements a versatile API for sorting, filtering, and paginating an Eloquent query builder. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +abstract class Sprunje +{ + /** + * @var UserFrosting\Sprinkle\Core\Util\ClassMapper + */ + protected $classMapper; + + /** + * Name of this Sprunje, used when generating output files. + * + * @var string + */ + protected $name = ''; + + /** + * The base (unfiltered) query. + * + * @var \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation + */ + protected $query; + + /** + * Default HTTP request parameters + * + * @var array[string] + */ + protected $options = [ + 'sorts' => [], + 'filters' => [], + 'lists' => [], + 'size' => 'all', + 'page' => null, + 'format' => 'json' + ]; + + /** + * Fields to allow filtering upon. + * + * @var array[string] + */ + protected $filterable = []; + + /** + * Fields to allow listing (enumeration) upon. + * + * @var array[string] + */ + protected $listable = []; + + /** + * Fields to allow sorting upon. + * + * @var array[string] + */ + protected $sortable = []; + + /** + * List of fields to exclude when processing an "_all" filter. + * + * @var array[string] + */ + protected $excludeForAll = []; + + /** + * Separator to use when splitting filter values to treat them as ORs. + * + * @var string + */ + protected $orSeparator = '||'; + + /** + * Array key for the total unfiltered object count. + * + * @var string + */ + protected $countKey = 'count'; + + /** + * Array key for the filtered object count. + * + * @var string + */ + protected $countFilteredKey = 'count_filtered'; + + /** + * Array key for the actual result set. + * + * @var string + */ + protected $rowsKey = 'rows'; + + /** + * Array key for the list of enumerated columns and their enumerations. + * + * @var string + */ + protected $listableKey = 'listable'; + + /** + * Constructor. + * + * @param ClassMapper $classMapper + * @param mixed[] $options + */ + public function __construct(ClassMapper $classMapper, array $options) + { + $this->classMapper = $classMapper; + + // Validation on input data + $v = new Validator($options); + $v->rule('array', ['sorts', 'filters', 'lists']); + $v->rule('regex', 'sorts.*', '/asc|desc/i'); + $v->rule('regex', 'size', '/all|[0-9]+/i'); + $v->rule('integer', 'page'); + $v->rule('regex', 'format', '/json|csv/i'); + + // TODO: translated rules + if(!$v->validate()) { + $e = new BadRequestException(); + foreach ($v->errors() as $idx => $field) { + foreach($field as $eidx => $error) { + $e->addUserMessage($error); + } + } + throw $e; + } + + $this->options = array_replace_recursive($this->options, $options); + + $this->query = $this->baseQuery(); + + // Start a new query on any Model instances + if (is_a($this->baseQuery(), '\Illuminate\Database\Eloquent\Model')) { + $this->query = $this->baseQuery()->newQuery(); + } + } + + /** + * Extend the query by providing a callback. + * + * @param callable $callback A callback which accepts and returns a Builder instance. + * @return $this + */ + public function extendQuery(callable $callback) + { + $this->query = $callback($this->query); + return $this; + } + + /** + * Execute the query and build the results, and append them in the appropriate format to the response. + * + * @param ResponseInterface $response + * @return ResponseInterface + */ + public function toResponse(Response $response) + { + $format = $this->options['format']; + + if ($format == 'csv') { + $result = $this->getCsv(); + + // Prepare response + $settings = http_build_query($this->options); + $date = Carbon::now()->format('Ymd'); + $response = $response->withAddedHeader('Content-Disposition', "attachment;filename=$date-{$this->name}-$settings.csv"); + $response = $response->withAddedHeader('Content-Type', 'text/csv; charset=utf-8'); + return $response->write($result); + // Default to JSON + } else { + $result = $this->getArray(); + return $response->withJson($result, 200, JSON_PRETTY_PRINT); + } + } + + /** + * Executes the sprunje query, applying all sorts, filters, and pagination. + * + * Returns an array containing `count` (the total number of rows, before filtering), `count_filtered` (the total number of rows after filtering), + * and `rows` (the filtered result set). + * @return mixed[] + */ + public function getArray() + { + list($count, $countFiltered, $rows) = $this->getModels(); + + // Return sprunjed results + return [ + $this->countKey => $count, + $this->countFilteredKey => $countFiltered, + $this->rowsKey => $rows->values()->toArray(), + $this->listableKey => $this->getListable() + ]; + } + + /** + * Run the query and build a CSV object by flattening the resulting collection. Ignores any pagination. + * + * @return SplTempFileObject + */ + public function getCsv() + { + $filteredQuery = clone $this->query; + + // Apply filters + $this->applyFilters($filteredQuery); + + // Apply sorts + $this->applySorts($filteredQuery); + + $collection = collect($filteredQuery->get()); + + // Perform any additional transformations on the dataset + $this->applyTransformations($collection); + + $csv = Writer::createFromFileObject(new \SplTempFileObject()); + + $columnNames = []; + + // Flatten collection while simultaneously building the column names from the union of each element's keys + $collection->transform(function ($item, $key) use (&$columnNames) { + $item = array_dot($item->toArray()); + foreach ($item as $itemKey => $itemValue) { + if (!in_array($itemKey, $columnNames)) { + $columnNames[] = $itemKey; + } + } + return $item; + }); + + $csv->insertOne($columnNames); + + // Insert the data as rows in the CSV document + $collection->each(function ($item) use ($csv, $columnNames) { + $row = []; + foreach ($columnNames as $itemKey) { + // Only add the value if it is set and not an array. Laravel's array_dot sometimes creates empty child arrays :( + // See https://github.com/laravel/framework/pull/13009 + if (isset($item[$itemKey]) && !is_array($item[$itemKey])) { + $row[] = $item[$itemKey]; + } else { + $row[] = ''; + } + } + + $csv->insertOne($row); + }); + + return $csv; + } + + /** + * Executes the sprunje query, applying all sorts, filters, and pagination. + * + * Returns the filtered, paginated result set and the counts. + * @return mixed[] + */ + public function getModels() + { + // Count unfiltered total + $count = $this->count($this->query); + + // Clone the Query\Builder, Eloquent\Builder, or Relation + $filteredQuery = clone $this->query; + + // Apply filters + $this->applyFilters($filteredQuery); + + // Count filtered total + $countFiltered = $this->countFiltered($filteredQuery); + + // Apply sorts + $this->applySorts($filteredQuery); + + // Paginate + $this->applyPagination($filteredQuery); + + $collection = collect($filteredQuery->get()); + + // Perform any additional transformations on the dataset + $this->applyTransformations($collection); + + return [$count, $countFiltered, $collection]; + } + + /** + * Get lists of values for specified fields in 'lists' option, calling a custom lister callback when appropriate. + * + * @return array + */ + public function getListable() + { + $result = []; + foreach ($this->listable as $name) { + + // Determine if a custom filter method has been defined + $methodName = 'list'.studly_case($name); + + if (method_exists($this, $methodName)) { + $result[$name] = $this->$methodName(); + } else { + $result[$name] = $this->getColumnValues($name); + } + } + + return $result; + } + + /** + * Get the underlying queriable object in its current state. + * + * @return Builder + */ + public function getQuery() + { + return $this->query; + } + + /** + * Set the underlying QueryBuilder object. + * + * @param Builder $query + * @return $this + */ + public function setQuery($query) + { + $this->query = $query; + return $this; + } + + /** + * Apply any filters from the options, calling a custom filter callback when appropriate. + * + * @param Builder $query + * @return $this + */ + public function applyFilters($query) + { + foreach ($this->options['filters'] as $name => $value) { + // Check that this filter is allowed + if (($name != '_all') && !in_array($name, $this->filterable)) { + $e = new BadRequestException(); + $e->addUserMessage('VALIDATE.SPRUNJE.BAD_FILTER', ['name' => $name]); + throw $e; + } + // Since we want to match _all_ of the fields, we wrap the field callback in a 'where' callback + $query->where(function ($fieldQuery) use ($name, $value) { + $this->buildFilterQuery($fieldQuery, $name, $value); + }); + } + + return $this; + } + + /** + * Apply any sorts from the options, calling a custom sorter callback when appropriate. + * + * @param Builder $query + * @return $this + */ + public function applySorts($query) + { + foreach ($this->options['sorts'] as $name => $direction) { + // Check that this sort is allowed + if (!in_array($name, $this->sortable)) { + $e = new BadRequestException(); + $e->addUserMessage('VALIDATE.SPRUNJE.BAD_SORT', ['name' => $name]); + throw $e; + } + + // Determine if a custom sort method has been defined + $methodName = 'sort'.studly_case($name); + + if (method_exists($this, $methodName)) { + $this->$methodName($query, $direction); + } else { + $query->orderBy($name, $direction); + } + } + + return $this; + } + + /** + * Apply pagination based on the `page` and `size` options. + * + * @param Builder $query + * @return $this + */ + public function applyPagination($query) + { + if ( + ($this->options['page'] !== null) && + ($this->options['size'] !== null) && + ($this->options['size'] != 'all') + ) { + $offset = $this->options['size']*$this->options['page']; + $query->skip($offset) + ->take($this->options['size']); + } + + return $this; + } + + /** + * Match any filter in `filterable`. + * + * @param Builder $query + * @param mixed $value + * @return $this + */ + protected function filterAll($query, $value) + { + foreach ($this->filterable as $name) { + if (studly_case($name) != 'all' && !in_array($name, $this->excludeForAll)) { + // Since we want to match _any_ of the fields, we wrap the field callback in a 'orWhere' callback + $query->orWhere(function ($fieldQuery) use ($name, $value) { + $this->buildFilterQuery($fieldQuery, $name, $value); + }); + } + } + + return $this; + } + + /** + * Build the filter query for a single field. + * + * @param Builder $query + * @param string $name + * @param mixed $value + * @return $this + */ + protected function buildFilterQuery($query, $name, $value) + { + $methodName = 'filter'.studly_case($name); + + // Determine if a custom filter method has been defined + if (method_exists($this, $methodName)) { + $this->$methodName($query, $value); + } else { + $this->buildFilterDefaultFieldQuery($query, $name, $value); + } + + return $this; + } + + /** + * Perform a 'like' query on a single field, separating the value string on the or separator and + * matching any of the supplied values. + * + * @param Builder $query + * @param string $name + * @param mixed $value + * @return $this + */ + protected function buildFilterDefaultFieldQuery($query, $name, $value) + { + // Default filter - split value on separator for OR queries + // and search by column name + $values = explode($this->orSeparator, $value); + foreach ($values as $value) { + $query->orLike($name, $value); + } + + return $this; + } + + /** + * Set any transformations you wish to apply to the collection, after the query is executed. + * + * @param \Illuminate\Database\Eloquent\Collection $collection + * @return \Illuminate\Database\Eloquent\Collection + */ + protected function applyTransformations($collection) + { + return $collection; + } + + /** + * Set the initial query used by your Sprunje. + * + * @return Builder|Relation|Model + */ + abstract protected function baseQuery(); + + /** + * Returns a list of distinct values for a specified column. + * Formats results to have a "value" and "text" attribute. + * + * @param string $column + * @return array + */ + protected function getColumnValues($column) + { + $rawValues = $this->query->select($column)->distinct()->orderBy($column, 'asc')->get(); + $values = []; + foreach ($rawValues as $raw) { + $values[] = [ + 'value' => $raw[$column], + 'text' => $raw[$column] + ]; + } + return $values; + } + + /** + * Get the unpaginated count of items (before filtering) in this query. + * + * @param Builder $query + * @return int + */ + protected function count($query) + { + return $query->count(); + } + + /** + * Get the unpaginated count of items (after filtering) in this query. + * + * @param Builder $query + * @return int + */ + protected function countFiltered($query) + { + return $query->count(); + } + + /** + * Executes the sprunje query, applying all sorts, filters, and pagination. + * + * Returns an array containing `count` (the total number of rows, before filtering), `count_filtered` (the total number of rows after filtering), + * and `rows` (the filtered result set). + * @deprecated since 4.1.7 Use getArray() instead. + * @return mixed[] + */ + public function getResults() + { + return $this->getArray(); + } +} diff --git a/main/app/sprinkles/core/src/Throttle/ThrottleRule.php b/main/app/sprinkles/core/src/Throttle/ThrottleRule.php new file mode 100755 index 0000000..b71f296 --- /dev/null +++ b/main/app/sprinkles/core/src/Throttle/ThrottleRule.php @@ -0,0 +1,140 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Throttle; + +/** + * ThrottleRule Class + * + * Represents a request throttling rule. + * @author Alex Weissman (https://alexanderweissman.com) + */ +class ThrottleRule +{ + /** @var string Set to 'ip' for ip-based throttling, 'data' for request-data-based throttling. */ + protected $method; + + /** @var int The amount of time, in seconds, to look back in determining attempts to consider. */ + protected $interval; + + /** + * @var int[] A mapping of minimum observation counts (x) to delays (y), in seconds. + * Any throttleable event that has occurred more than x times in this rule's interval, + * must wait y seconds after the last occurrence before another attempt is permitted. + */ + protected $delays; + + /** + * Create a new ThrottleRule object. + * + * @param string $method Set to 'ip' for ip-based throttling, 'data' for request-data-based throttling. + * @param int $interval The amount of time, in seconds, to look back in determining attempts to consider. + * @param int[] $delays A mapping of minimum observation counts (x) to delays (y), in seconds. + */ + public function __construct($method, $interval, $delays) + { + $this->setMethod($method); + $this->setInterval($interval); + $this->setDelays($delays); + } + + /** + * Get the current delay on this rule for a particular number of event counts. + * + * @param Carbon\Carbon $lastEventTime The timestamp for the last countable event. + * @param int $count The total number of events which have occurred in an interval. + */ + public function getDelay($lastEventTime, $count) + { + // Zero occurrences always maps to a delay of 0 seconds. + if ($count == 0) { + return 0; + } + + foreach ($this->delays as $observations => $delay) { + // Skip any delay rules for which we haven't met the requisite number of observations + if ($count < $observations) { + continue; + } + + // If this rule meets the observed number of events, and violates the required delay, then return the remaining time left + if ($lastEventTime->diffInSeconds() < $delay) { + return $lastEventTime->addSeconds($delay)->diffInSeconds(); + } + } + + return 0; + } + + /** + * Gets the current mapping of attempts (int) to delays (seconds). + * + * @return int[] + */ + public function getDelays() + { + return $this->delays; + } + + /** + * Gets the current throttling interval (seconds). + * + * @return int + */ + public function getInterval() + { + return $this->interval; + } + + /** + * Gets the current throttling method ('ip' or 'data'). + * + * @return string + */ + public function getMethod() + { + return $this->method; + } + + /** + * Sets the current mapping of attempts (int) to delays (seconds). + * + * @param int[] A mapping of minimum observation counts (x) to delays (y), in seconds. + */ + public function setDelays($delays) + { + // Sort the array by key, from highest to lowest value + $this->delays = $delays; + krsort($this->delays); + + return $this; + } + + /** + * Sets the current throttling interval (seconds). + * + * @param int The amount of time, in seconds, to look back in determining attempts to consider. + */ + public function setInterval($interval) + { + $this->interval = $interval; + + return $this; + } + + /** + * Sets the current throttling method ('ip' or 'data'). + * + * @param string Set to 'ip' for ip-based throttling, 'data' for request-data-based throttling. + */ + public function setMethod($method) + { + $this->method = $method; + + return $this; + } +} diff --git a/main/app/sprinkles/core/src/Throttle/Throttler.php b/main/app/sprinkles/core/src/Throttle/Throttler.php new file mode 100755 index 0000000..0d42442 --- /dev/null +++ b/main/app/sprinkles/core/src/Throttle/Throttler.php @@ -0,0 +1,178 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Throttle; + +use Carbon\Carbon; +use UserFrosting\Sprinkle\Core\Util\ClassMapper; + +/** + * Handles throttling (rate limiting) of specific types of requests. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Throttler +{ + /** + * @var UserFrosting\Sprinkle\Core\Util\ClassMapper + */ + protected $classMapper; + + /** + * @var ThrottleRule[] An array mapping throttle names to throttle rules. + */ + protected $throttleRules; + + /** + * Create a new Throttler object. + * + * @param ClassMapper $classMapper Maps generic class identifiers to specific class names. + */ + public function __construct(ClassMapper $classMapper) + { + $this->classMapper = $classMapper; + $this->throttleRules = []; + } + + /** + * Add a throttling rule for a particular throttle event type. + * + * @param string $type The type of throttle event to check against. + * @param ThrottleRule $rule The rule to use when throttling this type of event. + */ + public function addThrottleRule($type, $rule) + { + if (!($rule instanceof ThrottleRule || ($rule === null))) { + throw new ThrottlerException('$rule must be of type ThrottleRule (or null).'); + } + + $this->throttleRules[$type] = $rule; + + return $this; + } + + /** + * Check the current request against a specified throttle rule. + * + * @param string $type The type of throttle event to check against. + * @param mixed[] $requestData Any additional request parameters to use in checking the throttle. + * @return bool + */ + public function getDelay($type, $requestData = []) + { + $throttleRule = $this->getRule($type); + + if (is_null($throttleRule)) { + return 0; + } + + // Get earliest time to start looking for throttleable events + $startTime = Carbon::now() + ->subSeconds($throttleRule->getInterval()); + + // Fetch all throttle events of the specified type, that match the specified rule + if ($throttleRule->getMethod() == 'ip') { + $events = $this->classMapper->staticMethod('throttle', 'where', 'type', $type) + ->where('created_at', '>', $startTime) + ->where('ip', $_SERVER['REMOTE_ADDR']) + ->get(); + } else { + $events = $this->classMapper->staticMethod('throttle', 'where', 'type', $type) + ->where('created_at', '>', $startTime) + ->get(); + + // Filter out only events that match the required JSON data + $events = $events->filter(function ($item, $key) use ($requestData) { + $data = json_decode($item->request_data); + + // If a field is not specified in the logged data, or it doesn't match the value we're searching for, + // then filter out this event from the collection. + foreach ($requestData as $name => $value) { + if (!isset($data->$name) || ($data->$name != $value)) { + return false; + } + } + + return true; + }); + } + + // Check the collection of events against the specified throttle rule. + return $this->computeDelay($events, $throttleRule); + } + + /** + * Get a registered rule of a particular type. + * + * @param string $type + * @throws ThrottlerException + * @return ThrottleRule[] + */ + public function getRule($type) + { + if (!array_key_exists($type, $this->throttleRules)) { + throw new ThrottlerException("The throttling rule for '$type' could not be found."); + } + + return $this->throttleRules[$type]; + } + + /** + * Get the current throttling rules. + * + * @return ThrottleRule[] + */ + public function getThrottleRules() + { + return $this->throttleRules; + } + + /** + * Log a throttleable event to the database. + * + * @param string $type the type of event + * @param string[] $requestData an array of field names => values that are relevant to throttling for this event (e.g. username, email, etc). + */ + public function logEvent($type, $requestData = []) + { + // Just a check to make sure the rule exists + $throttleRule = $this->getRule($type); + + if (is_null($throttleRule)) { + return $this; + } + + $event = $this->classMapper->createInstance('throttle', [ + 'type' => $type, + 'ip' => $_SERVER['REMOTE_ADDR'], + 'request_data' => json_encode($requestData) + ]); + + $event->save(); + + return $this; + } + + /** + * Returns the current delay for a specified throttle rule. + * + * @param Throttle[] $events a Collection of throttle events. + * @param ThrottleRule $throttleRule a rule representing the strategy to use for throttling a particular type of event. + * @return int seconds remaining until a particular event is permitted to be attempted again. + */ + protected function computeDelay($events, $throttleRule) + { + // If no matching events found, then there is no delay + if (!$events->count()) { + return 0; + } + + // Great, now we compare our delay against the most recent attempt + $lastEvent = $events->last(); + return $throttleRule->getDelay($lastEvent->created_at, $events->count()); + } +} diff --git a/main/app/sprinkles/core/src/Throttle/ThrottlerException.php b/main/app/sprinkles/core/src/Throttle/ThrottlerException.php new file mode 100755 index 0000000..2fd9035 --- /dev/null +++ b/main/app/sprinkles/core/src/Throttle/ThrottlerException.php @@ -0,0 +1,18 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Throttle; + +/** + * Throttler exception. Used when there is a problem with the request throttler. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class ThrottlerException extends \RuntimeException +{ + +} diff --git a/main/app/sprinkles/core/src/Twig/CacheHelper.php b/main/app/sprinkles/core/src/Twig/CacheHelper.php new file mode 100755 index 0000000..14aea49 --- /dev/null +++ b/main/app/sprinkles/core/src/Twig/CacheHelper.php @@ -0,0 +1,58 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Twig; + +use Interop\Container\ContainerInterface; +use Illuminate\Filesystem\Filesystem; + +/** + * Provides helper function to delete the Twig cache directory + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class CacheHelper +{ + + /** + * @var ContainerInterface The global container object, which holds all your services. + */ + protected $ci; + + /** + * Constructor. + * + * @param ContainerInterface $ci The global container object, which holds all your services. + */ + public function __construct(ContainerInterface $ci) + { + $this->ci = $ci; + } + + /** + * Function that delete the Twig cache directory content + * + * @access public + * @return bool true/false if operation is successfull + */ + public function clearCache() + { + // Get location + $path = $this->ci->locator->findResource('cache://twig', true, true); + + // Get Filesystem instance + $fs = new FileSystem; + + // Make sure directory exist and delete it + if ($fs->exists($path)) { + return $fs->deleteDirectory($path, true); + } + + // It's still considered a success if directory doesn't exist yet + return true; + } +} diff --git a/main/app/sprinkles/core/src/Twig/CoreExtension.php b/main/app/sprinkles/core/src/Twig/CoreExtension.php new file mode 100755 index 0000000..6a89d12 --- /dev/null +++ b/main/app/sprinkles/core/src/Twig/CoreExtension.php @@ -0,0 +1,124 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Twig; + +use Interop\Container\ContainerInterface; +use UserFrosting\Sprinkle\Core\Util\Util; + +/** + * Extends Twig functionality for the Core sprinkle. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class CoreExtension extends \Twig_Extension implements \Twig_Extension_GlobalsInterface +{ + + /** + * @var ContainerInterface The global container object, which holds all your services. + */ + protected $services; + + /** + * Constructor. + * + * @param ContainerInterface $services The global container object, which holds all your services. + */ + public function __construct(ContainerInterface $services) + { + $this->services = $services; + } + + /** + * Get the name of this extension. + * + * @return string + */ + public function getName() + { + return 'userfrosting/core'; + } + + /** + * Adds Twig functions `getAlerts` and `translate`. + * + * @return array[\Twig_SimpleFunction] + */ + public function getFunctions() + { + return array( + // Add Twig function for fetching alerts + new \Twig_SimpleFunction('getAlerts', function ($clear = true) { + if ($clear) { + return $this->services['alerts']->getAndClearMessages(); + } else { + return $this->services['alerts']->messages(); + } + }), + new \Twig_SimpleFunction('translate', function ($hook, $params = array()) { + return $this->services['translator']->translate($hook, $params); + }, [ + 'is_safe' => ['html'] + ]) + ); + } + + /** + * Adds Twig filters `unescape`. + * + * @return array[\Twig_SimpleFilter] + */ + public function getFilters() + { + return array( + /** + * Converts phone numbers to a standard format. + * + * @param String $num A unformatted phone number + * @return String Returns the formatted phone number + */ + new \Twig_SimpleFilter('phone', function ($num) { + return Util::formatPhoneNumber($num); + }), + new \Twig_SimpleFilter('unescape', function ($string) { + return html_entity_decode($string); + }) + ); + } + + /** + * Adds Twig global variables `site` and `assets`. + * + * @return array[mixed] + */ + public function getGlobals() + { + // CSRF token name and value + $csrfNameKey = $this->services->csrf->getTokenNameKey(); + $csrfValueKey = $this->services->csrf->getTokenValueKey(); + $csrfName = $this->services->csrf->getTokenName(); + $csrfValue = $this->services->csrf->getTokenValue(); + + $csrf = [ + 'csrf' => [ + 'keys' => [ + 'name' => $csrfNameKey, + 'value' => $csrfValueKey + ], + 'name' => $csrfName, + 'value' => $csrfValue + ] + ]; + + $site = array_replace_recursive($this->services->config['site'], $csrf); + + return [ + 'site' => $site, + 'assets' => $this->services->assets + ]; + } +} diff --git a/main/app/sprinkles/core/src/Util/BadClassNameException.php b/main/app/sprinkles/core/src/Util/BadClassNameException.php new file mode 100755 index 0000000..09c4ea5 --- /dev/null +++ b/main/app/sprinkles/core/src/Util/BadClassNameException.php @@ -0,0 +1,18 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Util; + +/** + * Bad class name exception. Used when a class name is dynamically invoked, but the class does not exist. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class BadClassNameException extends \LogicException +{ + // +} diff --git a/main/app/sprinkles/core/src/Util/Captcha.php b/main/app/sprinkles/core/src/Util/Captcha.php new file mode 100755 index 0000000..c788b77 --- /dev/null +++ b/main/app/sprinkles/core/src/Util/Captcha.php @@ -0,0 +1,159 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Util; + +use UserFrosting\Session\Session; + +/** + * Captcha Class + * + * Implements the captcha for user registration. + * + * @author r3wt + * @author Alex Weissman (https://alexanderweissman.com) + * @see http://www.userfrosting.com/components/#messages + */ +class Captcha +{ + /** + * @var string The randomly generated captcha code. + */ + protected $code; + + /** + * @var string The captcha image, represented as a binary string. + */ + protected $image; + + /** + * @var UserFrosting\Session\Session We use the session object so that the hashed captcha token will automatically appear in the session. + */ + protected $session; + + /** + * @var string + */ + protected $key; + + /** + * Create a new captcha. + */ + public function __construct($session, $key) + { + $this->session = $session; + $this->key = $key; + + if (!$this->session->has($key)) { + $this->session[$key] = array(); + } + } + + /** + * Generates a new captcha for the user registration form. + * + * This generates a random 5-character captcha and stores it in the session with an md5 hash. + * Also, generates the corresponding captcha image. + */ + public function generateRandomCode() + { + $md5_hash = md5(rand(0,99999)); + $this->code = substr($md5_hash, 25, 5); + $enc = md5($this->code); + + // Store the generated captcha value to the session + $this->session[$this->key] = $enc; + + $this->generateImage(); + } + + /** + * Returns the captcha code. + */ + public function getCaptcha() + { + return $this->code; + } + + /** + * Returns the captcha image. + */ + public function getImage() + { + return $this->image; + } + + /** + * Check that the specified code, when hashed, matches the code in the session. + * + * Also, stores the specified code in the session with an md5 hash. + * @param string + * @return bool + */ + public function verifyCode($code) + { + return (md5($code) == $this->session[$this->key]); + } + + /** + * Generate the image for the current captcha. + * + * This generates an image as a binary string. + */ + protected function generateImage() + { + $width = 150; + $height = 30; + + $image = imagecreatetruecolor(150, 30); + + //color pallette + $white = imagecolorallocate($image, 255, 255, 255); + $black = imagecolorallocate($image, 0, 0, 0); + $red = imagecolorallocate($image,255,0,0); + $yellow = imagecolorallocate($image, 255, 255, 0); + $dark_grey = imagecolorallocate($image, 64,64,64); + $blue = imagecolorallocate($image, 0,0,255); + + //create white rectangle + imagefilledrectangle($image,0,0,150,30,$white); + + //add some lines + for($i=0;$i<2;$i++) { + imageline($image,0,rand()%10,10,rand()%30,$dark_grey); + imageline($image,0,rand()%30,150,rand()%30,$red); + imageline($image,0,rand()%30,150,rand()%30,$yellow); + } + + // RandTab color pallette + $randc[0] = imagecolorallocate($image, 0, 0, 0); + $randc[1] = imagecolorallocate($image,255,0,0); + $randc[2] = imagecolorallocate($image, 255, 255, 0); + $randc[3] = imagecolorallocate($image, 64,64,64); + $randc[4] = imagecolorallocate($image, 0,0,255); + + //add some dots + for($i=0;$i<1000;$i++) { + imagesetpixel($image,rand()%200,rand()%50,$randc[rand()%5]); + } + + //calculate center of text + $x = ( 150 - 0 - imagefontwidth( 5 ) * strlen( $this->code ) ) / 2 + 0 + 5; + + //write string twice + imagestring($image,5, $x, 7, $this->code, $black); + imagestring($image,5, $x, 7, $this->code, $black); + //start ob + ob_start(); + imagepng($image); + + //get binary image data + $this->image = ob_get_clean(); + + return $this->image; + } +} diff --git a/main/app/sprinkles/core/src/Util/CheckEnvironment.php b/main/app/sprinkles/core/src/Util/CheckEnvironment.php new file mode 100755 index 0000000..26925d9 --- /dev/null +++ b/main/app/sprinkles/core/src/Util/CheckEnvironment.php @@ -0,0 +1,340 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Util; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Slim\Http\Body; + +/** + * Performs pre-flight tests on your server environment to check that it meets the requirements. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class CheckEnvironment +{ + /** + * @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator Locator service for stream resources. + */ + protected $locator; + + /** + * @var array The results of any failed checks performed. + */ + protected $resultsFailed = []; + + /** + * @var array The results of any successful checks performed. + */ + protected $resultsSuccess = []; + + /** + * @var \Slim\Views\Twig The view object, needed for rendering error page. + */ + protected $view; + + /** + * @var \Illuminate\Cache\CacheManager Cache service for cache access. + */ + protected $cache; + + /** + * Constructor. + * + * @param $view \Slim\Views\Twig The view object, needed for rendering error page. + * @param $locator \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator Locator service for stream resources. + */ + public function __construct($view, $locator, $cache) + { + $this->view = $view; + $this->locator = $locator; + $this->cache = $cache; + } + + /** + * Invoke the CheckEnvironment middleware, performing all pre-flight checks and returning an error page if problems were found. + * + * @param \Psr\Http\Message\ServerRequestInterface $request PSR7 request + * @param \Psr\Http\Message\ResponseInterface $response PSR7 response + * @param callable $next Next middleware + * + * @return \Psr\Http\Message\ResponseInterface + */ + public function __invoke($request, $response, $next) + { + $problemsFound = false; + + // If production environment and no cached checks, perform environment checks + if ($this->isProduction() && $this->cache->get('checkEnvironment') != 'pass') { + $problemsFound = $this->checkAll(); + + // Cache if checks passed + if (!$problemsFound) { + $this->cache->forever('checkEnvironment', 'pass'); + } + } elseif (!$this->isProduction()) { + $problemsFound = $this->checkAll(); + } + + if ($problemsFound) { + $results = array_merge($this->resultsFailed, $this->resultsSuccess); + + $response = $this->view->render($response, 'pages/error/config-errors.html.twig', [ + "messages" => $results + ]); + } else { + $response = $next($request, $response); + } + + return $response; + } + + /** + * Run through all pre-flight checks. + */ + public function checkAll() + { + $problemsFound = false; + + if ($this->checkApache()) $problemsFound = true; + + if ($this->checkPhp()) $problemsFound = true; + + if ($this->checkPdo()) $problemsFound = true; + + if ($this->checkGd()) $problemsFound = true; + + if ($this->checkImageFunctions()) $problemsFound = true; + + if ($this->checkPermissions()) $problemsFound = true; + + return $problemsFound; + } + + /** + * For Apache environments, check that required Apache modules are installed. + */ + public function checkApache() + { + $problemsFound = false; + + // Perform some Apache checks. We may also need to do this before any routing takes place. + if (strpos(php_sapi_name(), 'apache') !== false) { + + $require_apache_modules = ['mod_rewrite']; + $apache_modules = apache_get_modules(); + + $apache_status = []; + + foreach ($require_apache_modules as $module) { + if (!in_array($module, $apache_modules)) { + $problemsFound = true; + $this->resultsFailed['apache-' . $module] = [ + "title" => "<i class='fa fa-server fa-fw'></i> Missing Apache module <b>$module</b>.", + "message" => "Please make sure that the <code>$module</code> Apache module is installed and enabled. If you use shared hosting, you will need to ask your web host to do this for you.", + "success" => false + ]; + } else { + $this->resultsSuccess['apache-' . $module] = [ + "title" => "<i class='fa fa-server fa-fw'></i> Apache module <b>$module</b> is installed and enabled.", + "message" => "Great, we found the <code>$module</code> Apache module!", + "success" => true + ]; + } + } + } + + return $problemsFound; + } + + /** + * Check for GD library (required for Captcha). + */ + public function checkGd() + { + $problemsFound = false; + + if (!(extension_loaded('gd') && function_exists('gd_info'))) { + $problemsFound = true; + $this->resultsFailed['gd'] = [ + "title" => "<i class='fa fa-image fa-fw'></i> GD library not installed", + "message" => "We could not confirm that the <code>GD</code> library is installed and enabled. GD is an image processing library that UserFrosting uses to generate captcha codes for user account registration.", + "success" => false + ]; + } else { + $this->resultsSuccess['gd'] = [ + "title" => "<i class='fa fa-image fa-fw'></i> GD library installed!", + "message" => "Great, you have <code>GD</code> installed and enabled.", + "success" => true + ]; + } + + return $problemsFound; + } + + /** + * Check that all image* functions used by Captcha exist. + * + * Some versions of GD are missing one or more of these functions, thus why we check for them explicitly. + */ + public function checkImageFunctions() + { + $problemsFound = false; + + $funcs = [ + 'imagepng', + 'imagecreatetruecolor', + 'imagecolorallocate', + 'imagefilledrectangle', + 'imageline', + 'imagesetpixel', + 'imagefontwidth', + 'imagestring' + ]; + + foreach ($funcs as $func) { + if (!function_exists($func)) { + $problemsFound = true; + $this->resultsFailed['function-' . $func] = [ + "title" => "<i class='fa fa-code fa-fw'></i> Missing image manipulation function.", + "message" => "It appears that function <code>$func</code> is not available. UserFrosting needs this to render captchas.", + "success" => false + ]; + } else { + $this->resultsSuccess['function-' . $func] = [ + "title" => "<i class='fa fa-code fa-fw'></i> Function <b>$func</b> is available!", + "message" => "Sweet!", + "success" => true + ]; + } + } + + return $problemsFound; + } + + /** + * Check that PDO is installed and enabled. + */ + public function checkPdo() + { + $problemsFound = false; + + if (!class_exists('PDO')) { + $problemsFound = true; + $this->resultsFailed['pdo'] = [ + "title" => "<i class='fa fa-database fa-fw'></i> PDO is not installed.", + "message" => "I'm sorry, you must have PDO installed and enabled in order for UserFrosting to access the database. If you don't know what PDO is, please see <a href='http://php.net/manual/en/book.pdo.php'>http://php.net/manual/en/book.pdo.php</a>.", + "success" => false + ]; + } else { + $this->resultsSuccess['pdo'] = [ + "title" => "<i class='fa fa-database fa-fw'></i> PDO is installed!", + "message" => "You've got PDO installed. Good job!", + "success" => true + ]; + } + + return $problemsFound; + } + + /** + * Check that log, cache, and session directories are writable, and that other directories are set appropriately for the environment. + */ + function checkPermissions() + { + $problemsFound = false; + + $shouldBeWriteable = [ + $this->locator->findResource('log://') => true, + $this->locator->findResource('cache://') => true, + $this->locator->findResource('session://') => true + ]; + + if ($this->isProduction()) { + // Should be write-protected in production! + $shouldBeWriteable = array_merge($shouldBeWriteable, [ + \UserFrosting\SPRINKLES_DIR => false, + \UserFrosting\VENDOR_DIR => false + ]); + } + + // Check for essential files & perms + foreach ($shouldBeWriteable as $file => $assertWriteable) { + $is_dir = false; + if (!file_exists($file)) { + $problemsFound = true; + $this->resultsFailed['file-' . $file] = [ + "title" => "<i class='fa fa-file-o fa-fw'></i> File or directory does not exist.", + "message" => "We could not find the file or directory <code>$file</code>.", + "success" => false + ]; + } else { + $writeable = is_writable($file); + if ($assertWriteable !== $writeable) { + $problemsFound = true; + $this->resultsFailed['file-' . $file] = [ + "title" => "<i class='fa fa-file-o fa-fw'></i> Incorrect permissions for file or directory.", + "message" => "<code>$file</code> is " + . ($writeable ? "writeable" : "not writeable") + . ", but it should " + . ($assertWriteable ? "be writeable" : "not be writeable") + . ". Please modify the OS user or group permissions so that user <b>" + . exec('whoami') . "</b> " + . ($assertWriteable ? "has" : "does not have") . " write permissions for this directory.", + "success" => false + ]; + } else { + $this->resultsSuccess['file-' . $file] = [ + "title" => "<i class='fa fa-file-o fa-fw'></i> File/directory check passed!", + "message" => "<code>$file</code> exists and is correctly set as <b>" + . ($writeable ? "writeable" : "not writeable") + . "</b>.", + "success" => true + ]; + } + } + } + return $problemsFound; + } + + /** + * Check that PHP meets the minimum required version. + */ + public function checkPhp() + { + $problemsFound = false; + + // Check PHP version + if (version_compare(phpversion(), \UserFrosting\PHP_MIN_VERSION, '<')) { + $problemsFound = true; + $this->resultsFailed['phpVersion'] = [ + "title" => "<i class='fa fa-code fa-fw'></i> You need to upgrade your PHP installation.", + "message" => "I'm sorry, UserFrosting requires version " . \UserFrosting\PHP_MIN_VERSION . " or greater. Please upgrade your version of PHP, or contact your web hosting service and ask them to upgrade it for you.", + "success" => false + ]; + } else { + $this->resultsSuccess['phpVersion'] = [ + "title" => "<i class='fa fa-code fa-fw'></i> PHP version checks out!", + "message" => "You're using PHP " . \UserFrosting\PHP_MIN_VERSION . "or higher. Great!", + "success" => true + ]; + } + + return $problemsFound; + } + + /** + * Determine whether or not we are running in production mode. + * + * @return bool + */ + public function isProduction() + { + return (getenv('UF_MODE') == 'production'); + } +} diff --git a/main/app/sprinkles/core/src/Util/ClassMapper.php b/main/app/sprinkles/core/src/Util/ClassMapper.php new file mode 100755 index 0000000..5fa0881 --- /dev/null +++ b/main/app/sprinkles/core/src/Util/ClassMapper.php @@ -0,0 +1,94 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ + +namespace UserFrosting\Sprinkle\Core\Util; + +/** + * UserFrosting class mapper. + * + * This creates an abstraction layer for overrideable classes. + * For example, if we want to replace usages of the User class with MyUser, this abstraction layer handles that. + * + * @author Alex Weissman (https://alexanderweissman.com) + * @author Roger Ardibee + */ +class ClassMapper +{ + /** + * Mapping of generic class identifiers to specific class names. + */ + protected $classMappings = []; + + /** + * Creates an instance for a requested class identifier. + * + * @param string $identifier The identifier for the class, e.g. 'user' + * @param mixed ...$arg Whatever needs to be passed to the constructor. + */ + public function createInstance($identifier) + { + $className = $this->getClassMapping($identifier); + + $params = array_slice(func_get_args(), 1); + + // We must use reflection in PHP < 5.6. See http://stackoverflow.com/questions/8734522/dynamically-call-class-with-variable-number-of-parameters-in-the-constructor + $reflection = new \ReflectionClass($className); + + return $reflection->newInstanceArgs($params); + } + + /** + * Gets the fully qualified class name for a specified class identifier. + * + * @param string $identifier + * @return string + */ + public function getClassMapping($identifier) + { + if (isset($this->classMappings[$identifier])) { + return $this->classMappings[$identifier]; + } else { + throw new \OutOfBoundsException("There is no class mapped to the identifier '$identifier'."); + } + } + + /** + * Assigns a fully qualified class name to a specified class identifier. + * + * @param string $identifier + * @param string $className + * @return ClassMapper + */ + public function setClassMapping($identifier, $className) + { + // Check that class exists + if (!class_exists($className)) { + throw new BadClassNameException("Unable to find the class '$className'." ); + } + + $this->classMappings[$identifier] = $className; + + return $this; + } + + /** + * Call a static method for a specified class. + * + * @param string $identifier The identifier for the class, e.g. 'user' + * @param string $methodName The method to be invoked. + * @param mixed ...$arg Whatever needs to be passed to the method. + */ + public function staticMethod($identifier, $methodName) + { + $className = $this->getClassMapping($identifier); + + $params = array_slice(func_get_args(), 2); + + return call_user_func_array("$className::$methodName", $params); + } +} diff --git a/main/app/sprinkles/core/src/Util/EnvironmentInfo.php b/main/app/sprinkles/core/src/Util/EnvironmentInfo.php new file mode 100755 index 0000000..aba9837 --- /dev/null +++ b/main/app/sprinkles/core/src/Util/EnvironmentInfo.php @@ -0,0 +1,68 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Util; + +use Illuminate\Database\Capsule\Manager as Capsule; + +/** + * EnvironmentInfo Class + * + * Gets basic information about the application environment. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class EnvironmentInfo +{ + /** + * @var ContainerInterface The DI container for your application. + */ + public static $ci; + + /** + * Get an array of key-value pairs containing basic information about the default database. + * + * @return string[] the properties of this database. + */ + public static function database() + { + static::$ci['db']; + + $pdo = Capsule::connection()->getPdo(); + $results = []; + + try { + $results['type'] = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + } catch (Exception $e) { + $results['type'] = "Unknown"; + } + + try { + $results['version'] = $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION); + } catch (Exception $e) { + $results['version'] = ""; + } + + return $results; + } + + /** + * Test whether a DB connection can be established. + * + * @return bool true if the connection can be established, false otherwise. + */ + public static function canConnectToDatabase() + { + try { + Capsule::connection()->getPdo(); + } catch (\PDOException $e) { + return false; + } + + return true; + } +} diff --git a/main/app/sprinkles/core/src/Util/ShutdownHandler.php b/main/app/sprinkles/core/src/Util/ShutdownHandler.php new file mode 100755 index 0000000..e7a6903 --- /dev/null +++ b/main/app/sprinkles/core/src/Util/ShutdownHandler.php @@ -0,0 +1,167 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Util; + +use Interop\Container\ContainerInterface; +use UserFrosting\Sprinkle\Core\Http\Concerns\DeterminesContentType; + +/** + * Registers a handler to be invoked whenever the application shuts down. + * If it shut down due to a fatal error, will generate a clean error message. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class ShutdownHandler +{ + use DeterminesContentType; + + /** + * @var ContainerInterface The global container object, which holds all your services. + */ + protected $ci; + + /** + * @var bool + */ + protected $displayErrorInfo; + + /** + * Constructor. + * + * @param ContainerInterface $ci The global container object, which holds all your services. + * @param bool $displayErrorInfo + */ + public function __construct(ContainerInterface $ci, $displayErrorInfo) + { + $this->ci = $ci; + $this->displayErrorInfo = $displayErrorInfo; + } + + /** + * Register this class with the shutdown handler. + * + * @return void + */ + public function register() + { + register_shutdown_function([$this, 'fatalHandler']); + } + + /** + * Set up the fatal error handler, so that we get a clean error message and alert instead of a WSOD. + */ + public function fatalHandler() + { + $error = error_get_last(); + $fatalErrorTypes = [ + E_ERROR, + E_PARSE, + E_CORE_ERROR, + E_COMPILE_ERROR, + E_RECOVERABLE_ERROR + ]; + + // Handle fatal errors and parse errors + if ($error !== NULL && in_array($error['type'], $fatalErrorTypes)) { + + // Build the appropriate error message (debug or client) + if ($this->displayErrorInfo) { + $errorMessage = $this->buildErrorInfoMessage($error); + } else { + $errorMessage = "Oops, looks like our server might have goofed. If you're an admin, please ensure that <code>php.log_errors</code> is enabled, and then check the <strong>PHP</strong> error log."; + } + + // For CLI, just print the message and exit. + if (php_sapi_name() === 'cli') { + exit($errorMessage . PHP_EOL); + } + + // For all other environments, print a debug response for the requested data type + echo $this->buildErrorPage($errorMessage); + + // If this is an AJAX request and AJAX debugging is turned off, write message to the alert stream + if ($this->ci->request->isXhr() && !$this->ci->config['site.debug.ajax']) { + if ($this->ci->alerts && is_object($this->ci->alerts)) { + $this->ci->alerts->addMessageTranslated('danger', $errorMessage); + } + } + + header('HTTP/1.1 500 Internal Server Error'); + exit(); + } + } + + /** + * Build the error message string. + * + * @param array $error + * @return string + */ + protected function buildErrorInfoMessage(array $error) + { + $errfile = $error['file']; + $errline = (string) $error['line']; + $errstr = $error['message']; + + $errorTypes = [ + E_ERROR => 'Fatal error', + E_PARSE => 'Parse error', + E_CORE_ERROR => 'PHP core error', + E_COMPILE_ERROR => 'Zend compile error', + E_RECOVERABLE_ERROR => 'Catchable fatal error' + ]; + + return "<strong>" . $errorTypes[$error['type']] . "</strong>: $errstr in <strong>$errfile</strong> on line <strong>$errline</strong>"; + } + + /** + * Build an error response of the appropriate type as determined by the request's Accept header. + * + * @param string $message + * @return string + */ + protected function buildErrorPage($message) + { + $contentType = $this->determineContentType($this->ci->request, $this->ci->config['site.debug.ajax']); + + switch ($contentType) { + case 'application/json': + $error = ['message' => $message]; + return json_encode($error, JSON_PRETTY_PRINT); + + case 'text/html': + return $this->buildHtmlErrorPage($message); + + default: + case 'text/plain': + return $message; + } + } + + /** + * Build an HTML error page from an error string. + * + * @param string $errorMessage + * @return string + */ + protected function buildHtmlErrorPage($message) + { + $title = 'UserFrosting Application Error'; + $html = "<p>$message</p>"; + + return sprintf( + "<html><head><meta http-equiv='Content-Type' content='text/html; charset=utf-8'>" . + "<title>%s</title><style>body{margin:0;padding:30px;font:12px/1.5 Helvetica,Arial,Verdana," . + "sans-serif;}h1{margin:0;font-size:48px;font-weight:normal;line-height:48px;}" . + "</style></head><body><h1>%s</h1>%s</body></html>", + $title, + $title, + $html + ); + } +} diff --git a/main/app/sprinkles/core/src/Util/Util.php b/main/app/sprinkles/core/src/Util/Util.php new file mode 100755 index 0000000..ae551cf --- /dev/null +++ b/main/app/sprinkles/core/src/Util/Util.php @@ -0,0 +1,173 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Core\Util; + +/** + * Util Class + * + * Static utility functions. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Util +{ + /** + * Extracts specific fields from one associative array, and places them into another. + * + * @param mixed[] $inputArray + * @param string[] $fieldArray + * @param bool $remove + * @return mixed[] + */ + static public function extractFields(&$inputArray, $fieldArray, $remove = true) + { + $result = []; + + foreach ($fieldArray as $name) { + if (array_key_exists($name, $inputArray)) { + $result[$name] = $inputArray[$name]; + + // Optionally remove value from input array + if ($remove) { + unset($inputArray[$name]); + } + } + } + + return $result; + } + + /** + * Extracts numeric portion of a string (for example, for normalizing phone numbers). + * + * @param string $str + * @return string + */ + static public function extractDigits($str) + { + return preg_replace('/[^0-9]/', '', $str); + } + + /** + * Formats a phone number as a standard 7- or 10-digit string (xxx) xxx-xxxx + * + * @param string $phone + * @return string + */ + static public function formatPhoneNumber($phone) + { + $num = static::extractDigits($phone); + + $len = strlen($num); + + if ($len == 7) { + $num = preg_replace('/([0-9]{3})([0-9]{4})/', '$1-$2', $num); + } elseif ($len == 10) { + $num = preg_replace('/([0-9]{3})([0-9]{3})([0-9]{4})/', '($1) $2-$3', $num); + } + + return $num; + } + + /** + * Nicely format an array for printing. + * See https://stackoverflow.com/a/9776726/2970321 + * + * @param array $arr + * @return string + */ + static public function prettyPrintArray(array $arr) + { + $json = json_encode($arr); + $result = ''; + $level = 0; + $inQuotes = false; + $inEscape = false; + $endsLineLevel = NULL; + $jsonLength = strlen($json); + + for ($i = 0; $i < $jsonLength; $i++) { + $char = $json[$i]; + $newLineLevel = NULL; + $post = ''; + if ($endsLineLevel !== NULL) { + $newLineLevel = $endsLineLevel; + $endsLineLevel = NULL; + } + if ($inEscape) { + $inEscape = false; + } elseif ($char === '"') { + $inQuotes = !$inQuotes; + } elseif (!$inQuotes) { + switch ($char) { + case '}': case ']': + $level--; + $endsLineLevel = NULL; + $newLineLevel = $level; + break; + + case '{': case '[': + $level++; + + case ',': + $endsLineLevel = $level; + break; + + case ':': + $post = ' '; + break; + + case ' ': case '\t': case '\n': case '\r': + $char = ''; + $endsLineLevel = $newLineLevel; + $newLineLevel = NULL; + break; + } + } elseif ($char === '\\') { + $inEscape = true; + } + + if ($newLineLevel !== NULL) { + $result .= '<br>'.str_repeat( ' ', $newLineLevel); + } + + $result .= $char.$post; + } + + return $result; + } + + /** + * Generate a random phrase, consisting of a specified number of adjectives, followed by a noun. + * + * @param int $numAdjectives + * @param int $maxLength + * @param int $maxTries + * @param string $separator + * @return string + */ + static public function randomPhrase($numAdjectives, $maxLength = 9999999, $maxTries = 10, $separator = '-') + { + $adjectives = include('extra://adjectives.php'); + $nouns = include('extra://nouns.php'); + + for ($n = 0; $n < $maxTries; $n++) { + $keys = array_rand($adjectives, $numAdjectives); + $matches = array_only($adjectives, $keys); + + $result = implode($separator, $matches); + $result .= $separator . $nouns[array_rand($nouns)]; + $result = str_slug($result, $separator); + if (strlen($result) < $maxLength) { + return $result; + } + } + + return ''; + } +} |