diff --git a/.drone.yml b/.drone.yml index de3758236c4e2..40130718d2ee8 100644 --- a/.drone.yml +++ b/.drone.yml @@ -81,6 +81,11 @@ pipeline: commands: - bash libraries/vendor/joomla/test-system/src/drone-run.sh "$(pwd)" + api-tests: + image: joomlaprojects/docker-systemtests:latest + commands: + - bash libraries/vendor/joomla/test-api/drone-run.sh "$(pwd)" + analysis3x: image: rips/rips-cli secrets: [rips_username, rips_password] diff --git a/administrator/components/com_admin/sql/updates/mysql/4.0.0-2019-01-05.sql b/administrator/components/com_admin/sql/updates/mysql/4.0.0-2019-01-05.sql new file mode 100644 index 0000000000000..dffb48720873f --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/4.0.0-2019-01-05.sql @@ -0,0 +1,3 @@ +INSERT INTO `#__extensions` (`extension_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `manifest_cache`, `params`, `custom_data`, `system_data`, `checked_out`, `checked_out_time`, `ordering`, `state`) VALUES +(493, 0, 'plg_api-authentication_basic', 'plugin', 'basic', 'api-authentication', 0, 1, 1, 0, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), +(494, 0, 'plg_webservices_content', 'plugin', 'content', 'webservices', 0, 1, 1, 0, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), diff --git a/administrator/components/com_admin/sql/updates/postgresql/4.0.0-2019-01-05.sql b/administrator/components/com_admin/sql/updates/postgresql/4.0.0-2019-01-05.sql new file mode 100644 index 0000000000000..80fa35c2b51fa --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/4.0.0-2019-01-05.sql @@ -0,0 +1,3 @@ +INSERT INTO "#__extensions" ("extension_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "manifest_cache", "params", "custom_data", "system_data", "checked_out", "checked_out_time", "ordering", "state") VALUES +(493, 0, 'plg_api-authentication_basic', 'plugin', 'basic', 'api-authentication', 0, 1, 1, 0, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), +(494, 0, 'plg_webservices_content', 'plugin', 'content', 'webservices', 0, 1, 1, 0, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), diff --git a/administrator/components/com_content/Model/ArticlesModel.php b/administrator/components/com_content/Model/ArticlesModel.php index a1ad422ecf9be..b3b9c0eaae16f 100644 --- a/administrator/components/com_content/Model/ArticlesModel.php +++ b/administrator/components/com_content/Model/ArticlesModel.php @@ -199,7 +199,7 @@ protected function getListQuery() 'list.select', 'DISTINCT a.id, a.title, a.alias, a.checked_out, a.checked_out_time, a.catid' . ', a.state, a.access, a.created, a.created_by, a.created_by_alias, a.modified, a.ordering, a.featured, a.language, a.hits' . - ', a.publish_up, a.publish_down' + ', a.publish_up, a.publish_down, a.introtext' ) ); $query->from('#__content AS a'); @@ -600,6 +600,18 @@ public function getItems() } } + $asset = new \Joomla\CMS\Table\Asset($this->getDbo()); + + foreach (array_keys($items) as $x) + { + $items[$x]->typeAlias = 'com_content.article'; + + $asset->loadByName('com_content.article.' . $items[$x]->id); + + // Re-inject the asset id. + $items[$x]->asset_id = $asset->id; + } + return $items; } } diff --git a/administrator/includes/defines.php b/administrator/includes/defines.php index 1e2eb7b80a31a..e1f482a1fe8c6 100644 --- a/administrator/includes/defines.php +++ b/administrator/includes/defines.php @@ -23,3 +23,4 @@ define('JPATH_THEMES', JPATH_BASE . DIRECTORY_SEPARATOR . 'templates'); define('JPATH_CACHE', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'cache'); define('JPATH_MANIFESTS', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'manifests'); +define('JPATH_API', JPATH_ROOT . DIRECTORY_SEPARATOR . 'api'); diff --git a/administrator/language/en-GB/en-GB.plg_api-authentication_basic.ini b/administrator/language/en-GB/en-GB.plg_api-authentication_basic.ini new file mode 100644 index 0000000000000..11e2ac3bbf2c0 --- /dev/null +++ b/administrator/language/en-GB/en-GB.plg_api-authentication_basic.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; Copyright (C) 2005 - 2017 Open Source Matters. All rights reserved. +; License GNU General Public License version 2 or later; see LICENSE.txt, see LICENSE.php +; Note : All ini files need to be saved as UTF-8 + +PLG_API-AUTHENTICATION_BASIC="API Authentication - Basic Auth" +PLG_AUTH_AUTHENTICATION_BASIC_XML_DESCRIPTION="Used to allow basic authentication to Web Services in Joomla." diff --git a/administrator/language/en-GB/en-GB.plg_api-authentication_basic.sys.ini b/administrator/language/en-GB/en-GB.plg_api-authentication_basic.sys.ini new file mode 100644 index 0000000000000..11e2ac3bbf2c0 --- /dev/null +++ b/administrator/language/en-GB/en-GB.plg_api-authentication_basic.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; Copyright (C) 2005 - 2017 Open Source Matters. All rights reserved. +; License GNU General Public License version 2 or later; see LICENSE.txt, see LICENSE.php +; Note : All ini files need to be saved as UTF-8 + +PLG_API-AUTHENTICATION_BASIC="API Authentication - Basic Auth" +PLG_AUTH_AUTHENTICATION_BASIC_XML_DESCRIPTION="Used to allow basic authentication to Web Services in Joomla." diff --git a/administrator/language/en-GB/en-GB.plg_webservices_content.ini b/administrator/language/en-GB/en-GB.plg_webservices_content.ini new file mode 100644 index 0000000000000..9cd632a094518 --- /dev/null +++ b/administrator/language/en-GB/en-GB.plg_webservices_content.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; Copyright (C) 2005 - 2017 Open Source Matters. All rights reserved. +; License GNU General Public License version 2 or later; see LICENSE.txt, see LICENSE.php +; Note : All ini files need to be saved as UTF-8 + +PLG_WEBSERVICES_CONTENT="Web Services - Content" +PLG_WEBSERVICES_CONTENT_XML_DESCRIPTION="Used to add articles routes to the API for your website." diff --git a/administrator/language/en-GB/en-GB.plg_webservices_content.sys.ini b/administrator/language/en-GB/en-GB.plg_webservices_content.sys.ini new file mode 100644 index 0000000000000..9cd632a094518 --- /dev/null +++ b/administrator/language/en-GB/en-GB.plg_webservices_content.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; Copyright (C) 2005 - 2017 Open Source Matters. All rights reserved. +; License GNU General Public License version 2 or later; see LICENSE.txt, see LICENSE.php +; Note : All ini files need to be saved as UTF-8 + +PLG_WEBSERVICES_CONTENT="Web Services - Content" +PLG_WEBSERVICES_CONTENT_XML_DESCRIPTION="Used to add articles routes to the API for your website." diff --git a/api/components/com_content/Controller/ArticleController.php b/api/components/com_content/Controller/ArticleController.php new file mode 100644 index 0000000000000..a6385fcfd76df --- /dev/null +++ b/api/components/com_content/Controller/ArticleController.php @@ -0,0 +1,30 @@ +setStart($startTime, $startMem)->mark('afterLoad') : null; + +// Boot the DI container +$container = \Joomla\CMS\Factory::getContainer(); + +/* + * Alias the session service keys to the web session service as that is the primary session backend for this application + * + * In addition to aliasing "common" service keys, we also create aliases for the PHP classes to ensure autowiring objects + * is supported. This includes aliases for aliased class names, and the keys for alised class names should be considered + * deprecated to be removed when the class name alias is removed as well. + */ +$container->alias('session', 'session.cli') + ->alias('JSession', 'session.cli') + ->alias(\Joomla\CMS\Session\Session::class, 'session.cli') + ->alias(\Joomla\Session\Session::class, 'session.cli') + ->alias(\Joomla\Session\SessionInterface::class, 'session.cli'); + +// Instantiate the application. +$app = $container->get(\Joomla\CMS\Application\ApiApplication::class); + +// Set the application as global app +\Joomla\CMS\Factory::$application = $app; + +// Execute the application. +$app->execute(); diff --git a/api/includes/defines.php b/api/includes/defines.php new file mode 100644 index 0000000000000..12c10eed0f13d --- /dev/null +++ b/api/includes/defines.php @@ -0,0 +1,26 @@ +isInDevelopmentState()))) +{ + if (file_exists(JPATH_INSTALLATION . '/index.php')) + { + header('HTTP/1.1 500 Internal Server Error'); + echo json_encode( + array('error' => 'You must install Joomla to use the API') + ); + + exit(); + } + else + { + header('HTTP/1.1 500 Internal Server Error'); + echo json_encode( + array('error' => 'No configuration file found and no installation code available. Exiting...') + ); + + exit; + } +} + +// Pre-Load configuration. Don't remove the Output Buffering due to BOM issues, see JCode 26026 +ob_start(); +require_once JPATH_CONFIGURATION . '/configuration.php'; +ob_end_clean(); + +// System configuration. +$config = new JConfig; + +// Set the error_reporting +switch ($config->error_reporting) +{ + case 'default': + case '-1': + break; + + case 'none': + case '0': + error_reporting(0); + + break; + + case 'simple': + error_reporting(E_ERROR | E_WARNING | E_PARSE); + ini_set('display_errors', 1); + + break; + + case 'maximum': + error_reporting(E_ALL); + ini_set('display_errors', 1); + + break; + + case 'development': + error_reporting(-1); + ini_set('display_errors', 1); + + break; + + default: + error_reporting($config->error_reporting); + ini_set('display_errors', 1); + + break; +} + +define('JDEBUG', $config->debug); + +unset($config); + +// System profiler +if (JDEBUG) +{ + // @deprecated 4.0 - The $_PROFILER global will be removed + $_PROFILER = JProfiler::getInstance('Application'); +} diff --git a/api/index.php b/api/index.php new file mode 100644 index 0000000000000..cbafb7fcf4626 --- /dev/null +++ b/api/index.php @@ -0,0 +1,35 @@ + sprintf('Joomla requires PHP version %s to run', JOOMLA_MINIMUM_PHP)) + ); + + return; +} + +/** + * Constant that is checked in included files to prevent direct access. + * define() is used rather than "const" to not error for PHP 5.2 and lower + */ +define('_JEXEC', 1); + +// Run the application - All executable code should be triggered through this file +require_once dirname(__FILE__) . '/includes/app.php'; diff --git a/composer.json b/composer.json index f318988479dd4..cc32c4f32ae6f 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ "joomla/oauth1": "~2.0@dev", "joomla/oauth2": "~2.0@dev", "joomla/registry": "~2.0@dev", + "joomla/router": "~2.0@dev", "joomla/session": "~2.0@dev", "joomla/string": "~2.0@dev", "joomla/uri": "~2.0@dev", @@ -86,6 +87,8 @@ "symfony/yaml": "3.4.*", "wamania/php-stemmer": "^1.2", "maximebf/debugbar": "^1.15", + "tobscure/json-api": "^0.4.1", + "willdurand/negotiation": "^2.3", "ext-json": "*" }, "require-dev": { @@ -98,6 +101,7 @@ "joomla-projects/joomla-browser": "~4.0@dev", "joomla-projects/robo-joomla": "dev-develop", "joomla/test-system": "dev-4.0-dev", - "joomla/test-unit": "dev-4.0-dev" + "joomla/test-unit": "dev-4.0-dev", + "joomla/test-api": "dev-4.0-dev" } } diff --git a/composer.lock b/composer.lock index e5bc155b2d244..5adeebcc37512 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "59485b6ff077529cb9c9ad7872b31ddb", + "content-hash": "ff7779b0bdd2c9a064c943f1be315f0a", "packages": [ { "name": "composer/ca-bundle", @@ -1269,6 +1269,55 @@ ], "time": "2018-10-03T02:55:25+00:00" }, + { + "name": "joomla/router", + "version": "dev-2.0-dev", + "source": { + "type": "git", + "url": "https://github.com/joomla-framework/router.git", + "reference": "7558239b9c8ffa0f447068e5fad14e9b3650dbe5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/joomla-framework/router/zipball/7558239b9c8ffa0f447068e5fad14e9b3650dbe5", + "reference": "7558239b9c8ffa0f447068e5fad14e9b3650dbe5", + "shasum": "" + }, + "require": { + "php": "~7.0" + }, + "require-dev": { + "jeremeamia/superclosure": "~1.0", + "joomla/coding-standards": "~2.0@alpha", + "phpunit/phpunit": "~6.3" + }, + "suggest": { + "jeremeamia/superclosure": "If you use Closure controllers, and want to be able to cache / serialize the router, please install jeremeamia/superclosure:~1.0" + }, + "type": "joomla-package", + "extra": { + "branch-alias": { + "dev-2.0-dev": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Joomla\\Router\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "description": "Joomla Router Package", + "homepage": "https://github.com/joomla-framework/router", + "keywords": [ + "framework", + "joomla", + "router" + ], + "time": "2018-10-03T01:22:25+00:00" + }, { "name": "joomla/session", "version": "dev-2.0-dev", @@ -2749,6 +2798,56 @@ "homepage": "https://symfony.com", "time": "2018-11-11T19:48:54+00:00" }, + { + "name": "tobscure/json-api", + "version": "v0.4.1", + "source": { + "type": "git", + "url": "https://github.com/tobscure/json-api.git", + "reference": "d4ba8437977c33a5189d95d9ffa751997f13b104" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tobscure/json-api/zipball/d4ba8437977c33a5189d95d9ffa751997f13b104", + "reference": "d4ba8437977c33a5189d95d9ffa751997f13b104", + "shasum": "" + }, + "require": { + "php": "^5.5.9 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Tobscure\\JsonApi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Toby Zerner", + "email": "toby.zerner@gmail.com" + } + ], + "description": "JSON-API responses in PHP", + "keywords": [ + "api", + "json", + "jsonapi", + "standard" + ], + "time": "2017-03-05T21:16:24+00:00" + }, { "name": "wamania/php-stemmer", "version": "1.2", @@ -2793,6 +2892,58 @@ ], "time": "2017-01-27T17:16:44+00:00" }, + { + "name": "willdurand/negotiation", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/willdurand/Negotiation.git", + "reference": "03436ededa67c6e83b9b12defac15384cb399dc9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/willdurand/Negotiation/zipball/03436ededa67c6e83b9b12defac15384cb399dc9", + "reference": "03436ededa67c6e83b9b12defac15384cb399dc9", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "autoload": { + "psr-4": { + "Negotiation\\": "src/Negotiation" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "William Durand", + "email": "will+git@drnd.me" + } + ], + "description": "Content Negotiation tools for PHP provided as a standalone library.", + "homepage": "http://williamdurand.fr/Negotiation/", + "keywords": [ + "accept", + "content", + "format", + "header", + "negotiation" + ], + "time": "2017-05-14T17:21:12+00:00" + }, { "name": "zendframework/zend-diactoros", "version": "1.8.6", @@ -4486,6 +4637,37 @@ ], "time": "2018-10-16T23:34:41+00:00" }, + { + "name": "joomla/test-api", + "version": "dev-4.0-dev", + "source": { + "type": "git", + "url": "https://github.com/joomla/test-api.git", + "reference": "40691b3e595218c61c1f7f61c0e25c2c0bb118ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/joomla/test-api/zipball/40691b3e595218c61c1f7f61c0e25c2c0bb118ce", + "reference": "40691b3e595218c61c1f7f61c0e25c2c0bb118ce", + "shasum": "" + }, + "require": { + "codeception/codeception": "~2.3", + "php": ">=7.0.0" + }, + "type": "project", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0+" + ], + "description": "Joomla CMS API tests", + "homepage": "https://github.com/joomla/joomla-cms", + "keywords": [ + "cms", + "joomla" + ], + "time": "2018-05-19T08:17:12+00:00" + }, { "name": "joomla/test-system", "version": "dev-4.0-dev", @@ -6861,6 +7043,7 @@ "joomla/oauth1": 20, "joomla/oauth2": 20, "joomla/registry": 20, + "joomla/router": 20, "joomla/session": 20, "joomla/string": 20, "joomla/uri": 20, @@ -6869,7 +7052,8 @@ "joomla-projects/joomla-browser": 20, "joomla-projects/robo-joomla": 20, "joomla/test-system": 20, - "joomla/test-unit": 20 + "joomla/test-unit": 20, + "joomla/test-api": 20 }, "prefer-stable": false, "prefer-lowest": false, diff --git a/includes/defines.php b/includes/defines.php index f6a059f059dec..3ca26b0b6a8c6 100644 --- a/includes/defines.php +++ b/includes/defines.php @@ -22,3 +22,4 @@ define('JPATH_THEMES', JPATH_BASE . DIRECTORY_SEPARATOR . 'templates'); define('JPATH_CACHE', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'cache'); define('JPATH_MANIFESTS', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'manifests'); +define('JPATH_API', JPATH_ROOT . DIRECTORY_SEPARATOR . 'api'); diff --git a/installation/includes/defines.php b/installation/includes/defines.php index 27f5f60cd542f..fee6911e0e15f 100644 --- a/installation/includes/defines.php +++ b/installation/includes/defines.php @@ -24,3 +24,5 @@ define('JPATH_THEMES', JPATH_BASE); define('JPATH_CACHE', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'cache'); define('JPATH_MANIFESTS', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'manifests'); +define('JPATH_API', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'api'); + diff --git a/installation/sql/mysql/joomla.sql b/installation/sql/mysql/joomla.sql index 9cb843b5945a8..b4762f934a323 100644 --- a/installation/sql/mysql/joomla.sql +++ b/installation/sql/mysql/joomla.sql @@ -675,6 +675,8 @@ INSERT INTO `#__extensions` (`extension_id`, `package_id`, `name`, `type`, `elem (490, 0, 'plg_extension_namespacemap', 'plugin', 'namespacemap', 'extension', 0, 1, 1, 1, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), (491, 0, 'plg_installer_override', 'plugin', 'override', 'installer', 0, 1, 1, 1, '', '', 0, '0000-00-00 00:00:00', 4, 0), (492, 0, 'plg_quickicon_overridecheck', 'plugin', 'overridecheck', 'quickicon', 0, 1, 1, 1, '', '', 0, '0000-00-00 00:00:00', 0, 0), +(493, 0, 'plg_api-authentication_basic', 'plugin', 'basic', 'api-authentication', 0, 1, 1, 0, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), +(494, 0, 'plg_webservices_content', 'plugin', 'content', 'webservices', 0, 1, 1, 0, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), (509, 0, 'atum', 'template', 'atum', '', 1, 1, 1, 0, '', '', 0, '0000-00-00 00:00:00', 0, 0), (510, 0, 'cassiopeia', 'template', 'cassiopeia', '', 0, 1, 1, 0, '', '{"logoFile":"","fluidContainer":"0","sidebarLeftWidth":"3","sidebarRightWidth":"3"}', 0, '0000-00-00 00:00:00', 0, 0), (600, 802, 'English (en-GB)', 'language', 'en-GB', '', 0, 1, 1, 1, '', '', 0, '0000-00-00 00:00:00', 0, 0), diff --git a/installation/sql/postgresql/joomla.sql b/installation/sql/postgresql/joomla.sql index 14f6aa3fadff7..5b9bad215a190 100644 --- a/installation/sql/postgresql/joomla.sql +++ b/installation/sql/postgresql/joomla.sql @@ -684,6 +684,8 @@ INSERT INTO "#__extensions" ("extension_id", "package_id", "name", "type", "elem (490, 0, 'plg_extension_namespacemap', 'plugin', 'namespacemap', 'extension', 0, 1, 1, 1, '', '{}', 0, '1970-01-01 00:00:00', 0, 0), (491, 0, 'plg_installer_override', 'plugin', 'override', 'installer', 0, 1, 1, 1, '', '', 0, '1970-01-01 00:00:00', 4, 0), (492, 0, 'plg_quickicon_overridecheck', 'plugin', 'overridecheck', 'quickicon', 0, 1, 1, 1, '', '', 0, '1970-01-01 00:00:00', 0, 0), +(493, 0, 'plg_api-authentication_basic', 'plugin', 'basic', 'api-authentication', 0, 1, 1, 0, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), +(494, 0, 'plg_webservices_content', 'plugin', 'content', 'webservices', 0, 1, 1, 0, '', '{}', 0, '0000-00-00 00:00:00', 0, 0), (600, 802, 'English (en-GB)', 'language', 'en-GB', '', 0, 1, 1, 1, '', '', 0, '1970-01-01 00:00:00', 0, 0), (601, 802, 'English (en-GB)', 'language', 'en-GB', '', 1, 1, 1, 1, '', '', 0, '1970-01-01 00:00:00', 0, 0), (700, 0, 'files_joomla', 'file', 'joomla', '', 0, 1, 1, 1, '', '', 0, '1970-01-01 00:00:00', 0, 0), diff --git a/libraries/namespacemap.php b/libraries/namespacemap.php index 1281881c794cc..46cb9d9ab8d4c 100644 --- a/libraries/namespacemap.php +++ b/libraries/namespacemap.php @@ -63,6 +63,7 @@ public function ensureMapFileExists() public function create() { $extensions = $this->getNamespaces('administrator/components'); + $extensions = array_merge($extensions, $this->getNamespaces('api/components')); $extensions = array_merge($extensions, $this->getNamespaces('modules')); $extensions = array_merge($extensions, $this->getNamespaces('administrator/modules')); @@ -200,6 +201,7 @@ private function getNamespaces(string $dir): array if (strpos($extension, 'com_') === 0) { $extensions[$namespace . 'Site\\\\'] = str_replace('administrator/', '', $namespacePath) . $namespaceNode->attributes()->path; + $extensions[$namespace . 'Api\\\\'] = str_replace('administrator/', 'api/', $namespacePath) . $namespaceNode->attributes()->path; } // Add the application specific segment when not a plugin diff --git a/libraries/src/Access/Exception/AuthenticationFailed.php b/libraries/src/Access/Exception/AuthenticationFailed.php new file mode 100644 index 0000000000000..e2d722eed081a --- /dev/null +++ b/libraries/src/Access/Exception/AuthenticationFailed.php @@ -0,0 +1,20 @@ +name = 'api'; + + // Register the client ID + $this->clientId = 3; + + // Execute the parent constructor + parent::__construct($input, $config, $client, $container); + + $this->addFormatMap('application/json', 'json'); + $this->addFormatMap('application/vnd.api+json', 'jsonapi'); + + // Set the root in the URI based on the application name + \JUri::root(null, str_ireplace('/' . $this->getName(), '', \JUri::base(true))); + } + + + /** + * Method to run the application routines. + * + * Most likely you will want to instantiate a controller and execute it, or perform some sort of task directly. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function doExecute() + { + // Initialise the application + $this->initialiseApp(); + + // Mark afterInitialise in the profiler. + JDEBUG ? $this->profiler->mark('afterInitialise') : null; + + // Route the application + $this->route(); + + // Mark afterApiRoute in the profiler. + JDEBUG ? $this->profiler->mark('afterApiRoute') : null; + + // Dispatch the application + $this->dispatch(); + + // Mark afterDispatch in the profiler. + JDEBUG ? $this->profiler->mark('afterDispatch') : null; + } + + /** + * Adds a mapping from a content type to the format stored. Note the format type cannot be overwritten. + * + * @param string $contentHeader The content header + * @param string $format The content type format + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function addFormatMap($contentHeader, $format) + { + if (!array_key_exists($contentHeader, $this->formatMapper)) + { + $this->formatMapper[$contentHeader] = $format; + } + } + + /** + * Rendering is the process of pushing the document buffers into the template + * placeholders, retrieving data from the document and pushing it into + * the application response buffer. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * + * @note Rendering should be overridden to get rid of the theme files. + */ + protected function render() + { + // Render the document + $this->setBody($this->document->render($this->allowCache())); + } + + /** + * Method to send the application response to the client. All headers will be sent prior to the main application output data. + * + * @param array $options An optional argument to enable CORS. (Temporary) + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function respond($options = array()) + { + // Set the Joomla! API signature + $this->setHeader('X-Powered-By', 'JoomlaAPI/1.0', true); + + if (array_key_exists('cors', $options)) + { + // Enable CORS (Cross-origin resource sharing) + $this->setHeader('Access-Control-Allow-Origin', '*', true); + $this->setHeader('Access-Control-Allow-Headers', 'Authorization'); + } + + // Parent function can be overridden later on for debugging. + parent::respond(); + + } + + /** + * Gets the name of the current template. + * + * @param boolean $params True to return the template parameters + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getTemplate($params = false) + { + // The API application should not need to use a template + return 'system'; + } + + /** + * Route the application. + * + * Routing is the process of examining the request environment to determine which + * component should receive the request. The component optional parameters + * are then set in the request object to be processed when the application is being + * dispatched. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function route() + { + $router = $this->getApiRouter(); + + // Trigger the onBeforeApiRoute event. + PluginHelper::importPlugin('webservices'); + $this->triggerEvent('onBeforeApiRoute', array(&$router, $this)); + $caught404 = false; + + try + { + $route = $router->parseApiRoute($this->input->getMethod()); + } + catch (RouteNotFoundException $e) + { + $caught404 = true; + } + + /** + * Now we have an API perform content negotation to ensure we have a valid header. Assume if the route doesn't + * tell us otherwise it uses the pain JSON API + */ + $priorities = array('application/vnd.api+json'); + + if (!$caught404 && array_key_exists('format', $route['vars'])) + { + $priorities = $route['vars']['format']; + } + + $negotiator = new Negotiator; + + try + { + $mediaType = $negotiator->getBest($this->input->server->getString('HTTP_ACCEPT'), $priorities); + } + catch (InvalidArgument $e) + { + $mediaType = null; + } + + // If we can't find a match bail with a 406 - Not Acceptable + if ($mediaType === null) + { + throw new Exception\NotAcceptable('Could not match accept header', 406); + } + + /** @var $mediaType Accept */ + $format = $mediaType->getValue(); + + if (array_key_exists($mediaType->getValue(), $this->formatMapper)) + { + $format = $this->formatMapper[$mediaType->getValue()]; + } + + $this->input->set('format', $format); + + if ($caught404) + { + throw $e; + } + + $this->input->set('option', $route['vars']['component']); + $this->input->set('controller', $route['controller']); + $this->input->set('task', $route['task']); + + foreach ($route['vars'] as $key => $value) + { + if ($key !== 'component') + { + if ($this->input->getMethod() === 'POST') + { + $this->input->post->set($key, $value); + } + else + { + $this->input->set($key, $value); + } + } + } + + $this->triggerEvent('onAfterApiRoute', array($this)); + + if (!isset($route['vars']['public']) || $route['vars']['public'] === false) + { + if (!$this->login(array('username' => ''), array('silent' => true, 'action' => 'core.login.api'))) + { + throw new AuthenticationFailed; + } + } + } + + /** + * Returns the application Router object. + * + * @return ApiRouter + * + * @since __DEPLOY_VERSION__ + */ + public function getApiRouter() + { + return \JFactory::getContainer()->get('ApiRouter'); + } + + /** + * Dispatch the application + * + * @param string $component The component which is being rendered. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function dispatch($component = null) + { + // Get the component if not set. + if (!$component) + { + $component = $this->input->get('option', null); + } + + // Load the document to the API + $this->loadDocument(); + + // Set up the params + $document = \JFactory::getDocument(); + + // Register the document object with \JFactory + \JFactory::$document = $document; + + $contents = ComponentHelper::renderComponent($component); + $document->setBuffer($contents, 'component'); + + // Trigger the onAfterDispatch event. + PluginHelper::importPlugin('system'); + $this->triggerEvent('onAfterDispatch'); + } +} diff --git a/libraries/src/Application/ApplicationHelper.php b/libraries/src/Application/ApplicationHelper.php index f212819485435..e1b429c33e274 100644 --- a/libraries/src/Application/ApplicationHelper.php +++ b/libraries/src/Application/ApplicationHelper.php @@ -144,6 +144,12 @@ public static function getClientInfo($id = null, $byName = false) $obj->name = 'installation'; $obj->path = JPATH_INSTALLATION; self::$_clients[2] = clone $obj; + + // Installation Client + $obj->id = 3; + $obj->name = 'api'; + $obj->path = JPATH_API; + self::$_clients[3] = clone $obj; } // If no client id has been passed return the whole array diff --git a/libraries/src/Application/CMSApplication.php b/libraries/src/Application/CMSApplication.php index 5813660a23b78..e12e57779b175 100644 --- a/libraries/src/Application/CMSApplication.php +++ b/libraries/src/Application/CMSApplication.php @@ -116,6 +116,14 @@ abstract class CMSApplication extends WebApplication implements ContainerAwareIn */ protected $pathway = null; + /** + * The authentication plugin type + * + * @type string + * @since __DEPLOY_VERSION__ + */ + protected $authenticationPluginType = 'authentication'; + /** * Class constructor. * @@ -766,7 +774,7 @@ protected function loadLibraryLanguage() public function login($credentials, $options = array()) { // Get the global Authentication object. - $authenticate = Authentication::getInstance(); + $authenticate = Authentication::getInstance($this->authenticationPluginType); $response = $authenticate->authenticate($credentials, $options); // Import the user plugin group. diff --git a/libraries/src/Application/Exception/NotAcceptable.php b/libraries/src/Application/Exception/NotAcceptable.php new file mode 100644 index 0000000000000..6c6de3e1eb8cb --- /dev/null +++ b/libraries/src/Application/Exception/NotAcceptable.php @@ -0,0 +1,20 @@ +setDispatcher($dispatcher); + $this->pluginType = $pluginType; - $isLoaded = PluginHelper::importPlugin('authentication'); + $isLoaded = PluginHelper::importPlugin($this->pluginType); if (!$isLoaded) { @@ -104,18 +114,20 @@ public function __construct(DispatcherInterface $dispatcher = null) * Returns the global authentication object, only creating it * if it doesn't already exist. * + * @param string $pluginType The plugin type to run authorisation and authentication on + * * @return Authentication The global Authentication object * * @since 1.7.0 */ - public static function getInstance() + public static function getInstance(string $pluginType = 'authentication') { - if (empty(self::$instance)) + if (empty(self::$instance[$pluginType])) { - self::$instance = new static; + self::$instance[$pluginType] = new static($pluginType); } - return self::$instance; + return self::$instance[$pluginType]; } /** @@ -133,7 +145,7 @@ public static function getInstance() public function authenticate($credentials, $options = array()) { // Get plugins - $plugins = PluginHelper::getPlugin('authentication'); + $plugins = PluginHelper::getPlugin($this->pluginType); // Create authentication response $response = new AuthenticationResponse; @@ -150,7 +162,7 @@ public function authenticate($credentials, $options = array()) */ foreach ($plugins as $plugin) { - $className = 'plg' . $plugin->type . $plugin->name; + $className = 'plg' . str_replace('-', '', $plugin->type) . $plugin->name; if (class_exists($className)) { @@ -205,12 +217,12 @@ public function authenticate($credentials, $options = array()) * @return AuthenticationResponse[] Array of authentication response objects * * @since 1.7.0 + * @throws \Exception */ - public static function authorise($response, $options = array()) + public function authorise($response, $options = array()) { // Get plugins in case they haven't been imported already PluginHelper::importPlugin('user'); - PluginHelper::importPlugin('authentication'); $results = Factory::getApplication()->triggerEvent('onUserAuthorisation', array($response, $options)); return $results; diff --git a/libraries/src/Component/ComponentHelper.php b/libraries/src/Component/ComponentHelper.php index d660430c04dcc..c91904ed0825e 100644 --- a/libraries/src/Component/ComponentHelper.php +++ b/libraries/src/Component/ComponentHelper.php @@ -303,12 +303,15 @@ public static function filterText($text) public static function renderComponent($option, $params = array()) { $app = Factory::getApplication(); - - // Load template language files. - $template = $app->getTemplate(true)->template; $lang = Factory::getLanguage(); - $lang->load('tpl_' . $template, JPATH_BASE, null, false, true) + + if (!$app->isClient('api')) + { + // Load template language files. + $template = $app->getTemplate(true)->template; + $lang->load('tpl_' . $template, JPATH_BASE, null, false, true) || $lang->load('tpl_' . $template, JPATH_THEMES . "/$template", null, false, true); + } if (empty($option)) { diff --git a/libraries/src/Dispatcher/ApiDispatcher.php b/libraries/src/Dispatcher/ApiDispatcher.php new file mode 100644 index 0000000000000..87bcfaf6f11bb --- /dev/null +++ b/libraries/src/Dispatcher/ApiDispatcher.php @@ -0,0 +1,147 @@ +app = $app; + $this->input = $input ?: $app->input; + $this->option = $this->input->get('option'); + + $db = Factory::getDbo(); + + $query = $db->getQuery(true); + + $query->select($db->quoteName(array('namespace'))) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('namespace') . ' IS NOT NULL AND ' . $db->quoteName('namespace') . ' != ""') + ->where($db->quoteName('element') . ' = ' . $db->quote($this->option)); + + $db->setQuery($query); + + $this->namespace = $db->loadResult(); + + if ($this->namespace === null) + { + throw new \RuntimeException('Namespace can not be empty!'); + } + + $this->loadLanguage(); + } + + /** + * Load the component's administrator language + * + * @since __DEPLOY_VERSION__ + * + * @return void + */ + protected function loadLanguage() + { + // Load common and local language files. + $this->app->getLanguage()->load($this->option, JPATH_BASE, null, false, true) || + $this->app->getLanguage()->load($this->option, JPATH_COMPONENT_ADMINISTRATOR, null, false, true); + } + + /** + * Dispatch a controller task. Redirecting the user if appropriate. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function dispatch() + { + $command = $this->input->getCmd('task', 'display'); + $task = $command; + + // Check for a controller.task command. + if (strpos($command, '.') !== false) + { + // Explode the controller.task command. + list ($controllerName, $task) = explode('.', $command); + + $this->input->set('controller', $controllerName); + $this->input->set('task', $task); + } + + // Build controller config data + $config['option'] = $this->option; + + // Set name of controller if it is passed in the request + if ($this->input->exists('controller')) + { + $config['name'] = strtolower($this->input->get('controller')); + } + + // Execute the task for this component + $namespace = rtrim($this->namespace, '\\') . '\\'; + $controller = new ApiController($config, new ApiMVCFactory($namespace), $this->app, $this->input); + $controller->execute($task); + $controller->redirect(); + } +} diff --git a/libraries/src/Document/JsonapiDocument.php b/libraries/src/Document/JsonapiDocument.php new file mode 100644 index 0000000000000..1003174897863 --- /dev/null +++ b/libraries/src/Document/JsonapiDocument.php @@ -0,0 +1,201 @@ +_mime = 'application/vnd.api+json'; + $this->_type = 'jsonapi'; + + if (array_key_exists('api_document', $options) && $options['api_document'] instanceof Document) + { + $this->document = $options['api_document']; + } + else + { + $this->document = new Document; + } + } + + /** + * Set the data object. + * + * @param ElementInterface $element Element interface. + * + * @return $this + * + * @since __DEPLOY_VERSION__ + */ + public function setData(ElementInterface $element) + { + $this->document->setData($element); + + return $this; + } + + /** + * Set the errors array. + * + * @param array $errors Error array. + * + * @return $this + * + * @since __DEPLOY_VERSION__ + */ + public function setErrors($errors) + { + $this->document->setErrors($errors); + + return $this; + } + + /** + * Set the JSON-API array. + * + * @param array $jsonapi JSON-API array. + * + * @return $this + * + * @since __DEPLOY_VERSION__ + */ + public function setJsonapi($jsonapi) + { + $this->setJsonapi($jsonapi); + + return $this; + } + + /** + * Map everything to arrays. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function toArray() + { + return $this->document->toArray(); + } + + /** + * Map to string. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function __toString() + { + return json_encode($this->toArray()); + } + + /** + * Outputs the document. + * + * @param boolean $cache If true, cache the output. + * @param array $params Associative array of attributes. + * + * @return string The rendered data. + * + * @since __DEPLOY_VERSION__ + */ + public function render($cache = false, $params = array()) + { + $app = Factory::getApplication(); + + if ($mdate = $this->getModifiedDate()) + { + $app->modifiedDate = $mdate; + } + + $app->mimeType = $this->_mime; + $app->charSet = $this->_charset; + + return json_encode($this->document); + } + + /** + * Serialize for JSON usage. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * Add a link to the output. + * + * @param string $key The name of the link + * @param string $value The link + * + * @return $this + * + * @since __DEPLOY_VERSION__ + */ + public function addLink($key, $value) + { + $this->document->addLink($key, $value); + + return $this; + } + + /** + * Add a link to the output. + * + * @param string $key The name of the metadata key + * @param string $value The value + * + * @return $this + * + * @since __DEPLOY_VERSION__ + */ + public function addMeta($key, $value) + { + $this->document->addMeta($key, $value); + + return $this; + } +} diff --git a/libraries/src/Error/JsonApi/AuthenticationFailedExceptionHandler.php b/libraries/src/Error/JsonApi/AuthenticationFailedExceptionHandler.php new file mode 100644 index 0000000000000..f97663901278d --- /dev/null +++ b/libraries/src/Error/JsonApi/AuthenticationFailedExceptionHandler.php @@ -0,0 +1,63 @@ + 'Forbidden']; + + $code = $e->getCode(); + + if ($code) + { + $error['code'] = $code; + } + + return new ResponseBag($status, [$error]); + } +} diff --git a/libraries/src/Error/JsonApi/InvalidRouteExceptionHandler.php b/libraries/src/Error/JsonApi/InvalidRouteExceptionHandler.php new file mode 100644 index 0000000000000..bba120e12d19b --- /dev/null +++ b/libraries/src/Error/JsonApi/InvalidRouteExceptionHandler.php @@ -0,0 +1,63 @@ + 'Resource not found']; + + $code = $e->getCode(); + + if ($code) + { + $error['code'] = $code; + } + + return new ResponseBag($status, [$error]); + } +} diff --git a/libraries/src/Error/JsonApi/NotAcceptableExceptionHandler.php b/libraries/src/Error/JsonApi/NotAcceptableExceptionHandler.php new file mode 100644 index 0000000000000..0c5b73c369d2f --- /dev/null +++ b/libraries/src/Error/JsonApi/NotAcceptableExceptionHandler.php @@ -0,0 +1,63 @@ + 'Not Acceptable']; + + $code = $e->getCode(); + + if ($code) + { + $error['code'] = $code; + } + + return new ResponseBag($status, [$error]); + } +} diff --git a/libraries/src/Error/JsonApi/NotAllowedExceptionHandler.php b/libraries/src/Error/JsonApi/NotAllowedExceptionHandler.php new file mode 100644 index 0000000000000..bbcb0bd681a44 --- /dev/null +++ b/libraries/src/Error/JsonApi/NotAllowedExceptionHandler.php @@ -0,0 +1,63 @@ + 'Access Denied']; + + $code = $e->getCode(); + + if ($code) + { + $error['code'] = $code; + } + + return new ResponseBag($status, [$error]); + } +} diff --git a/libraries/src/Error/JsonApi/ResourceNotFoundExceptionHandler.php b/libraries/src/Error/JsonApi/ResourceNotFoundExceptionHandler.php new file mode 100644 index 0000000000000..5dad89c7413e5 --- /dev/null +++ b/libraries/src/Error/JsonApi/ResourceNotFoundExceptionHandler.php @@ -0,0 +1,61 @@ + 'Resource not found']; + + $code = $e->getCode(); + + if ($code) + { + $error['code'] = $code; + } + + return new ResponseBag($status, [$error]); + } +} diff --git a/libraries/src/Error/Renderer/JsonapiRenderer.php b/libraries/src/Error/Renderer/JsonapiRenderer.php new file mode 100644 index 0000000000000..f6481a983f25a --- /dev/null +++ b/libraries/src/Error/Renderer/JsonapiRenderer.php @@ -0,0 +1,87 @@ +registerHandler(new InvalidRouteExceptionHandler); + $errors->registerHandler(new AuthenticationFailedExceptionHandler); + $errors->registerHandler(new NotAcceptableExceptionHandler); + $errors->registerHandler(new NotAllowedExceptionHandler); + $errors->registerHandler(new InvalidParameterExceptionHandler); + $errors->registerHandler(new ResourceNotFoundExceptionHandler); + $errors->registerHandler(new FallbackExceptionHandler(JDEBUG)); + + $response = $errors->handle($error); + } + else + { + $code = 500; + $error = ['code' => $code, 'title' => 'Internal server error']; + + if (JDEBUG) + { + $error['detail'] = (string) $error; + } + + $response = new ResponseBag($code, $error); + } + + $this->getDocument()->setErrors($response->getErrors()); + Factory::getApplication()->setHeader('status', $response->getStatus()); + + if (ob_get_contents()) + { + ob_end_clean(); + } + + return $this->getDocument()->render(); + } +} diff --git a/libraries/src/Extension/Service/Provider/MVCFactory.php b/libraries/src/Extension/Service/Provider/MVCFactory.php index dd0a91c456453..5c976b020c144 100644 --- a/libraries/src/Extension/Service/Provider/MVCFactory.php +++ b/libraries/src/Extension/Service/Provider/MVCFactory.php @@ -11,6 +11,7 @@ defined('JPATH_PLATFORM') or die; use Joomla\CMS\Form\FormFactoryInterface; +use Joomla\CMS\MVC\Factory\ApiMVCFactory; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; @@ -58,7 +59,15 @@ public function register(Container $container) MVCFactoryInterface::class, function (Container $container) { - $factory = new \Joomla\CMS\MVC\Factory\MVCFactory($this->namespace); + if (\Joomla\CMS\Factory::getApplication()->isClient('api')) + { + $factory = new ApiMVCFactory($this->namespace); + } + else + { + $factory = new \Joomla\CMS\MVC\Factory\MVCFactory($this->namespace); + } + $factory->setFormFactory($container->get(FormFactoryInterface::class)); return $factory; diff --git a/libraries/src/Factory.php b/libraries/src/Factory.php index d85c9ba75a924..d96b343ecc1da 100644 --- a/libraries/src/Factory.php +++ b/libraries/src/Factory.php @@ -559,7 +559,8 @@ protected static function createContainer(): Container ->registerServiceProvider(new \Joomla\CMS\Service\Provider\HTMLRegistry) ->registerServiceProvider(new \Joomla\CMS\Service\Provider\Session) ->registerServiceProvider(new \Joomla\CMS\Service\Provider\Toolbar) - ->registerServiceProvider(new \Joomla\CMS\Service\Provider\WebAsset); + ->registerServiceProvider(new \Joomla\CMS\Service\Provider\WebAsset) + ->registerServiceProvider(new \Joomla\CMS\Service\Provider\ApiRouter); return $container; } diff --git a/libraries/src/Installer/Adapter/ComponentAdapter.php b/libraries/src/Installer/Adapter/ComponentAdapter.php index 9f4c8232231e5..c23faf40ec5f8 100644 --- a/libraries/src/Installer/Adapter/ComponentAdapter.php +++ b/libraries/src/Installer/Adapter/ComponentAdapter.php @@ -42,6 +42,17 @@ class ComponentAdapter extends InstallerAdapter * */ protected $oldAdminFiles = null; + /** + * The list of current files fo the Joomla! CMS API that are installed and is read + * from the manifest on disk in the update area to handle doing a diff + * and deleting files that are in the old files list and not in the new + * files list. + * + * @var array + * @since __DEPLOY_VERSION__ + * */ + protected $oldApiFiles = null; + /** * The list of current files that are installed and is read * from the manifest on disk in the update area to handle doing a diff @@ -85,7 +96,9 @@ protected function checkExtensionInFilesystem() * If the component site or admin directory already exists, then we will assume that the component is already * installed or another component is using that directory. */ - if (file_exists($this->parent->getPath('extension_site')) || file_exists($this->parent->getPath('extension_administrator'))) + if (file_exists($this->parent->getPath('extension_site')) + || file_exists($this->parent->getPath('extension_administrator')) + || file_exists($this->parent->getPath('extension_api'))) { // Look for an update function or update tag $updateElement = $this->getManifest()->update; @@ -111,11 +124,22 @@ protected function checkExtensionInFilesystem() ); } - // If the admin exists say so + if (file_exists($this->parent->getPath('extension_administrator'))) + { + // If the admin exists say so + throw new \RuntimeException( + Text::sprintf( + 'JLIB_INSTALLER_ERROR_COMP_INSTALL_DIR_ADMIN', + $this->parent->getPath('extension_administrator') + ) + ); + } + + // If the API exists say so throw new \RuntimeException( Text::sprintf( - 'JLIB_INSTALLER_ERROR_COMP_INSTALL_DIR_ADMIN', - $this->parent->getPath('extension_administrator') + 'JLIB_INSTALLER_ERROR_COMP_INSTALL_DIR_API', + $this->parent->getPath('extension_api') ) ); } @@ -180,6 +204,29 @@ protected function copyBaseFiles() } } + // Copy API files + if ($this->getManifest()->api->files) + { + if ($this->route === 'update') + { + $result = $this->parent->parseFiles($this->getManifest()->api->files, 1, $this->oldApiFiles); + } + else + { + $result = $this->parent->parseFiles($this->getManifest()->api->files, 1); + } + + if ($result === false) + { + throw new \RuntimeException( + Text::sprintf( + 'JLIB_INSTALLER_ABORT_COMP_FAIL_API_FILES', + Text::_('JLIB_INSTALLER_' . strtoupper($this->route)) + ) + ); + } + } + // If there is a manifest script, let's copy it. if ($this->manifest_script) { @@ -253,7 +300,7 @@ protected function createExtensionRoot() Text::sprintf( 'JLIB_INSTALLER_ERROR_COMP_FAILED_TO_CREATE_DIRECTORY', Text::_('JLIB_INSTALLER_' . strtoupper($this->route)), - $this->parent->getPath('extension_site') + $this->parent->getPath('extension_administrator') ) ); } @@ -272,6 +319,37 @@ protected function createExtensionRoot() ) ); } + + // If the component API directory does not exist, let's create it + $created = false; + + if (!file_exists($this->parent->getPath('extension_api'))) + { + if (!$created = Folder::create($this->parent->getPath('extension_api'))) + { + throw new \RuntimeException( + Text::sprintf( + 'JLIB_INSTALLER_ERROR_COMP_FAILED_TO_CREATE_DIRECTORY', + Text::_('JLIB_INSTALLER_' . strtoupper($this->route)), + $this->parent->getPath('extension_api') + ) + ); + } + } + + /* + * Since we created the component API directory and we will want to remove it if we have to roll + * back the installation, let's add it to the installation step stack + */ + if ($created) + { + $this->parent->pushStep( + array( + 'type' => 'folder', + 'path' => $this->parent->getPath('extension_api'), + ) + ); + } } /** @@ -443,6 +521,16 @@ protected function finaliseUninstall(): bool } } + // Delete the component API directory + if (is_dir($this->parent->getPath('extension_api'))) + { + if (!Folder::delete($this->parent->getPath('extension_api'))) + { + Log::add(Text::_('JLIB_INSTALLER_ERROR_COMP_UNINSTALL_FAILED_REMOVE_DIRECTORY_API'), Log::WARNING, 'jerror'); + $retval = false; + } + } + // Now we will no longer need the extension object, so let's delete it $this->extension->delete($this->extension->extension_id); @@ -488,7 +576,33 @@ public function getElement($element = null) public function loadLanguage($path = null) { $source = $this->parent->getPath('source'); - $client = $this->parent->extension->client_id ? JPATH_ADMINISTRATOR : JPATH_SITE; + + switch ($this->parent->extension->client_id) + { + case 0: + $client = JPATH_SITE; + + break; + + case 1: + $client = JPATH_ADMINISTRATOR; + + break; + + case 3: + $client = JPATH_API; + + break; + + default: + throw new \InvalidArgumentException( + sprintf( + 'Unsupported client ID %d for component %s', + $this->parent->extension->client_id, + $this->parent->extension->element + ) + ); + } if (!$source) { @@ -502,6 +616,10 @@ public function loadLanguage($path = null) { $element = $this->getManifest()->administration->files; } + elseif ($this->getManifest()->api->files) + { + $element = $this->getManifest()->api->files; + } elseif ($this->getManifest()->files) { $element = $this->getManifest()->files; @@ -672,6 +790,7 @@ protected function setupInstallPaths() // Set the installation target paths $this->parent->setPath('extension_site', Path::clean(JPATH_SITE . '/components/' . $this->element)); $this->parent->setPath('extension_administrator', Path::clean(JPATH_ADMINISTRATOR . '/components/' . $this->element)); + $this->parent->setPath('extension_api', Path::clean(JPATH_API . '/components/' . $this->element)); // Copy the admin path as it's used as a common base $this->parent->setPath('extension_root', $this->parent->getPath('extension_administrator')); @@ -694,6 +813,7 @@ protected function setupUninstall() { // Get the admin and site paths for the component $this->parent->setPath('extension_administrator', Path::clean(JPATH_ADMINISTRATOR . '/components/' . $this->extension->element)); + $this->parent->setPath('extension_api', Path::clean(JPATH_API . '/components/' . $this->extension->element)); $this->parent->setPath('extension_site', Path::clean(JPATH_SITE . '/components/' . $this->extension->element)); // Copy the admin path as it's used as a common base @@ -711,6 +831,7 @@ protected function setupUninstall() { // Make sure we delete the folders if no manifest exists Folder::delete($this->parent->getPath('extension_administrator')); + Folder::delete($this->parent->getPath('extension_api')); Folder::delete($this->parent->getPath('extension_site')); // Remove the menu @@ -758,6 +879,7 @@ protected function setupUpdates() if ($old_manifest) { $this->oldAdminFiles = $old_manifest->administration->files; + $this->oldApiFiles = $old_manifest->api->files; $this->oldFiles = $old_manifest->files; } } @@ -1209,6 +1331,7 @@ public function discover() $results = array(); $site_components = Folder::folders(JPATH_SITE . '/components'); $admin_components = Folder::folders(JPATH_ADMINISTRATOR . '/components'); + $api_components = Folder::folders(JPATH_API . '/components'); foreach ($site_components as $component) { @@ -1251,6 +1374,26 @@ public function discover() } } + foreach ($api_components as $component) + { + if (file_exists(JPATH_API . '/components/' . $component . '/' . str_replace('com_', '', $component) . '.xml')) + { + $manifest_details = Installer::parseXMLInstallFile( + JPATH_API . '/components/' . $component . '/' . str_replace('com_', '', $component) . '.xml' + ); + $extension = Table::getInstance('extension'); + $extension->set('type', 'component'); + $extension->set('client_id', 3); + $extension->set('element', $component); + $extension->set('folder', ''); + $extension->set('name', $component); + $extension->set('state', -1); + $extension->set('manifest_cache', json_encode($manifest_details)); + $extension->set('params', '{}'); + $results[] = $extension; + } + } + return $results; } diff --git a/libraries/src/MVC/Controller/ApiController.php b/libraries/src/MVC/Controller/ApiController.php new file mode 100644 index 0000000000000..16fee1ef7d1d0 --- /dev/null +++ b/libraries/src/MVC/Controller/ApiController.php @@ -0,0 +1,507 @@ +option)) + { + $this->option = ComponentHelper::getComponentName($this, $this->getName()); + } + + // Guess the \JText message prefix. Defaults to the option. + if (empty($this->text_prefix)) + { + $this->text_prefix = strtoupper($this->option); + } + + // Guess the context as the suffix, eg: OptionControllerContent. + if (empty($this->context)) + { + $r = null; + + if (!preg_match('/(.*)Controller(.*)/i', get_class($this), $r)) + { + throw new \Exception(Text::_('JLIB_APPLICATION_ERROR_CONTROLLER_GET_NAME'), 500); + } + + $this->context = str_replace('\\', '', strtolower($r[2])); + } + } + + /** + * Basic display of an item view + * + * @param integer $id The primary key to display. Leave empty if you want to retrieve data from the request + * + * @return static A \JControllerLegacy object to support chaining. + * + * @since __DEPLOY_VERSION__ + */ + public function displayItem($id = null) + { + if ($id === null) + { + $id = $this->input->get('id', 0, 'int'); + } + + $viewType = $this->app->getDocument()->getType(); + $viewName = $this->input->get('view', $this->contentType); + $viewLayout = $this->input->get('layout', 'default', 'string'); + + try + { + /** @var JsonApiView $view */ + $view = $this->getView($viewName, $viewType, '', ['base_path' => $this->basePath, 'layout' => $viewLayout, 'contentType' => $this->contentType]); + } + catch (\Exception $e) + { + return $this; + } + + // Create the model, ignoring request data so we can safely set the state in the request, without it being + // reinitialised on the first getState call + $model = $this->getModel('', '', ['ignore_request' => true]); + + if (!$model) + { + throw new \RuntimeException('Unable to create the model'); + } + + try + { + $modelName = $model->getName(); + } + catch (\Exception $e) + { + return $this; + } + + $model->setState($modelName . '.id', $id); + + // Push the model into the view (as default) + $view->setModel($model, true); + + $view->document = $this->app->getDocument(); + $view->displayItem(); + + return $this; + } + + /** + * Basic display of a list view + * + * @return static A \JControllerLegacy object to support chaining. + * + * @since __DEPLOY_VERSION__ + */ + public function displayList() + { + // Assemble pagination information (using recommended JsonApi pagination notation for offset strategy) + $paginationInfo = $this->input->get('page', [], 'array'); + $internalPaginationMapping = []; + + if (array_key_exists('offset', $paginationInfo)) + { + $this->input->set('limitstart', $paginationInfo['offset']); + } + + if (array_key_exists('limit', $paginationInfo)) + { + $internalPaginationMapping['limit'] = $paginationInfo['limit']; + } + + $this->input->set('list', $internalPaginationMapping); + + $viewType = $this->app->getDocument()->getType(); + $viewName = $this->input->get('view', $this->contentType); + $viewLayout = $this->input->get('layout', 'default', 'string'); + + try + { + /** @var JsonApiView $view */ + $view = $this->getView($viewName, $viewType, '', ['base_path' => $this->basePath, 'layout' => $viewLayout, 'contentType' => $this->contentType]); + } + catch (\Exception $e) + { + return $this; + } + + /** @var ListModel $model */ + $model = $this->getModel($this->contentType); + + if (!$model) + { + throw new \RuntimeException('Model failed to be created', 500); + } + + // Push the model into the view (as default) + $view->setModel($model, true); + + /** + * Sanity check we don't have too much data being requested as regularly in html we automatically set it back to + * the last page of data. If there isn't a limit start then set + */ + if (!$this->input->getInt('limit', null)) + { + $model->setState('list.limit', $this->itemsPerPage); + } + + if ($this->input->getInt('limitstart', 0) > $model->getTotal()) + { + throw new Exception\ResourceNotFound; + } + + $view->document = $this->app->getDocument(); + + $view->displayList(); + + return $this; + } + + /** + * Removes an item. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function delete() + { + if (!$this->app->getIdentity()->authorise('core.delete', $this->option)) + { + throw new NotAllowed('JLIB_APPLICATION_ERROR_DELETE_NOT_PERMITTED', 403); + } + + $id = $this->input->get('id', 0, 'int'); + + /** @var \Joomla\CMS\MVC\Model\AdminModel $model */ + $model = $this->getModel(); + + // Remove the item. + if (!$model->delete($id)) + { + throw new \RuntimeException($model->getError(), 500); + } + + $this->app->setHeader('status', 204); + } + + /** + * Method to add a new record. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * @throws NotAllowed + * @throws \RuntimeException + */ + public function add() + { + // Access check. + if (!$this->allowAdd()) + { + throw new NotAllowed('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED', 403); + } + else + { + $success = $this->save(); + + if (!$success) + { + throw new \RuntimeException($this->message); + } + + $this->displayItem($success); + } + } + + /** + * Method to edit an existing record. + * + * @return boolean True if save succeeded after access level check and checkout passes, false otherwise. + * + * @since __DEPLOY_VERSION__ + */ + public function edit() + { + /** @var \Joomla\CMS\MVC\Model\AdminModel $model */ + $model = $this->getModel(); + + try + { + $table = $model->getTable(); + } + catch (\Exception $e) + { + $this->setMessage($e->getMessage()); + + return false; + } + + $recordId = $this->input->getInt('id'); + + if (!$recordId) + { + // TODO: Lang string for exception + throw new Exception\ResourceNotFound('Record does not exist', 404); + } + + $key = $table->getKeyName(); + $checkin = property_exists($table, $table->getColumnAlias('checked_out')); + + // Access check. + if (!$this->allowEdit(array($key => $recordId), $key)) + { + throw new NotAllowed('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED', 403); + } + + // Attempt to check-out the new record for editing and redirect. + if ($checkin && !$model->checkout($recordId)) + { + // Check-out failed, display a notice but allow the user to see the record. + $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_CHECKOUT_FAILED', $model->getError()), 'error'); + + return false; + } + + if (!$this->save($recordId)) + { + throw new \RuntimeException($this->message); + } + + return true; + } + + /** + * Method to save a record. + * + * @param int $recordKey The primary key of the item (if exists) + * + * @return int|boolean The record ID on success, false on failure + * + * @since __DEPLOY_VERSION__ + */ + protected function save($recordKey = null) + { + /** @var \Joomla\CMS\MVC\Model\AdminModel $model */ + $model = $this->getModel(); + + try + { + $table = $model->getTable(); + } + catch (\Exception $e) + { + $this->setMessage($e->getMessage()); + + return false; + } + + $key = $table->getKeyName(); + $data = json_decode($this->input->json->getRaw(), true); + $checkin = property_exists($table, $table->getColumnAlias('checked_out')); + $data[$key] = $recordKey; + + // TODO: Not the cleanest thing ever but it works... + Form::addFormPath(JPATH_COMPONENT_ADMINISTRATOR . '/forms'); + + // Validate the posted data. + $form = $model->getForm($data, false); + + if (!$form) + { + $this->setMessage($model->getError(), 'error'); + + return false; + } + + // Test whether the data is valid. + $validData = $model->validate($form, $data); + + // Check for validation errors. + if ($validData === false) + { + // Get the validation messages. + $errors = $model->getErrors(); + + // Push up to three validation messages out to the user. + for ($i = 0, $n = count($errors); $i < $n && $i < 3; $i++) + { + if ($errors[$i] instanceof \Exception) + { + $this->setMessage($errors[$i]->getMessage(), 'warning'); + } + else + { + $this->setMessage($errors[$i], 'warning'); + } + } + + return false; + } + + if (!isset($validData['tags'])) + { + $validData['tags'] = array(); + } + + // Attempt to save the data. + if (!$model->save($validData)) + { + $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_SAVE_FAILED', $model->getError()), 'error'); + + return false; + } + + try + { + $modelName = $model->getName(); + } + catch (\Exception $e) + { + $this->setMessage($e->getMessage()); + + return false; + } + + // Ensure we have the record ID in case we created a new article + $recordId = $model->getState($modelName . '.id'); + + if ($recordId === null) + { + $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_CHECKIN_FAILED', $model->getError()), 'error'); + + return false; + } + + // Save succeeded, so check-in the record. + if ($checkin && $model->checkin($recordId) === false) + { + // Check-in failed, so go back to the record and display a notice. + $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_CHECKIN_FAILED', $model->getError()), 'error'); + + return false; + } + + return $recordId; + } + + /** + * Method to check if you can edit an existing record. + * + * Extended classes can override this if necessary. + * + * @param array $data An array of input data. + * @param string $key The name of the key for the primary key; default is id. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + protected function allowEdit($data = array(), $key = 'id') + { + return $this->app->getIdentity()->authorise('core.edit', $this->option); + } + + /** + * Method to check if you can add a new record. + * + * Extended classes can override this if necessary. + * + * @param array $data An array of input data. + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + protected function allowAdd($data = array()) + { + $user = $this->app->getIdentity(); + + return $user->authorise('core.create', $this->option) || count($user->getAuthorisedCategories($this->option, 'core.create')); + } +} diff --git a/libraries/src/MVC/Controller/BaseController.php b/libraries/src/MVC/Controller/BaseController.php index 1cc8cd1e85e2e..8b7104805703b 100644 --- a/libraries/src/MVC/Controller/BaseController.php +++ b/libraries/src/MVC/Controller/BaseController.php @@ -763,6 +763,13 @@ public function getModel($name = '', $prefix = '', $config = array()) // Task is a reserved state $model->setState('task', $this->task); + // We don't have the concept on a menu tree in the api app, so skip setting it's information and + // return early + if ($this->app->isClient('api')) + { + return $model; + } + // Let's get the application object and set menu information if it's available $menu = Factory::getApplication()->getMenu(); diff --git a/libraries/src/MVC/Controller/Exception/ResourceNotFound.php b/libraries/src/MVC/Controller/Exception/ResourceNotFound.php new file mode 100644 index 0000000000000..c6273d81f4750 --- /dev/null +++ b/libraries/src/MVC/Controller/Exception/ResourceNotFound.php @@ -0,0 +1,20 @@ +getState('list.limit') - (int) $this->getState('list.links'); // Create the pagination object and add the object to the internal cache. - $this->cache[$store] = new \JPagination($this->getTotal(), $this->getStart(), $limit); + $this->cache[$store] = new Pagination($this->getTotal(), $this->getStart(), $limit); return $this->cache[$store]; } @@ -337,7 +340,7 @@ public function getStart() * @param array $data data * @param boolean $loadData load current data * - * @return \JForm|boolean The \JForm object or false on error + * @return Form|boolean The \JForm object or false on error * * @since 3.2 */ @@ -374,7 +377,7 @@ public function getFilterForm($data = array(), $loadData = true) * @param boolean $clear Optional argument to force load a new form. * @param string|boolean $xpath An optional xpath to search for the fields. * - * @return \JForm|boolean \JForm object on success, False on error. + * @return Form|boolean \JForm object on success, False on error. * * @see \JForm * @since 3.2 @@ -394,13 +397,13 @@ protected function loadForm($name, $source = null, $options = array(), $clear = } // Get the form. - \JForm::addFormPath(JPATH_COMPONENT . '/forms'); - \JForm::addFormPath(JPATH_COMPONENT . '/models/forms'); - \JForm::addFieldPath(JPATH_COMPONENT . '/models/fields'); + Form::addFormPath(JPATH_COMPONENT . '/forms'); + Form::addFormPath(JPATH_COMPONENT . '/models/forms'); + Form::addFieldPath(JPATH_COMPONENT . '/models/fields'); try { - $form = \JForm::getInstance($name, $source, $options, false, $xpath); + $form = Form::getInstance($name, $source, $options, false, $xpath); if (isset($options['load_data']) && $options['load_data']) { @@ -650,7 +653,7 @@ protected function populateState($ordering = null, $direction = null) /** * Method to allow derived classes to preprocess the form. * - * @param \JForm $form A \JForm object. + * @param Form $form A \JForm object. * @param mixed $data The data expected for the form. * @param string $group The name of the plugin group to import (defaults to "content"). * @@ -659,7 +662,7 @@ protected function populateState($ordering = null, $direction = null) * @since 3.2 * @throws \Exception if there is an error in the form event. */ - protected function preprocessForm(\JForm $form, $data, $group = 'content') + protected function preprocessForm(Form $form, $data, $group = 'content') { // Import the appropriate plugin group. \JPluginHelper::importPlugin($group); diff --git a/libraries/src/MVC/View/JsonApiView.php b/libraries/src/MVC/View/JsonApiView.php new file mode 100644 index 0000000000000..2da46f6c5882b --- /dev/null +++ b/libraries/src/MVC/View/JsonApiView.php @@ -0,0 +1,176 @@ +type = $config['contentType']; + } + + parent::__construct($config); + } + + /** + * Execute and display a template script. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function displayList() + { + /** @var \Joomla\CMS\MVC\Model\ListModel $model */ + $model = $this->getModel(); + + $items = $model->getItems(); + $pagination = $model->getPagination(); + + // Check for errors. + if (count($errors = $this->get('Errors'))) + { + throw new \JViewGenericdataexception(implode("\n", $errors), 500); + } + + if ($this->type === null) + { + throw new \RuntimeException('Content type missing'); + } + + // Set up links for pagination + $currentUrl = Uri::getInstance(); + $currentPageDefaultInformation = array('offset' => $pagination->limitstart, 'limit' => $pagination->limit); + $currentPageQuery = $currentUrl->getVar('page', $currentPageDefaultInformation); + $totalPagesAvailable = ($pagination->pagesTotal * $pagination->limit); + + $firstPage = clone $currentUrl; + $firstPageQuery = $currentPageQuery; + $firstPageQuery['offset'] = 0; + $firstPage->setVar('page', $firstPageQuery); + + $nextPage = clone $currentUrl; + $nextPageQuery = $currentPageQuery; + $nextOffset = $currentPageQuery['offset'] + $pagination->limit; + $nextPageQuery['offset'] = ($nextOffset > ($totalPagesAvailable * $pagination->limit)) ? $totalPagesAvailable - $pagination->limit : $nextOffset; + $nextPage->setVar('page', $nextPageQuery); + + $previousPage = clone $currentUrl; + $previousPageQuery = $currentPageQuery; + $previousOffset = $currentPageQuery['offset'] - $pagination->limit; + $previousPageQuery['offset'] = $previousOffset >= 0 ? $previousOffset : 0; + $previousPage->setVar('page', $previousPageQuery); + + $lastPage = clone $currentUrl; + $lastPageQuery = $currentPageQuery; + $lastPageQuery['offset'] = $totalPagesAvailable - $pagination->limit; + $lastPage->setVar('page', $lastPageQuery); + + $collection = (new Collection($items, new JoomlaSerializer($this->type))) + ->fields([$this->type => $this->fieldsToRender]); + + // Set the data into the document and render it + $this->document->addMeta('total-pages', $pagination->pagesTotal) + ->setData($collection) + ->addLink('self', (string) $currentUrl) + ->addLink('first', (string) $firstPage) + ->addLink('next', (string) $nextPage) + ->addLink('previous', (string) $previousPage) + ->addLink('last', (string) $lastPage); + + return $this->document->render(); + } + + /** + * Execute and display a template script. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function displayItem() + { + /** @var \Joomla\CMS\MVC\Model\AdminModel $model */ + $model = $this->getModel(); + $item = $model->getItem(); + + if ($item->id === null) + { + throw new RouteNotFoundException('Item does not exist'); + } + + // Check for errors. + if (count($errors = $this->get('Errors'))) + { + throw new \JViewGenericdataexception(implode("\n", $errors), 500); + } + + if ($this->type === null) + { + throw new \RuntimeException('Content type missing'); + } + + $serializer = new JoomlaSerializer($this->type); + $element = (new Resource($item, $serializer)) + ->fields([$this->type => $this->fieldsToRender]); + + $this->document->setData($element); + $this->document->addLink('self', Uri::current()); + + return $this->document->render(); + } +} diff --git a/libraries/src/MVC/View/JsonView.php b/libraries/src/MVC/View/JsonView.php new file mode 100644 index 0000000000000..bfd24284788d1 --- /dev/null +++ b/libraries/src/MVC/View/JsonView.php @@ -0,0 +1,99 @@ +_charset = $config['charset']; + } + + // Set a base path for use by the view + if (array_key_exists('base_path', $config)) + { + $this->_basePath = $config['base_path']; + } + else + { + $this->_basePath = JPATH_COMPONENT; + } + } + + /** + * Execute and display a template script. + * + * @param string $tpl The name of the template file to parse; automatically searches through the template paths. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function display($tpl = null) + { + // Serializing the output + $result = json_encode($this->_output); + + // Pushing output to the document + $this->document->setBuffer($result); + } +} diff --git a/libraries/src/Router/ApiRouter.php b/libraries/src/Router/ApiRouter.php new file mode 100644 index 0000000000000..c158cc0e0f931 --- /dev/null +++ b/libraries/src/Router/ApiRouter.php @@ -0,0 +1,161 @@ +app = $app; + + parent::__construct($maps); + } + + /** + * Creates routes map for CRUD + * + * @param string $baseName The base name of the component. + * @param string $controller The name of the controller that contains CRUD functions. + * @param array $defaults An array of default values that are used when the URL is matched. + * @param bool $publicGets Allow the public to make GET requests. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function createCRUDRoutes($baseName, $controller, $defaults = array(), $publicGets = false) + { + $getDefaults = array_merge(array('public' => $publicGets), $defaults); + + $routes = array( + new Route(['GET'], $baseName, $controller . '.displayList', [], $getDefaults), + new Route(['GET'], $baseName . '/:id', $controller . '.displayItem', ['id' => '(\d+)'], $getDefaults), + new Route(['POST'], $baseName, $controller . '.add', [], $defaults), + new Route(['PUT'], $baseName . '/:id', $controller . '.edit', ['id' => '(\d+)'], $defaults), + new Route(['DELETE'], $baseName . '/:id', $controller . '.delete', ['id' => '(\d+)'], $defaults), + ); + + $this->addRoutes($routes); + } + + /** + * Parse the given route and return the name of a controller mapped to the given route. + * + * @param string $method Request method to match. One of GET, POST, PUT, DELETE, HEAD, OPTIONS, TRACE or PATCH + * + * @return array An array containing the controller and the matched variables. + * + * @since __DEPLOY_VERSION__ + * @throws \InvalidArgumentException + */ + public function parseApiRoute($method = 'GET') + { + $method = strtoupper($method); + + $validMethods = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "TRACE", "PATCH"]; + + if (!in_array($method, $validMethods)) + { + throw new \InvalidArgumentException(sprintf('%s is not a valid HTTP method.', $method)); + } + + // Get the path from the route and remove and leading or trailing slash. + $uri = \JUri::getInstance(); + $path = urldecode($uri->getPath()); + + /** + * In some environments (e.g. CLI we can't form a valid base URL). In this case we catch the exception thrown + * by URI and set an empty base URI for further work. + * TODO: This should probably be handled better + */ + try + { + $baseUri = \JUri::base(true); + } + catch (\RuntimeException $e) + { + $baseUri = ''; + } + + // Remove the base URI path. + $path = substr_replace($path, '', 0, strlen($baseUri)); + + if (!$this->app->get('sef_rewrite')) + { + // Transform the route + if ($path === 'index.php') + { + $path = ''; + } + else + { + $path = str_replace('index.php/', '', $path); + } + } + + $query = \JUri::getInstance()->getQuery(true); + + // Iterate through all of the known routes looking for a match. + foreach ($this->routes as $route) + { + if (in_array($method, $route->getMethods())) + { + if (preg_match($route->getRegex(), ltrim($path, '/'), $matches)) + { + // If we have gotten this far then we have a positive match. + $vars = $route->getDefaults(); + + foreach ($route->getRouteVariables() as $i => $var) + { + $vars[$var] = $matches[$i + 1]; + } + + $controller = preg_split("/[.]+/", $route->getController()); + $vars = array_merge($vars, $query); + + return [ + 'controller' => $controller[0], + 'task' => $controller[1], + 'vars' => $vars + ]; + } + } + } + + throw new RouteNotFoundException(sprintf('Unable to handle request for route `%s`.', $path)); + } +} diff --git a/libraries/src/Serializer/JoomlaSerializer.php b/libraries/src/Serializer/JoomlaSerializer.php new file mode 100644 index 0000000000000..54bbf51a3b7ba --- /dev/null +++ b/libraries/src/Serializer/JoomlaSerializer.php @@ -0,0 +1,80 @@ +type = $type; + } + + /** + * Get the attributes array. + * + * @param Table|array|\stdClass|CMSobject $post The model + * @param array $fields The fields can be array or null + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getAttributes($post, array $fields = null) + { + if (!($post instanceof Table) && !($post instanceof \stdClass) && !(is_array($post)) + && !($post instanceof CMSObject)) + { + $message = sprintf( + 'Invalid argument for TableSerializer. Expected array or %s. Got %s', + Table::class, + gettype($post) + ); + + throw new \InvalidArgumentException($message); + } + + // The response from a standard ListModel query + if ($post instanceof \stdClass) + { + $post = (array) $post; + } + + // The response from a standard AdminModel query + if ($post instanceof CMSObject) + { + $post = $post->getProperties(); + } + + // TODO: Find a way to make this an instance of TableInterface instead of the concrete class + if ($post instanceof Table) + { + $post = $post->getProperties(); + } + + return is_array($fields) ? array_intersect_key($post, array_flip($fields)) : $post; + } +} diff --git a/libraries/src/Service/Provider/ApiRouter.php b/libraries/src/Service/Provider/ApiRouter.php new file mode 100644 index 0000000000000..c714281f320f9 --- /dev/null +++ b/libraries/src/Service/Provider/ApiRouter.php @@ -0,0 +1,45 @@ +alias('ApiRouter', 'Joomla\CMS\Router\ApiRouter') + ->share( + 'Joomla\CMS\Router\ApiRouter', + function (Container $container) + { + return new \Joomla\CMS\Router\ApiRouter($container->get(SiteApplication::class)); + }, + true + ); + } +} diff --git a/libraries/src/Service/Provider/Application.php b/libraries/src/Service/Provider/Application.php index 11f88eba243a2..a26b7e9d7eaa1 100644 --- a/libraries/src/Service/Provider/Application.php +++ b/libraries/src/Service/Provider/Application.php @@ -12,6 +12,7 @@ defined('JPATH_PLATFORM') or die; use Joomla\CMS\Application\AdministratorApplication; +use Joomla\CMS\Application\ApiApplication; use Joomla\CMS\Application\ConsoleApplication; use Joomla\CMS\Application\SiteApplication; use Joomla\CMS\Console\SessionGcCommand; @@ -125,5 +126,26 @@ function (Container $container) }, true ); + + $container->alias(ApiApplication::class, 'JApplicationApi') + ->share( + 'JApplicationApi', + function (Container $container) { + $app = new ApiApplication(null, null, null, $container); + + // The session service provider needs JFactory::$application, set it if still null + if (Factory::$application === null) + { + Factory::$application = $app; + } + + $app->setDispatcher($container->get('Joomla\Event\DispatcherInterface')); + $app->setLogger($container->get(LoggerInterface::class)); + $app->setSession($container->get('Joomla\Session\SessionInterface')); + + return $app; + }, + true + ); } } diff --git a/plugins/api-authentication/basic/basic.php b/plugins/api-authentication/basic/basic.php new file mode 100644 index 0000000000000..1dd80ae13033d --- /dev/null +++ b/plugins/api-authentication/basic/basic.php @@ -0,0 +1,119 @@ +type = 'Basic'; + + $username = $this->app->input->server->get('PHP_AUTH_USER'); + $password = $this->app->input->server->get('PHP_AUTH_PW'); + + if (empty($password)) + { + $response->status = Authentication::STATUS_FAILURE; + $response->error_message = Text::_('JGLOBAL_AUTH_EMPTY_PASS_NOT_ALLOWED'); + + return; + } + + // Get a database object + $query = $this->db->getQuery(true) + ->select($this->db->quoteName(array('id', 'password'))) + ->from($this->db->quoteName('#__users')) + ->where($this->db->quoteName('username') . '=' . $this->db->quote($username)); + + $this->db->setQuery($query); + $result = $this->db->loadObject(); + + if ($result) + { + $match = UserHelper::verifyPassword($password, $result->password, $result->id); + + if ($match === true) + { + // Bring this in line with the rest of the system + $user = User::getInstance($result->id); + $response->email = $user->email; + $response->fullname = $user->name; + $response->username = $username; + + if ($this->app->isClient('administrator')) + { + $response->language = $user->getParam('admin_language'); + } + + else + { + $response->language = $user->getParam('language'); + } + + $response->status = Authentication::STATUS_SUCCESS; + $response->error_message = ''; + } + else + { + // Invalid password + $response->status = Authentication::STATUS_FAILURE; + $response->error_message = Text::_('JGLOBAL_AUTH_INVALID_PASS'); + } + } + else + { + // Let's hash the entered password even if we don't have a matching user for some extra response time + // By doing so, we mitigate side channel user enumeration attacks + UserHelper::hashPassword($password); + + // Invalid user + $response->status = Authentication::STATUS_FAILURE; + $response->error_message = Text::_('JGLOBAL_AUTH_NO_USER'); + } + } +} diff --git a/plugins/api-authentication/basic/basic.xml b/plugins/api-authentication/basic/basic.xml new file mode 100644 index 0000000000000..775cfb90a28aa --- /dev/null +++ b/plugins/api-authentication/basic/basic.xml @@ -0,0 +1,19 @@ + + + plg_api-authentication_basic + Joomla! Project + November 2005 + Copyright (C) 2005 - 2017 Open Source Matters. All rights reserved. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.0.0 + PLG_AUTH_AUTHENTICATION_BASIC_XML_DESCRIPTION + + basic.php + + + en-GB.plg_authentication_api_basic.ini + en-GB.plg_authentication_api_basic.sys.ini + + diff --git a/plugins/system/debug/debug.php b/plugins/system/debug/debug.php index 354b6818c6be3..e19330443ec4d 100644 --- a/plugins/system/debug/debug.php +++ b/plugins/system/debug/debug.php @@ -196,7 +196,7 @@ public function __construct(&$subject, $config) public function onAfterDispatch() { // Only if debugging or language debug is enabled. - if ((JDEBUG || $this->debugLang) && $this->isAuthorisedDisplayDebug()) + if ((JDEBUG || $this->debugLang) && $this->isAuthorisedDisplayDebug() && strtolower($this->app->getDocument()->getType()) === 'html') { HTMLHelper::_('stylesheet', 'plg_system_debug/debug.css', array('version' => 'auto', 'relative' => true)); HTMLHelper::_('script', 'plg_system_debug/debug.min.js', array('version' => 'auto', 'relative' => true)); @@ -219,7 +219,7 @@ public function onAfterDispatch() public function onAfterRespond() { // Do not render if debugging or language debug is not enabled. - if (!JDEBUG && !$this->debugLang || $this->isAjax) + if (!JDEBUG && !$this->debugLang || $this->isAjax || strtolower($this->app->getDocument()->getType()) !== 'html') { return; } diff --git a/plugins/webservices/content/content.php b/plugins/webservices/content/content.php new file mode 100644 index 0000000000000..dfd867886082b --- /dev/null +++ b/plugins/webservices/content/content.php @@ -0,0 +1,43 @@ +createCRUDRoutes('article', 'article', ['component' => 'com_content']); + } +} diff --git a/plugins/webservices/content/content.xml b/plugins/webservices/content/content.xml new file mode 100644 index 0000000000000..6754aa7698453 --- /dev/null +++ b/plugins/webservices/content/content.xml @@ -0,0 +1,19 @@ + + + plg_webservices_content + Joomla! Project + August 2017 + (C) 2005 - 2017 Open Source Matters. All rights reserved. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.0.0 + PLG_WEBSERVICES_CONTENT_XML_DESCRIPTION + + content.php + + + language/en-GB/en-GB.plg_webservices_content.ini + language/en-GB/en-GB.plg_webservices_content.sys.ini + +