diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7213d3684ff7c36001d2505fa6714fd3b25de2..028c374785914214db0fead74539fbb9f08e4448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,14 @@ This is a changelog for Piwik platform developers. All changes for our HTTP API' ### New features * New segment `actionType` lets you segment all actions of a given type, eg. `actionType==events` or `actionType==downloads`. Action types values are: `pageviews`, `contents`, `sitesearches`, `events`, `outlinks`, `downloads` -* The JavaScript Tracker method `PiwikTracker.setDomains()` can now handle paths. This means when setting eg `_paq.push(['setDomains, '*.piwik.org/website1'])` all link that goes to the same domain `piwik.org` but to any other path than `website1/*` will be treated as outlink. + * The JavaScript Tracker method `PiwikTracker.setDomains()` can now handle paths. This means when setting eg `_paq.push(['setDomains, '*.piwik.org/website1'])` all link that goes to the same domain `piwik.org` but to any other path than `website1/*` will be treated as outlink. ### Internal change * When generating a new plugin skeleton via `generate:plugin` command, plugin name must now contain only letters and numbers. * JavaScript Tracker tests no longer require `SQLite`. The existing MySQL configuration for tests is used now. In order to run the tests make sure Piwik is installed and `[database_tests]` is configured in `config/config.ini.php`. * The definitions for search engine and social network detection have been moved from bundled data files to a separate package (see [https://github.com/piwik/searchengine-and-social-list](https://github.com/piwik/searchengine-and-social-list)). * In [UI screenshot tests](https://developer.piwik.org/guides/tests-ui), a test environment `configOverride` setting should be no longer overwritten. Instead new values should be added to the existing `configOverride` array in PHP or JavaScript. For example instead of `testEnvironment.configOverride = {group: {name: 1}}` use `testEnvironment.overrideConfig('group', 'name', '1')`. - + ### New APIs * Add your own SMS/Text provider by creating a new class in the `SMSProvider` directory of your plugin. The class has to extend `Piwik\Plugins\MobileMessaging\SMSProvider` and implement the required methods. * Segments can now be composed by a union of multiple segments. To do this set an array of segments that shall be used for that segment `$segment->setUnionOfSegments(array('outlinkUrl', 'downloadUrl'))` instead of defining a SQL column. @@ -21,6 +21,9 @@ This is a changelog for Piwik platform developers. All changes for our HTTP API' ### Deprecations * The method `DB::tableExists` was un-used and has been removed. +### New commands + * New command `config:set` lets you set INI config options from the command line. This command can be used for convenience or for automation. + ## Piwik 2.15.0 ### New commands diff --git a/plugins/CoreAdminHome/Commands/SetConfig.php b/plugins/CoreAdminHome/Commands/SetConfig.php new file mode 100644 index 0000000000000000000000000000000000000000..49b1925fd3fac90a42a8bc0060ae6a668b305f63 --- /dev/null +++ b/plugins/CoreAdminHome/Commands/SetConfig.php @@ -0,0 +1,97 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +namespace Piwik\Plugins\CoreAdminHome\Commands; + +use Piwik\Config; +use Piwik\Plugin\ConsoleCommand; +use Piwik\Plugins\CoreAdminHome\Commands\SetConfig\ConfigSettingManipulation; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class SetConfig extends ConsoleCommand +{ + protected function configure() + { + $this->setName('config:set'); + $this->setDescription('Set one or more config settings in the file config/config.ini.php'); + $this->addArgument('assignment', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + "List of config setting assignments, eg, Section.key=1 or Section.array_key[]=value"); + $this->addOption('section', null, InputOption::VALUE_REQUIRED, 'The section the INI config setting belongs to.'); + $this->addOption('key', null, InputOption::VALUE_REQUIRED, 'The name of the INI config setting.'); + $this->addOption('value', null, InputOption::VALUE_REQUIRED, 'The value of the setting. (Not JSON encoded)'); + $this->setHelp("This command can be used to set INI config settings on a Piwik instance. + +You can set config values two ways, via --section, --key, --value or by command arguments. + +To use --section, --key, --value, simply supply those options. You can only set one setting this way, and you cannot +append to arrays. + +To use arguments, supply one or more arguments in the following format: Section.config_setting_name=\"value\" +'Section' is the name of the section, 'config_setting_name' the name of the setting and 'value' is the value. +NOTE: 'value' must be JSON encoded, so Section.config_setting_name=\"value\" would work but +Section.config_setting_name=value would not. + +To append to an array setting, supply an argument like this: Section.config_setting_name[]=\"value to append\" + +To reset an array setting, supply an argument like this: Section.config_setting_name=[] +Resetting an array will not work if the array has default values in global.ini.php (such as, [log] log_writers). +In this case the values in global.ini.php will be used, since there is no way to explicitly set an +array setting to empty in INI config. + +Use the --piwik-domain option to specify which instance to modify. + +"); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $section = $input->getOption('section'); + $key = $input->getOption('key'); + $value = $input->getOption('value'); + + $manipulations = $this->getAssignments($input); + + $isSingleAssignment = !empty($section) && !empty($key) && $value !== false; + if ($isSingleAssignment) { + $manipulations[] = new ConfigSettingManipulation($section, $key, $value); + } + + if (empty($manipulations)) { + throw new \InvalidArgumentException("Nothing to assign. Add assignments as arguments or use the " + . "--section, --key and --value options."); + } + + $config = Config::getInstance(); + foreach ($manipulations as $manipulation) { + $manipulation->manipulate($config); + + $output->writeln("<info>Setting [{$manipulation->getSectionName()}] {$manipulation->getName()} = {$manipulation->getValueString()}</info>"); + } + + $config->forceSave(); + + $this->writeSuccessMessage($output, array("Done.")); + } + + /** + * @return ConfigSettingManipulation[] + */ + private function getAssignments(InputInterface $input) + { + $assignments = $input->getArgument('assignment'); + + $result = array(); + foreach ($assignments as $assignment) { + $result[] = ConfigSettingManipulation::make($assignment); + } + return $result; + } +} \ No newline at end of file diff --git a/plugins/CoreAdminHome/Commands/SetConfig/ConfigSettingManipulation.php b/plugins/CoreAdminHome/Commands/SetConfig/ConfigSettingManipulation.php new file mode 100644 index 0000000000000000000000000000000000000000..e13fa3baa69ebb7483ba06f300603005c3f0d9ee --- /dev/null +++ b/plugins/CoreAdminHome/Commands/SetConfig/ConfigSettingManipulation.php @@ -0,0 +1,176 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +namespace Piwik\Plugins\CoreAdminHome\Commands\SetConfig; + +use Piwik\Config; + +/** + * Representation of a INI config manipulation operation. Only supports two types + * of manipulations: appending to a config array and assigning a config value. + */ +class ConfigSettingManipulation +{ + /** + * @var string + */ + private $sectionName; + + /** + * @var string + */ + private $name; + + /** + * @var string + */ + private $value; + + /** + * @var bool + */ + private $isArrayAppend; + + /** + * @param string $sectionName + * @param string $name + * @param string $value + * @param bool $isArrayAppend + */ + public function __construct($sectionName, $name, $value, $isArrayAppend = false) + { + $this->sectionName = $sectionName; + $this->name = $name; + $this->value = $value; + $this->isArrayAppend = $isArrayAppend; + } + + /** + * Performs the INI config manipulation. + * + * @param Config $config + * @throws \Exception if trying to append to a non-array setting value or if trying to set an + * array value to a non-array setting + */ + public function manipulate(Config $config) + { + if ($this->isArrayAppend) { + $this->appendToArraySetting($config); + } else { + $this->setSingleConfigValue($config); + } + } + + private function setSingleConfigValue(Config $config) + { + $sectionName = $this->sectionName; + $section = $config->$sectionName; + + if (isset($section[$this->name]) + && is_array($section[$this->name]) + && !is_array($this->value) + ) { + throw new \Exception("Trying to set non-array value to array setting " . $this->getSettingString() . "."); + } + + $section[$this->name] = $this->value; + $config->$sectionName = $section; + } + + private function appendToArraySetting(Config $config) + { + $sectionName = $this->sectionName; + $section = $config->$sectionName; + + if (isset($section[$this->name]) + && !is_array($section[$this->name]) + ) { + throw new \Exception("Trying to append to non-array setting value " . $this->getSettingString() . "."); + } + + $section[$this->name][] = $this->value; + $config->$sectionName = $section; + } + + /** + * Creates a ConfigSettingManipulation instance from a string like: + * + * `SectionName.setting_name=value` + * + * or + * + * `SectionName.setting_name[]=value` + * + * The value must be JSON so `="string"` will work but `=string` will not. + * + * @param string $assignment + * @return self + */ + public static function make($assignment) + { + if (!preg_match('/^([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)(\[\])?=(.*)/', $assignment, $matches)) { + throw new \InvalidArgumentException("Invalid assignment string '$assignment': expected section.name=value or section.name[]=value"); + } + + $section = $matches[1]; + $name = $matches[2]; + $isAppend = !empty($matches[3]); + + $value = json_decode($matches[4], $isAssoc = true); + if ($value === null) { + throw new \InvalidArgumentException("Invalid assignment string '$assignment': could not parse value as JSON"); + } + + return new self($section, $name, $value, $isAppend); + } + + private function getSettingString() + { + return "[{$this->sectionName}] {$this->name}"; + } + + /** + * @return string + */ + public function getSectionName() + { + return $this->sectionName; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * @return boolean + */ + public function isArrayAppend() + { + return $this->isArrayAppend; + } + + /** + * @return string + */ + public function getValueString() + { + return json_encode($this->value); + } +} \ No newline at end of file diff --git a/plugins/CoreAdminHome/tests/Integration/SetConfigTest.php b/plugins/CoreAdminHome/tests/Integration/SetConfigTest.php new file mode 100644 index 0000000000000000000000000000000000000000..bd1ed5e6181daf88f6b360a8f0e8a8451dbc002f --- /dev/null +++ b/plugins/CoreAdminHome/tests/Integration/SetConfigTest.php @@ -0,0 +1,194 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +namespace Piwik\Plugins\CoreAdminHome\tests\Integration\Commands; + +use Interop\Container\ContainerInterface; +use Piwik\Application\Kernel\GlobalSettingsProvider; +use Piwik\Config; +use Piwik\Tests\Framework\TestCase\ConsoleCommandTestCase; +use Piwik\Url; + +/** + * @group CoreAdminHome + * @group CoreAdminHome_Integration + */ +class SetConfigTest extends ConsoleCommandTestCase +{ + const TEST_CONFIG_PATH = '/tmp/test.config.ini.php'; + + public static function setUpBeforeClass() + { + self::removeTestConfigFile(); + + parent::setUpBeforeClass(); + } + + public function setUp() + { + self::removeTestConfigFile(); + + parent::setUp(); + } + + public function test_Command_SucceedsWhenOptionsUsed() + { + $code = $this->applicationTester->run(array( + 'command' => 'config:set', + '--section' => 'MySection', + '--key' => 'setting', + '--value' => 'myvalue', + '-vvv' => false, + )); + + $this->assertEquals(0, $code, $this->getCommandDisplayOutputErrorMessage()); + + $config = $this->makeNewConfig(); + $this->assertEquals(array('setting' => 'myvalue'), $config->MySection); + + $this->assertContains('Setting [MySection] setting = "myvalue"', $this->applicationTester->getDisplay()); + } + + /** + * @dataProvider getInvalidArgumentsForTest + */ + public function test_Command_FailsWhenInvalidArgumentsUsed($invalidArgument) + { + $code = $this->applicationTester->run(array( + 'command' => 'config:set', + 'assignment' => array($invalidArgument), + '-vvv' => false, + )); + + $this->assertNotEquals(0, $code, $this->getCommandDisplayOutputErrorMessage()); + $this->assertContains('Invalid assignment string', $this->applicationTester->getDisplay()); + } + + public function getInvalidArgumentsForTest() + { + return array( + array("garbage"), + array("ab&cd.ghi=23"), + array("section.value = 34"), + array("section.value = notjson"), + array("section.array[0]=23"), + ); + } + + public function test_Command_SucceedsWhenArgumentsUsed() + { + $config = Config::getInstance(); + $config->General['trusted_hosts'] = array('www.trustedhost.com'); + $config->MySection['other_array_value'] = array('1', '2'); + $config->forceSave(); + + $code = $this->applicationTester->run(array( + 'command' => 'config:set', + 'assignment' => array( + 'General.action_url_category_delimiter="+"', + 'General.trusted_hosts[]="www.trustedhost2.com"', + 'MySection.array_value=["abc","def"]', + 'MySection.object_value={"abc":"def"}', + 'MySection.other_array_value=[]', + ), + '-vvv' => false, + )); + + $this->assertEquals(0, $code, $this->getCommandDisplayOutputErrorMessage()); + + $config = self::makeNewConfig(); // create a new config instance so we read what's in the file + + $this->assertEquals('+', $config->General['action_url_category_delimiter']); + $this->assertEquals(array('www.trustedhost.com', 'www.trustedhost2.com'), $config->General['trusted_hosts']); + $this->assertEquals(array('abc', 'def'), $config->MySection['array_value']); + $this->assertEquals(array('def'), $config->MySection['object_value']); + $this->assertArrayNotHasKey('other_array_value', $config->MySection); + + $this->assertContains("Done.", $this->applicationTester->getDisplay()); + } + + /** + * @dataProvider getOptionsForSettingValueToZeroTests + */ + public function test_Command_SucceedsWhenSettingValueToZero($options) + { + $config = Config::getInstance(); + $config->Tracker['debug'] = 1; + $config->forceSave(); + + $code = $this->applicationTester->run($options); + + $this->assertEquals(0, $code, $this->getCommandDisplayOutputErrorMessage()); + + $config = self::makeNewConfig(); + + $this->assertEquals(0, $config->Tracker['debug']); + $this->assertContains("Done.", $this->applicationTester->getDisplay()); + } + + public function getOptionsForSettingValueToZeroTests() + { + return array( + array( + array( + 'command' => 'config:set', + '--section' => 'Tracker', + '--key' => 'debug', + '--value' => 0, + ), + ), + array( + array( + 'command' => 'config:set', + 'assignment' => array( + 'Tracker.debug=0', + ), + ), + ), + ); + } + + private static function getTestConfigFilePath() + { + return PIWIK_INCLUDE_PATH . self::TEST_CONFIG_PATH; + } + + public static function provideContainerConfigBeforeClass() + { + return array( + // use a config instance that will save to a test INI file + 'Piwik\Config' => function (ContainerInterface $c) { + /** @var GlobalSettingsProvider $actualGlobalSettingsProvider */ + $actualGlobalSettingsProvider = $c->get('Piwik\Application\Kernel\GlobalSettingsProvider'); + + $config = SetConfigTest::makeNewConfig(); + + // copy over sections required for tests + $config->tests = $actualGlobalSettingsProvider->getSection('tests'); + $config->database = $actualGlobalSettingsProvider->getSection('database'); + $config->database_tests = $actualGlobalSettingsProvider->getSection('database_tests'); + + return $config; + }, + ); + } + + private static function makeNewConfig() + { + $settings = new GlobalSettingsProvider(null, SetConfigTest::getTestConfigFilePath()); + return new Config($settings); + } + + private static function removeTestConfigFile() + { + $configPath = self::getTestConfigFilePath(); + if (file_exists($configPath)) { + unlink($configPath); + } + } +} diff --git a/plugins/CoreAdminHome/tests/Unit/SetConfig/ConfigSettingManipulationTest.php b/plugins/CoreAdminHome/tests/Unit/SetConfig/ConfigSettingManipulationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..88a5d0c79c72db2fe04db852e49b25a39feb6f67 --- /dev/null +++ b/plugins/CoreAdminHome/tests/Unit/SetConfig/ConfigSettingManipulationTest.php @@ -0,0 +1,165 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +namespace Piwik\Plugins\CoreAdminHome\tests\Unit\Commands\SetConfig; + +use Piwik\Config; +use Piwik\Plugins\CoreAdminHome\Commands\SetConfig\ConfigSettingManipulation; + +// phpunit mocks can't return references, so we need a manual one +class DumbMockConfig extends \Piwik\Config +{ + /** + * @var array + */ + public $mockConfigData; + + public function __construct() + { + // empty + } + + public function &__get($sectionName) + { + if (!isset($this->mockConfigData[$sectionName])) { + $this->mockConfigData[$sectionName] = array(); + } + + $result =& $this->mockConfigData[$sectionName]; + return $result; + } + + public function __set($sectionName, $section) + { + $this->mockConfigData[$sectionName] = $section; + } +} + +/** + * @group CoreAdminHome + * @group CoreAdminHome_Unit + */ +class ConfigSettingManipulationTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var Config + */ + private $mockConfig; + + protected function setUp() + { + $this->mockConfig = new DumbMockConfig(); + $this->mockConfigData = array(); + } + + /** + * @dataProvider getTestDataForMake + */ + public function test_make_CreatesCorrectManipulation($assignmentString, $expectedSectionName, $expectedSettingName, + $expectedSettingValue, $expectedIsArrayAppend) + { + $manipulation = ConfigSettingManipulation::make($assignmentString); + + $this->assertEquals($expectedSectionName, $manipulation->getSectionName()); + $this->assertEquals($expectedSettingName, $manipulation->getName()); + $this->assertEquals($expectedSettingValue, $manipulation->getValue()); + $this->assertEquals($expectedIsArrayAppend, $manipulation->isArrayAppend()); + } + + public function getTestDataForMake() + { + return array( + // normal assign + array("General.myconfig=0", "General", "myconfig", 0, false), + + // array append + array("General.myconfig444[]=5", "General", "myconfig444", 5, true), + + // assign array + array("1General1.2config2=[\"abc\",\"def\"]", "1General1", "2config2", array('abc', 'def'), false), + + // assign string + array("MySection.value=\"ghi\"", "MySection", "value", "ghi", false), + + // assign boolean + array("MySection.value=false", "MySection", "value", false, false), + array("MySection.value=true", "MySection", "value", true, false), + ); + } + + /** + * @dataProvider getFailureTestDataForMake + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Invalid assignment string + */ + public function test_make_ThrowsWhenInvalidAssignmentStringSupplied($assignmentString) + { + ConfigSettingManipulation::make($assignmentString); + } + + public function getFailureTestDataForMake() + { + return array( + array("General&.value=1"), + array("General.val&*ue=12"), + array("General.value=[notjson]"), + array("General.value=notjson"), + array("General.array[abc]=\"def\""), + ); + } + + /** + * @expectedException \Exception + * @expectedExceptionMessage Trying to append to non-array setting value + */ + public function test_manipulate_ThrowsIfAppendingNonArraySetting() + { + $this->mockConfig->mockConfigData['General']['config'] = "5"; + + $manipulation = new ConfigSettingManipulation("General", "config", "10", true); + $manipulation->manipulate($this->mockConfig); + } + + /** + * @expectedException \Exception + * @expectedExceptionMessage Trying to set non-array value to array setting + */ + public function test_manipulate_ThrowsIfAssigningNonArrayValue_ToArraySetting() + { + $this->mockConfig->mockConfigData['General']['config'] = array("5"); + + $manipulation = new ConfigSettingManipulation("General", "config", "10", false); + $manipulation->manipulate($this->mockConfig); + } + + /** + * @dataProvider getTestDataForManipulate + */ + public function test_manipulate_CorrectlyManipulatesConfig($sectionName, $name, $value, $isArrayAppend, $expectedConfig) + { + $manipulation = new ConfigSettingManipulation($sectionName, $name, $value, $isArrayAppend); + $manipulation->manipulate($this->mockConfig); + + $this->assertEquals($expectedConfig, $this->mockConfig->mockConfigData); + } + + public function getTestDataForManipulate() + { + return array( + // normal assign (string, int, array, bool) + array("Section", "config_setting", "stringvalue", false, array("Section" => array("config_setting" => "stringvalue"))), + array("Section", "config_setting", 25, false, array("Section" => array("config_setting" => 25))), + array("Section", "config_setting", array('a' => 'b'), false, array("Section" => array("config_setting" => array('a' => 'b')))), + array("Section", "config_setting", false, false, array("Section" => array("config_setting" => false))), + + // array append + array("Section", "config_setting", "value", true, array("Section" => array("config_setting" => array('value')))), + array("Section", "config_setting", array(1,2), true, array("Section" => array("config_setting" => array(array(1,2))))), + ); + } +} \ No newline at end of file