From fb1aee6af9197bff24ce2b72a6854abb18a6a843 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Fri, 9 Oct 2026 03:14:15 -0400 Subject: [PATCH 1/6] Update bundled XMF to 1.3.2 Sync xoops_lib/Xmf with XMF 1.3.2 while keeping the 2.6 integration: - Take 1.3.2 as-is for framework-neutral classes and add the new Ulid, ProxyCheck, I18n, Security, Mail and SchemaDefinitionException - Tables: 1.3.2 fixes (DEFAULT clause quoting, definition validation, DEFAULT_GENERATED) on the 2.6 Doctrine connection - Language: 1.3.2 path containment using XoopsBaseConfig root-path - Keep the 2.6 Debug, TableLoad, Cache and Permission helpers; add Permission::getItemIds() - Keep the 2.6 Admin::menuIconPath() (2.6 admin builds icon paths) - Retain 2.6-only Request::getDateTime() and FilterInput::gather() --- docs/changelog.26.txt | 1 + xoops_lib/Xmf/Assert.php | 6 +- xoops_lib/Xmf/Database/Migrate.php | 206 +++- .../Database/SchemaDefinitionException.php | 52 + xoops_lib/Xmf/Database/Tables.php | 180 +++- xoops_lib/Xmf/FilterInput.php | 140 ++- xoops_lib/Xmf/Highlighter.php | 7 +- xoops_lib/Xmf/I18n/BlockPosition.php | 112 +++ xoops_lib/Xmf/I18n/Direction.php | 219 ++++ xoops_lib/Xmf/I18n/ImageResolver.php | 181 ++++ xoops_lib/Xmf/I18n/Translator.php | 48 + xoops_lib/Xmf/IPAddress.php | 63 +- xoops_lib/Xmf/Jwt/JsonWebToken.php | 21 +- xoops_lib/Xmf/Jwt/KeyFactory.php | 5 +- xoops_lib/Xmf/Jwt/TokenFactory.php | 5 +- xoops_lib/Xmf/Jwt/TokenReader.php | 17 +- xoops_lib/Xmf/Key/ArrayStorage.php | 5 +- xoops_lib/Xmf/Key/Basic.php | 9 +- xoops_lib/Xmf/Key/FileStorage.php | 8 +- xoops_lib/Xmf/Key/KeyAbstract.php | 11 +- xoops_lib/Xmf/Key/StorageInterface.php | 5 +- xoops_lib/Xmf/Language.php | 21 +- xoops_lib/Xmf/Mail/SendmailException.php | 91 ++ xoops_lib/Xmf/Mail/SendmailRunner.php | 386 ++++++++ xoops_lib/Xmf/Metagen.php | 75 +- xoops_lib/Xmf/Module/Admin.php | 42 +- xoops_lib/Xmf/Module/Helper.php | 7 +- .../Xmf/Module/Helper/AbstractHelper.php | 13 +- xoops_lib/Xmf/Module/Helper/GenericHelper.php | 85 +- xoops_lib/Xmf/Module/Helper/Permission.php | 17 + xoops_lib/Xmf/Module/Helper/Session.php | 7 +- xoops_lib/Xmf/ProxyCheck.php | 171 ++++ xoops_lib/Xmf/Random.php | 5 +- xoops_lib/Xmf/Request.php | 153 ++- .../Xmf/Security/DecompressionException.php | 29 + xoops_lib/Xmf/Security/Format.php | 40 + xoops_lib/Xmf/Security/SerializableTrait.php | 152 +++ xoops_lib/Xmf/Security/Serializer.php | 937 ++++++++++++++++++ xoops_lib/Xmf/Security/example.md | 52 + xoops_lib/Xmf/StopWords.php | 10 +- xoops_lib/Xmf/Ulid.php | 652 ++++++++++++ xoops_lib/Xmf/Uuid.php | 9 +- xoops_lib/Xmf/Yaml.php | 102 +- 43 files changed, 4017 insertions(+), 340 deletions(-) create mode 100644 xoops_lib/Xmf/Database/SchemaDefinitionException.php create mode 100644 xoops_lib/Xmf/I18n/BlockPosition.php create mode 100644 xoops_lib/Xmf/I18n/Direction.php create mode 100644 xoops_lib/Xmf/I18n/ImageResolver.php create mode 100644 xoops_lib/Xmf/I18n/Translator.php create mode 100644 xoops_lib/Xmf/Mail/SendmailException.php create mode 100644 xoops_lib/Xmf/Mail/SendmailRunner.php create mode 100644 xoops_lib/Xmf/ProxyCheck.php create mode 100644 xoops_lib/Xmf/Security/DecompressionException.php create mode 100644 xoops_lib/Xmf/Security/Format.php create mode 100644 xoops_lib/Xmf/Security/SerializableTrait.php create mode 100644 xoops_lib/Xmf/Security/Serializer.php create mode 100644 xoops_lib/Xmf/Security/example.md create mode 100644 xoops_lib/Xmf/Ulid.php diff --git a/docs/changelog.26.txt b/docs/changelog.26.txt index eca514a8c..a52c1bf3a 100644 --- a/docs/changelog.26.txt +++ b/docs/changelog.26.txt @@ -8,6 +8,7 @@ Updated: - jQuery to 3.7.0 (mamba) - jQueryUI to 1.13.2 (mamba) - jQuery Form to 4.3.0 (mamba) + - XMF to 1.3.2, keeping the 2.6 Doctrine/Xoops adapters (mamba) ================================== 2013/04/15: Version 2.6.0 Alpha 3 diff --git a/xoops_lib/Xmf/Assert.php b/xoops_lib/Xmf/Assert.php index e44e2cecb..68680ae53 100644 --- a/xoops_lib/Xmf/Assert.php +++ b/xoops_lib/Xmf/Assert.php @@ -1,4 +1,5 @@ - * @copyright 2017 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Assert extends BaseAssertion { - } diff --git a/xoops_lib/Xmf/Database/Migrate.php b/xoops_lib/Xmf/Database/Migrate.php index 08dc3e557..b592e1fec 100644 --- a/xoops_lib/Xmf/Database/Migrate.php +++ b/xoops_lib/Xmf/Database/Migrate.php @@ -1,4 +1,5 @@ - * @copyright 2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Migrate { - - /** @var false|\Xmf\Module\Helper|\Xoops\Module\Helper\HelperAbstract */ + /** @var false|\Xmf\Module\Helper|\Xoops\Module\Helper|\Xoops\Module\Helper\HelperAbstract */ protected $helper; /** @var string[] table names used by module */ @@ -46,8 +46,8 @@ class Migrate /** @var string yaml definition file */ protected $tableDefinitionFile; - /** @var array target table definitions in Xmf\Database\Tables::dumpTables() format */ - protected $targetDefinitions; + /** @var array|null target table definitions in Xmf\Database\Tables::dumpTables() format */ + protected $targetDefinitions = null; /** * Migrate constructor @@ -68,7 +68,19 @@ public function __construct($dirname) if (empty($this->moduleTables)) { throw new \RuntimeException("No tables established in module"); } - $version = $module->getInfo('version'); + + $version = preg_replace_callback( + '/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/', + function ($match) { + $semver = $match[1] . '_' . $match[2] . '_' . $match[3]; + if (!empty($match[4])) { + $semver .= '_' . substr($match[4], 0, 8); + } + return $semver; + }, + $module->getInfo('version') + ); + $this->tableDefinitionFile = $this->helper->path("sql/{$dirname}_{$version}_migrate.yml"); $this->tableHandler = new Tables(); } @@ -103,7 +115,9 @@ public function saveCurrentSchema() public function getCurrentSchema() { foreach ($this->moduleTables as $tableName) { - $this->tableHandler->useTable($tableName); + if (false === $this->tableHandler->useTable($tableName)) { + $this->tableHandler->addTable($tableName); + } } return $this->tableHandler->dumpTables(); @@ -112,18 +126,20 @@ public function getCurrentSchema() /** * Return the target database condition * - * @return array|bool table structure or false on error + * @return array table structure * - * @throws \RuntimeException + * @throws SchemaDefinitionException */ public function getTargetDefinitions() { - if (!isset($this->targetDefinitions)) { - $this->targetDefinitions = Yaml::read($this->tableDefinitionFile); - if (null === $this->targetDefinitions) { - throw new \RuntimeException("No schema definition " . $this->tableDefinitionFile); + if ($this->targetDefinitions === null) { + $definitions = Yaml::read($this->tableDefinitionFile); + if (!is_array($definitions) || $definitions === []) { + throw SchemaDefinitionException::forFile($this->tableDefinitionFile); } + $this->targetDefinitions = $definitions; } + return $this->targetDefinitions; } @@ -187,19 +203,43 @@ protected function preSyncActions() * @param string $tableName table to add * * @return void + * + * @throws SchemaDefinitionException */ protected function addMissingTable($tableName) { + $targetTable = $this->targetDefinitions[$tableName] ?? null; + if ( + !is_array($targetTable) + || !isset($targetTable['options'], $targetTable['columns']) + || !is_string($targetTable['options']) + || !is_array($targetTable['columns']) + ) { + throw SchemaDefinitionException::forTable($tableName); + } + $this->tableHandler->addTable($tableName); - $this->tableHandler->setTableOptions($tableName, $this->targetDefinitions[$tableName]['options']); - foreach ($this->targetDefinitions[$tableName]['columns'] as $column) { - $this->tableHandler->addColumn($tableName, $column['name'], $column['attributes']); + $this->tableHandler->setTableOptions($tableName, $targetTable['options']); + foreach ($targetTable['columns'] as $column) { + $validatedColumn = $this->validatedTargetColumn($tableName, $column); + $this->tableHandler->addColumn($tableName, $validatedColumn['name'], $validatedColumn['attributes']); } - foreach ($this->targetDefinitions[$tableName]['keys'] as $key => $keyData) { - if ($key === 'PRIMARY') { - $this->tableHandler->addPrimaryKey($tableName, $keyData['columns']); - } else { - $this->tableHandler->addIndex($key, $tableName, $keyData['columns'], $keyData['unique']); + if (isset($targetTable['keys']) && is_array($targetTable['keys'])) { + foreach ($targetTable['keys'] as $key => $keyData) { + if (!is_string($key)) { + continue; + } + $validatedKey = $this->validatedTargetKey($tableName, $key, $keyData); + if ($key === 'PRIMARY') { + $this->tableHandler->addPrimaryKey($tableName, $validatedKey['columns']); + } else { + $this->tableHandler->addIndex( + $key, + $tableName, + $validatedKey['columns'], + $validatedKey['unique'] + ); + } } } } @@ -210,15 +250,23 @@ protected function addMissingTable($tableName) * @param string $tableName table to synchronize * * @return void + * + * @throws SchemaDefinitionException */ protected function synchronizeTable($tableName) { - foreach ($this->targetDefinitions[$tableName]['columns'] as $column) { - $attributes = $this->tableHandler->getColumnAttributes($tableName, $column['name']); + $targetTable = $this->targetDefinitions[$tableName] ?? null; + if (!is_array($targetTable) || !isset($targetTable['columns']) || !is_array($targetTable['columns'])) { + throw SchemaDefinitionException::forTable($tableName); + } + + foreach ($targetTable['columns'] as $column) { + $validatedColumn = $this->validatedTargetColumn($tableName, $column); + $attributes = $this->tableHandler->getColumnAttributes($tableName, $validatedColumn['name']); if ($attributes === false) { - $this->tableHandler->addColumn($tableName, $column['name'], $column['attributes']); - } elseif ($column['attributes'] !== $attributes) { - $this->tableHandler->alterColumn($tableName, $column['name'], $column['attributes']); + $this->tableHandler->addColumn($tableName, $validatedColumn['name'], $validatedColumn['attributes']); + } elseif ($validatedColumn['attributes'] !== $attributes) { + $this->tableHandler->alterColumn($tableName, $validatedColumn['name'], $validatedColumn['attributes']); } } @@ -232,36 +280,116 @@ protected function synchronizeTable($tableName) } $existingIndexes = $this->tableHandler->getTableIndexes($tableName); - if (isset($this->targetDefinitions[$tableName]['keys'])) { - foreach ($this->targetDefinitions[$tableName]['keys'] as $key => $keyData) { + if (isset($targetTable['keys']) && is_array($targetTable['keys'])) { + foreach ($targetTable['keys'] as $key => $keyData) { + if (!is_string($key)) { + continue; + } + $validatedKey = $this->validatedTargetKey($tableName, $key, $keyData); if ($key === 'PRIMARY') { if (!isset($existingIndexes[$key])) { - $this->tableHandler->addPrimaryKey($tableName, $keyData['columns']); - } elseif ($existingIndexes[$key]['columns'] !== $keyData['columns']) { + $this->tableHandler->addPrimaryKey($tableName, $validatedKey['columns']); + } elseif ($existingIndexes[$key]['columns'] !== $validatedKey['columns']) { $this->tableHandler->dropPrimaryKey($tableName); - $this->tableHandler->addPrimaryKey($tableName, $keyData['columns']); + $this->tableHandler->addPrimaryKey($tableName, $validatedKey['columns']); } } else { if (!isset($existingIndexes[$key])) { - $this->tableHandler->addIndex($key, $tableName, $keyData['columns'], $keyData['unique']); - } elseif ($existingIndexes[$key]['unique'] !== $keyData['unique'] - || $existingIndexes[$key]['columns'] !== $keyData['columns'] + $this->tableHandler->addIndex( + $key, + $tableName, + $validatedKey['columns'], + $validatedKey['unique'] + ); + } elseif ( + $existingIndexes[$key]['unique'] !== $validatedKey['unique'] + || $existingIndexes[$key]['columns'] !== $validatedKey['columns'] ) { $this->tableHandler->dropIndex($key, $tableName); - $this->tableHandler->addIndex($key, $tableName, $keyData['columns'], $keyData['unique']); + $this->tableHandler->addIndex( + $key, + $tableName, + $validatedKey['columns'], + $validatedKey['unique'] + ); } } } } if (false !== $existingIndexes) { foreach ($existingIndexes as $key => $keyData) { - if (!isset($this->targetDefinitions[$tableName]['keys'][$key])) { - $this->tableHandler->dropIndex($key, $tableName); + if (!isset($targetTable['keys'][$key])) { + if ($key === 'PRIMARY') { + $this->tableHandler->dropPrimaryKey($tableName); + } else { + $this->tableHandler->dropIndex($key, $tableName); + } } } } } + /** + * @param mixed $column + * + * @return array{name: string, attributes: string} + * + * @throws SchemaDefinitionException + */ + private function validatedTargetColumn(string $tableName, mixed $column): array + { + if ( + !is_array($column) + || !isset($column['name'], $column['attributes']) + || !is_string($column['name']) + || $column['name'] === '' + || !is_string($column['attributes']) + || $column['attributes'] === '' + ) { + throw SchemaDefinitionException::forTable($tableName); + } + + return [ + 'name' => $column['name'], + 'attributes' => $column['attributes'], + ]; + } + + /** + * @param mixed $keyData + * + * @return array{columns: string, unique: bool} + * + * @throws SchemaDefinitionException + */ + private function validatedTargetKey(string $tableName, string $keyName, mixed $keyData): array + { + if ( + !is_array($keyData) + || !isset($keyData['columns']) + || !is_string($keyData['columns']) + || $keyData['columns'] === '' + ) { + throw SchemaDefinitionException::forTable($tableName); + } + + if ($keyName === 'PRIMARY') { + return [ + 'columns' => $keyData['columns'], + 'unique' => true, + ]; + } + + if (!array_key_exists('unique', $keyData) || !is_bool($keyData['unique'])) { + throw SchemaDefinitionException::forTable($tableName); + } + + return [ + 'columns' => $keyData['columns'], + 'unique' => $keyData['unique'], + ]; + } + /** * determine if a column on a table exists in the target definitions * diff --git a/xoops_lib/Xmf/Database/SchemaDefinitionException.php b/xoops_lib/Xmf/Database/SchemaDefinitionException.php new file mode 100644 index 000000000..f1bed23b7 --- /dev/null +++ b/xoops_lib/Xmf/Database/SchemaDefinitionException.php @@ -0,0 +1,52 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +class SchemaDefinitionException extends \RuntimeException +{ + /** + * Build an exception for a missing schema definition file. + * + * @param string $file schema definition file path + * + * @return self + */ + public static function forFile(string $file): self + { + return new self('No schema definition ' . basename($file)); + } + + /** + * Build an exception for a missing table definition. + * + * @param string $tableName table name + * + * @return self + */ + public static function forTable(string $tableName): self + { + return new self("No schema definition for table {$tableName}"); + } +} diff --git a/xoops_lib/Xmf/Database/Tables.php b/xoops_lib/Xmf/Database/Tables.php index c5df3c5d8..ed683230d 100644 --- a/xoops_lib/Xmf/Database/Tables.php +++ b/xoops_lib/Xmf/Database/Tables.php @@ -1,4 +1,5 @@ - * @copyright 2011-2019 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org */ class Tables { @@ -114,7 +116,7 @@ public function addColumn($table, $column, $attributes) array_push($tableDef['columns'], $columnDef); } else { foreach ($tableDef['columns'] as $col) { - if (strcasecmp($col['name'], $column) == 0) { + if (strcasecmp($col['name'], $column) === 0) { return true; } } @@ -181,12 +183,19 @@ public function addIndex($name, $table, $column, $unique = false) $firstComma = ', '; } if (isset($this->tables[$table])) { - if (isset($this->tables[$table]['create']) && $this->tables[$table]['create']) { - $this->tables[$table]['keys'][$name]['columns'] = $columnList; - $this->tables[$table]['keys'][$name]['unique'] = (bool) $unique; + $tableDef = &$this->tables[$table]; + if (isset($tableDef['create']) && $tableDef['create']) { + if (!isset($tableDef['keys']) || !is_array($tableDef['keys'])) { + $tableDef['keys'] = []; + } + $tableDef['keys'][$name] = [ + 'columns' => $columnList, + 'unique' => (bool) $unique, + ]; } else { $add = ($unique ? 'ADD UNIQUE INDEX' : 'ADD INDEX'); - $this->queue[] = "ALTER TABLE `{$this->tables[$table]['name']}` {$add} `{$name}` ({$columnList})"; + $tableName = isset($tableDef['name']) && is_string($tableDef['name']) ? $tableDef['name'] : $table; + $this->queue[] = "ALTER TABLE `{$tableName}` {$add} `{$name}` ({$columnList})"; } } else { return $this->tableNotEstablished(); @@ -283,14 +292,14 @@ public function useTable($table) * @param string $table table containing the column * @param string $column column to alter * - * @return string|bool attribute string, or false if error encountered + * @return string|false attribute string, or false if error encountered */ public function getColumnAttributes($table, $column) { // Find table def. if (isset($this->tables[$table])) { $tableDef = $this->tables[$table]; - // loop thru and find the column + // loop through and find the column foreach ($tableDef['columns'] as $col) { if (strcasecmp($col['name'], $column) === 0) { return $col['attributes']; @@ -306,7 +315,7 @@ public function getColumnAttributes($table, $column) * * @param string $table get indexes for this named table * - * @return array|bool array of indexes, or false if error encountered + * @return array|false array of indexes, or false if error encountered */ public function getTableIndexes($table) { @@ -338,9 +347,9 @@ public function alterColumn($table, $column, $attributes, $newName = '') $tableDef = &$this->tables[$table]; // Is this on a table we are adding? if (isset($tableDef['create']) && $tableDef['create']) { - // loop thru and find the column + // loop through and find the column foreach ($tableDef['columns'] as &$col) { - if (strcasecmp($col['name'], $column) == 0) { + if (strcasecmp($col['name'], $column) === 0) { $col['name'] = $newName; $col['attributes'] = $attributes; break; @@ -351,9 +360,9 @@ public function alterColumn($table, $column, $attributes, $newName = '') } else { $this->queue[] = "ALTER TABLE `{$tableDef['name']}` " . "CHANGE COLUMN `{$column}` `{$newName}` {$attributes} "; - // loop thru and find the column + // loop through and find the column foreach ($tableDef['columns'] as &$col) { - if (strcasecmp($col['name'], $column) == 0) { + if (strcasecmp($col['name'], $column) === 0) { $col['name'] = $newName; $col['attributes'] = $attributes; break; @@ -597,6 +606,11 @@ public function executeQueue($force = false) $ddl = $this->renderTableCreate($ddl['createtable']); } } + if (!is_string($ddl)) { + $this->lastError = 'Failed to render DDL'; + $this->lastErrNo = -1; + return false; + } $result = $this->execSql($ddl, $force); if (!$result) { $this->lastError = $this->db->errorInfo(); @@ -614,7 +628,7 @@ public function executeQueue($force = false) * Create a DELETE statement and add it to the work queue * * @param string $table table - * @param string|CriteriaElement $criteria string where clause or object criteria + * @param string|\CriteriaElement $criteria string where clause or object criteria * * @return bool true if no errors, false if errors encountered */ @@ -626,6 +640,7 @@ public function delete($table, $criteria) if (is_scalar($criteria)) { $where = $criteria; } elseif (is_object($criteria)) { + /** @var \CriteriaCompo $criteria */ $where = $criteria->renderWhere(); } $this->queue[] = "DELETE FROM `{$tableDef['name']}` {$where}"; @@ -652,13 +667,27 @@ public function insert($table, $columns, $quoteValue = true) $colSql = ''; $valSql = ''; foreach ($tableDef['columns'] as $col) { + if (!isset($col['name']) || !is_string($col['name'])) { + trigger_error( + 'Skipping malformed column definition in ' . __METHOD__ . ': ' . var_export($col, true), + E_USER_WARNING + ); + continue; + } + + $columnName = $col['name']; $comma = empty($colSql) ? '' : ', '; - if (isset($columns[$col['name']])) { - $colSql .= "{$comma}`{$col['name']}`"; + if (isset($columns[$columnName])) { + $colSql .= "{$comma}`{$columnName}`"; $valSql .= $comma - . ($quoteValue ? $this->db->quote($columns[$col['name']]) : $columns[$col['name']]); + . ($quoteValue ? $this->db->quote($columns[$columnName]) : $columns[$columnName]); } } + if ($colSql === '' || $valSql === '') { + $this->lastError = 'No valid columns supplied for insert'; + $this->lastErrNo = -1; + return false; + } $sql = "INSERT INTO `{$tableDef['name']}` ({$colSql}) VALUES({$valSql})"; $this->queue[] = $sql; @@ -673,7 +702,7 @@ public function insert($table, $columns, $quoteValue = true) * * @param string $table table * @param array $columns array of 'column'=>'value' entries - * @param string|CriteriaElement $criteria string where clause or object criteria + * @param string|\CriteriaElement $criteria string where clause or object criteria * @param boolean $quoteValue true to quote values, false if caller handles quoting * * @return boolean true if no errors, false if errors encountered @@ -686,16 +715,31 @@ public function update($table, $columns, $criteria, $quoteValue = true) if (is_scalar($criteria)) { $where = $criteria; } elseif (is_object($criteria)) { + /** @var \CriteriaCompo $criteria */ $where = $criteria->renderWhere(); } $colSql = ''; foreach ($tableDef['columns'] as $col) { + if (!isset($col['name']) || !is_string($col['name'])) { + trigger_error( + 'Skipping malformed column definition in ' . __METHOD__ . ': ' . var_export($col, true), + E_USER_WARNING + ); + continue; + } + + $columnName = $col['name']; $comma = empty($colSql) ? '' : ', '; - if (isset($columns[$col['name']])) { - $colSql .= "{$comma}`{$col['name']}` = " - . ($quoteValue ? $this->db->quote($columns[$col['name']]) : $columns[$col['name']]); + if (isset($columns[$columnName])) { + $colSql .= "{$comma}`{$columnName}` = " + . ($quoteValue ? $this->db->quote($columns[$columnName]) : $columns[$columnName]); } } + if ($colSql === '') { + $this->lastError = 'No valid columns supplied for update'; + $this->lastErrNo = -1; + return false; + } $sql = "UPDATE `{$tableDef['name']}` SET {$colSql} {$where}"; $this->queue[] = $sql; @@ -740,19 +784,46 @@ protected function renderTableCreate($table, $prefixed = false) { if (isset($this->tables[$table])) { $tableDef = $this->tables[$table]; - $tableName = ($prefixed ? $tableDef['name'] : $table); + if ( + !is_array($tableDef) + || !isset($tableDef['columns'], $tableDef['options']) + || !is_array($tableDef['columns']) + || !is_string($tableDef['options']) + ) { + return false; + } + + $tableName = $table; + if ($prefixed && isset($tableDef['name']) && is_string($tableDef['name'])) { + $tableName = $tableDef['name']; + } $sql = "CREATE TABLE `{$tableName}` ("; $firstComma = ''; foreach ($tableDef['columns'] as $col) { + if ( + !is_array($col) + || !isset($col['name'], $col['attributes']) + || !is_string($col['name']) + || $col['name'] === '' + || !is_string($col['attributes']) + || $col['attributes'] === '' + ) { + return false; + } + $sql .= "{$firstComma}\n `{$col['name']}` {$col['attributes']}"; $firstComma = ','; } $keySql = ''; - foreach ($tableDef['keys'] as $keyName => $key) { + $keys = isset($tableDef['keys']) && is_array($tableDef['keys']) ? $tableDef['keys'] : []; + foreach ($keys as $keyName => $key) { + if (!is_string($keyName) || !is_array($key) || !isset($key['columns']) || !is_string($key['columns'])) { + continue; + } if ($keyName === 'PRIMARY') { $keySql .= ",\n PRIMARY KEY ({$key['columns']})"; } else { - $unique = $key['unique'] ? 'UNIQUE ' : ''; + $unique = !empty($key['unique']) ? 'UNIQUE ' : ''; $keySql .= ",\n {$unique}KEY {$keyName} ({$key['columns']})"; } } @@ -801,6 +872,32 @@ protected function fetch($result) return $result->fetchAssociative(); } + /** + * create default value clause for DDL + * + * @param string|null $default the default value to be quoted + * + * @return string the correctly quoted default value + */ + protected function quoteDefaultClause($default) + { + // . (($column['COLUMN_DEFAULT'] === null) ? '' : " DEFAULT '" . $column['COLUMN_DEFAULT'] . "' ") + // no default specified + if (null === $default) { + return ''; + } + + // functions should not be quoted + // this section will need expanded when XOOPS minimum is no longer a mysql 5 version + // Until mysql 8, only allowed function is CURRENT_TIMESTAMP + if ($default === 'CURRENT_TIMESTAMP') { + return ' DEFAULT CURRENT_TIMESTAMP '; + } + + // surround default with quotes — escape embedded single quotes for valid DDL + return " DEFAULT '" . str_replace("'", "''", $default) . "' "; + } + /** * get table definition from INFORMATION_SCHEMA * @@ -839,12 +936,16 @@ protected function getTable($table) $sql .= ' ORDER BY `ORDINAL_POSITION` '; $result = $this->execSql($sql); + if (!$result) { + return false; + } while ($column = $this->fetch($result)) { $attributes = ' ' . $column['COLUMN_TYPE'] . ' ' . (($column['IS_NULLABLE'] === 'NO') ? ' NOT NULL ' : '') - . (($column['COLUMN_DEFAULT'] === null) ? '' : " DEFAULT '" . $column['COLUMN_DEFAULT'] . "' ") - . $column['EXTRA']; + . $this->quoteDefaultClause($column['COLUMN_DEFAULT']) + //. $column['EXTRA']; + . str_replace('DEFAULT_GENERATED ', '', $column['EXTRA']); $columnDef = array( 'name' => $column['COLUMN_NAME'], @@ -862,17 +963,28 @@ protected function getTable($table) $sql .= ' ORDER BY `INDEX_NAME`, `SEQ_IN_INDEX` '; $result = $this->execSql($sql); + if (!$result) { + return false; + } $lastKey = ''; $keyCols = ''; $keyUnique = false; + $tableDef['keys'] = []; while ($key = $this->fetch($result)) { - if ($lastKey != $key['INDEX_NAME']) { + $currentKey = $key['INDEX_NAME']; + if (!is_string($currentKey) || !is_string($key['COLUMN_NAME'])) { + continue; + } + + if ($lastKey != $currentKey) { if (!empty($lastKey)) { - $tableDef['keys'][$lastKey]['columns'] = $keyCols; - $tableDef['keys'][$lastKey]['unique'] = $keyUnique; + $tableDef['keys'][$lastKey] = [ + 'columns' => $keyCols, + 'unique' => $keyUnique, + ]; } - $lastKey = $key['INDEX_NAME']; + $lastKey = $currentKey; $keyCols = $key['COLUMN_NAME']; if (!empty($key['SUB_PART'])) { $keyCols .= ' (' . $key['SUB_PART'] . ')'; @@ -886,8 +998,10 @@ protected function getTable($table) } }; if (!empty($lastKey)) { - $tableDef['keys'][$lastKey]['columns'] = $keyCols; - $tableDef['keys'][$lastKey]['unique'] = $keyUnique; + $tableDef['keys'][$lastKey] = [ + 'columns' => $keyCols, + 'unique' => $keyUnique, + ]; } return $tableDef; diff --git a/xoops_lib/Xmf/FilterInput.php b/xoops_lib/Xmf/FilterInput.php index 1659d2f82..ec8ed6581 100644 --- a/xoops_lib/Xmf/FilterInput.php +++ b/xoops_lib/Xmf/FilterInput.php @@ -1,4 +1,5 @@ * @copyright 2005 Daniel Morris * @copyright 2005 - 2013 Open Source Matters, Inc. All rights reserved. - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2011-2023 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class FilterInput { + /** + * Passes remove() makes before it gives up on a value. Real input settles + * in at most a few passes; this bounds any input that would not. + */ + private const MAX_FILTER_PASSES = 10; + protected $tagsArray; // default is empty array protected $attrArray; // default is empty array @@ -65,7 +72,7 @@ class FilterInput 'title', 'xml' ); - // also will strip ALL event handlers + // also, it will strip ALL event handlers protected $attrBlacklist = array('action', 'background', 'codebase', 'dynsrc', 'lowsrc'); /** @@ -146,7 +153,7 @@ public static function getInstance( * * @param mixed $source - input string/array-of-string to be 'cleaned' * - * @return string $source - 'cleaned' version of input parameter + * @return string|array $source - 'cleaned' version of input parameter */ public function process($source) { @@ -159,7 +166,8 @@ public function process($source) } } return $source; - } elseif (is_string($source)) { + } + if (is_string($source)) { // clean this string return $this->remove($this->decode($source)); } else { @@ -214,14 +222,14 @@ public function cleanVar($source, $type = 'string') case 'INTEGER': // Only use the first integer value preg_match('/-?\d+/', (string) $source, $matches); - $result = @ (int) $matches[0]; + $result = isset($matches[0]) ? (int) $matches[0] : 0; break; case 'FLOAT': case 'DOUBLE': // Only use the first floating point value preg_match('/-?\d+(\.\d+)?/', (string) $source, $matches); - $result = @ (float) $matches[0]; + $result = isset($matches[0]) ? (float) $matches[0] : 0; break; case 'BOOL': @@ -259,7 +267,7 @@ public function cleanVar($source, $type = 'string') $source = trim((string) $source); $pattern = '/^([-_\.\/A-Z0-9=&%?~]+)(.*)$/i'; preg_match($pattern, $source, $matches); - $result = @ (string) $matches[1]; + $result = isset($matches[1]) ? (string) $matches[1] : ''; break; case 'USERNAME': @@ -267,16 +275,24 @@ public function cleanVar($source, $type = 'string') break; case 'WEBURL': - $result = (string) $this->process($source); - // allow only relative, http or https - $urlparts = parse_url($result); - if (!empty($urlparts['scheme']) - && !($urlparts['scheme'] === 'http' || $urlparts['scheme'] === 'https') - ) { + /** @var string $result */ + $result = trim((string) $this->process($source)); + // reject protocol-relative URLs (//evil.example) and non-http(s) schemes + if (str_starts_with($result, '//')) { $result = ''; } + if ($result !== '') { + $urlparts = parse_url($result); + if ( + is_array($urlparts) + && !empty($urlparts['scheme']) + && !($urlparts['scheme'] === 'http' || $urlparts['scheme'] === 'https') + ) { + $result = ''; + } + } // do not allow quotes, tag brackets or controls - if (!preg_match('#^[^"<>\x00-\x1F]+$#', $result)) { + if (!preg_match('#^[^"<>\x00-\x1F]+\z#', $result)) { $result = ''; } break; @@ -314,11 +330,13 @@ public function cleanVar($source, $type = 'string') */ protected function remove($source) { - $loopCounter = 0; - // provides nested-tag protection - while ($source != $this->filterTags($source)) { - $source = $this->filterTags($source); - ++$loopCounter; + // provides nested-tag protection; re-filter until the output is stable + for ($pass = 0; ($filtered = $this->filterTags($source)) !== $source; ++$pass) { + if ($pass >= self::MAX_FILTER_PASSES) { + // a value that does not settle is dropped rather than filtered without bound + return ''; + } + $source = $filtered; } return $source; @@ -352,7 +370,7 @@ protected function filterTags($source) // next start of tag (for nested tag assessment) $tagOpen_nested = strpos($fromTagOpen, '<'); if (($tagOpen_nested !== false) && ($tagOpen_nested < $tagOpen_end)) { - $preTag .= substr($postTag, 0, ($tagOpen_nested + 1)); + $preTag .= $this->stripTagOpeners(substr($postTag, 0, ($tagOpen_nested + 1))); $postTag = substr($postTag, ($tagOpen_nested + 1)); $tagOpen_start = strpos($postTag, '<'); continue; @@ -360,7 +378,12 @@ protected function filterTags($source) $currentTag = substr($fromTagOpen, 0, $tagOpen_end); $tagLength = strlen($currentTag); if (!$tagOpen_end) { - $preTag .= $postTag; + // "<>" is not a tag: keep it as text and move past it. Appending the whole + // remainder here made every pass longer, so remove() never terminated. + $preTag .= '<>'; + $postTag = substr($postTag, 2); + $tagOpen_start = strpos($postTag, '<'); + continue; } // iterate through tag finding attribute pairs - setup $tagLeft = $currentTag; @@ -376,9 +399,11 @@ protected function filterTags($source) $isCloseTag = false; list($tagName) = explode(' ', $currentTag); } - // excludes all "non-regular" tagnames OR no tagname OR remove if xssauto is on and tag is blacklisted - if ((!preg_match("/^[a-z][a-z0-9]*$/i", $tagName)) - || (!$tagName) + // excludes all "non-regular" tagnames OR remove if xssauto is on and tag is blacklisted. + // An empty (or "0") tag name is already rejected by the regex below, so a + // separate "!$tagName" test would be dead code. + if ( + (!preg_match('/^[a-z][a-z0-9]*\z/i', $tagName)) || ((in_array(strtolower($tagName), $this->tagBlacklist)) && ($this->xssAuto)) ) { @@ -396,7 +421,8 @@ protected function filterTags($source) // another equals exists if (strpos($fromSpace, '=') !== false) { // opening and closing quotes exists - if (($openQuotes !== false) + if ( + ($openQuotes !== false) && (strpos(substr($fromSpace, ($openQuotes + 1)), '"') !== false) ) { $attr = substr($fromSpace, 0, ($closeQuotes + 1)); @@ -421,7 +447,7 @@ protected function filterTags($source) // appears in array specified by user $tagFound = in_array(strtolower($tagName), $this->tagsArray); // remove this tag on condition - if ((!$tagFound && $this->tagsMethod) || ($tagFound && !$this->tagsMethod)) { + if ($tagFound !== (bool) $this->tagsMethod) { // reconstruct tag with allowed attributes if (!$isCloseTag) { $attrSet = $this->filterAttr($attrSet); @@ -445,12 +471,27 @@ protected function filterTags($source) $postTag = substr($postTag, ($tagLength + 2)); $tagOpen_start = strpos($postTag, '<'); } - // append any code after end of tags - $preTag .= $postTag; + // append any code after end of tags; an unclosed "stripTagOpeners($postTag); return $preTag; } + /** + * Remove every "<" that could open a tag from text that is not a complete tag. + * + * Per the HTML tokenizer only "<" followed by a letter, "/", "!" or "?" starts + * markup; any other "<" (as in "a < b" or "<>") is kept as text. + * + * @param string $text text outside any recognised tag + * + * @return string + */ + protected function stripTagOpeners($text) + { + return preg_replace('#<(?=[a-z/!?])#i', '', $text) ?? ''; + } + /** * Internal method to strip a tag of certain attributes * @@ -471,11 +512,13 @@ protected function filterAttr($attrSet) // split into attr name and value $attrSubSet = explode('=', trim($attrSet[$i])); list($attrSubSet[0]) = explode(' ', $attrSubSet[0]); + $attrSubSet[1] = $attrSubSet[1] ?? ''; // removes all "non-regular" attr names AND also attr blacklisted - if ((!preg_match('/[a-z]*$/i', $attrSubSet[0])) + if ( + (!preg_match('/^[a-z][a-z0-9_:.-]*\z/i', $attrSubSet[0])) || (($this->xssAuto) && ((in_array(strtolower($attrSubSet[0]), $this->attrBlacklist)) - || (substr($attrSubSet[0], 0, 2) === 'on'))) + || (strncasecmp($attrSubSet[0], 'on', 2) === 0))) ) { continue; } @@ -489,7 +532,8 @@ protected function filterAttr($attrSet) $attrSubSet[1] = str_replace('"', '', $attrSubSet[1]); // [requested feature] convert single quotes from either side to doubles // (Single quotes shouldn't be used to pad attr value) - if ((substr($attrSubSet[1], 0, 1) === "'") + if ( + (substr($attrSubSet[1], 0, 1) === "'") && (substr($attrSubSet[1], (strlen($attrSubSet[1]) - 1), 1) === "'") ) { $attrSubSet[1] = substr($attrSubSet[1], 1, (strlen($attrSubSet[1]) - 2)); @@ -498,13 +542,19 @@ protected function filterAttr($attrSet) $attrSubSet[1] = stripslashes($attrSubSet[1]); } // auto strip attr's with "javascript: - if (((strpos(strtolower($attrSubSet[1]), 'expression') !== false) - && (strtolower($attrSubSet[0]) === 'style')) || - (strpos(strtolower($attrSubSet[1]), 'javascript:') !== false) || - (strpos(strtolower($attrSubSet[1]), 'behaviour:') !== false) || - (strpos(strtolower($attrSubSet[1]), 'vbscript:') !== false) || - (strpos(strtolower($attrSubSet[1]), 'mocha:') !== false) || - (strpos(strtolower($attrSubSet[1]), 'livescript:') !== false) + // Check the value as a browser reads it: named entities decoded (":", " ") + // and whitespace/control characters removed ("java\tscript:"). + $attrValue = strtolower( + preg_replace('/[\x00-\x20]+/', '', html_entity_decode($attrSubSet[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '' + ); + if ( + ((strpos($attrValue, 'expression') !== false) + && (strtolower($attrSubSet[0]) === 'style')) + || (strpos($attrValue, 'javascript:') !== false) + || (strpos($attrValue, 'behaviour:') !== false) + || (strpos($attrValue, 'vbscript:') !== false) + || (strpos($attrValue, 'mocha:') !== false) + || (strpos($attrValue, 'livescript:') !== false) ) { continue; } @@ -512,11 +562,11 @@ protected function filterAttr($attrSet) // if matches user defined array $attrFound = in_array(strtolower($attrSubSet[0]), $this->attrArray); // keep this attr on condition - if ((!$attrFound && $this->attrMethod) || ($attrFound && !$this->attrMethod)) { + if ($attrFound !== (bool) $this->attrMethod) { if ($attrSubSet[1]) { // attr has value $newSet[] = $attrSubSet[0] . '="' . $attrSubSet[1] . '"'; - } elseif ($attrSubSet[1] == "0") { + } elseif ($attrSubSet[1] === "0") { // attr has decimal zero as value $newSet[] = $attrSubSet[0] . '="0"'; } else { @@ -544,16 +594,16 @@ protected function decode($source) // convert decimal $source = preg_replace_callback( '/&#(\d+);/m', - function ($matches) { - return chr($matches[1]); + function ($matches) use ($charset) { + return html_entity_decode('&#' . $matches[1] . ';', ENT_NOQUOTES, $charset); }, $source ); // convert hex notation $source = preg_replace_callback( '/&#x([a-f0-9]+);/mi', - function ($matches) { - return chr('0x' . $matches[1]); + function ($matches) use ($charset) { + return html_entity_decode('&#x' . $matches[1] . ';', ENT_NOQUOTES, $charset); }, $source ); diff --git a/xoops_lib/Xmf/Highlighter.php b/xoops_lib/Xmf/Highlighter.php index 7b5fd3519..26518eeaa 100644 --- a/xoops_lib/Xmf/Highlighter.php +++ b/xoops_lib/Xmf/Highlighter.php @@ -1,4 +1,5 @@ - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Highlighter @@ -26,7 +27,7 @@ class Highlighter /** * mbstring encoding */ - const ENCODING = 'UTF-8'; + public const ENCODING = 'UTF-8'; /** * Apply highlight to words in body text diff --git a/xoops_lib/Xmf/I18n/BlockPosition.php b/xoops_lib/Xmf/I18n/BlockPosition.php new file mode 100644 index 000000000..05379ad66 --- /dev/null +++ b/xoops_lib/Xmf/I18n/BlockPosition.php @@ -0,0 +1,112 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +final class BlockPosition +{ + /** XOOPS convention: integer representing the left/start side */ + public const DEFAULT_LEFT = 1; + + /** XOOPS convention: integer representing the right/end side */ + public const DEFAULT_RIGHT = 0; + + /** + * Map a side integer to logical class based on convention. + * + * @param int $side Physical side (convention: 1=left, 0=right in LTR context) + * @param int $leftValue What integer represents "left" in your CMS + * @param int $rightValue What integer represents "right" in your CMS + * + * @return string 'start', 'end', or 'center' + */ + public static function toLogical( + int $side, + int $leftValue = self::DEFAULT_LEFT, + int $rightValue = self::DEFAULT_RIGHT + ): string { + if ($side === $leftValue) { + return 'start'; + } + if ($side === $rightValue) { + return 'end'; + } + return 'center'; + } + + /** + * Get CSS class for a block position. + * + * @param int $side Physical side value from database + * @param int $leftValue What represents "left" + * @param int $rightValue What represents "right" + * + * @return string CSS class: 'block-start', 'block-end', or 'block-center' + */ + public static function toCssClass( + int $side, + int $leftValue = self::DEFAULT_LEFT, + int $rightValue = self::DEFAULT_RIGHT + ): string { + $logical = self::toLogical($side, $leftValue, $rightValue); + return "block-{$logical}"; + } + + /** + * Get appropriate side value for "start" position in given direction. + * + * @param string|null $direction 'ltr' or 'rtl' + * @param int $leftValue What represents "left" + * @param int $rightValue What represents "right" + * + * @return int Side value for start position + */ + public static function getStartValue( + ?string $direction = null, + int $leftValue = self::DEFAULT_LEFT, + int $rightValue = self::DEFAULT_RIGHT + ): int { + $dir = $direction ?? Direction::dir(); + return ($dir === Direction::RTL) ? $rightValue : $leftValue; + } + + /** + * Get appropriate side value for "end" position in given direction. + * + * @param string|null $direction 'ltr' or 'rtl' + * @param int $leftValue What represents "left" + * @param int $rightValue What represents "right" + * + * @return int Side value for end position + */ + public static function getEndValue( + ?string $direction = null, + int $leftValue = self::DEFAULT_LEFT, + int $rightValue = self::DEFAULT_RIGHT + ): int { + $dir = $direction ?? Direction::dir(); + return ($dir === Direction::RTL) ? $leftValue : $rightValue; + } +} diff --git a/xoops_lib/Xmf/I18n/Direction.php b/xoops_lib/Xmf/I18n/Direction.php new file mode 100644 index 000000000..9fe4bfb74 --- /dev/null +++ b/xoops_lib/Xmf/I18n/Direction.php @@ -0,0 +1,219 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +final class Direction +{ + public const LTR = 'ltr'; + public const RTL = 'rtl'; + public const AUTO = 'auto'; + + private static ?string $cachedDir = null; + + /** @var array */ + private static array $cacheByLocale = []; + + private static bool $rtlDeprecationWarned = false; + + private const MAX_LOCALE_CACHE = 50; + + private const RTL_LANGS = [ + 'ar', // Arabic + 'arc', // Aramaic + 'bcc', // Southern Balochi + 'bqi', // Bakhtiari + 'ckb', // Central Kurdish + 'dv', // Dhivehi + 'fa', // Persian + 'glk', // Gilaki + 'he', // Hebrew + 'iw', // Hebrew (old code) + 'khw', // Khowar + 'ks', // Kashmiri + 'ku', // Kurdish + 'mzn', // Mazanderani + 'pnb', // Western Punjabi + 'ps', // Pashto + 'sd', // Sindhi + 'ug', // Uyghur + 'ur', // Urdu + 'yi', // Yiddish + ]; + + private const RTL_SCRIPTS = ['Arab', 'Hebr', 'Thaa', 'Syrc', 'Nkoo', 'Adlm', 'Mand', 'Samr']; + + /** + * Get text direction for a locale. + * + * @param string|null $locale Locale code, or null/AUTO for global locale + * + * @return string 'ltr' or 'rtl' + */ + public static function dir(?string $locale = null): string + { + if ($locale === self::AUTO) { + $locale = null; + } + + $isGlobal = ($locale === null); + + if ($isGlobal && self::$cachedDir !== null) { + return self::$cachedDir; + } + + $isLegacy = !\class_exists('Xoops', false); + + $resolved = $locale; + if ($resolved === null && $isLegacy && \defined('_LANGCODE')) { + /** @var mixed $langCode */ + $langCode = \constant('_LANGCODE'); + if (\is_string($langCode)) { + $resolved = $langCode; + } + } + if ($resolved === null) { + $resolved = 'en'; + } + + if (!$isGlobal && isset(self::$cacheByLocale[$resolved])) { + return self::$cacheByLocale[$resolved]; + } + + $result = null; + + // Priority 1: Explicit _TEXT_DIRECTION (normalized for robustness) + if ($isGlobal && $isLegacy && \defined('_TEXT_DIRECTION')) { + /** @var mixed $raw */ + $raw = \constant('_TEXT_DIRECTION'); + if (\is_string($raw)) { + $decl = \strtolower(\trim($raw)); + if (\in_array($decl, [self::RTL, self::LTR], true)) { + $result = $decl; + } else { + \trigger_error( + 'Constant _TEXT_DIRECTION has invalid value "' . $raw + . '". Expected \'ltr\' or \'rtl\'.', + E_USER_WARNING + ); + } + } + } + + // Priority 2: Legacy _RTL constant + if ($result === null && $isGlobal && $isLegacy && \defined('_RTL')) { + if (!self::$rtlDeprecationWarned) { + \trigger_error( + 'Constant _RTL is deprecated. Define _TEXT_DIRECTION as \'ltr\' or \'rtl\' instead.', + E_USER_DEPRECATED + ); + self::$rtlDeprecationWarned = true; + } + $result = ((bool) \constant('_RTL')) ? self::RTL : self::LTR; + } + + // Priority 3: Auto-detect from locale + if ($result === null) { + $result = self::detect($resolved); + } + + // Cache + if ($isGlobal) { + self::$cachedDir = $result; + } else { + if (\count(self::$cacheByLocale) >= self::MAX_LOCALE_CACHE) { + \array_shift(self::$cacheByLocale); + } + self::$cacheByLocale[$resolved] = $result; + } + + return $result; + } + + /** + * Check if a locale uses right-to-left text direction. + * + * @param string|null $locale Locale code, or null for global locale + * + * @return bool True if RTL, false if LTR + */ + public static function isRtl(?string $locale = null): bool + { + return self::dir($locale) === self::RTL; + } + + /** + * Core detection logic. + * + * @param string $locale Resolved locale code (never null) + * + * @return string 'ltr' or 'rtl' + */ + private static function detect(string $locale): string + { + $locale = \trim($locale); + if ($locale === '') { + return self::LTR; + } + + $norm = \str_replace('_', '-', \strtolower($locale)); + $primary = \explode('-', $norm, 2)[0]; + + if (\in_array($primary, self::RTL_LANGS, true)) { + return self::RTL; + } + + if (\extension_loaded('intl')) { + try { + $script = \Locale::getScript($norm) ?: ''; + if (\in_array($script, self::RTL_SCRIPTS, true)) { + return self::RTL; + } + } catch (\IntlException $e) { + $debug = (\defined('XOOPS_DEBUG_MODE') && (bool) \constant('XOOPS_DEBUG_MODE')) + || (\defined('XOOPS_DEBUG') && (bool) \constant('XOOPS_DEBUG')); + if ($debug) { + \error_log( + 'Direction: ICU script detection failed for locale "' + . $locale . '": ' . $e->getMessage() + ); + } + } + } + + return self::LTR; + } + + /** + * Clear cached direction (useful for testing or runtime locale changes). + * + * @return void + */ + public static function clearCache(): void + { + self::$cachedDir = null; + self::$cacheByLocale = []; + self::$rtlDeprecationWarned = false; + } +} diff --git a/xoops_lib/Xmf/I18n/ImageResolver.php b/xoops_lib/Xmf/I18n/ImageResolver.php new file mode 100644 index 000000000..fc6b28a2a --- /dev/null +++ b/xoops_lib/Xmf/I18n/ImageResolver.php @@ -0,0 +1,181 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +final class ImageResolver +{ + /** @var array */ + private static array $cache = []; + + private const MAX_CACHE_SIZE = 200; + + /** + * Resolve an image path with language/direction fallbacks. + * + * Order (base = images/arrow.png, lang = pt-BR, dir = rtl): + * images/arrow.pt-br.png + * images/arrow.pt.png + * images/arrow.rtl.png + * images/arrow.ltr.png + * images/arrow.png + * + * @param string $basePath Base image path (relative or absolute URL) + * @param string|null $lang Locale code, or null for global locale + * @param string|null $dir 'ltr' or 'rtl', or null for auto-detection + * + * @return string Resolved image path + */ + public static function resolve(string $basePath, ?string $lang = null, ?string $dir = null): string + { + if ($basePath === '') { + return ''; + } + + // Absolute URL? Return as-is + if (\preg_match('#^(https?:)?//#i', $basePath) === 1) { + return $basePath; + } + + // Reject path traversal attempts + if (\strpos($basePath, '..') !== false) { + return $basePath; + } + + $isLegacy = !\class_exists('Xoops', false); + + if ($lang === null && $isLegacy && \defined('_LANGCODE')) { + /** @var mixed $langCode */ + $langCode = \constant('_LANGCODE'); + if (\is_string($langCode)) { + $lang = $langCode; + } + } + if ($lang === null) { + $lang = 'en'; + } + + // Reject lang values containing path traversal sequences + if (\strpos($lang, '..') !== false || \strpos($lang, '/') !== false) { + return $basePath; + } + + $dir = $dir ?? Direction::dir($lang); + if ($dir !== Direction::LTR && $dir !== Direction::RTL) { + $dir = Direction::dir($lang); + } + + $cacheKey = \strtolower($lang) . "\0" . $dir . "\0" . $basePath; + if (isset(self::$cache[$cacheKey])) { + return self::$cache[$cacheKey]; + } + + $parts = \pathinfo($basePath); + $dirname = $parts['dirname'] === '.' ? '' : $parts['dirname']; + $filename = $parts['filename']; + $extension = $parts['extension'] ?? ''; + if ($filename === '' || $extension === '') { + return $basePath; // malformed path + } + + $prefix = $dirname === '' ? '' : ($dirname === '/' ? '/' : $dirname . '/'); + + $otherDir = ($dir === Direction::RTL) ? Direction::LTR : Direction::RTL; + + $candidates = []; + foreach (self::expandLang($lang) as $l) { + $candidates[] = $prefix . "{$filename}.{$l}.{$extension}"; + } + $candidates[] = $prefix . "{$filename}.{$dir}.{$extension}"; + $candidates[] = $prefix . "{$filename}.{$otherDir}.{$extension}"; + $candidates[] = $basePath; + + // Resolve XOOPS_ROOT_PATH via constant() to avoid PHPStan stub narrowing + $root = ''; + if (\defined('XOOPS_ROOT_PATH')) { + /** @var mixed $rootValue */ + $rootValue = \constant('XOOPS_ROOT_PATH'); + if (\is_string($rootValue) && $rootValue !== '') { + $root = \rtrim($rootValue, '/'); + } + } + + $result = $basePath; + if ($root !== '') { + foreach ($candidates as $rel) { + $full = $root . '/' . \ltrim($rel, '/'); + if (\is_file($full)) { + $result = $rel; + break; + } + } + } + + self::remember($cacheKey, $result); + return $result; + } + + /** + * Expand a locale code into candidate suffixes. + * + * @param string $lang Locale code (e.g. 'pt-BR') + * + * @return string[] e.g. ['pt-br', 'pt'] + */ + private static function expandLang(string $lang): array + { + $lang = \strtolower(\str_replace('_', '-', \trim($lang))); + if ($lang === '') { + return []; + } + $parts = \explode('-', $lang, 2); + return isset($parts[1]) ? [$lang, $parts[0]] : [$lang]; + } + + /** + * Store a resolved path in the cache. + * + * @param string $key Cache key + * @param string $value Resolved path + * + * @return void + */ + private static function remember(string $key, string $value): void + { + if (\count(self::$cache) >= self::MAX_CACHE_SIZE) { + \array_shift(self::$cache); + } + self::$cache[$key] = $value; + } + + /** + * Clear the resolution cache (useful for testing). + * + * @return void + */ + public static function clearCache(): void + { + self::$cache = []; + } +} diff --git a/xoops_lib/Xmf/I18n/Translator.php b/xoops_lib/Xmf/I18n/Translator.php new file mode 100644 index 000000000..13d54d849 --- /dev/null +++ b/xoops_lib/Xmf/I18n/Translator.php @@ -0,0 +1,48 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +final class Translator +{ + /** + * Translate a label string by resolving it as a PHP constant if it + * matches the XOOPS naming convention (_UPPERCASE_NAME). + * + * Note: The regex restricts lookup to uppercase constants starting + * with an underscore, which limits the set of resolvable constants. + * + * @param string $label Label to translate (e.g. '_MI_MYMODULE_NAME') + * + * @return string Resolved constant value, or the raw label if not a defined constant + */ + public static function t(string $label): string + { + if (\preg_match('/^_[A-Z][A-Z0-9_]*$/', $label) === 1 && \defined($label)) { + $value = \constant($label); + return \is_string($value) ? $value : $label; + } + return $label; + } +} diff --git a/xoops_lib/Xmf/IPAddress.php b/xoops_lib/Xmf/IPAddress.php index 0f17e2e18..ebff28c09 100644 --- a/xoops_lib/Xmf/IPAddress.php +++ b/xoops_lib/Xmf/IPAddress.php @@ -1,4 +1,5 @@ - * @copyright 2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class IPAddress { - /** @var false|string presentation form of ip address, or false if invalid */ protected $ip; @@ -47,10 +47,14 @@ public function __construct($ip) */ public static function fromRequest() { - $ip = (array_key_exists('REMOTE_ADDR', $_SERVER)) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0'; + $ip = (array_key_exists('REMOTE_ADDR', $_SERVER) && is_string($_SERVER['REMOTE_ADDR'])) + ? $_SERVER['REMOTE_ADDR'] + : '0.0.0.0'; $class = get_called_class(); - $instance = new $class($ip); - return $instance; + $proxyCheck = new ProxyCheck(); + $proxyIP = $proxyCheck->get(); + $ip = (false === $proxyIP) ? $ip : $proxyIP; + return new $class($ip); } /** @@ -62,8 +66,11 @@ public static function fromRequest() */ protected function normalize($ip) { - $normal = inet_ntop(inet_pton($ip)); - return $normal; + $packed = inet_pton($ip); + if ($packed === false) { + return false; + } + return inet_ntop($packed); } /** @@ -86,8 +93,7 @@ public function asBinary() if (false === $this->ip) { return false; } - $binary = inet_pton($this->ip); - return $binary; + return inet_pton($this->ip); } /** @@ -97,14 +103,15 @@ public function asBinary() */ public function ipVersion() { + $return = false; if (false === $this->ip) { - return false; + $return = false; } elseif (false !== filter_var($this->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - return 4; + $return = 4; } elseif (false !== filter_var($this->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - return 6; + $return = 6; } - return false; + return $return; } /** @@ -122,20 +129,18 @@ public function ipVersion() public function sameSubnet($matchIp, $netMask4, $netMask6) { $match = new IPAddress($matchIp); - if (false === $this->ipVersion() || ($this->ipVersion() !== $match->ipVersion())) { - return false; - } - switch ($this->ipVersion()) { - case 4: - $mask = (-1) << (32 - $netMask4); - return ((ip2long($this->ip) & $mask) === (ip2long($match->asReadable()) & $mask)); - break; - case 6: - $ipBits = $this->asBinaryString($this); - $matchBits = $this->asBinaryString($match); - $match = (0 === strncmp($ipBits, $matchBits, $netMask6)); - return $match; - break; + if (false !== $this->ipVersion() && ($this->ipVersion() === $match->ipVersion())) { + switch ($this->ipVersion()) { + case 4: + $mask = (-1) << (32 - $netMask4); + return ((ip2long($this->ip) & $mask) === (ip2long($match->asReadable()) & $mask)); + case 6: + $ipBits = $this->asBinaryString($this); + $matchBits = $this->asBinaryString($match); + return (0 === strncmp($ipBits, $matchBits, $netMask6)); + default: + break; + } } return false; } @@ -152,7 +157,7 @@ protected function asBinaryString(IPAddress $ip) $length = (4 === $ip->ipVersion()) ? 4 : 16; $binaryIp = $ip->asBinary(); $bits = ''; - for ($i = 0; $i < $length; $i++) { + for ($i = 0; $i < $length; ++$i) { $byte = decbin(ord($binaryIp[$i])); $bits .= substr("00000000" . $byte, -8); } diff --git a/xoops_lib/Xmf/Jwt/JsonWebToken.php b/xoops_lib/Xmf/Jwt/JsonWebToken.php index 14962ab9f..7b9efa858 100644 --- a/xoops_lib/Xmf/Jwt/JsonWebToken.php +++ b/xoops_lib/Xmf/Jwt/JsonWebToken.php @@ -1,4 +1,5 @@ - * @copyright 2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class JsonWebToken @@ -82,16 +83,16 @@ public function setAlgorithm($algorithm) public function decode($jwtString, $assertClaims = array()) { try { - $jwtKey = new Key($this->key->getVerifying(), $this->algorithm); - $values = JWT::decode($jwtString, $jwtKey); + $values = JWT::decode($jwtString, new Key($this->key->getVerifying(), $this->algorithm)); } catch (\Exception $e) { - trigger_error($e->getMessage(), E_USER_NOTICE); return false; } foreach ($assertClaims as $claim => $assert) { if (!property_exists($values, $claim)) { return false; - } elseif ($values->$claim != $assert) { + } + + if ($values->$claim !== $assert) { return false; } } @@ -107,16 +108,16 @@ public function decode($jwtString, $assertClaims = array()) * * @return string encoded and signed jwt string * - * @throws \DomainException; - * @throws \InvalidArgumentException; - * @throws \UnexpectedValueException; + * @throws \DomainException + * @throws \InvalidArgumentException + * @throws \UnexpectedValueException */ public function create($payload, $expirationOffset = 0) { if ((int) $expirationOffset > 0) { $payload['exp'] = time() + (int) $expirationOffset; } - $value = JWT::encode($payload, $this->key->getSigning(), $this->algorithm); + $value = JWT::encode((array) $payload, $this->key->getSigning(), $this->algorithm); return $value; } } diff --git a/xoops_lib/Xmf/Jwt/KeyFactory.php b/xoops_lib/Xmf/Jwt/KeyFactory.php index 3c5687dbd..83378e82f 100644 --- a/xoops_lib/Xmf/Jwt/KeyFactory.php +++ b/xoops_lib/Xmf/Jwt/KeyFactory.php @@ -1,4 +1,5 @@ - * @copyright 2016-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class KeyFactory diff --git a/xoops_lib/Xmf/Jwt/TokenFactory.php b/xoops_lib/Xmf/Jwt/TokenFactory.php index 1936f4ab6..bf7a3ad92 100644 --- a/xoops_lib/Xmf/Jwt/TokenFactory.php +++ b/xoops_lib/Xmf/Jwt/TokenFactory.php @@ -1,4 +1,5 @@ - * @copyright 2016-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class TokenFactory diff --git a/xoops_lib/Xmf/Jwt/TokenReader.php b/xoops_lib/Xmf/Jwt/TokenReader.php index fc71b491a..8259a6e2c 100644 --- a/xoops_lib/Xmf/Jwt/TokenReader.php +++ b/xoops_lib/Xmf/Jwt/TokenReader.php @@ -1,4 +1,5 @@ - * @copyright 2016-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class TokenReader @@ -103,11 +104,15 @@ public static function fromHeader($key, $assertClaims = array(), $headerName = ' return false; } $header = trim($header); - $space = strpos($header, ' '); // expecting "Bearer base64-token-string" - if (false !== $space) { - $header = substr($header, $space); + if (strcasecmp($headerName, 'Authorization') === 0) { + $parts = explode(' ', $header, 2); + if (count($parts) !== 2 || strcasecmp($parts[0], 'Bearer') !== 0) { + return false; + } + $token = trim($parts[1]); + } else { + $token = $header; } - $token = trim($header); return static::fromString($key, $token, $assertClaims); } } diff --git a/xoops_lib/Xmf/Key/ArrayStorage.php b/xoops_lib/Xmf/Key/ArrayStorage.php index e44e68f85..c04945946 100644 --- a/xoops_lib/Xmf/Key/ArrayStorage.php +++ b/xoops_lib/Xmf/Key/ArrayStorage.php @@ -1,4 +1,5 @@ - * @copyright 2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class ArrayStorage extends \ArrayObject implements StorageInterface diff --git a/xoops_lib/Xmf/Key/Basic.php b/xoops_lib/Xmf/Key/Basic.php index d3c2f675b..52c5064b2 100644 --- a/xoops_lib/Xmf/Key/Basic.php +++ b/xoops_lib/Xmf/Key/Basic.php @@ -1,4 +1,5 @@ - * @copyright 2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Basic extends KeyAbstract diff --git a/xoops_lib/Xmf/Key/FileStorage.php b/xoops_lib/Xmf/Key/FileStorage.php index 32dacbbe4..3f365a461 100644 --- a/xoops_lib/Xmf/Key/FileStorage.php +++ b/xoops_lib/Xmf/Key/FileStorage.php @@ -1,4 +1,5 @@ - * @copyright 2016-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class FileStorage implements StorageInterface { - /** * @var string $storagePath filesystem path to storage */ @@ -88,7 +88,7 @@ public function save($name, $data) throw new \DomainException('Invalid key data'); } $fileContents = "fileName($name), $fileContents)); } diff --git a/xoops_lib/Xmf/Key/KeyAbstract.php b/xoops_lib/Xmf/Key/KeyAbstract.php index 561bce54b..6134a6aa7 100644 --- a/xoops_lib/Xmf/Key/KeyAbstract.php +++ b/xoops_lib/Xmf/Key/KeyAbstract.php @@ -1,4 +1,5 @@ - * @copyright 2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ abstract class KeyAbstract @@ -34,7 +35,7 @@ abstract class KeyAbstract /** * KeyAbstract constructor. * @param StorageInterface $storage key store - * @param string $name case insensitive key name, allow only A-Z, 0-9, _ and - + * @param string $name case-insensitive key name, allow only A-Z, 0-9, _ and - */ public function __construct(StorageInterface $storage, $name) { diff --git a/xoops_lib/Xmf/Key/StorageInterface.php b/xoops_lib/Xmf/Key/StorageInterface.php index d7bf87ad0..b37fc1b78 100644 --- a/xoops_lib/Xmf/Key/StorageInterface.php +++ b/xoops_lib/Xmf/Key/StorageInterface.php @@ -1,4 +1,5 @@ - * @copyright 2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ interface StorageInterface diff --git a/xoops_lib/Xmf/Language.php b/xoops_lib/Xmf/Language.php index 6d4072241..013d2a175 100644 --- a/xoops_lib/Xmf/Language.php +++ b/xoops_lib/Xmf/Language.php @@ -1,4 +1,5 @@ - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Language @@ -79,10 +80,18 @@ protected static function loadFile($filename) if (preg_match('/[[:cntrl:]]/i', $filename)) { throw new \InvalidArgumentException('Security check: Illegal character in filename'); } - if (file_exists($filename)) { - include_once $filename; - return true; + $realPath = realpath($filename); + if ($realPath === false) { + return false; + } + $allowedDir = realpath((string) \XoopsBaseConfig::get('root-path')); + if ($allowedDir !== false) { + $allowedDirWithSep = rtrim($allowedDir, '/\\') . DIRECTORY_SEPARATOR; + if (strpos($realPath, $allowedDirWithSep) !== 0 && $realPath !== $allowedDir) { + return false; + } } - return false; + include_once $realPath; + return true; } } diff --git a/xoops_lib/Xmf/Mail/SendmailException.php b/xoops_lib/Xmf/Mail/SendmailException.php new file mode 100644 index 000000000..02bcb9cf3 --- /dev/null +++ b/xoops_lib/Xmf/Mail/SendmailException.php @@ -0,0 +1,91 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +final class SendmailException extends \RuntimeException +{ + /** + * Build an exception for an invalid sendmail path. + * + * @return self + */ + public static function invalidPath(): self + { + return new self('Invalid sendmail path.'); + } + + /** + * Build an exception for a sendmail process startup failure. + * + * @return self + */ + public static function failedToStartProcess(): self + { + return new self('Failed to start sendmail process.'); + } + + /** + * Build an exception for invalid sendmail pipe resources. + * + * @return self + */ + public static function failedToOpenPipes(): self + { + return new self('Failed to open sendmail pipes.'); + } + + /** + * Build an exception for a broken sendmail stdin pipe. + * + * @return self + */ + public static function writeFailure(): self + { + return new self('Failed to write message to sendmail (broken pipe).'); + } + + /** + * Build an exception for a sendmail process closing stdin too early. + * + * @return self + */ + public static function prematurePipeClosure(): self + { + return new self('sendmail closed the input pipe prematurely.'); + } + + /** + * Build an exception for a non-zero sendmail exit code. + * + * @param int $code process exit code + * @param string $firstLine first stderr line + * + * @return self + */ + public static function exitedWithCode(int $code, string $firstLine = ''): self + { + return new self('Sendmail exited with code ' . $code . ($firstLine !== '' ? ': ' . $firstLine : '')); + } +} diff --git a/xoops_lib/Xmf/Mail/SendmailRunner.php b/xoops_lib/Xmf/Mail/SendmailRunner.php new file mode 100644 index 000000000..ceedaabd9 --- /dev/null +++ b/xoops_lib/Xmf/Mail/SendmailRunner.php @@ -0,0 +1,386 @@ + true]) + * - Strict validation: + * • absolute ASCII path format + * • allowlist enforcement + * • canonical target check via realpath() + * • executable file check (optional symlink policy) + * - Optional, validated envelope sender (-f) + * - CRLF normalization (str_replace-based) + * - Diagnostics: clipped stdout/stderr on failure; warns if stderr on success + * + * Customize: + * - Pass a custom $allowlist (array of absolute paths) in the constructor. + * - Toggle $allowSymlinks (default true) to allow symlinks that resolve + * to a canonical allowlisted target. + * - Inject filesystem check callables (is_executable/is_link/is_file) for testing. + * + * @category Xmf\Mail + * @package Xmf + * @author XOOPS Development Team + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +final class SendmailRunner +{ + /** @var string[] absolute paths considered for allowlisting */ + private array $allowlist; + + /** @var string[] canonical realpaths of allowlisted binaries */ + private array $allowlistCanonical; + + /** @var bool allow symlinks that resolve to a canonical allowlist target */ + private bool $allowSymlinks; + + /** @var callable(string):bool */ + private $isExecutable; + /** @var callable(string):bool */ + private $isLink; + /** @var callable(string):bool */ + private $isFile; + + public function __construct( + ?array $allowlist = null, + ?callable $isExecutable = null, + ?callable $isLink = null, + ?callable $isFile = null, + bool $allowSymlinks = true + ) { + $this->allowlist = $allowlist ?? [ + '/usr/sbin/sendmail', + '/usr/lib/sendmail', + '/usr/bin/sendmail', + '/usr/bin/msmtp', + '/usr/sbin/ssmtp', + '/usr/local/sbin/sendmail', + '/usr/local/bin/sendmail', + ]; + $this->isExecutable = $isExecutable ?? 'is_executable'; + $this->isLink = $isLink ?? 'is_link'; + $this->isFile = $isFile ?? 'is_file'; + $this->allowSymlinks = $allowSymlinks; + + // Build canonical allowlist by resolving real targets of allowlisted entries. + $canon = []; + foreach ($this->allowlist as $p) { + $rp = realpath($p); // string|false + if (is_string($rp)) { + $canon[$rp] = true; // set-like de-dupe + } + } + $this->allowlistCanonical = array_keys($canon); + } + + /** + * Discover installed, allowlisted binaries (literal allowlist entries that + * currently exist and meet executable criteria). Symlinks accepted only if + * they pass isValidBinary() and policy allows them. + * + * @return string[] list of literal paths from the allowlist that are valid + */ + public function discover(): array + { + $found = []; + foreach ($this->allowlist as $path) { + $real = realpath($path); // string|false + $ok = $this->isValidBinary($path, is_string($real) ? $real : null); + if ($ok) { + $found[] = $path; + } + } + // Keep literal paths for UI consistency; remove duplicates just in case. + return array_values(array_unique($found)); + } + + /** + * Validate an absolute ASCII path against format, allowlist policy, + * canonical real target, and filesystem permissions. + * + * @return string|null the canonical (resolved) path if valid; null otherwise + */ + public function validatePath(string $path): ?string + { + $path = trim($path); + if (!preg_match('~^/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+$~', $path)) { + return null; + } + + $resolved = realpath($path); // string|false + if (!is_string($resolved)) { + return null; + } + + if ($resolved === $path) { + // Not a symlink: the literal path must be allowlisted. + if (!in_array($path, $this->allowlist, true)) { + return null; + } + } else { + // Symlink: allow only if policy permits and the resolved target is canonical-allowlisted. + if (!$this->allowSymlinks || !in_array($resolved, $this->allowlistCanonical, true)) { + return null; + } + } + + return $this->isValidBinary($path, $resolved) ? $resolved : null; + } + + /** + * Deliver an RFC 5322 message via sendmail -t -i, optionally with -f. + * + * @param string $sendmailPath validated absolute path (literal form) + * @param string $rfc822 headers + CRLF CRLF + body + * @param string|null $envelopeFrom optional envelope sender (validated) + * + * @return void + * + * @throws SendmailException on invalid path, process startup or pipe-open failures, + * write failures, premature pipe closure, or non-zero exit + */ + public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeFrom = null): void + { + $validatedPath = $this->validatePath($sendmailPath); + if ($validatedPath === null) { + throw SendmailException::invalidPath(); + } + + // Normalize line endings to CRLF for RFC 5322 compliance (two-step, no double expansion). + $rfc822 = str_replace("\r\n", "\n", $rfc822); + $rfc822 = str_replace("\n", "\r\n", $rfc822); + + // Prefer the literal path if it resolves to the same canonical target; else use canonical. + $literal = $sendmailPath; + $resolved = realpath($literal); + if (!is_string($resolved) || $resolved !== $validatedPath) { + $literal = $validatedPath; + } + + $argv = [$literal]; + + // Optional, strictly-validated envelope sender (-f). + $validatedEnvelopeFrom = $this->validateEnvelopeFrom($envelopeFrom); + if ($validatedEnvelopeFrom !== null) { + $argv[] = '-f'; + $argv[] = $validatedEnvelopeFrom; + } + + // Safe flags only. + $argv[] = '-t'; + $argv[] = '-i'; + + $spec = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $pipes = []; + $proc = @proc_open($argv, $spec, $pipes, null, null, ['bypass_shell' => true]); + if (!is_resource($proc)) { + throw SendmailException::failedToStartProcess(); + } + /** @var array{0?: resource, 1?: resource, 2?: resource} $pipes */ + $stdin = $pipes[0] ?? null; + $stdoutPipe = $pipes[1] ?? null; + $stderrPipe = $pipes[2] ?? null; + if (!is_resource($stdin) || !is_resource($stdoutPipe) || !is_resource($stderrPipe)) { + proc_close($proc); + throw SendmailException::failedToOpenPipes(); + } + + $stdout = ''; + $stderr = ''; + $code = null; + stream_set_blocking($stdin, false); + stream_set_blocking($stdoutPipe, false); + stream_set_blocking($stderrPipe, false); + + try { + $len = strlen($rfc822); + $off = 0; + $stdinOpen = true; + $chunkSize = 8192; + + if ($len === 0) { + fclose($stdin); + $stdinOpen = false; + } + + while ($stdinOpen || !feof($stdoutPipe) || !feof($stderrPipe)) { + $read = []; + $write = []; + $except = null; + + if (!feof($stdoutPipe)) { + $read[] = $stdoutPipe; + } + if (!feof($stderrPipe)) { + $read[] = $stderrPipe; + } + if ($stdinOpen && $off < $len) { + $write[] = $stdin; + } + if ($read === [] && $write === []) { + break; + } + + $ready = @stream_select($read, $write, $except, 1); + if ($ready === false) { + throw SendmailException::failedToOpenPipes(); + } + if ($ready === 0) { + if ($stdinOpen && $off < $len && feof($stdin)) { + throw SendmailException::prematurePipeClosure(); + } + continue; + } + + foreach ($write as $stream) { + if ($stream !== $stdin) { + continue; + } + $chunk = substr($rfc822, $off, $chunkSize); + $n = @fwrite($stdin, $chunk); + if ($n === false) { + throw SendmailException::writeFailure(); + } + if ($n === 0) { + if (feof($stdin)) { + throw SendmailException::prematurePipeClosure(); + } + continue; + } + $off += $n; + if ($off >= $len) { + fclose($stdin); + $stdinOpen = false; + } + } + + foreach ($read as $stream) { + $chunk = stream_get_contents($stream); + if ($chunk === false || $chunk === '') { + continue; + } + if ($stream === $stdoutPipe) { + $stdout .= $chunk; + } elseif ($stream === $stderrPipe) { + $stderr .= $chunk; + } + } + } + + if ($stdinOpen && $off < $len) { + throw SendmailException::prematurePipeClosure(); + } + } finally { + if (is_resource($stdin)) { + fclose($stdin); + } + if (is_resource($stdoutPipe)) { + fclose($stdoutPipe); + } + if (is_resource($stderrPipe)) { + fclose($stderrPipe); + } + // $proc is guaranteed a resource here (checked right after proc_open), + // so close it unconditionally; this also makes $code an int below. + $code = proc_close($proc); + } + + // Warn if stderr contains content despite success. + if ($code === 0 && $stderr !== '') { + trigger_error('sendmail warning (success): ' . $this->clipForLog($stderr), E_USER_WARNING); + } + + if ($code !== 0) { + $sOut = $this->clipForLog($stdout); + $sErr = $this->clipForLog($stderr); + $first = $this->firstLine($stderr); + $displayPath = basename($literal); + trigger_error( + "sendmail failure: path={$displayPath} code={$code} stderr=\"{$sErr}\" stdout=\"{$sOut}\"", + E_USER_WARNING + ); + throw SendmailException::exitedWithCode($code, $first); + } + } + + /* ====================== helpers ====================== */ + + /** + * Filesystem checks for the target binary. + * Uses $real (canonical target) when provided; otherwise uses $path. + */ + private function isValidBinary(string $path, ?string $real = null): bool + { + $target = $real ?? $path; + + if (!($this->isFile)($target) || !($this->isExecutable)($target)) { + return false; + } + // If symlinks are globally disallowed, reject when the input is a symlink. + if (!$this->allowSymlinks && ($this->isLink)($path)) { + return false; + } + return true; + } + + /** + * Validate an email address for use in -f (envelope sender). + * Returns sanitized address or null if unusable. + */ + private function validateEnvelopeFrom(?string $addr): ?string + { + if ($addr === null || $addr === '') { + return null; + } + // Extract if a "Name " form was supplied. + if (preg_match('/<([^>]+)>/', $addr, $m)) { + $addr = $m[1]; + } + // Forbid any whitespace/control to prevent header/arg injection. + if (preg_match('/\s/', $addr) || preg_match('/[\r\n]/', $addr)) { + return null; + } + return filter_var($addr, FILTER_VALIDATE_EMAIL) ? $addr : null; + } + + /** Clip a string for logs (remove most control chars, escape line breaks, limit length). */ + private function clipForLog(string $s, int $max = 400): string + { + $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $s) ?? ''; + $s = str_replace(["\r", "\n"], ['\\r', '\\n'], $s); + if (strlen($s) > $max) { + $s = substr($s, 0, $max) . '…'; + } + return $s; + } + + /** Get the first (non-empty) line from a blob, for concise error messages. */ + private function firstLine(string $s): string + { + $pos = strpos($s, "\n"); + $line = $pos === false ? $s : substr($s, 0, $pos); + return $this->clipForLog($line, 200); + } +} diff --git a/xoops_lib/Xmf/Metagen.php b/xoops_lib/Xmf/Metagen.php index e801b0d94..dc741a67d 100644 --- a/xoops_lib/Xmf/Metagen.php +++ b/xoops_lib/Xmf/Metagen.php @@ -1,4 +1,5 @@ * @author trabis - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Metagen { - /** * mbstring encoding */ - const ENCODING = 'UTF-8'; + public const ENCODING = 'UTF-8'; /** * horizontal ellipsis * This will be used to replace omitted text. */ - const ELLIPSIS = "…"; // unicode horizontal ellipsis U+2026 + public const ELLIPSIS = "..."; /** * assignTitle set the page title @@ -59,7 +59,7 @@ public static function assignTitle($title) */ public static function assignKeywords($keywords) { - if (!empty($keywords) && is_array($keywords)) { + if (!empty($keywords) && \is_array($keywords)) { $keyword_tag = implode(', ', $keywords); static::assignThemeMeta('keywords', $keyword_tag); } @@ -151,7 +151,8 @@ public static function generateKeywords( if (static::stopWordsObject()->check($originalKeyword)) { $secondRoundKeywords = explode("'", $originalKeyword); foreach ($secondRoundKeywords as $secondRoundKeyword) { - if (static::stopWordsObject()->check($secondRoundKeyword) + if ( + static::stopWordsObject()->check($secondRoundKeyword) && strlen($secondRoundKeyword) >= $minLength ) { $keyCount[$secondRoundKeyword] = @@ -269,10 +270,9 @@ protected static function nonEmptyString($var) public static function generateSeoTitle($title = '', $extension = '') { $title = preg_replace("/[^\p{N}\p{L}]/u", "-", $title); - $title = \Normalizer::normalize($title, \Normalizer::FORM_C); $tableau = explode("-", $title); - $tableau = array_filter($tableau, 'static::nonEmptyString'); + $tableau = array_filter($tableau, static::nonEmptyString(...)); $tableau = array_filter($tableau, array(static::stopWordsObject(), 'check')); $title = implode("-", $tableau); @@ -352,7 +352,7 @@ public static function getSearchSummary($haystack, $needles = null, $length = 12 */ protected static function asPlainText($rawText) { - $text = $rawText; + $text = static::normalizeMultilingual($rawText); $text = static::html2text($text); $text = static::purifyText($text); @@ -362,6 +362,51 @@ protected static function asPlainText($rawText) return trim($text); } + /** @var callable|null normalizer applied to reduce multilingual markup */ + protected static $multilingualNormalizer; + + /** + * Register a callback that reduces multilingual markup to a single language + * before description/keyword extraction — for example selecting the active + * language from markup such as [en]...[/en]. + * + * A module such as xlanguage can register its processor (e.g. + * Xlanguage\Utility::cleanMultiLang) from a preload, so XMF needs no hard + * dependency on it. Pass null to clear. + * + * @param callable|null $normalizer signature: function (string $text): string + * + * @return void + */ + public static function setMultilingualNormalizer(?callable $normalizer) + { + static::$multilingualNormalizer = $normalizer; + } + + /** + * Apply the registered multilingual normalizer, if any, before plain-text + * extraction. No-op when none is registered — which avoids both the crash + * reported when xlanguage markup reached Metagen (issue #86) and the data + * loss of a blanket bracket strip (which would mash every language together + * and remove legitimate bracketed content). Override in a subclass for a + * different strategy. + * + * @param string $text raw text possibly containing multilingual markup + * + * @return string text reduced to the active language, or unchanged + */ + protected static function normalizeMultilingual($text) + { + if (is_string($text) && null !== static::$multilingualNormalizer) { + $result = call_user_func(static::$multilingualNormalizer, $text); + if (is_string($result)) { + $text = $result; + } + } + + return $text; + } + /** * getNeedlePositions - Essentially this is a strpos() for an array of needles. * Given a haystack and an array of needles, return an array of all initial @@ -404,7 +449,7 @@ protected static function purifyText($text, $keyword = false) $text = str_replace('
', ' ', $text); $text = str_replace(' - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Admin { - /** * The real ModuleAdmin object * @@ -49,7 +49,7 @@ protected function __construct() /** * Retrieve a module admin instance * - * If we are on a next generation system this will be the a native Xoops\Module\Admin instance. + * If we are on a next generation system this will be the native Xoops\Module\Admin instance. * Older systems with the Frameworks based admin class will get an instance of this class which * provides compatible methods built from the old Frameworks version. * @@ -62,11 +62,11 @@ public static function getInstance() if ($instance === null) { if (class_exists('\Xoops\Module\Admin', true)) { - $instance = new \Xoops\Module\Admin; + $instance = new \Xoops\Module\Admin(); static::$ModuleAdmin = $instance; } else { include_once $GLOBALS['xoops']->path('Frameworks/moduleclasses/moduleadmin/moduleadmin.php'); - static::$ModuleAdmin = new \ModuleAdmin; + static::$ModuleAdmin = new \ModuleAdmin(); Language::load('xmf'); $instance = new static(); } @@ -231,6 +231,18 @@ public function displayIndex() echo $this->renderIndex(); } + /** + * Render the navigation menu + * + * @param string $menu menu key (script name, i.e. index.php) + * + * @return string + */ + public function renderNavigation($menu = '') + { + return static::$ModuleAdmin->addNavigation($menu); + } + /** * Display the navigation menu * @@ -280,7 +292,7 @@ public function addConfigError($value = '') $line = ""; $line .= ""; $line .= ""; - $line .= $value; + $line .= htmlspecialchars($value, ENT_QUOTES, defined('_CHARSET') ? \constant('_CHARSET') : 'UTF-8'); $line .= ""; $value = $line; $type = 'default'; @@ -301,7 +313,7 @@ public function addConfigAccept($value = '') $line = ""; $line .= ""; $line .= ""; - $line .= $value; + $line .= htmlspecialchars($value, ENT_QUOTES, defined('_CHARSET') ? \constant('_CHARSET') : 'UTF-8'); $line .= ""; $value = $line; $type = 'default'; @@ -322,7 +334,7 @@ public function addConfigWarning($value = '') $line = ""; $line .= ""; $line .= ""; - $line .= $value; + $line .= htmlspecialchars($value, ENT_QUOTES, defined('_CHARSET') ? \constant('_CHARSET') : 'UTF-8'); $line .= ""; $value = $line; $type = 'default'; @@ -335,7 +347,7 @@ public function addConfigWarning($value = '') * Check for installed module and version and do addConfigBoxLine() * * @param string $moddir - module directory name - * @param integer $minversion - minimum acceptable module version (100 = V1.00) + * @param integer $minversion - minimum acceptable module version * * @return bool true if requested version of the module is available */ @@ -345,15 +357,15 @@ public function addConfigModuleVersion($moddir, $minversion) $helper = Helper::getHelper($moddir); if (is_object($helper) && is_object($helper->getModule())) { $mod_modversion = $helper->getModule()->getVar('version'); - $mod_version_f = $mod_modversion / 100; - $min_version_f = $minversion / 100; + $mod_version_f = $mod_modversion; + $min_version_f = $minversion; $value = sprintf( _AM_XMF_MODULE_VERSION, strtoupper($moddir), $min_version_f, $mod_version_f ); - if ($mod_modversion >= $minversion) { + if ($helper->getModule()->versionCompare($min_version_f, $mod_version_f, '<=')) { $this->addConfigAccept($value); $return = true; } else { @@ -363,7 +375,7 @@ public function addConfigModuleVersion($moddir, $minversion) $value = sprintf( _AM_XMF_MODULE_NOTFOUND, strtoupper($moddir), - $minversion / 100 + $minversion ); $this->addConfigError($value); } @@ -448,7 +460,7 @@ public static function iconUrl($name = '', $size = '32') } /** - * set paypal for 2.5.x renderAbout + * set PayPal for 2.5.x renderAbout * * not part of next generation Xoops\Module\Admin * diff --git a/xoops_lib/Xmf/Module/Helper.php b/xoops_lib/Xmf/Module/Helper.php index 6124c5d01..772de8186 100644 --- a/xoops_lib/Xmf/Module/Helper.php +++ b/xoops_lib/Xmf/Module/Helper.php @@ -1,4 +1,5 @@ * @author Richard Griffith - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Helper extends GenericHelper @@ -47,7 +48,7 @@ public static function getHelper($dirname = 'system') if (class_exists('Xoops', false)) { $instance[$dirname] = \Xoops\Module\Helper::getHelper($dirname); } else { - // otherwise get a GenericHelper instance for dirname + // otherwise, get a GenericHelper instance for dirname if (xoops_isActiveModule($dirname)) { $instance[$dirname] = new static($dirname); } diff --git a/xoops_lib/Xmf/Module/Helper/AbstractHelper.php b/xoops_lib/Xmf/Module/Helper/AbstractHelper.php index 8ba1cfc19..3f7ce7367 100644 --- a/xoops_lib/Xmf/Module/Helper/AbstractHelper.php +++ b/xoops_lib/Xmf/Module/Helper/AbstractHelper.php @@ -1,4 +1,5 @@ * @author Richard Griffith - * @copyright 2016-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ abstract class AbstractHelper @@ -67,7 +68,7 @@ public function __construct($dirname = null) if (isset($xoops)) { $moduleHandler = $xoops->getHandlerModule(); } else { - /* @var \XoopsModuleHandler $moduleHandler */ + /** @var \XoopsModuleHandler $moduleHandler */ $moduleHandler = xoops_getHandler('module'); } $this->module = $moduleHandler->getByDirname($dirname); @@ -121,7 +122,7 @@ public function addLog($log) if ($this->debug) { $message = $this->serializeForHelperLog($log); if (class_exists('Xoops', false)) { - \Xoops::getInstance()->logger()->debug($message, array('channel'=>'Extra')); + \Xoops::getInstance()->logger()->debug($message, array('channel' => 'Extra')); } elseif (is_object($GLOBALS['xoopsLogger'])) { $GLOBALS['xoopsLogger']->addExtra(get_called_class(), $message); } @@ -137,8 +138,8 @@ public function addLog($log) */ protected function serializeForHelperLog($value) { - if (is_resource($value) || !is_null(@get_resource_type($value))) { - $value = '(resource)'; + if (is_resource($value)) { + $value = '(resource:' . get_resource_type($value) . ')'; } if (!is_string($value)) { $value = json_encode($value); diff --git a/xoops_lib/Xmf/Module/Helper/GenericHelper.php b/xoops_lib/Xmf/Module/Helper/GenericHelper.php index 574fc4ee3..0ceb600cd 100644 --- a/xoops_lib/Xmf/Module/Helper/GenericHelper.php +++ b/xoops_lib/Xmf/Module/Helper/GenericHelper.php @@ -1,4 +1,5 @@ * @author Richard Griffith - * @copyright 2016-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ abstract class GenericHelper extends AbstractHelper { + /** Path segment for the modules directory, relative to the site root. */ + private const MODULE_PATH = '/modules/'; + /** * @var \XoopsModule * @deprecated - use $module -- will be removed @@ -136,12 +140,13 @@ public function getHandler($name) protected function initObject() { global $xoopsModule; - if (isset($xoopsModule) && is_object($xoopsModule) + if ( + isset($xoopsModule) && is_object($xoopsModule) && $xoopsModule->getVar('dirname') === $this->dirname ) { $this->module = $xoopsModule; } else { - /* @var \XoopsModuleHandler $module_handler */ + /** @var \XoopsModuleHandler $module_handler */ $module_handler = xoops_getHandler('module'); $this->module = $module_handler->getByDirname($this->dirname); } @@ -157,13 +162,14 @@ protected function initConfig() { $this->addLog('INIT CONFIG'); global $xoopsModule; - if (isset($xoopsModule) && is_object($xoopsModule) + if ( + isset($xoopsModule) && is_object($xoopsModule) && $xoopsModule->getVar('dirname') === $this->dirname ) { global $xoopsModuleConfig; $this->configs = $xoopsModuleConfig; } else { - /* @var \XoopsConfigHandler $config_handler */ + /** @var \XoopsConfigHandler $config_handler */ $config_handler = xoops_getHandler('config'); $this->configs = $config_handler->getConfigsByCat(0, $this->getModule()->getVar('mid')); } @@ -249,7 +255,34 @@ public function isUserAdmin() */ public function url($url = '') { - return XOOPS_URL . '/modules/' . $this->dirname . '/' . $url; + return XOOPS_URL . self::MODULE_PATH . $this->dirname . '/' . $url; + } + + /** + * Return a root relative URL for a module relative URL. + * + * Unlike url(), this omits the XOOPS_URL scheme and host and returns a path + * rooted at the web server root - the form expected by APIs such as + * $xoTheme->addScript(). The site's base path is taken from XOOPS_URL, so it + * is correct for a sub-directory install too: + * root install -> /modules/mymodule/assets/app.js + * /xoops subfolder -> /xoops/modules/mymodule/assets/app.js + * + * @param string $url module relative URL + * + * @return string + */ + public function relativeUrl($url = '') + { + $basePath = ''; + if (defined('XOOPS_URL')) { + $urlPath = parse_url((string) XOOPS_URL, PHP_URL_PATH); + if (is_string($urlPath)) { + $basePath = rtrim($urlPath, '/'); + } + } + + return $basePath . self::MODULE_PATH . $this->dirname . '/' . ltrim($url, '/'); } /** @@ -261,7 +294,7 @@ public function url($url = '') */ public function path($path = '') { - return XOOPS_ROOT_PATH . '/modules/' . $this->dirname . '/' . $path; + return XOOPS_ROOT_PATH . self::MODULE_PATH . $this->dirname . '/' . $path; } /** @@ -277,4 +310,38 @@ public function redirect($url, $time = 3, $message = '') { redirect_header($this->url($url), $time, $message); } + + /** + * Return absolute URL for a module relative upload file + * + * Uploads may be stored in special directories for many reasons, + * such as permissions, security, replication and directory balancing + * Rather than build their own URL's, modules should use this method + * which will know how to reference the upload now and in the future. + * + * @param string $url module relative upload URL + * + * @return string + */ + public function uploadUrl($url = '') + { + return XOOPS_UPLOAD_URL . '/' . $this->dirname . '/' . $url; + } + + /** + * Return absolute filesystem path for a module relative upload file + * + * Uploads may be stored in special directories for many reasons, + * such as permissions, security, replication and directory balancing + * Rather than build their own URL's, modules should use this method + * which will know how to reference the upload now and in the future. + * + * @param string $path module relative upload file path + * + * @return string + */ + public function uploadPath($path = '') + { + return XOOPS_UPLOAD_PATH . '/' . $this->dirname . '/' . $path; + } } diff --git a/xoops_lib/Xmf/Module/Helper/Permission.php b/xoops_lib/Xmf/Module/Helper/Permission.php index 5e8ea4175..73b32f3c1 100644 --- a/xoops_lib/Xmf/Module/Helper/Permission.php +++ b/xoops_lib/Xmf/Module/Helper/Permission.php @@ -78,6 +78,23 @@ public function checkPermission($gperm_name, $gperm_itemid, $trueifadmin = true) ); } + /** + * Get all item IDs for which a group (or set of groups) has a specific permission + * + * @param string $gperm_name Name of permission + * @param int|array $gperm_groupid A group ID or an array of group IDs + * + * @return array array of item IDs + */ + public function getItemIds($gperm_name, $gperm_groupid) + { + return $this->permissionHandler->getItemIds( + $gperm_name, + $gperm_groupid, + $this->mid + ); + } + /** * Redirect to a url if user does not have permission for an item * diff --git a/xoops_lib/Xmf/Module/Helper/Session.php b/xoops_lib/Xmf/Module/Helper/Session.php index c3d992aa9..b70d98d9e 100644 --- a/xoops_lib/Xmf/Module/Helper/Session.php +++ b/xoops_lib/Xmf/Module/Helper/Session.php @@ -1,4 +1,5 @@ * @author Richard Griffith - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Session extends AbstractHelper @@ -82,7 +83,7 @@ public function get($name, $default = false) { $prefixedName = $this->prefix($name); if (isset($_SESSION[$prefixedName])) { - return unserialize($_SESSION[$prefixedName]); + return unserialize($_SESSION[$prefixedName], ['allowed_classes' => false]); } else { return $default; } diff --git a/xoops_lib/Xmf/ProxyCheck.php b/xoops_lib/Xmf/ProxyCheck.php new file mode 100644 index 000000000..81b6a4bcf --- /dev/null +++ b/xoops_lib/Xmf/ProxyCheck.php @@ -0,0 +1,171 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + */ +class ProxyCheck +{ + public const PROXY_ENVIRONMENT_VARIABLE = 'proxy_env'; + + public const FORWARDED = 'HTTP_FORWARDED'; + + /** @var string|false header name determines how to process */ + protected $proxyHeaderName = false; + + /** @var string|false header data to process */ + protected $proxyHeader = false; + + /** + * ProxyCheck constructor. + */ + public function __construct() + { + /* must declare expected proxy in $xoopsConfig['proxy_env'] */ + $this->proxyHeaderName = $this->getProxyEnvConfig(); + $this->proxyHeader = $this->getProxyHeader(); + } + + /** + * Get IP address from proxy header specified in $xoopsConfig['proxy_env'] + * + * Returns proxy revealed valid client address, or false if such address was + * not found. + * + * @return string|false + */ + public function get() + { + if (false === $this->proxyHeaderName || false === $this->proxyHeader) { + return false; + } + $proxyVars = $this->splitOnComma($this->proxyHeader); + // only consider the first (left most) value + $header = reset($proxyVars); + $ip = false; + switch ($this->proxyHeaderName) { + case static::FORWARDED: + $ip = $this->getFor($header); + break; + default: + $ip = $this->getXForwardedFor($header); + break; + } + + return $ip; + } + + /** + * Split comma delimited string + * + * @param string $header + * + * @return string[] + */ + protected function splitOnComma($header) + { + $parts = explode(',', $header); + return array_map('trim', $parts); + } + + /** + * get configured proxy environment variable + * + * @return string|bool + */ + protected function getProxyEnvConfig() + { + global $xoopsConfig; + + /* must declare expected proxy in $xoopsConfig['proxy_env'] */ + if ( + !isset($xoopsConfig[static::PROXY_ENVIRONMENT_VARIABLE]) + || empty($xoopsConfig[static::PROXY_ENVIRONMENT_VARIABLE]) + ) { + return false; + } + return trim($xoopsConfig[static::PROXY_ENVIRONMENT_VARIABLE]); + } + + /** + * get the configured proxy header + * + * @return string|false + */ + protected function getProxyHeader() + { + if (false === $this->proxyHeaderName || empty($_SERVER[$this->proxyHeaderName])) { + return false; + } + + // Use PHP 5.3 compatible type casting + return (string)$_SERVER[$this->proxyHeaderName]; + } + + /** + * Extract 'for' IP address in FORWARDED header as in RFC 7239 + * + * @param string $header + * + * @return string|false IP address, or false if invalid + */ + protected function getFor($header) + { + $start = strpos($header, 'for='); + if ($start === false) { + return false; + } + $ip = substr($header, $start + 4); + $end = strpos($ip, ';'); + if ($end !== false) { + $ip = substr($ip, 0, $end); + } + $ip = trim($ip, '"[] '); + + return $this->validateRoutableIP($ip); + } + + /** + * Process an X-Forwarded-For or Client-IP style header + * + * @param string $ip expected to be an IP address + * + * @return string|false IP address, or false if invalid + */ + protected function getXForwardedFor($ip) + { + return $this->validateRoutableIP($ip); + } + + /** + * Validate that an IP address is routable + * + * @param string $ip an IP address to validate + * + * @return string|false IP address or false if invalid + */ + protected function validateRoutableIP($ip) + { + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + return $ip; + } +} diff --git a/xoops_lib/Xmf/Random.php b/xoops_lib/Xmf/Random.php index 9f7c8cce0..9814dfe8c 100644 --- a/xoops_lib/Xmf/Random.php +++ b/xoops_lib/Xmf/Random.php @@ -1,4 +1,5 @@ - * @copyright 2015-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Random diff --git a/xoops_lib/Xmf/Request.php b/xoops_lib/Xmf/Request.php index 17a71cd9c..62cdfbdf1 100644 --- a/xoops_lib/Xmf/Request.php +++ b/xoops_lib/Xmf/Request.php @@ -1,4 +1,5 @@ * @author trabis * @author Joomla! - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org */ class Request @@ -34,9 +35,9 @@ class Request /** * Available masks for cleaning variables */ - const MASK_NO_TRIM = 1; - const MASK_ALLOW_RAW = 2; - const MASK_ALLOW_HTML = 4; + public const MASK_NO_TRIM = 1; + public const MASK_ALLOW_RAW = 2; + public const MASK_ALLOW_HTML = 4; /** * Gets the request method @@ -66,12 +67,13 @@ public static function getMethod() * - cookie $_COOKIE * - env $_ENV * - server $_SERVER + * - session $_SESSION (returns default if no active session) * - method via current $_SERVER['REQUEST_METHOD'] * - default $_REQUEST * * @param string $name Variable name * @param mixed $default Default value if the variable does not exist - * @param string $hash Source of variable value (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Source of variable value (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * @param string $type Return type for the variable (INT, FLOAT, BOOLEAN, WORD, * ALPHANUM, CMD, BASE64, STRING, ARRAY, PATH, NONE) For more * information see FilterInput::clean(). @@ -83,7 +85,7 @@ public static function getVar($name, $default = null, $hash = 'default', $type = { // Ensure hash and type are uppercase $hash = strtoupper($hash); - if ($hash === 'METHOD') { + if ('METHOD' === $hash) { $hash = static::getMethod(); } $type = strtoupper($type); @@ -108,21 +110,26 @@ public static function getVar($name, $default = null, $hash = 'default', $type = case 'SERVER': $input = &$_SERVER; break; + case 'SESSION': + if (session_status() !== PHP_SESSION_ACTIVE) { + $input = []; + break; + } + $input = $_SESSION; + break; default: $input = &$_REQUEST; break; } - if (isset($input[$name]) && $input[$name] !== null) { + if (isset($input[$name]) && null !== $input[$name]) { // Get the variable from the input hash and clean it $var = static::cleanVar($input[$name], $mask, $type); + } elseif (null !== $default) { + // Clean the default value + $var = static::cleanVar($default, $mask, $type); } else { - if ($default !== null) { - // Clean the default value - $var = static::cleanVar($default, $mask, $type); - } else { - $var = $default; - } + $var = $default; } return $var; @@ -137,13 +144,13 @@ public static function getVar($name, $default = null, $hash = 'default', $type = * * @param string $name Variable name * @param int $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return int Requested variable */ public static function getInt($name, $default = 0, $hash = 'default') { - return static::getVar($name, $default, $hash, 'int'); + return (int) static::getVar($name, $default, $hash, 'int'); } /** @@ -155,13 +162,13 @@ public static function getInt($name, $default = 0, $hash = 'default') * * @param string $name Variable name * @param float $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return float Requested variable */ public static function getFloat($name, $default = 0.0, $hash = 'default') { - return static::getVar($name, $default, $hash, 'float'); + return (float) static::getVar($name, $default, $hash, 'float'); } /** @@ -173,13 +180,13 @@ public static function getFloat($name, $default = 0.0, $hash = 'default') * * @param string $name Variable name * @param bool $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return bool Requested variable */ public static function getBool($name, $default = false, $hash = 'default') { - return static::getVar($name, $default, $hash, 'bool'); + return (bool) static::getVar($name, $default, $hash, 'bool'); } /** @@ -191,7 +198,7 @@ public static function getBool($name, $default = false, $hash = 'default') * * @param string $name Variable name * @param string $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return string Requested variable */ @@ -208,7 +215,7 @@ public static function getWord($name, $default = '', $hash = 'default') * * @param string $name Variable name * @param string $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return string Requested variable */ @@ -226,7 +233,7 @@ public static function getCmd($name, $default = '', $hash = 'default') * * @param string $name Variable name * @param string $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * @param int $mask Filter mask for the variable * * @return string Requested variable @@ -242,7 +249,7 @@ public static function getString($name, $default = '', $hash = 'default', $mask * * @param string $name Variable name * @param mixed $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return array */ @@ -256,7 +263,7 @@ public static function getArray($name, $default = array(), $hash = 'default') * * @param string $name Variable name * @param string $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return string Requested variable */ @@ -270,7 +277,7 @@ public static function getText($name, $default = '', $hash = 'default') * * @param string $name Variable name * @param string $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return string Requested variable */ @@ -284,7 +291,7 @@ public static function getUrl($name, $default = '', $hash = 'default') * * @param string $name Variable name * @param string $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return string Requested variable */ @@ -298,7 +305,7 @@ public static function getPath($name, $default = '', $hash = 'default') * * @param string $name Variable name * @param string $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return string email address or default if invalid */ @@ -313,7 +320,7 @@ public static function getEmail($name, $default = '', $hash = 'default') * * @param string $name Variable name * @param string $default Default value if the variable does not exist - * @param string $hash Where the var should come from (POST, GET, FILES, COOKIE, METHOD) + * @param string $hash Where the var should come from (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * * @return string IP address or default if invalid */ @@ -350,13 +357,14 @@ public static function getDateTime($name, $default = null, $hash = 'default') /** * get request header * - * @param string $headerName name of header to retrieve, case insensitive + * @param string $headerName name of header to retrieve, case-insensitive * @param string|null $default default to return if named header is not found * * @return string header value or default if header was not found */ public static function getHeader($headerName, $default = '') { + /** @var string[] $headers */ static $headers = null; if (null === $headers) { @@ -367,10 +375,10 @@ public static function getHeader($headerName, $default = '') $headers[strtolower($name)] = $value; } } else { - // From joyview - http://php.net/manual/en/function.getallheaders.php + // From joyview - https://php.net/manual/en/function.getallheaders.php foreach ($_SERVER as $name => $value) { - if (substr($name, 0, 5) === 'HTTP_') { - $translatedName = str_replace(' ', '-', strtolower(str_replace('_', ' ', substr($name, 5)))); + if ('HTTP_' === substr($name, 0, 5)) { + $translatedName = (string)str_replace(' ', '-', strtolower(str_replace('_', ' ', substr($name, 5)))); $headers[$translatedName] = $value; } } @@ -379,7 +387,13 @@ public static function getHeader($headerName, $default = '') $name = strtolower($headerName); if (isset($headers[$name])) { - return static::cleanVar($headers[$name]); + $headerValue = $headers[$name]; + if (is_string($headerValue)) { + $cleanedHeader = static::cleanVar($headerValue); + if (is_string($cleanedHeader)) { + return $cleanedHeader; + } + } } return $default; } @@ -390,12 +404,12 @@ public static function getHeader($headerName, $default = '') * @param string $name variable to look for * @param string $hash hash to check * - * @return boolean True if hash has an element 'name', otherwise false + * @return bool True if hash has an element 'name', otherwise false */ public static function hasVar($name, $hash = 'default') { $hash = strtoupper($hash); - if ($hash === 'METHOD') { + if ('METHOD' === $hash) { $hash = strtoupper($_SERVER['REQUEST_METHOD']); } @@ -410,17 +424,19 @@ public static function hasVar($name, $hash = 'default') /** * Set a variable in one of the request variables * - * @param string $name Name - * @param string $value Value - * @param string $hash Hash - * @param boolean $overwrite Boolean + * For SESSION, the write is silently skipped if no session is active. + * + * @param string $name Name + * @param mixed $value Value + * @param string $hash Hash (GET, POST, REQUEST, COOKIE, FILES, ENV, SERVER, SESSION, METHOD) + * @param bool $overwrite Boolean * - * @return string Previous value + * @return mixed Previous value */ public static function setVar($name, $value = null, $hash = 'method', $overwrite = true) { $hash = strtoupper($hash); - if ($hash === 'METHOD') { + if ('METHOD' === $hash) { $hash = strtoupper($_SERVER['REQUEST_METHOD']); } @@ -457,10 +473,15 @@ public static function setVar($name, $value = null, $hash = 'method', $overwrite $_FILES[$name] = $value; break; case 'ENV': - $_ENV['name'] = $value; + $_ENV[$name] = $value; break; case 'SERVER': - $_SERVER['name'] = $value; + $_SERVER[$name] = $value; + break; + case 'SESSION': + if (session_status() === PHP_SESSION_ACTIVE) { + $_SESSION[$name] = $value; + } break; } @@ -482,10 +503,11 @@ public static function setVar($name, $value = null, $hash = 'method', $overwrite * - cookie $_COOKIE * - env $_ENV * - server $_SERVER + * - session $_SESSION (returns empty if no active session) * - method via current $_SERVER['REQUEST_METHOD'] * - default $_REQUEST * - * @param string $hash to get (POST, GET, FILES, METHOD) + * @param string $hash to get (GET, POST, FILES, COOKIE, ENV, SERVER, SESSION, METHOD, DEFAULT/REQUEST) * @param int $mask Filter mask for the variable * * @return mixed Request hash @@ -494,7 +516,7 @@ public static function get($hash = 'default', $mask = 0) { $hash = strtoupper($hash); - if ($hash === 'METHOD') { + if ('METHOD' === $hash) { $hash = strtoupper($_SERVER['REQUEST_METHOD']); } @@ -517,6 +539,13 @@ public static function get($hash = 'default', $mask = 0) case 'SERVER': $input = &$_SERVER; break; + case 'SESSION': + if (session_status() !== PHP_SESSION_ACTIVE) { + $input = []; + break; + } + $input = &$_SESSION; + break; default: $input = $_REQUEST; break; @@ -530,9 +559,9 @@ public static function get($hash = 'default', $mask = 0) /** * Sets a request variable * - * @param array $array An associative array of key-value pairs - * @param string $hash The request variable to set (POST, GET, FILES, METHOD) - * @param boolean $overwrite If true and an existing key is found, the value is overwritten, + * @param array $array An associative array of key-value pairs + * @param string $hash The request variable to set (GET, POST, REQUEST, COOKIE, FILES, ENV, SERVER, SESSION, METHOD) + * @param bool $overwrite If true and an existing key is found, the value is overwritten, * otherwise it is ignored * * @return void @@ -557,7 +586,7 @@ public static function set($array, $hash = 'method', $overwrite = true) * - If no bits other than the 1 bit is set, a strict filter is applied. * @param string $type The variable type. See {@link FilterInput::clean()}. * - * @return string + * @return mixed */ protected static function cleanVar($var, $mask = 0, $type = null) { @@ -566,7 +595,7 @@ protected static function cleanVar($var, $mask = 0, $type = null) static $safeHtmlFilter = null; // convert $var in array if $type is ARRAY - if (strtolower($type) === 'array' && !is_array($var)) { + if ('array' === strtolower((string)$type) && !is_array($var)) { $var = array($var); } @@ -576,7 +605,7 @@ protected static function cleanVar($var, $mask = 0, $type = null) } // Now we handle input filtering - // If the allow raw flag is set, do not modify the variable + // If the 'allow raw' flag is set, do not modify the variable if (!($mask & static::MASK_ALLOW_RAW)) { if ($mask & static::MASK_ALLOW_HTML) { // If the allow html flag is set, apply a safe html filter to the variable @@ -589,7 +618,7 @@ protected static function cleanVar($var, $mask = 0, $type = null) if (null === $noHtmlFilter) { $noHtmlFilter = FilterInput::getInstance(); } - $var = $noHtmlFilter->clean($var, $type); + $var = $noHtmlFilter::clean($var, $type); } } @@ -603,7 +632,7 @@ protected static function cleanVar($var, $mask = 0, $type = null) * @param int $mask Filter bit mask. See {@link Request::cleanVar()} * @param string $type The variable type. See {@link FilterInput::clean()}. * - * @return string + * @return mixed */ protected static function cleanVars($var, $mask = 0, $type = null) { @@ -617,4 +646,20 @@ protected static function cleanVars($var, $mask = 0, $type = null) return $var; } + + /** + * Strips slashes recursively on an array + * + * @param string|array $value string of Array of (nested arrays of) strings + * + * @return array The input array with stripslashes applied to it + */ + protected static function stripSlashesRecursive($value) + { + $value = is_array($value) + ? array_map(array(get_called_class(), 'stripSlashesRecursive'), $value) + : stripslashes($value); + + return $value; + } } diff --git a/xoops_lib/Xmf/Security/DecompressionException.php b/xoops_lib/Xmf/Security/DecompressionException.php new file mode 100644 index 000000000..6f8b2d6e1 --- /dev/null +++ b/xoops_lib/Xmf/Security/DecompressionException.php @@ -0,0 +1,29 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +class DecompressionException extends \RuntimeException +{ +} diff --git a/xoops_lib/Xmf/Security/Format.php b/xoops_lib/Xmf/Security/Format.php new file mode 100644 index 000000000..7f93806ae --- /dev/null +++ b/xoops_lib/Xmf/Security/Format.php @@ -0,0 +1,40 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +final class Format +{ + public const JSON = 'json'; + public const PHP = 'php'; + public const LEGACY = 'legacy'; // base64-encoded PHP serialize + public const AUTO = 'auto'; // auto-detect format + + /** + * Prevent instantiation + */ + private function __construct() + { + } +} diff --git a/xoops_lib/Xmf/Security/SerializableTrait.php b/xoops_lib/Xmf/Security/SerializableTrait.php new file mode 100644 index 000000000..85781ae95 --- /dev/null +++ b/xoops_lib/Xmf/Security/SerializableTrait.php @@ -0,0 +1,152 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +trait SerializableTrait +{ + /** + * Serialize a property for database storage + * + * Uses Serializer::toJson() for Format::JSON or Serializer::toPhp() + * for Format::PHP. Other formats are not supported for serialization. + * + * @param mixed $value The value to serialize + * @param string $format The target format (Format::JSON or Format::PHP) + * + * @return string The serialized representation + * + * @throws \InvalidArgumentException On unsupported format + */ + protected function serializeProperty($value, string $format = Format::JSON): string + { + if ($format === Format::JSON) { + return Serializer::toJson($value); + } + if ($format === Format::PHP) { + return Serializer::toPhp($value); + } + + throw new \InvalidArgumentException( + sprintf('Unsupported serialization format "%s"', $format) + ); + } + + /** + * Deserialize a property from database + * + * Uses Serializer::tryFrom() with Format::AUTO to auto-detect format. + * + * @param string $data The serialized data + * @param mixed $default Default value if deserialization fails + * @param array $allowedClasses Whitelist of allowed class names + * + * @return mixed The deserialized value or default + */ + protected function unserializeProperty(string $data, $default = null, array $allowedClasses = []) + { + return Serializer::tryFrom($data, $default, Format::AUTO, $allowedClasses); + } + + /** + * Serialize all marked properties + * + * Iterates getSerializableProperties() and uses serializeProperty() + * to produce an associative array of serialized values. + * + * @return array Map of property names to serialized values + * + * @throws \InvalidArgumentException On unsupported format in serializeProperty() + */ + public function serializeProperties(): array + { + $serialized = []; + + foreach ($this->getSerializableProperties() as $property => $format) { + if (property_exists($this, $property)) { + try { + $serialized[$property] = $this->serializeProperty($this->$property, $format); + } catch (\Error $e) { + // Typed property may not be initialized — skip it + } + } + } + + return $serialized; + } + + /** + * Define which properties should be serialized + * + * Override in your class to specify properties and their formats. + * Example: return ['forum_moderators' => Format::JSON]; + * + * @return array Map of property name to Format constant + */ + protected function getSerializableProperties(): array + { + return [ + // 'property_name' => Format::JSON + ]; + } + + /** + * Migrate serialized data from old to new format + * + * Requires the using class to implement setVar() (e.g. XoopsObject). + * Migration is automatically tracked via Serializer::setLegacyLogger() + * when the legacy logger is configured. + * + * @param string $property Property name to migrate + * @param string $oldData Current serialized data + * + * @return bool True if migration was performed + */ + public function migrateSerializedData(string $property, string $oldData): bool + { + $format = Serializer::detect($oldData); + + if ($format === Format::PHP || $format === Format::LEGACY) { + try { + $value = $this->unserializeProperty($oldData); + $newData = $this->serializeProperty($value, Format::JSON); + $this->setVar($property, $newData); + + return true; + } catch (\Throwable $e) { + \error_log(\sprintf( + 'Serializer migration failed for property "%s": %s', + $property, + $e->getMessage() + )); + + return false; + } + } + + return false; + } +} diff --git a/xoops_lib/Xmf/Security/Serializer.php b/xoops_lib/Xmf/Security/Serializer.php new file mode 100644 index 000000000..5ba1a26a6 --- /dev/null +++ b/xoops_lib/Xmf/Security/Serializer.php @@ -0,0 +1,937 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +final class Serializer +{ + private const MAX_SIZE = 5000000; // 5MB + private const JSON_DEPTH = 512; + private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; + + /** @var \Closure|null Optional logger for legacy format detection */ + private static $legacyLogger = null; + + private static bool $debugMode = false; + + /** @var array|null}> */ + private static array $debugLog = []; + + private static ?float $startTime = null; + + // ═══════════════════════════════════════════════════════════ + // JSON Methods (Recommended for new code) + // ═══════════════════════════════════════════════════════════ + + /** + * Serialize data to JSON + * + * @param mixed $data + * + * @return string + * + * @throws JsonException On encoding failure + * @throws RuntimeException If encoded output exceeds MAX_SIZE + */ + public static function toJson($data): string + { + $json = json_encode($data, self::JSON_FLAGS); + + if (\strlen($json) > self::MAX_SIZE) { + throw new RuntimeException( + sprintf('Serialized JSON exceeds maximum allowed size of %d bytes', self::MAX_SIZE) + ); + } + + return $json; + } + + /** + * Deserialize JSON string + * + * @param string $json + * + * @return mixed + * + * @throws JsonException On invalid JSON + * @throws UnexpectedValueException On empty input + * @throws RuntimeException If payload exceeds MAX_SIZE + */ + public static function fromJson(string $json) + { + if ($json === '') { + throw new UnexpectedValueException('Cannot deserialize empty JSON'); + } + + if (\strlen($json) > self::MAX_SIZE) { + throw new RuntimeException( + sprintf('JSON payload exceeds maximum size of %d bytes', self::MAX_SIZE) + ); + } + + return json_decode($json, true, self::JSON_DEPTH, JSON_THROW_ON_ERROR); + } + + // ═══════════════════════════════════════════════════════════ + // PHP Serialize Methods (For complex data structures) + // ═══════════════════════════════════════════════════════════ + + /** + * Serialize data using PHP's native format + * + * Note: only validates top-level types; nested resources or closures + * within arrays/objects are not checked. + * + * @param mixed $data + * + * @return string + * + * @throws \InvalidArgumentException On unsupported types + */ + public static function toPhp($data): string + { + if (is_resource($data)) { + throw new \InvalidArgumentException('Cannot serialize resources'); + } + if ($data instanceof \Closure) { + throw new \InvalidArgumentException('Cannot serialize closures'); + } + + try { + $serialized = serialize($data); + } catch (\Throwable $e) { + throw new \InvalidArgumentException( + 'Failed to serialize data: contains unsupported type (possibly nested)', + 0, + $e + ); + } + + if (\strlen($serialized) > self::MAX_SIZE) { + throw new RuntimeException( + sprintf('Serialized PHP data exceeds maximum allowed size of %d bytes', self::MAX_SIZE) + ); + } + + return $serialized; + } + + // ═══════════════════════════════════════════════════════════ + // Debug Mode for Serializer + // ═══════════════════════════════════════════════════════════ + + /** + * Enable or disable debug mode + * + * @param bool $enable + * + * @return void + */ + public static function enableDebug(bool $enable = true): void + { + self::$debugMode = $enable; + if ($enable) { + self::$startTime = microtime(true); + self::$debugLog = []; + } else { + self::$startTime = null; + self::$debugLog = []; + } + } + + /** + * Get collected debug statistics + * + * @return array{total_operations: int, total_time: float, formats_detected: array, slow_operations: array|null}>, errors: array|null}>}|array{} + */ + public static function getDebugStats(): array + { + if (!self::$debugMode || self::$startTime === null) { + return []; + } + + $totalTime = microtime(true) - self::$startTime; + $formats = []; + foreach (array_count_values(array_column(self::$debugLog, 'format')) as $format => $count) { + $formats[(string) $format] = $count; + } + + $slowOperations = array_values( + array_filter(self::$debugLog, static fn(array $log): bool => $log['time'] > 0.01) + ); + /** @var array|null}> $errors */ + $errors = array_values( + array_filter(self::$debugLog, static fn(array $log): bool => $log['error'] !== null) + ); + + return [ + 'total_operations' => count(self::$debugLog), + 'total_time' => round($totalTime, 4), + 'formats_detected' => $formats, + 'slow_operations' => $slowOperations, + 'errors' => $errors + ]; + } + + /** + * Internal helper to log debug info + * + * @param string $operation + * @param string $format + * @param float $time + * @param string|null $error + * + * @return void + */ + private static function debug(string $operation, string $format, float $time, ?string $error = null): void + { + if (!self::$debugMode) { + return; + } + + // Only capture backtrace for errors to avoid performance overhead + $trace = null; + if ($error !== null) { + // Capture a short backtrace instead of relying on a fixed stack depth + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); + } + + self::$debugLog[] = [ + 'operation' => $operation, + 'format' => $format, + 'time' => round($time, 6), + 'memory' => memory_get_usage(true), + 'error' => $error, + 'trace' => $trace + ]; + } + + /** + * Deserialize PHP serialized string (secure by default) + * + * @param string $payload The serialized string + * @param array $allowedClasses Whitelist of allowed classes (empty = no objects) + * + * @return mixed + * + * @throws RuntimeException On security violation + * @throws UnexpectedValueException On deserialization failure + */ + public static function fromPhp(string $payload, array $allowedClasses = []) + { + $start = self::$debugMode ? microtime(true) : 0; + + try { + self::validateInput($payload); + self::validateSecurity($payload, empty($allowedClasses)); + $result = self::unserialize($payload, $allowedClasses); + + if (self::$debugMode) { + self::debug('fromPhp', Format::PHP, microtime(true) - $start); + } + + return $result; + } catch (\Throwable $e) { + if (self::$debugMode) { + self::debug('fromPhp', Format::PHP, microtime(true) - $start, $e->getMessage()); + } + throw $e; + } + } + + // ═══════════════════════════════════════════════════════════ + // Legacy Support (For migration from old XOOPS data) + // ═══════════════════════════════════════════════════════════ + + /** + * Create legacy format (base64-encoded serialized data) + * + * @deprecated Use toJson() for new code + * + * @param mixed $data + * + * @return string + * + * @throws \InvalidArgumentException On unsupported types (resource, closure) + */ + public static function toLegacy($data): string + { + return base64_encode(self::toPhp($data)); + } + + /** + * Deserialize legacy format (handles plain, base64, and optional gzip) + * + * @param string $payload + * @param array $allowedClasses Whitelist of allowed classes + * + * @return mixed + * + * @throws RuntimeException On security or format violation + * @throws UnexpectedValueException On deserialization failure + */ + public static function fromLegacy(string $payload, array $allowedClasses = []) + { + self::validateInput($payload); + self::validateSecurity($payload, empty($allowedClasses)); + + // Try plain PHP serialize first + $result = self::tryUnserialize($payload, $allowedClasses); + if ($result !== null) { + self::logLegacy($payload); + return $result; + } + + // Check if it looks like base64 before attempting decode + if (!self::isLikelyBase64($payload)) { + throw new RuntimeException('Invalid legacy format: not serialized or base64'); + } + + // Try base64-decoded + $decoded = base64_decode($payload, true); + if ($decoded === false) { + throw new RuntimeException('Invalid legacy format: base64 decode failed'); + } + + // Validate size after decoding + self::validateSize($decoded); + + // Check for optional gzip compression — stream-decompress to enforce size cap + if (self::isGzip($decoded)) { + $decoded = self::safeGzipDecode($decoded); + } + + self::logLegacy($payload); + self::validateSecurity($decoded, empty($allowedClasses)); + + return self::unserialize($decoded, $allowedClasses); + } + + // ═══════════════════════════════════════════════════════════ + // Smart Methods (Format detection and conversion) + // ═══════════════════════════════════════════════════════════ + + /** + * Deserialize with automatic format detection + * + * @param string $payload + * @param array $allowedClasses For PHP/legacy formats + * + * @return mixed + * + * @throws JsonException On JSON parse failure + * @throws RuntimeException On security or format violation + * @throws UnexpectedValueException On deserialization failure + */ + public static function from(string $payload, array $allowedClasses = []) + { + $format = self::detect($payload); + + // Use detected format if confident + if ($format === Format::JSON) { + return self::fromJson($payload); + } elseif ($format === Format::PHP) { + return self::fromPhp($payload, $allowedClasses); + } elseif ($format === Format::LEGACY) { + return self::fromLegacy($payload, $allowedClasses); + } + + // Format::AUTO - try sequence for migrations + try { + return self::fromJson($payload); + } catch (JsonException $e) { + // Not JSON, try other formats + } + + if (self::looksLikeSerialized(ltrim($payload))) { + return self::fromPhp($payload, $allowedClasses); + } + + return self::fromLegacy($payload, $allowedClasses); + } + + /** + * Detect the serialization format without deserializing + * + * @param string $payload + * + * @return string One of Format::* constants + */ + public static function detect(string $payload): string + { + if ($payload === '') { + return Format::AUTO; + } + + $trimmed = ltrim($payload); + + // Check JSON (use trimmed for consistency with the leading-char check) + if (isset($trimmed[0]) && ($trimmed[0] === '{' || $trimmed[0] === '[')) { + if (self::isValidJson($trimmed)) { + return Format::JSON; + } + } + + // Check PHP serialized + if (self::looksLikeSerialized($trimmed)) { + return Format::PHP; + } + + // Check base64-encoded serialized + if (self::isLikelyBase64($payload)) { + $decoded = base64_decode($payload, true); + if ( + $decoded !== false + && \strlen($decoded) <= self::MAX_SIZE + && self::looksLikeSerialized($decoded) + ) { + return Format::LEGACY; + } + } + + return Format::AUTO; + } + + // ═══════════════════════════════════════════════════════════ + // Convenience Methods (Type-safe helpers) + // ═══════════════════════════════════════════════════════════ + + /** + * Deserialize expecting an array result + * + * @param string $payload + * @param string $format + * + * @return array + * + * @throws UnexpectedValueException When result is not an array + * @throws JsonException On JSON parse failure + * @throws \InvalidArgumentException On unsupported format + */ + public static function toArray(string $payload, string $format = Format::AUTO): array + { + if ($format === Format::JSON) { + $data = self::fromJson($payload); + } elseif ($format === Format::PHP) { + $data = self::fromPhp($payload); + } elseif ($format === Format::LEGACY) { + $data = self::fromLegacy($payload); + } elseif ($format === Format::AUTO) { + $data = self::from($payload); + } else { + throw new \InvalidArgumentException( + sprintf('Unsupported format "%s"', $format) + ); + } + + if (!is_array($data)) { + throw new UnexpectedValueException( + sprintf('Expected array, got %s', self::getDebugType($data)) + ); + } + + return $data; + } + + /** + * Deserialize expecting a specific class instance + * + * @param string $payload + * @param string $className Fully-qualified class name + * @param string $format + * + * @return object + * + * @throws \InvalidArgumentException When $className does not exist + * @throws RuntimeException On unsupported format + * @throws UnexpectedValueException On type mismatch + */ + public static function toObject(string $payload, string $className, string $format = Format::PHP): object + { + if (!class_exists($className)) { + throw new \InvalidArgumentException( + sprintf('Class "%s" does not exist', $className) + ); + } + + /** @var array $allowed */ + $allowed = [$className]; + + if ($format !== Format::PHP && $format !== Format::LEGACY) { + throw new RuntimeException('Objects only supported in PHP/Legacy formats'); + } + + $data = ($format === Format::PHP) + ? self::fromPhp($payload, $allowed) + : self::fromLegacy($payload, $allowed); + + if (!$data instanceof $className) { + throw new UnexpectedValueException( + sprintf('Expected %s, got %s', $className, self::getDebugType($data)) + ); + } + + return $data; + } + + /** + * Safe deserialization with fallback + * + * @param string $payload + * @param mixed $default + * @param string $format + * @param array $allowedClasses + * + * @return mixed + */ + public static function tryFrom(string $payload, $default = null, string $format = Format::AUTO, array $allowedClasses = []) + { + try { + if ($format === Format::JSON) { + return self::fromJson($payload); + } elseif ($format === Format::PHP) { + return self::fromPhp($payload, $allowedClasses); + } elseif ($format === Format::LEGACY) { + return self::fromLegacy($payload, $allowedClasses); + } elseif ($format === Format::AUTO) { + return self::from($payload, $allowedClasses); + } + + return $default; + } catch (\Throwable $e) { + return $default; + } + } + + /** + * Try JSON-only deserialization (for mixed-field migrations) + * + * @param string $payload + * + * @return mixed|null Returns null on any failure + */ + public static function jsonOnly(string $payload) + { + if ($payload === '' || !isset(ltrim($payload)[0])) { + return null; + } + + // Single json_decode with try-catch avoids redundant double-parsing + // that would occur if isValidJson() were called first + try { + return json_decode($payload, true, self::JSON_DEPTH, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + return null; + } + } + + /** + * Deserialize expecting scalar or null + * + * @param string $payload + * @param string $format + * + * @return string|int|float|bool|null + * + * @throws UnexpectedValueException When result is not scalar or null + * @throws JsonException On JSON parse failure + * @throws RuntimeException On security or format violation + * @throws \InvalidArgumentException On unsupported format + */ + public static function scalarsOnly(string $payload, string $format = Format::AUTO) + { + if ($format === Format::JSON) { + $v = self::fromJson($payload); + } elseif ($format === Format::PHP) { + $v = self::fromPhp($payload); + } elseif ($format === Format::LEGACY) { + $v = self::fromLegacy($payload); + } elseif ($format === Format::AUTO) { + $v = self::from($payload); + } else { + throw new \InvalidArgumentException( + sprintf('Unsupported format "%s"', $format) + ); + } + + if (!is_scalar($v) && $v !== null) { + throw new UnexpectedValueException('Expected scalar or null'); + } + + return $v; + } + + // ═══════════════════════════════════════════════════════════ + // Configuration + // ═══════════════════════════════════════════════════════════ + + /** + * Set a logger to track legacy format usage (for migration tracking) + * + * @param callable|null $logger fn(string $file, int $line, string $preview): void + * + * @return void + */ + public static function setLegacyLogger(?callable $logger): void + { + self::$legacyLogger = $logger ? \Closure::fromCallable($logger) : null; + } + + // ═══════════════════════════════════════════════════════════ + // Private Helpers + // ═══════════════════════════════════════════════════════════ + + /** + * @param string $payload + * + * @return void + * + * @throws UnexpectedValueException + * @throws RuntimeException + */ + private static function validateInput(string $payload): void + { + if ($payload === '') { + throw new UnexpectedValueException('Cannot deserialize empty string'); + } + + if (\strlen($payload) > self::MAX_SIZE) { + throw new RuntimeException( + sprintf('Payload exceeds maximum size of %d bytes', self::MAX_SIZE) + ); + } + } + + /** + * @param string $payload + * + * @return void + * + * @throws RuntimeException + */ + private static function validateSize(string $payload): void + { + if (\strlen($payload) > self::MAX_SIZE) { + throw new RuntimeException( + sprintf('Decoded payload exceeds %d bytes', self::MAX_SIZE) + ); + } + } + + /** + * Decompress gzip data with streaming size enforcement. + * + * Uses inflate_init()/inflate_add() to decompress in chunks, aborting + * as soon as output exceeds MAX_SIZE. This prevents a small compressed + * payload from consuming unbounded memory during decompression. + * + * @param string $data gzip-compressed data + * + * @return string decompressed data + * + * @throws DecompressionException on decompression failure or size exceeded + */ + private static function safeGzipDecode(string $data): string + { + $context = inflate_init(ZLIB_ENCODING_GZIP); + if ($context === false) { + throw new DecompressionException('Failed to initialize gzip decompression'); + } + + $output = ''; + $offset = 0; + $chunkSize = 8192; + $maxSize = self::MAX_SIZE; + + while ($offset < \strlen($data)) { + $chunk = substr($data, $offset, $chunkSize); + $offset += \strlen($chunk); + + $inflated = inflate_add($context, $chunk); + if ($inflated === false) { + throw new DecompressionException('Gzip decompression failed'); + } + $output .= $inflated; + + if (\strlen($output) > $maxSize) { + throw new DecompressionException( + sprintf('Decompressed payload exceeds %d bytes', $maxSize) + ); + } + } + + // Flush remaining and verify stream completed + $inflated = inflate_add($context, '', ZLIB_FINISH); + if ($inflated === false) { + throw new DecompressionException('Gzip decompression failed during final flush'); + } + if (inflate_get_status($context) !== ZLIB_STREAM_END) { + throw new DecompressionException('Gzip stream is incomplete or truncated'); + } + if ($inflated !== '') { + $output .= $inflated; + if (\strlen($output) > $maxSize) { + throw new DecompressionException( + sprintf('Decompressed payload exceeds %d bytes', $maxSize) + ); + } + } + + return $output; + } + + /** + * Secondary defense against object injection in scalar/array payloads. + * + * PHP serialized objects use NUL bytes to mark private/protected + * property visibility boundaries. Legitimate scalar/array payloads + * should never contain them. This complements allowed_classes=false + * set in unserialize(). + * + * @param string $payload + * @param bool $noObjects + * + * @return void + * + * @throws RuntimeException + */ + private static function validateSecurity(string $payload, bool $noObjects): void + { + if ($noObjects && strpos($payload, "\0") !== false) { + throw new RuntimeException('NUL bytes detected in scalar/array payload'); + } + } + + /** + * Core unserialize with error handling + * + * @param string $payload + * @param array $allowedClasses + * + * @return mixed + * + * @throws UnexpectedValueException + */ + private static function unserialize(string $payload, array $allowedClasses) + { + /** @var array{allowed_classes: array|false} $options */ + $options = ['allowed_classes' => $allowedClasses !== [] ? array_values($allowedClasses) : false]; + + set_error_handler( + static function (int $severity, string $message): bool { + if ($severity !== E_WARNING && $severity !== E_NOTICE) { + return false; + } + // Only suppress warnings/notices that originate from unserialize() + return strpos($message, 'unserialize():') === 0; + }, + E_WARNING | E_NOTICE + ); + + try { + $result = \unserialize($payload, $options); + } finally { + restore_error_handler(); + } + + if ($result === false && $payload !== 'b:0;') { + throw new UnexpectedValueException('Failed to unserialize payload'); + } + + return $result; + } + + /** + * Attempt unserialization, returning null on failure instead of throwing. + * + * @param string $payload + * @param array $allowedClasses + * + * @return mixed|null + */ + private static function tryUnserialize(string $payload, array $allowedClasses) + { + try { + return self::unserialize($payload, $allowedClasses); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Check if a string looks like PHP serialized data + * + * @param string $str + * + * @return bool + */ + private static function looksLikeSerialized(string $str): bool + { + if (\strlen($str) < 2) { + return false; + } + + switch ($str[0]) { + case 'N': + return \strpos($str, 'N;') === 0; + + case 'i': + case 'b': + case 'r': + case 'R': + return (bool) preg_match('/^[ibrR]:-?\d+;/', $str); + + case 'd': + return (bool) preg_match('/^d:-?(?:\d+\.?\d*|\d*\.?\d+)(?:[eE][+-]?\d+)?;/', $str); + + case 's': + return (bool) preg_match('/^s:\d+:"/', $str); + + case 'a': + return (bool) preg_match('/^a:\d+:\{/', $str); + + case 'O': + case 'C': + return (bool) preg_match('/^[OC]:\d+:"/', $str); + + default: + return false; + } + } + + /** + * Check if data starts with gzip magic bytes + * + * @param string $bin + * + * @return bool + */ + private static function isGzip(string $bin): bool + { + return isset($bin[0], $bin[1]) && $bin[0] === "\x1f" && $bin[1] === "\x8b"; + } + + /** + * Heuristic check for base64-encoded data + * + * @param string $s + * + * @return bool + */ + private static function isLikelyBase64(string $s): bool + { + $len = \strlen($s); + + // Require reasonable minimum length and proper block alignment + if ($len < 16 || ($len % 4) !== 0) { + return false; + } + + return preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $s) === 1; + } + + /** + * Validate JSON string + * + * @param string $s + * + * @return bool + */ + private static function isValidJson(string $s): bool + { + // Use json_validate if available (PHP 8.3+) + if (function_exists('json_validate')) { + return json_validate($s, self::JSON_DEPTH) === true; + } + + // Fallback for older PHP versions + try { + json_decode($s, true, self::JSON_DEPTH, JSON_THROW_ON_ERROR); + return true; + } catch (JsonException $e) { + return false; + } + } + + /** + * Log legacy format detection + * + * @param string $payload + * + * @return void + */ + private static function logLegacy(string $payload): void + { + if (self::$legacyLogger === null) { + return; + } + + $preview = \substr($payload, 0, 50) . (\strlen($payload) > 50 ? '...' : ''); + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); + $caller = ['file' => 'unknown', 'line' => 0]; + foreach ($trace as $frame) { + if (!isset($frame['file'], $frame['line'])) { + continue; + } + if (isset($frame['class']) && $frame['class'] === self::class) { + continue; + } + $caller = $frame; + break; + } + + (self::$legacyLogger)( + $caller['file'], + $caller['line'], + $preview + ); + } + + /** + * PHP 7.4 compatible version of get_debug_type() + * + * @param mixed $value + * + * @return string + */ + private static function getDebugType($value): string + { + if (function_exists('get_debug_type')) { + return get_debug_type($value); + } + + // PHP 7.4 fallback + return is_object($value) ? get_class($value) : gettype($value); + } +} diff --git a/xoops_lib/Xmf/Security/example.md b/xoops_lib/Xmf/Security/example.md new file mode 100644 index 000000000..fd48fbb8a --- /dev/null +++ b/xoops_lib/Xmf/Security/example.md @@ -0,0 +1,52 @@ +## Debug Mode for Serializer + +**Enable during migration testing** +```php +Serializer::enableDebug(); +``` + +**Run your operations** +```php +$data1 = Serializer::from($payload1); +$data2 = Serializer::from($payload2); +``` + +**Get performance stats** +```php +$stats = Serializer::getDebugStats(); +// For debugging only; disable debug mode in production to avoid performance impact. +error_log('Serializer stats: ' . json_encode($stats)); +``` + + +## SerializableTrait + +```php +use Xmf\Security\SerializableTrait; +use Xmf\Security\Format; + +class ForumForum extends XoopsObject +{ + use SerializableTrait; + + protected function getSerializableProperties(): array + { + return [ + 'forum_moderators' => Format::JSON, + 'forum_settings' => Format::JSON, + ]; + } + + public function getModerators(): array + { + $data = $this->getVar('forum_moderators'); + return $this->unserializeProperty($data, [], []); // no object deserialization allowed + } + + public function setModerators(array $moderators): void + { + $data = $this->serializeProperty($moderators); + $this->setVar('forum_moderators', $data); + } +} +``` diff --git a/xoops_lib/Xmf/StopWords.php b/xoops_lib/Xmf/StopWords.php index 48723fe9a..2439eee74 100644 --- a/xoops_lib/Xmf/StopWords.php +++ b/xoops_lib/Xmf/StopWords.php @@ -1,4 +1,5 @@ * @author trabis - * @copyright 2011-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org * @see https://en.wikipedia.org/wiki/Stop_words */ class StopWords { - /** * mbstring encoding */ - const ENCODING = 'UTF-8'; + public const ENCODING = 'UTF-8'; - /** @var string[] */ + /** @var array */ protected $stopwordList = array(); /** diff --git a/xoops_lib/Xmf/Ulid.php b/xoops_lib/Xmf/Ulid.php new file mode 100644 index 000000000..d0f9e7c5c --- /dev/null +++ b/xoops_lib/Xmf/Ulid.php @@ -0,0 +1,652 @@ + + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @link https://xoops.org + */ +class Ulid +{ + /** + * Crockford's Base32 alphabet (excludes I, L, O, U to avoid confusion) + */ + public const ENCODING_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + + /** + * Number of characters in the encoding alphabet + */ + public const ENCODING_LENGTH = 32; + + /** + * Length of the time portion in characters + */ + public const TIME_LENGTH = 10; + + /** + * Length of the random portion in characters + */ + public const RANDOM_LENGTH = 16; + + /** + * Total ULID length in characters + */ + public const ULID_LENGTH = 26; + + /** + * Maximum valid timestamp (2^48 - 1 milliseconds, year 10889) + */ + public const MAX_TIME = 281474976710655; + + /** + * Length of binary ULID representation in bytes (128 bits) + */ + public const BINARY_LENGTH = 16; + + /** + * @var int|null Last timestamp used for monotonic generation + */ + private static $lastTime = null; + + /** + * @var string|null Last randomness used for monotonic generation + */ + private static $lastRandom = null; + + /** + * Ensure we are running on a 64-bit PHP build. + * + * All ULID timestamp operations require 64-bit integers because + * millisecond timestamps exceed PHP_INT_MAX on 32-bit builds. + * + * @throws \RuntimeException If PHP_INT_SIZE < 8 + */ + private static function require64Bit(): void + { + if (\PHP_INT_SIZE < 8) { + throw new \RuntimeException( + 'ULID timestamp operations require a 64-bit PHP build.' + ); + } + } + + /** + * Generate a new ULID. + * + * @param bool $upperCase Whether to return uppercase (default) or lowercase + * + * @return string The generated ULID (26 characters) + * @throws \Exception If random_bytes() fails + */ + public static function generate(bool $upperCase = true): string + { + $time = self::currentTimeMillis(); + $timeChars = self::encodeTime($time); + $randChars = self::encodeRandomness(); + $ulid = $timeChars . $randChars; + + return $upperCase ? $ulid : \strtolower($ulid); + } + + /** + * Get current time in milliseconds since Unix Epoch. + * + * @return int Milliseconds since 1970-01-01 00:00:00 UTC + */ + public static function currentTimeMillis(): int + { + self::require64Bit(); + + return (int) \floor(\microtime(true) * 1000); + } + + /** + * Encode a timestamp into a 10-character string. + * + * @param int $time Timestamp in milliseconds since Unix Epoch + * + * @return string 10-character encoded timestamp + * @throws \InvalidArgumentException If timestamp is negative or exceeds maximum + */ + public static function encodeTime(int $time): string + { + self::require64Bit(); + + if ($time < 0) { + throw new \InvalidArgumentException('Timestamp cannot be negative'); + } + + if ($time > self::MAX_TIME) { + throw new \InvalidArgumentException( + \sprintf('Timestamp %d exceeds maximum allowed value %d', $time, self::MAX_TIME) + ); + } + + $chars = self::ENCODING_CHARS; + $timeChars = ''; + + for ($i = self::TIME_LENGTH - 1; $i >= 0; $i--) { + $mod = $time % self::ENCODING_LENGTH; + $timeChars = $chars[$mod] . $timeChars; + $time = (int)(($time - $mod) / self::ENCODING_LENGTH); + } + return $timeChars; + } + + /** + * Encode 80 bits of randomness into a 16-character string. + * + * Uses an optimized bit-packing algorithm that extracts exactly 5 bits + * at a time from 10 random bytes (80 bits total), producing 16 base32 + * characters with no wasted bits. + * + * Bit layout (10 bytes = 80 bits → 16 × 5-bit characters): + * Byte 0: [4:0] → char 0, [7:5] → char 1 (partial) + * Byte 1: [1:0] → char 1 (cont), [6:2] → char 2, [7] → char 3 (partial) + * ... and so on + * + * @return string 16-character encoded randomness + * @throws \Exception If random_bytes() fails + */ + public static function encodeRandomness(): string + { + $chars = self::ENCODING_CHARS; + $randomBytes = \random_bytes(10); // 80 bits of randomness + $randChars = ''; + + // Convert bytes to an array of integers for bit manipulation + /** @var string[] $byteChars */ + $byteChars = \str_split($randomBytes); + $bytes = \array_map('ord', $byteChars); + + // Extract 16 groups of 5 bits from 80 bits (10 bytes) + // Process two bytes at a time to avoid any integer overflow concerns + // on 32-bit platforms. Maximum buffer size stays well within safe range. + $bitBuffer = 0; + $bitsInBuffer = 0; + $byteIndex = 0; + + for ($i = 0; $i < self::RANDOM_LENGTH; $i++) { + // Ensure we have at least 5 bits in the buffer + while ($bitsInBuffer < 5 && $byteIndex < 10) { + $bitBuffer = ($bitBuffer << 8) | $bytes[$byteIndex]; + $bitsInBuffer += 8; + $byteIndex++; + } + + // Extract the top 5 bits + $bitsInBuffer -= 5; + $value = ($bitBuffer >> $bitsInBuffer) & 0x1F; + // Mask the buffer to prevent unbounded growth + $bitBuffer &= (1 << $bitsInBuffer) - 1; + $randChars .= $chars[$value]; + } + return $randChars; + } + + /** + * Decode a ULID string into its components. + * + * @param string $ulid The ULID string to decode + * + * @return array{time: int, rand: string} Decoded components + * @throws \InvalidArgumentException If the ULID is invalid + */ + public static function decode(string $ulid): array + { + if (!self::isValid($ulid)) { + throw new \InvalidArgumentException('Invalid ULID string'); + } + + $ulid = \strtoupper($ulid); + + return [ + 'time' => self::decodeTime($ulid), + 'rand' => \substr($ulid, self::TIME_LENGTH), + ]; + } + + /** + * Decode the timestamp from a ULID string. + * + * @param string $ulid The ULID string + * + * @return int Timestamp in milliseconds since Unix Epoch + * @throws \InvalidArgumentException If the ULID is invalid + */ + public static function decodeTime(string $ulid): int + { + self::require64Bit(); + + if (!self::isValid($ulid)) { + throw new \InvalidArgumentException('Invalid ULID string'); + } + + $ulid = \strtoupper($ulid); + $time = 0; + + for ($i = 0; $i < self::TIME_LENGTH; $i++) { + $pos = \strpos(self::ENCODING_CHARS, $ulid[$i]); + if ($pos === false) { + // Callers validate via isValid() first, so this is an invariant guard; + // it also keeps a stray false from being silently coerced to 0. + throw new \InvalidArgumentException( + \sprintf('Invalid ULID character at position %d (0x%02X)', $i, \ord($ulid[$i])) + ); + } + $time = $time * self::ENCODING_LENGTH + $pos; + } + + return $time; + } + + /** + * Decode the randomness portion from a ULID string. + * + * Returns the 16-character base32 string representation of the + * random portion. Previous versions returned an integer, which is + * a backward-incompatible change (see changelog for v1.2.32). + * + * @param string $ulid The ULID string + * + * @return string The 16-character randomness portion + * @throws \InvalidArgumentException If the ULID is invalid + * + * @since 1.2.32 Return type changed from int to string + */ + public static function decodeRandomness(string $ulid): string + { + if (!self::isValid($ulid)) { + throw new \InvalidArgumentException('Invalid ULID string'); + } + + return \substr(\strtoupper($ulid), self::TIME_LENGTH); + } + + /** + * Validate a ULID string. + * + * @param string $ulid The string to validate + * + * @return bool True if valid, false otherwise + */ + public static function isValid(string $ulid): bool + { + // Check length + if (\strlen($ulid) !== self::ULID_LENGTH) { + return false; + } + + // Normalize to uppercase for validation + $ulid = \strtoupper($ulid); + + // Check all characters are valid Crockford Base32 + for ($i = 0; $i < self::ULID_LENGTH; $i++) { + if (\strpos(self::ENCODING_CHARS, $ulid[$i]) === false) { + return false; + } + } + + // Per ULID spec, timestamp is 48 bits, so the first character + // (most significant 5 bits) must encode a value in the range 0-7. + $firstCharPos = \strpos(self::ENCODING_CHARS, $ulid[0]); + if ($firstCharPos === false || $firstCharPos > 7) { + return false; + } + + return true; + } + + /** + * Convert a ULID to a UUID string format. + * + * Requires ext-bcmath. + * + * @param string $ulid The ULID to convert + * + * @return string UUID format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + * @throws \InvalidArgumentException If the ULID is invalid + * @throws \RuntimeException If BCMath extension is not available + */ + public static function toUuid(string $ulid): string + { + if (!\extension_loaded('bcmath')) { + throw new \RuntimeException('BCMath extension is required for UUID conversion'); + } + + if (!self::isValid($ulid)) { + throw new \InvalidArgumentException('Invalid ULID string'); + } + + $hex = self::toHex($ulid); + + return \sprintf( + '%s-%s-%s-%s-%s', + \substr($hex, 0, 8), + \substr($hex, 8, 4), + \substr($hex, 12, 4), + \substr($hex, 16, 4), + \substr($hex, 20, 12) + ); + } + + /** + * Create a ULID from a UUID string. + * + * Requires ext-bcmath. + * + * @param string $uuid The UUID to convert (with or without hyphens) + * + * @return string The ULID representation + * @throws \InvalidArgumentException If the UUID is invalid + * @throws \RuntimeException If BCMath extension is not available + */ + public static function fromUuid(string $uuid): string + { + if (!\extension_loaded('bcmath')) { + throw new \RuntimeException('BCMath extension is required for UUID conversion'); + } + + // Remove hyphens and validate + $hex = \str_replace('-', '', $uuid); + $hex = \strtolower($hex); + + if (\strlen($hex) !== 32) { + throw new \InvalidArgumentException( + 'Invalid UUID format: expected 32 hex characters, got ' . \strlen($hex) + ); + } + + if (!\ctype_xdigit($hex)) { + throw new \InvalidArgumentException('Invalid UUID: contains non-hexadecimal characters'); + } + + // Convert hex to decimal using BCMath + $decimal = '0'; + for ($i = 0; $i < 32; $i++) { + $decimal = (string) \bcmul($decimal, '16'); + $decimal = (string) \bcadd($decimal, (string) \hexdec($hex[$i])); + } + + // Convert decimal to base32 (ULID) + $ulid = ''; + for ($i = 0; $i < self::ULID_LENGTH; $i++) { + $remainder = (int) \bcmod($decimal, '32'); + $ulid = self::ENCODING_CHARS[$remainder] . $ulid; + $decimal = (string) \bcdiv($decimal, '32', 0); + } + + return $ulid; + } + + /** + * Convert a ULID to hexadecimal representation. + * + * @param string $ulid The ULID to convert + * + * @return string 32-character hexadecimal string + * + * @throws \InvalidArgumentException if $ulid contains a non-base32 character + */ + private static function toHex(string $ulid): string + { + $ulid = \strtoupper($ulid); + + // Convert base32 to a big integer, then to hex + // Using BCMath for arbitrary precision + $decimal = '0'; + + for ($i = 0; $i < self::ULID_LENGTH; $i++) { + $value = \strpos(self::ENCODING_CHARS, $ulid[$i]); + if ($value === false) { + // Callers validate first (isValid()), so this is an invariant guard; + // it also gives bcadd() a guaranteed numeric-string second argument. + throw new \InvalidArgumentException( + \sprintf('Invalid ULID character at position %d (0x%02X)', $i, \ord($ulid[$i])) + ); + } + $decimal = \bcmul($decimal, '32'); + $decimal = \bcadd($decimal, (string) $value); + } + + // Convert decimal to hex + $hex = ''; + while (\bccomp($decimal, '0') > 0) { + $remainder = (int) \bcmod($decimal, '16'); + $hex = \dechex($remainder) . $hex; + $decimal = (string) \bcdiv($decimal, '16', 0); + } + + // Pad to 32 characters + return \str_pad($hex, 32, '0', STR_PAD_LEFT); + } + + /** + * Get the timestamp from a ULID as a DateTime object. + * + * @param string $ulid The ULID string + * + * @return \DateTimeImmutable The timestamp as a DateTime object + * @throws \InvalidArgumentException If the ULID is invalid + */ + public static function getDateTime(string $ulid): \DateTimeImmutable + { + $timestamp = self::decodeTime($ulid); + $seconds = (int) ($timestamp / 1000); + $microseconds = ($timestamp % 1000) * 1000; + + $dateTime = \DateTimeImmutable::createFromFormat( + 'U u', + \sprintf('%d %06d', $seconds, $microseconds), + new \DateTimeZone('UTC') + ); + + if ($dateTime === false) { + throw new \RuntimeException('Failed to create DateTime from ULID timestamp'); + } + + return $dateTime->setTimezone(new \DateTimeZone('UTC')); + } + + /** + * Compare two ULIDs. + * + * @param string $ulid1 First ULID + * @param string $ulid2 Second ULID + * + * @return int -1 if $ulid1 < $ulid2, 0 if equal, 1 if $ulid1 > $ulid2 + */ + public static function compare(string $ulid1, string $ulid2): int + { + return \strcmp(\strtoupper($ulid1), \strtoupper($ulid2)) <=> 0; + } + + /** + * Generate a monotonically increasing ULID. + * + * Within the same millisecond, the random portion is incremented + * to guarantee strict ordering. When the timestamp advances, a + * fresh random portion is generated. + * + * @param bool $upperCase Whether to return uppercase (default) or lowercase + * + * @return string The generated ULID (26 characters) + * @throws \Exception If random_bytes() fails + */ + public static function generateMonotonic(bool $upperCase = true): string + { + $time = self::currentTimeMillis(); + + if (self::$lastTime !== null && $time <= self::$lastTime && self::$lastRandom !== null) { + // Same or earlier millisecond — use logical time and increment the random portion + $time = self::$lastTime; + + $currentRandom = self::$lastRandom; + $nextRandom = self::incrementBase32($currentRandom); + + if (\strcmp($nextRandom, $currentRandom) > 0) { + // No overflow: random portion increased, keep same logical time + self::$lastRandom = $nextRandom; + } else { + // Overflow: advance logical time by 1 ms and reset randomness + $time = self::$lastTime + 1; + self::$lastTime = $time; + self::$lastRandom = self::encodeRandomness(); + } + } else { + // New (later) millisecond — generate fresh randomness and update logical time + self::$lastRandom = self::encodeRandomness(); + self::$lastTime = $time; + } + + $ulid = self::encodeTime($time) . self::$lastRandom; + + return $upperCase ? $ulid : \strtolower($ulid); + } + + /** + * Reset the monotonic generation state. + * + * Useful for testing or when you want to start a fresh sequence. + * + * @return void + */ + public static function resetMonotonicState(): void + { + self::$lastTime = null; + self::$lastRandom = null; + } + + /** + * Convert a ULID string to a 16-byte binary representation. + * + * @param string $ulid The ULID to convert + * + * @return string 16-byte binary string + * @throws \InvalidArgumentException If the ULID is invalid + * @throws \RuntimeException If BCMath extension is not available + */ + public static function toBinary(string $ulid): string + { + if (!\extension_loaded('bcmath')) { + throw new \RuntimeException('BCMath extension is required for binary conversion'); + } + + if (!self::isValid($ulid)) { + throw new \InvalidArgumentException('Invalid ULID string'); + } + + $hex = self::toHex($ulid); + + return \hex2bin($hex); + } + + /** + * Create a ULID from a 16-byte binary representation. + * + * @param string $binary 16-byte binary string + * + * @return string The ULID representation + * @throws \InvalidArgumentException If the binary length is invalid + * @throws \RuntimeException If BCMath extension is not available + */ + public static function fromBinary(string $binary): string + { + if (!\extension_loaded('bcmath')) { + throw new \RuntimeException('BCMath extension is required for binary conversion'); + } + + if (\strlen($binary) !== self::BINARY_LENGTH) { + throw new \InvalidArgumentException( + 'Invalid binary length: expected 16, got ' . \strlen($binary) + ); + } + + $hex = \bin2hex($binary); + + return self::fromUuid($hex); + } + + /** + * Increment a base32 string by 1. + * + * @param string $base32 The base32 string to increment + * + * @return string The incremented base32 string + */ + private static function incrementBase32(string $base32): string + { + $chars = self::ENCODING_CHARS; + /** @var string[] $result */ + $result = \str_split($base32); + $carry = true; + + for ($i = \count($result) - 1; $i >= 0 && $carry; $i--) { + $pos = (int) \strpos($chars, $result[$i]); + $pos++; + if ($pos >= self::ENCODING_LENGTH) { + $result[$i] = $chars[0]; + // carry remains true + } else { + $result[$i] = $chars[$pos]; + $carry = false; + } + } + + return \implode('', $result); + } + + /** + * Convert microtime to ULID-compatible millisecond timestamp. + * + * @param float $microtime Microtime value (e.g. from microtime(true)) + * + * @return int Milliseconds since Unix Epoch + * + * @deprecated Use Ulid::currentTimeMillis() instead. This method previously + * subtracted a Y2K epoch offset which does not match the ULID spec. + * It now returns standard Unix epoch milliseconds for correctness. + */ + public static function microtimeToUlidTime($microtime): int + { + \trigger_error( + 'Ulid::microtimeToUlidTime() is deprecated. Use Ulid::currentTimeMillis() instead.', + \E_USER_DEPRECATED + ); + + return (int) \floor($microtime * 1000); + } +} diff --git a/xoops_lib/Xmf/Uuid.php b/xoops_lib/Xmf/Uuid.php index 8a9b3b940..f118a79b2 100644 --- a/xoops_lib/Xmf/Uuid.php +++ b/xoops_lib/Xmf/Uuid.php @@ -1,4 +1,5 @@ - * @copyright 2017-2019 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) */ class Uuid { // match spec for version 4 UUID as per rfc4122 - protected const UUID_REGEX = '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/'; + public const UUID_REGEX = '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/'; /** * generate - generate a version 4 (random) UUID * - * Based on comment by pavel.volyntsev(at)gmail at http://php.net/manual/en/function.com-create-guid.php + * Based on comment by pavel.volyntsev(at)gmail at https://php.net/manual/en/function.com-create-guid.php * * @return string UUID * diff --git a/xoops_lib/Xmf/Yaml.php b/xoops_lib/Xmf/Yaml.php index 3106afc9d..1d91bb932 100644 --- a/xoops_lib/Xmf/Yaml.php +++ b/xoops_lib/Xmf/Yaml.php @@ -1,4 +1,5 @@ - * @copyright 2013-2018 XOOPS Project (https://xoops.org) - * @license GNU GPL 2 or later (http://www.gnu.org/licenses/gpl-2.0.html) + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @link https://xoops.org * @see http://www.yaml.org/ */ class Yaml { - /** * Dump an PHP array as a YAML string * @@ -50,7 +50,7 @@ public static function dump($var, $inline = 4, $indent = 4) { try { $ret = VendorYaml::dump($var, $inline, $indent); - } catch (\Exception $e) { + } catch (\Throwable $e) { static::logError($e); $ret = false; } @@ -68,7 +68,7 @@ public static function load($yamlString) { try { $ret = VendorYaml::parse($yamlString); - } catch (\Exception $e) { + } catch (\Throwable $e) { static::logError($e); $ret = false; } @@ -80,14 +80,35 @@ public static function load($yamlString) * * @param string $yamlFile filename of YAML file * - * @return array|boolean PHP array or false on error + * @return array|false PHP array or false on error */ public static function read($yamlFile) { + if (!file_exists($yamlFile)) { + return false; + } + $maxSize = 2 * 1024 * 1024; // 2 MB + $fileSize = @filesize($yamlFile); + if ($fileSize === false) { + trigger_error("Unable to determine YAML file size", E_USER_WARNING); + return false; + } + if ($fileSize > $maxSize) { + trigger_error("YAML file exceeds maximum size of 2MB", E_USER_WARNING); + return false; + } + if (!is_readable($yamlFile)) { + trigger_error("Failed to read YAML file (not readable): " . basename($yamlFile), E_USER_WARNING); + return false; + } try { - $yamlString = file_get_contents($yamlFile); + $yamlString = @file_get_contents($yamlFile); + if ($yamlString === false) { + trigger_error("Failed to read YAML file: " . basename($yamlFile), E_USER_WARNING); + return false; + } $ret = VendorYaml::parse($yamlString); - } catch (\Exception $e) { + } catch (\Throwable $e) { static::logError($e); $ret = false; } @@ -109,7 +130,7 @@ public static function save($var, $yamlFile, $inline = 4, $indent = 4) try { $yamlString = VendorYaml::dump($var, $inline, $indent); $ret = file_put_contents($yamlFile, $yamlString); - } catch (\Exception $e) { + } catch (\Throwable $e) { static::logError($e); $ret = false; } @@ -119,10 +140,10 @@ public static function save($var, $yamlFile, $inline = 4, $indent = 4) /** * Dump an PHP array as a YAML string with a php wrapper * - * The wrap is a php header that surrounds the yaml with section markers, - * '---' and '...' along with php comment markers. The php wrapper keeps the - * yaml file contents from being revealed by serving the file directly from - * a poorly configured server. + * The wrap is a php __halt_compiler() guard followed by YAML section markers + * '---' and '...'. The guard stops the PHP lexer entirely, preventing the + * file contents from being parsed or revealed by serving the file directly + * from a poorly configured server. * * @param mixed $var Variable which will be dumped * @param integer $inline Nesting level where you switch to inline YAML @@ -134,8 +155,8 @@ public static function dumpWrapped($var, $inline = 4, $indent = 4) { try { $yamlString = VendorYaml::dump($var, $inline, $indent); - $ret = empty($yamlString) ? false : "\n---\n" . $yamlString . "\n...\n"; + } catch (\Throwable $e) { static::logError($e); $ret = false; } @@ -145,10 +166,9 @@ public static function dumpWrapped($var, $inline = 4, $indent = 4) /** * Load a YAML string with a php wrapper into a PHP array * - * The wrap is a php header that surrounds the yaml with section markers, - * '---' and '...' along with php comment markers. The php wrapper keeps the - * yaml file contents from being revealed by serving the file directly from - * a poorly configured server. + * Supports both the current __halt_compiler() guard format and the legacy + * PHP block comment format. Content between '---' and '...' markers is + * extracted regardless of wrapper style. * * @param string $yamlString YAML dump string * @@ -158,6 +178,10 @@ public static function loadWrapped($yamlString) { try { $lines = preg_split('/\R/', $yamlString); + if ($lines === false) { + trigger_error('Failed to split wrapped YAML content', E_USER_WARNING); + return false; + } $count = count($lines); for ($index = $count; --$index > 0;) { if ('...' === $lines[$index]) { @@ -174,7 +198,7 @@ public static function loadWrapped($yamlString) } $unwrapped = implode("\n", $lines); $ret = VendorYaml::parse($unwrapped); - } catch (\Exception $e) { + } catch (\Throwable $e) { static::logError($e); $ret = false; } @@ -184,21 +208,41 @@ public static function loadWrapped($yamlString) /** * Read a file containing YAML with a php wrapper into a PHP array * - * The wrap is a php header that surrounds the yaml with section markers, - * '---' and '...' along with php comment markers. The php wrapper keeps the - * yaml file contents from being revealed by serving the file directly from - * a poorly configured server. + * Supports both the current __halt_compiler() guard format and the legacy + * PHP block comment format. Content between '---' and '...' markers is + * extracted regardless of wrapper style. * * @param string $yamlFile filename of YAML file * - * @return array|boolean PHP array or false on error + * @return array|false PHP array or false on error */ public static function readWrapped($yamlFile) { + if (!file_exists($yamlFile)) { + return false; + } + $maxSize = 2 * 1024 * 1024; // 2 MB + $fileSize = @filesize($yamlFile); + if ($fileSize === false) { + trigger_error("Unable to determine YAML file size", E_USER_WARNING); + return false; + } + if ($fileSize > $maxSize) { + trigger_error("YAML file exceeds maximum size of 2MB", E_USER_WARNING); + return false; + } + if (!is_readable($yamlFile)) { + trigger_error("Failed to read YAML file (not readable): " . basename($yamlFile), E_USER_WARNING); + return false; + } + $yamlString = @file_get_contents($yamlFile); + if ($yamlString === false) { + trigger_error("Failed to read YAML file: " . basename($yamlFile), E_USER_WARNING); + return false; + } try { - $yamlString = file_get_contents($yamlFile); $ret = static::loadWrapped($yamlString); - } catch (\Exception $e) { + } catch (\Throwable $e) { static::logError($e); $ret = false; } @@ -225,7 +269,7 @@ public static function saveWrapped($var, $yamlFile, $inline = 4, $indent = 4) try { $yamlString = static::dumpWrapped($var, $inline, $indent); $ret = file_put_contents($yamlFile, $yamlString); - } catch (\Exception $e) { + } catch (\Throwable $e) { static::logError($e); $ret = false; } @@ -233,7 +277,7 @@ public static function saveWrapped($var, $yamlFile, $inline = 4, $indent = 4) } /** - * @param \Exception $e throwable to log + * @param \Throwable $e throwable to log */ protected static function logError($e) { From 56c1c4b37417335804cc719ffaa44f11a5bd02b0 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Fri, 9 Oct 2026 03:52:38 -0400 Subject: [PATCH 2/6] Address review feedback on the XMF 1.3.2 port - Restore the 2.6 Admin::addConfigModuleVersion(): 2.6 stores module versions as integer hundredths and XoopsModule has no versionCompare() - Document that Session helper values are not restored as objects - Add the upstream XMF 1.3.2 unit tests for Ulid, ProxyCheck, I18n, Mail and SchemaDefinitionException, using PHPUnit 11 attributes --- .../SchemaDefinitionExceptionTest.php | 22 + .../xoopsLib/Xmf/I18n/BlockPositionTest.php | 96 ++ .../unit/xoopsLib/Xmf/I18n/DirectionTest.php | 137 ++ .../xoopsLib/Xmf/I18n/ImageResolverTest.php | 110 ++ .../unit/xoopsLib/Xmf/I18n/TranslatorTest.php | 54 + .../Xmf/Mail/SendmailExceptionTest.php | 18 + .../xoopsLib/Xmf/Mail/SendmailRunnerTest.php | 135 ++ tests/unit/xoopsLib/Xmf/ProxyCheckTest.php | 68 + tests/unit/xoopsLib/Xmf/UlidTest.php | 1108 +++++++++++++++++ xoops_lib/Xmf/Module/Admin.php | 10 +- xoops_lib/Xmf/Module/Helper/Session.php | 3 + 11 files changed, 1756 insertions(+), 5 deletions(-) create mode 100644 tests/unit/xoopsLib/Xmf/Database/SchemaDefinitionExceptionTest.php create mode 100644 tests/unit/xoopsLib/Xmf/I18n/BlockPositionTest.php create mode 100644 tests/unit/xoopsLib/Xmf/I18n/DirectionTest.php create mode 100644 tests/unit/xoopsLib/Xmf/I18n/ImageResolverTest.php create mode 100644 tests/unit/xoopsLib/Xmf/I18n/TranslatorTest.php create mode 100644 tests/unit/xoopsLib/Xmf/Mail/SendmailExceptionTest.php create mode 100644 tests/unit/xoopsLib/Xmf/Mail/SendmailRunnerTest.php create mode 100644 tests/unit/xoopsLib/Xmf/ProxyCheckTest.php create mode 100644 tests/unit/xoopsLib/Xmf/UlidTest.php diff --git a/tests/unit/xoopsLib/Xmf/Database/SchemaDefinitionExceptionTest.php b/tests/unit/xoopsLib/Xmf/Database/SchemaDefinitionExceptionTest.php new file mode 100644 index 000000000..0a55187cf --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/SchemaDefinitionExceptionTest.php @@ -0,0 +1,22 @@ +assertSame('No schema definition schema.yml', $exception->getMessage()); + } + + public function testForTableBuildsExpectedMessage() + { + $exception = SchemaDefinitionException::forTable('xoops_users'); + + $this->assertSame('No schema definition for table xoops_users', $exception->getMessage()); + } +} diff --git a/tests/unit/xoopsLib/Xmf/I18n/BlockPositionTest.php b/tests/unit/xoopsLib/Xmf/I18n/BlockPositionTest.php new file mode 100644 index 000000000..744aaa293 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/I18n/BlockPositionTest.php @@ -0,0 +1,96 @@ +assertSame('start', BlockPosition::toLogical(1)); + } + + public function testToLogicalRightReturnsEnd(): void + { + $this->assertSame('end', BlockPosition::toLogical(0)); + } + + public function testToLogicalCenterForUnknownValue(): void + { + $this->assertSame('center', BlockPosition::toLogical(5)); + } + + public function testToLogicalWithCustomValues(): void + { + $this->assertSame('start', BlockPosition::toLogical(10, 10, 20)); + $this->assertSame('end', BlockPosition::toLogical(20, 10, 20)); + $this->assertSame('center', BlockPosition::toLogical(30, 10, 20)); + } + + public function testToCssClassLeft(): void + { + $this->assertSame('block-start', BlockPosition::toCssClass(1)); + } + + public function testToCssClassRight(): void + { + $this->assertSame('block-end', BlockPosition::toCssClass(0)); + } + + public function testToCssClassCenter(): void + { + $this->assertSame('block-center', BlockPosition::toCssClass(99)); + } + + public function testGetStartValueLtr(): void + { + $this->assertSame(1, BlockPosition::getStartValue(Direction::LTR)); + } + + public function testGetStartValueRtl(): void + { + $this->assertSame(0, BlockPosition::getStartValue(Direction::RTL)); + } + + public function testGetEndValueLtr(): void + { + $this->assertSame(0, BlockPosition::getEndValue(Direction::LTR)); + } + + public function testGetEndValueRtl(): void + { + $this->assertSame(1, BlockPosition::getEndValue(Direction::RTL)); + } + + public function testGetStartValueWithCustomValues(): void + { + $this->assertSame(10, BlockPosition::getStartValue(Direction::LTR, 10, 20)); + $this->assertSame(20, BlockPosition::getStartValue(Direction::RTL, 10, 20)); + } + + public function testGetEndValueWithCustomValues(): void + { + $this->assertSame(20, BlockPosition::getEndValue(Direction::LTR, 10, 20)); + $this->assertSame(10, BlockPosition::getEndValue(Direction::RTL, 10, 20)); + } + + public function testDefaultConstants(): void + { + $this->assertSame(1, BlockPosition::DEFAULT_LEFT); + $this->assertSame(0, BlockPosition::DEFAULT_RIGHT); + } +} diff --git a/tests/unit/xoopsLib/Xmf/I18n/DirectionTest.php b/tests/unit/xoopsLib/Xmf/I18n/DirectionTest.php new file mode 100644 index 000000000..2fbd14f6b --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/I18n/DirectionTest.php @@ -0,0 +1,137 @@ +assertSame(Direction::LTR, Direction::dir('en')); + } + + public function testDirDetectsRtlFromArabic(): void + { + $this->assertSame(Direction::RTL, Direction::dir('ar')); + } + + public function testDirDetectsRtlFromHebrew(): void + { + $this->assertSame(Direction::RTL, Direction::dir('he')); + } + + public function testDirDetectsRtlFromPersian(): void + { + $this->assertSame(Direction::RTL, Direction::dir('fa')); + } + + public function testDirDetectsRtlFromUrdu(): void + { + $this->assertSame(Direction::RTL, Direction::dir('ur')); + } + + public function testDirDetectsLtrFromFrench(): void + { + $this->assertSame(Direction::LTR, Direction::dir('fr')); + } + + public function testDirDetectsLtrFromGerman(): void + { + $this->assertSame(Direction::LTR, Direction::dir('de')); + } + + public function testDirHandlesLocaleWithRegion(): void + { + $this->assertSame(Direction::RTL, Direction::dir('ar-SA')); + $this->assertSame(Direction::LTR, Direction::dir('en-US')); + } + + public function testDirHandlesLocaleWithUnderscore(): void + { + $this->assertSame(Direction::RTL, Direction::dir('ar_SA')); + $this->assertSame(Direction::LTR, Direction::dir('en_US')); + } + + public function testDirHandlesEmptyLocale(): void + { + $this->assertSame(Direction::LTR, Direction::dir('')); + } + + public function testAutoSentinelIsNormalizedToNull(): void + { + // AUTO is normalized to null inside dir(), triggering global + // auto-detection. Without legacy constants defined, it defaults + // to 'en' and resolves to LTR. + $this->assertSame(Direction::LTR, Direction::dir(Direction::AUTO)); + } + + public function testIsRtlReturnsTrueForRtlLocale(): void + { + $this->assertTrue(Direction::isRtl('ar')); + $this->assertTrue(Direction::isRtl('he')); + } + + public function testIsRtlReturnsFalseForLtrLocale(): void + { + $this->assertFalse(Direction::isRtl('en')); + $this->assertFalse(Direction::isRtl('fr')); + } + + public function testCachingReturnsConsistentResults(): void + { + $first = Direction::dir('ar'); + $second = Direction::dir('ar'); + $this->assertSame($first, $second); + $this->assertSame(Direction::RTL, $first); + } + + public function testClearCacheResetsState(): void + { + Direction::dir('ar'); + Direction::clearCache(); + // After clearing, a subsequent call should still produce the same result + $this->assertSame(Direction::RTL, Direction::dir('ar')); + } + + public function testConstantValues(): void + { + $this->assertSame('ltr', Direction::LTR); + $this->assertSame('rtl', Direction::RTL); + $this->assertSame('auto', Direction::AUTO); + } + + public function testDirDetectsRtlFromOldHebrewCode(): void + { + $this->assertSame(Direction::RTL, Direction::dir('iw')); + } + + public function testDirDetectsRtlFromKurdish(): void + { + $this->assertSame(Direction::RTL, Direction::dir('ku')); + $this->assertSame(Direction::RTL, Direction::dir('ckb')); + } + + public function testDirDetectsRtlFromYiddish(): void + { + $this->assertSame(Direction::RTL, Direction::dir('yi')); + } + + public function testDirCaseInsensitive(): void + { + $this->assertSame(Direction::RTL, Direction::dir('AR')); + $this->assertSame(Direction::RTL, Direction::dir('Ar')); + } +} diff --git a/tests/unit/xoopsLib/Xmf/I18n/ImageResolverTest.php b/tests/unit/xoopsLib/Xmf/I18n/ImageResolverTest.php new file mode 100644 index 000000000..90bf899b1 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/I18n/ImageResolverTest.php @@ -0,0 +1,110 @@ +assertSame('', ImageResolver::resolve('')); + } + + public function testAbsoluteHttpsUrlReturnedAsIs(): void + { + $url = 'https://example.com/images/arrow.png'; + $this->assertSame($url, ImageResolver::resolve($url)); + } + + public function testAbsoluteHttpUrlReturnedAsIs(): void + { + $url = 'http://example.com/images/arrow.png'; + $this->assertSame($url, ImageResolver::resolve($url)); + } + + public function testProtocolRelativeUrlReturnedAsIs(): void + { + $url = '//cdn.example.com/images/arrow.png'; + $this->assertSame($url, ImageResolver::resolve($url)); + } + + public function testPathTraversalInBasePathRejected(): void + { + $this->assertSame('../secret/file.png', ImageResolver::resolve('../secret/file.png')); + $this->assertSame('images/../../etc/passwd.png', ImageResolver::resolve('images/../../etc/passwd.png')); + } + + public function testPathTraversalInLangRejected(): void + { + $result = ImageResolver::resolve('images/arrow.png', '../../etc'); + $this->assertSame('images/arrow.png', $result); + } + + public function testSlashInLangRejected(): void + { + $result = ImageResolver::resolve('images/arrow.png', 'en/../../etc'); + $this->assertSame('images/arrow.png', $result); + } + + public function testMalformedPathWithNoExtension(): void + { + $this->assertSame('noextension', ImageResolver::resolve('noextension', 'en', Direction::LTR)); + } + + public function testResolveWithoutXoopsRootReturnsBasePath(): void + { + // Without XOOPS_ROOT_PATH defined, no file system check occurs + // The method falls through to basePath + $result = ImageResolver::resolve('images/arrow.png', 'en', Direction::LTR); + $this->assertSame('images/arrow.png', $result); + } + + public function testCachingReturnsConsistentResults(): void + { + $first = ImageResolver::resolve('images/arrow.png', 'en', Direction::LTR); + $second = ImageResolver::resolve('images/arrow.png', 'en', Direction::LTR); + $this->assertSame($first, $second); + } + + public function testClearCacheWorks(): void + { + ImageResolver::resolve('images/arrow.png', 'en', Direction::LTR); + ImageResolver::clearCache(); + $result = ImageResolver::resolve('images/arrow.png', 'en', Direction::LTR); + $this->assertSame('images/arrow.png', $result); + } + + public function testResolveWithDirectoryPrefix(): void + { + $result = ImageResolver::resolve('img/icons/arrow.png', 'fr', Direction::LTR); + $this->assertSame('img/icons/arrow.png', $result); + } + + public function testResolveWithRtlDirection(): void + { + $result = ImageResolver::resolve('images/arrow.png', 'ar', Direction::RTL); + $this->assertSame('images/arrow.png', $result); + } + + public function testResolveWithNullLangDefaultsToEn(): void + { + $result = ImageResolver::resolve('images/arrow.png', null, Direction::LTR); + $this->assertSame('images/arrow.png', $result); + } +} diff --git a/tests/unit/xoopsLib/Xmf/I18n/TranslatorTest.php b/tests/unit/xoopsLib/Xmf/I18n/TranslatorTest.php new file mode 100644 index 000000000..315391351 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/I18n/TranslatorTest.php @@ -0,0 +1,54 @@ +assertSame('Translated Value', Translator::t('_TEST_TRANSLATOR_LABEL')); + } + + public function testTReturnsLabelWhenConstantNotDefined(): void + { + $this->assertSame('_UNDEFINED_CONST_XYZ', Translator::t('_UNDEFINED_CONST_XYZ')); + } + + public function testTReturnsLabelWhenNotMatchingPattern(): void + { + // Does not start with underscore + $this->assertSame('NOUNDERSCORE', Translator::t('NOUNDERSCORE')); + + // Lowercase characters + $this->assertSame('_lowercase', Translator::t('_lowercase')); + + // Empty string + $this->assertSame('', Translator::t('')); + + // Just an underscore + $this->assertSame('_', Translator::t('_')); + } + + public function testTReturnsLabelForNonStringConstant(): void + { + if (!\defined('_TEST_TRANSLATOR_INT')) { + \define('_TEST_TRANSLATOR_INT', 42); + } + $this->assertSame('_TEST_TRANSLATOR_INT', Translator::t('_TEST_TRANSLATOR_INT')); + } + + public function testTHandlesValidPatternFormats(): void + { + if (!\defined('_MI_MYMODULE_NAME')) { + \define('_MI_MYMODULE_NAME', 'My Module'); + } + $this->assertSame('My Module', Translator::t('_MI_MYMODULE_NAME')); + } +} diff --git a/tests/unit/xoopsLib/Xmf/Mail/SendmailExceptionTest.php b/tests/unit/xoopsLib/Xmf/Mail/SendmailExceptionTest.php new file mode 100644 index 000000000..6fa96a366 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Mail/SendmailExceptionTest.php @@ -0,0 +1,18 @@ +assertSame('Invalid sendmail path.', SendmailException::invalidPath()->getMessage()); + $this->assertSame('Failed to start sendmail process.', SendmailException::failedToStartProcess()->getMessage()); + $this->assertSame('Failed to open sendmail pipes.', SendmailException::failedToOpenPipes()->getMessage()); + $this->assertSame('Failed to write message to sendmail (broken pipe).', SendmailException::writeFailure()->getMessage()); + $this->assertSame('sendmail closed the input pipe prematurely.', SendmailException::prematurePipeClosure()->getMessage()); + $this->assertSame('Sendmail exited with code 1: failed', SendmailException::exitedWithCode(1, 'failed')->getMessage()); + } +} diff --git a/tests/unit/xoopsLib/Xmf/Mail/SendmailRunnerTest.php b/tests/unit/xoopsLib/Xmf/Mail/SendmailRunnerTest.php new file mode 100644 index 000000000..212d824bd --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Mail/SendmailRunnerTest.php @@ -0,0 +1,135 @@ +cleanupPaths as $path) { + if (file_exists($path)) { + @unlink($path); + } + } + $this->cleanupPaths = array(); + } + + public function testDeliverRejectsInvalidPath() + { + $runner = new SendmailRunner(); + + $this->expectException(SendmailException::class); + $this->expectExceptionMessage('Invalid sendmail path.'); + + $runner->deliver('/definitely/not/a/sendmail-binary', 'Subject: test'); + } + + public function testDeliverWritesMessageToSendmailProcess() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Sendmail runner process test requires a POSIX shell.'); + } + + $outputFile = $this->createTempFile('sendmail-output-', ''); + $script = $this->createExecutableScript( + "#!/usr/bin/env bash\ncat > " . escapeshellarg($outputFile) . "\nexit 0\n" + ); + $runner = new SendmailRunner(array($script)); + $message = "Subject: Test\n\nHello world\n"; + + $runner->deliver($script, $message); + + $this->assertSame("Subject: Test\r\n\r\nHello world\r\n", file_get_contents($outputFile)); + } + + public function testDeliverThrowsOnNonZeroExitCode() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Sendmail runner process test requires a POSIX shell.'); + } + + $script = $this->createExecutableScript( + "#!/usr/bin/env bash\ncat > /dev/null\nprintf 'boom\\n' >&2\nexit 1\n" + ); + $runner = new SendmailRunner(array($script)); + + set_error_handler(static function (): bool { + return true; + }); + + try { + $this->expectException(SendmailException::class); + $this->expectExceptionMessage('Sendmail exited with code 1: boom'); + + $runner->deliver($script, "Subject: Test\n\nHello world\n"); + } finally { + restore_error_handler(); + } + } + + public function testDeliverWarnsWhenSendmailWritesToStderrOnSuccess() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Sendmail runner process test requires a POSIX shell.'); + } + + $script = $this->createExecutableScript( + "#!/usr/bin/env bash\ncat > /dev/null\nprintf 'minor warning\\n' >&2\nexit 0\n" + ); + $runner = new SendmailRunner(array($script)); + $warning = null; + + set_error_handler(static function (int $errno, string $errstr) use (&$warning): bool { + $warning = $errstr; + return true; + }); + + try { + $runner->deliver($script, "Subject: Test\n\nHello world\n"); + } finally { + restore_error_handler(); + } + + $this->assertSame('sendmail warning (success): minor warning\n', $warning); + } + + public function testDeliverEmptyPayloadDoesNotHang() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Sendmail runner process test requires a POSIX shell.'); + } + + $outputFile = $this->createTempFile('sendmail-output-', ''); + $script = $this->createExecutableScript( + "#!/usr/bin/env bash\ncat > " . escapeshellarg($outputFile) . "\nexit 0\n" + ); + $runner = new SendmailRunner(array($script)); + + $runner->deliver($script, ''); + + $this->assertSame('', file_get_contents($outputFile)); + } + + private function createTempFile(string $prefix, string $contents): string + { + $path = tempnam(sys_get_temp_dir(), $prefix); + $this->assertNotFalse($path, 'Failed to create temporary file.'); + file_put_contents($path, $contents); + $this->cleanupPaths[] = $path; + + return $path; + } + + private function createExecutableScript(string $contents): string + { + $path = $this->createTempFile('sendmail-script-', $contents); + chmod($path, 0700); + + return $path; + } +} diff --git a/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php b/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php new file mode 100644 index 000000000..ada8885f4 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php @@ -0,0 +1,68 @@ +proxyHeaderName = $name; + $this->proxyHeader = $header; + } +} + +#[CoversClass(ProxyCheck::class)] +class ProxyCheckTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var ProxyCheck + */ + protected $object; + + /** + * Sets up the fixture, for example, opens a network connection. + * This method is called before a test is executed. + */ + protected function setUp(): void + { + $this->object = new ProxyCheck(); + } + + /** + * Tears down the fixture, for example, closes a network connection. + * This method is called after a test is executed. + */ + protected function tearDown(): void + { + } + + public function testGet() + { + $ip = $this->object->get(); + $this->assertFalse($ip); + } + + public static function getProxyCheckTestData() + { + return array( +// ['name', 'header', 'expected'], + ['HTTP_FORWARDED', 'for=192.168.2.60;proto=http;by=203.0.113.43, for=192.0.2.43, for=198.51.100.17', false], + ['HTTP_FORWARDED', 'for=203.0.113.195;proto=http;by=203.0.113.43, for=192.0.2.43, for=198.51.100.17', '203.0.113.195'], + ['HTTP_FORWARDED', 'for="[2020:db8:85a3:8d3:1319:8a2e:370:7348]";proto=http;by=203.0.113.43', '2020:db8:85a3:8d3:1319:8a2e:370:7348'], + ['HTTP_NOT_FORWARDED', 'for="[2020:db8:85a3:8d3:1319:8a2e:370:7348]";proto=http;by=203.0.113.43', false], + ['HTTP_CLIENT_IP', '203.0.113.195, 70.41.3.18, 150.172.238.178', '203.0.113.195'], + ['STUFF', '2020:db8:85a3:8d3:1319:8a2e:370:7348', '2020:db8:85a3:8d3:1319:8a2e:370:7348'], + ); + } + + #[DataProvider('getProxyCheckTestData')] + public function testProxyCheck($name, $header, $expected) + { + $obj = new localProxyCheck($name, $header); + $this->assertSame($expected, $obj->get()); + } + +} diff --git a/tests/unit/xoopsLib/Xmf/UlidTest.php b/tests/unit/xoopsLib/Xmf/UlidTest.php new file mode 100644 index 000000000..d0734f454 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/UlidTest.php @@ -0,0 +1,1108 @@ +assertSame('01ARYZ6S41', $encoded); + } + + /** + * Test decoding the spec vector timestamp. + */ + public function testDecodeTimeMatchesSpecVector(): void + { + // Create a valid ULID with the known timestamp + $ulid = '01ARYZ6S410000000000000000'; + $decoded = Ulid::decodeTime($ulid); + + $this->assertSame(1469918176385, $decoded); + } + + /** + * Test round-trip encoding/decoding with spec vector. + */ + public function testSpecVectorRoundTrip(): void + { + $originalTimestamp = 1469918176385; + $encoded = Ulid::encodeTime($originalTimestamp); + $ulid = $encoded . '0000000000000000'; // Add dummy random part + $decoded = Ulid::decodeTime($ulid); + + $this->assertSame($originalTimestamp, $decoded); + } + + /** + * Test another known vector: Unix epoch (timestamp 0) + */ + public function testEncodeTimeAtEpoch(): void + { + $encoded = Ulid::encodeTime(0); + + $this->assertSame('0000000000', $encoded); + } + + /** + * Test known vector: maximum timestamp (year ~10889) + */ + public function testEncodeTimeAtMaximum(): void + { + $maxTime = 281474976710655; // 2^48 - 1 + $encoded = Ulid::encodeTime($maxTime); + + $this->assertSame('7ZZZZZZZZZ', $encoded); + } + + // ========================================================================= + // GENERATION TESTS + // ========================================================================= + + public function testGenerateReturns26Characters(): void + { + $ulid = Ulid::generate(); + + $this->assertSame(26, \strlen($ulid)); + } + + public function testGenerateReturnsValidUlid(): void + { + $ulid = Ulid::generate(); + + $this->assertTrue(Ulid::isValid($ulid)); + } + + public function testGenerateReturnsUppercaseByDefault(): void + { + $ulid = Ulid::generate(); + + $this->assertSame($ulid, \strtoupper($ulid)); + } + + public function testGenerateReturnsLowercaseWhenRequested(): void + { + $ulid = Ulid::generate(false); + + $this->assertSame($ulid, \strtolower($ulid)); + $this->assertTrue(Ulid::isValid($ulid)); // Should still be valid + } + + public function testGenerateUsesOnlyCrockfordBase32Characters(): void + { + $ulid = Ulid::generate(); + $validChars = Ulid::ENCODING_CHARS; + + for ($i = 0; $i < 26; $i++) { + $this->assertNotFalse( + \strpos($validChars, $ulid[$i]), + "Character '{$ulid[$i]}' at position $i is not a valid Crockford Base32 character" + ); + } + } + + public function testGenerateProducesUniqueValues(): void + { + $ulids = []; + + for ($i = 0; $i < 1000; $i++) { + $ulids[] = Ulid::generate(); + } + + $uniqueUlids = \array_unique($ulids); + + $this->assertCount(1000, $uniqueUlids, 'All 1000 generated ULIDs should be unique'); + } + + public function testGenerateProducesLexicographicallySortableValues(): void + { + Ulid::resetMonotonicState(); + $ulid1 = Ulid::generateMonotonic(); + $ulid2 = Ulid::generateMonotonic(); + + $this->assertLessThan(0, \strcmp($ulid1, $ulid2), 'Earlier ULID should sort before later ULID'); + } + + public function testGenerateProducesCorrectlySortedArray(): void + { + Ulid::resetMonotonicState(); + $ulids = []; + + for ($i = 0; $i < 10; $i++) { + $ulids[] = Ulid::generateMonotonic(); + } + + $sortedUlids = $ulids; + \sort($sortedUlids, SORT_STRING); + + $this->assertSame($ulids, $sortedUlids, 'ULIDs should already be in sorted order'); + } + + // ========================================================================= + // TIMESTAMP TESTS + // ========================================================================= + + public function testCurrentTimeMillisReturnsReasonableValue(): void + { + $time = Ulid::currentTimeMillis(); + + // Should be greater than Jan 1, 2020 in milliseconds + $this->assertGreaterThan(1577836800000, $time); + + // Should be less than Jan 1, 2100 in milliseconds + $this->assertLessThan(4102444800000, $time); + } + + public function testTimeEncodingDecodingRoundTrip(): void + { + $originalTime = Ulid::currentTimeMillis(); + $encoded = Ulid::encodeTime($originalTime); + + // Create a full ULID with random portion for decoding + $ulid = $encoded . Ulid::encodeRandomness(); + $decodedTime = Ulid::decodeTime($ulid); + + $this->assertSame($originalTime, $decodedTime); + } + + public function testEncodeTimeReturns10Characters(): void + { + $time = Ulid::currentTimeMillis(); + $encoded = Ulid::encodeTime($time); + + $this->assertSame(10, \strlen($encoded)); + } + + public function testEncodeTimeThrowsExceptionForNegativeTime(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Timestamp cannot be negative'); + + Ulid::encodeTime(-1); + } + + public function testEncodeTimeThrowsExceptionForOverflow(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/exceeds maximum/'); + + Ulid::encodeTime(Ulid::MAX_TIME + 1); + } + + public function testDecodeTimeExtractsCorrectTimestamp(): void + { + $ulid = Ulid::generate(); + $decodedTime = Ulid::decodeTime($ulid); + $currentTime = Ulid::currentTimeMillis(); + + // Decoded time should be within 1 second of current time + $this->assertLessThan(1000, \abs($currentTime - $decodedTime)); + } + + public function testGetDateTimeReturnsCorrectDateTime(): void + { + $ulid = Ulid::generate(); + $dateTime = Ulid::getDateTime($ulid); + $now = new \DateTimeImmutable(); + + // Should be within 1 second of now + $diff = \abs($now->getTimestamp() - $dateTime->getTimestamp()); + $this->assertLessThan(2, $diff); + } + + public function testGetDateTimeWithSpecVector(): void + { + // Timestamp: 1469918176385 (2016-07-30 22:36:16.385 UTC) + $ulid = '01ARYZ6S410000000000000000'; + $dateTime = Ulid::getDateTime($ulid); + + $this->assertSame('2016-07-30', $dateTime->format('Y-m-d')); + $this->assertSame('22:36:16', $dateTime->format('H:i:s')); + } + + // ========================================================================= + // RANDOMNESS TESTS + // ========================================================================= + + public function testEncodeRandomnessReturns16Characters(): void + { + $randomPart = Ulid::encodeRandomness(); + + $this->assertSame(16, \strlen($randomPart)); + } + + public function testEncodeRandomnessProducesDifferentValues(): void + { + $random1 = Ulid::encodeRandomness(); + $random2 = Ulid::encodeRandomness(); + + $this->assertNotSame($random1, $random2); + } + + public function testEncodeRandomnessUsesOnlyValidCharacters(): void + { + for ($i = 0; $i < 100; $i++) { + $random = Ulid::encodeRandomness(); + + for ($j = 0; $j < 16; $j++) { + $this->assertNotFalse( + \strpos(Ulid::ENCODING_CHARS, $random[$j]), + "Random character '{$random[$j]}' at position $j is invalid" + ); + } + } + } + + public function testDecodeRandomnessExtractsCorrectPortion(): void + { + $ulid = Ulid::generate(); + $randomPart = Ulid::decodeRandomness($ulid); + + $this->assertSame(\substr(\strtoupper($ulid), 10), $randomPart); + } + + // ========================================================================= + // VALIDATION TESTS + // ========================================================================= + + public function testIsValidReturnsTrueForValidUppercaseUlid(): void + { + $ulid = Ulid::generate(true); + + $this->assertTrue(Ulid::isValid($ulid)); + } + + public function testIsValidReturnsTrueForValidLowercaseUlid(): void + { + $ulid = Ulid::generate(false); + + $this->assertTrue(Ulid::isValid($ulid)); + } + + public function testIsValidReturnsFalseForTooShortString(): void + { + $this->assertFalse(Ulid::isValid('01ARZ3NDEKTSV4RRFFQ69G5FA')); // 25 chars + } + + public function testIsValidReturnsFalseForTooLongString(): void + { + $this->assertFalse(Ulid::isValid('01ARZ3NDEKTSV4RRFFQ69G5FAXX')); // 28 chars + } + + public function testIsValidReturnsFalseForEmptyString(): void + { + $this->assertFalse(Ulid::isValid('')); + } + + #[DataProvider('invalidCharacterProvider')] + public function testIsValidReturnsFalseForInvalidCharacters(string $invalidUlid): void + { + $this->assertFalse(Ulid::isValid($invalidUlid)); + } + + public static function invalidCharacterProvider(): array + { + return [ + 'contains I' => ['01ARZ3NDEKTSV4RRFFQI9G5FAV'], + 'contains L' => ['01ARZ3NDEKTSV4RRFFQL9G5FAV'], + 'contains O' => ['01ARZ3NDEKTSV4RRFFQO9G5FAV'], + 'contains U' => ['01ARZ3NDEKTSV4RRFFQU9G5FAV'], + 'contains special char' => ['01ARZ3NDEKTSV4RRFFQ@9G5FAV'], + 'contains space' => ['01ARZ3NDEKTSV4RRFFQ 9G5FAV'], + 'contains hyphen' => ['01ARZ3NDEK-SV4RRFFQ69G5FAV'], + ]; + } + + public function testIsValidIsCaseInsensitive(): void + { + $upperUlid = Ulid::generate(true); + $lowerUlid = \strtolower($upperUlid); + $mixedUlid = ''; + + for ($i = 0; $i < 26; $i++) { + $mixedUlid .= $i % 2 === 0 ? $upperUlid[$i] : \strtolower($upperUlid[$i]); + } + + $this->assertTrue(Ulid::isValid($upperUlid)); + $this->assertTrue(Ulid::isValid($lowerUlid)); + $this->assertTrue(Ulid::isValid($mixedUlid)); + } + + public function testIsValidReturnsFalseForOverflowFirstChar(): void + { + // First character > 7 would mean timestamp overflow (> 2^48 - 1) + $this->assertFalse(Ulid::isValid('80000000000000000000000000')); + } + + // ========================================================================= + // DECODE TESTS + // ========================================================================= + + public function testDecodeReturnsArrayWithTimeAndRand(): void + { + $ulid = Ulid::generate(); + $components = Ulid::decode($ulid); + + $this->assertArrayHasKey('time', $components); + $this->assertArrayHasKey('rand', $components); + } + + public function testDecodeTimeIsPositiveInteger(): void + { + $ulid = Ulid::generate(); + $components = Ulid::decode($ulid); + + $this->assertIsInt($components['time']); + $this->assertGreaterThan(0, $components['time']); + } + + public function testDecodeRandIs16Characters(): void + { + $ulid = Ulid::generate(); + $components = Ulid::decode($ulid); + + $this->assertSame(16, \strlen($components['rand'])); + } + + public function testDecodeThrowsExceptionForInvalidUlid(): void + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::decode('invalid-ulid'); + } + + public function testDecodeTimeThrowsExceptionForWrongLength(): void + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::decodeTime('01ARZ3NDEK'); // Only 10 chars + } + + public function testDecodeTimeThrowsExceptionForInvalidCharacter(): void + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::decodeTime('01ARI3NDEKTSV4RRFFQ09G5FAV'); // Contains 'I' in time portion + } + + public function testDecodeRandomnessThrowsExceptionForWrongLength(): void + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::decodeRandomness('short'); + } + + public function testDecodeRandomnessThrowsExceptionForInvalidCharacter(): void + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::decodeRandomness('01ARZ3NDEKTSV4RRFFQI9G5FAV'); // Contains 'I' + } + + // ========================================================================= + // COMPARISON TESTS + // ========================================================================= + + public function testCompareReturnNegativeForEarlierUlid(): void + { + Ulid::resetMonotonicState(); + $ulid1 = Ulid::generateMonotonic(); + $ulid2 = Ulid::generateMonotonic(); + + $this->assertSame(-1, Ulid::compare($ulid1, $ulid2)); + } + + public function testCompareReturnPositiveForLaterUlid(): void + { + Ulid::resetMonotonicState(); + $ulid1 = Ulid::generateMonotonic(); + $ulid2 = Ulid::generateMonotonic(); + + $this->assertSame(1, Ulid::compare($ulid2, $ulid1)); + } + + public function testCompareReturnZeroForSameUlid(): void + { + $ulid = Ulid::generate(); + + $this->assertSame(0, Ulid::compare($ulid, $ulid)); + } + + public function testCompareIsCaseInsensitive(): void + { + $ulid = Ulid::generate(); + $lowerUlid = \strtolower($ulid); + + $this->assertSame(0, Ulid::compare($ulid, $lowerUlid)); + } + + // ========================================================================= + // UUID CONVERSION TESTS (require ext-bcmath) + // ========================================================================= + + #[RequiresPhpExtension('bcmath')] + public function testToUuidReturnsValidUuidFormat(): void + { + $ulid = Ulid::generate(); + $uuid = Ulid::toUuid($ulid); + + // UUID format: 8-4-4-4-12 hex characters + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', + $uuid + ); + } + + #[RequiresPhpExtension('bcmath')] + public function testToUuidReturns36Characters(): void + { + $ulid = Ulid::generate(); + $uuid = Ulid::toUuid($ulid); + + $this->assertSame(36, \strlen($uuid)); + } + + #[RequiresPhpExtension('bcmath')] + public function testToUuidThrowsExceptionForInvalidUlid(): void + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::toUuid('invalid-ulid'); + } + + #[RequiresPhpExtension('bcmath')] + public function testToUuidProducesConsistentResult(): void + { + $ulid = Ulid::generate(); + $uuid1 = Ulid::toUuid($ulid); + $uuid2 = Ulid::toUuid($ulid); + + $this->assertSame($uuid1, $uuid2); + } + + #[RequiresPhpExtension('bcmath')] + public function testFromUuidReturnsValidUlid(): void + { + $uuid = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + $ulid = Ulid::fromUuid($uuid); + + $this->assertTrue(Ulid::isValid($ulid)); + $this->assertSame(26, \strlen($ulid)); + } + + #[RequiresPhpExtension('bcmath')] + public function testFromUuidWorksWithoutHyphens(): void + { + $uuidWithHyphens = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + $uuidWithoutHyphens = 'f47ac10b58cc4372a5670e02b2c3d479'; + + $ulid1 = Ulid::fromUuid($uuidWithHyphens); + $ulid2 = Ulid::fromUuid($uuidWithoutHyphens); + + $this->assertSame($ulid1, $ulid2); + } + + #[RequiresPhpExtension('bcmath')] + public function testFromUuidThrowsExceptionForInvalidLength(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid UUID format: expected 32 hex characters/'); + + Ulid::fromUuid('f47ac10b-58cc-4372-a567'); + } + + #[RequiresPhpExtension('bcmath')] + public function testFromUuidThrowsExceptionForInvalidCharacters(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/non-hexadecimal/'); + + Ulid::fromUuid('g47ac10b-58cc-4372-a567-0e02b2c3d479'); // 'g' is invalid + } + + #[RequiresPhpExtension('bcmath')] + public function testUuidRoundTrip(): void + { + $originalUlid = Ulid::generate(); + $uuid = Ulid::toUuid($originalUlid); + $convertedBack = Ulid::fromUuid($uuid); + + $this->assertSame($originalUlid, $convertedBack); + } + + #[RequiresPhpExtension('bcmath')] + public function testUuidRoundTripMultiple(): void + { + for ($i = 0; $i < 100; $i++) { + $originalUlid = Ulid::generate(); + $uuid = Ulid::toUuid($originalUlid); + $convertedBack = Ulid::fromUuid($uuid); + + $this->assertSame( + $originalUlid, + $convertedBack, + "Round-trip failed for ULID: $originalUlid" + ); + } + } + + #[RequiresPhpExtension('bcmath')] + public function testFromUuidRoundTrip(): void + { + $originalUuid = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + $ulid = Ulid::fromUuid($originalUuid); + $convertedBack = Ulid::toUuid($ulid); + + $this->assertSame(\strtolower($originalUuid), \strtolower($convertedBack)); + } + + /** + * Test known UUID to ULID conversion vector. + */ + #[RequiresPhpExtension('bcmath')] + public function testFromUuidKnownVector(): void + { + // UUID: 00000000-0000-0000-0000-000000000000 should convert to all zeros + $zeroUuid = '00000000-0000-0000-0000-000000000000'; + $ulid = Ulid::fromUuid($zeroUuid); + + $this->assertSame('00000000000000000000000000', $ulid); + } + + // ========================================================================= + // EDGE CASE TESTS + // ========================================================================= + + /** + * Test minimum timestamp (epoch) + */ + public function testMinimumTimestamp(): void + { + $encoded = Ulid::encodeTime(0); + $ulid = $encoded . '0000000000000000'; + + $this->assertTrue(Ulid::isValid($ulid)); + $this->assertSame(0, Ulid::decodeTime($ulid)); + } + + /** + * Test that generating many ULIDs in rapid succession still produces unique values + */ + public function testRapidGenerationUniqueness(): void + { + $ulids = []; + + for ($i = 0; $i < 100; $i++) { + $ulids[] = Ulid::generate(); + } + + $uniqueCount = \count(\array_unique($ulids)); + + $this->assertSame(100, $uniqueCount, 'All rapidly generated ULIDs should be unique'); + } + + /** + * Test that ULID works correctly around midnight/day boundaries + */ + public function testTimestampPrecision(): void + { + $ulid1 = Ulid::generate(); + $time1 = Ulid::decodeTime($ulid1); + + \usleep(1500); // Wait 1.5ms + + $ulid2 = Ulid::generate(); + $time2 = Ulid::decodeTime($ulid2); + + // Times should be different (at least 1ms apart) + $this->assertGreaterThanOrEqual(1, $time2 - $time1); + } + + // ========================================================================= + // ENCODING ALPHABET TESTS + // ========================================================================= + + /** + * Verify Crockford Base32 alphabet is correct + */ + public function testCrockfordBase32Alphabet(): void + { + $expected = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + + $this->assertSame($expected, Ulid::ENCODING_CHARS); + $this->assertSame(32, \strlen(Ulid::ENCODING_CHARS)); + + // Verify excluded characters are not present + $this->assertStringNotContainsString('I', Ulid::ENCODING_CHARS); + $this->assertStringNotContainsString('L', Ulid::ENCODING_CHARS); + $this->assertStringNotContainsString('O', Ulid::ENCODING_CHARS); + $this->assertStringNotContainsString('U', Ulid::ENCODING_CHARS); + } + + /** + * Test constants are correctly defined + */ + public function testConstants(): void + { + $this->assertSame(32, Ulid::ENCODING_LENGTH); + $this->assertSame(10, Ulid::TIME_LENGTH); + $this->assertSame(16, Ulid::RANDOM_LENGTH); + $this->assertSame(26, Ulid::ULID_LENGTH); + $this->assertSame(281474976710655, Ulid::MAX_TIME); + } + + // ========================================================================= + // CONSISTENCY TESTS + // ========================================================================= + + /** + * Test that encoding and decoding are inverse operations + */ + public function testEncodingDecodingConsistency(): void + { + for ($i = 0; $i < 100; $i++) { + $ulid = Ulid::generate(); + $components = Ulid::decode($ulid); + $reEncodedTime = Ulid::encodeTime($components['time']); + + $this->assertSame( + \substr(\strtoupper($ulid), 0, 10), + $reEncodedTime, + 'Time portion should be consistent after decode/encode cycle' + ); + } + } + + /** + * Test decoding works with mixed case input + */ + public function testDecodingWithMixedCase(): void + { + $upperUlid = Ulid::generate(true); + $lowerUlid = \strtolower($upperUlid); + + $upperComponents = Ulid::decode($upperUlid); + $lowerComponents = Ulid::decode($lowerUlid); + + $this->assertSame($upperComponents['time'], $lowerComponents['time']); + $this->assertSame($upperComponents['rand'], $lowerComponents['rand']); + } + + // ========================================================================= + // BIT-PACKING TESTS (for optimized encodeRandomness) + // ========================================================================= + + /** + * Test that encodeRandomness produces evenly distributed characters + */ + public function testRandomnessDistribution(): void + { + $charCounts = \array_fill_keys(\str_split(Ulid::ENCODING_CHARS), 0); + $iterations = 1000; + + for ($i = 0; $i < $iterations; $i++) { + $random = Ulid::encodeRandomness(); + for ($j = 0; $j < 16; $j++) { + $charCounts[$random[$j]]++; + } + } + + // Each character should appear roughly 1/32 of the time + // With 16000 total characters (1000 * 16), expect ~500 per character + $expectedCount = ($iterations * 16) / 32; + $tolerance = $expectedCount * 0.40; // 40% tolerance for smaller sample + + foreach ($charCounts as $char => $count) { + $this->assertGreaterThan( + $expectedCount - $tolerance, + $count, + "Character '$char' appeared too few times: $count (expected ~$expectedCount)" + ); + $this->assertLessThan( + $expectedCount + $tolerance, + $count, + "Character '$char' appeared too many times: $count (expected ~$expectedCount)" + ); + } + } + + // ========================================================================= + // MONOTONIC GENERATION TESTS + // ========================================================================= + + public function testGenerateMonotonicReturnsValidUlid(): void + { + Ulid::resetMonotonicState(); + $ulid = Ulid::generateMonotonic(); + + $this->assertTrue(Ulid::isValid($ulid)); + $this->assertSame(26, \strlen($ulid)); + } + + public function testGenerateMonotonicReturnsUppercaseByDefault(): void + { + Ulid::resetMonotonicState(); + $ulid = Ulid::generateMonotonic(); + + $this->assertSame($ulid, \strtoupper($ulid)); + } + + public function testGenerateMonotonicReturnsLowercaseWhenRequested(): void + { + Ulid::resetMonotonicState(); + $ulid = Ulid::generateMonotonic(false); + + $this->assertSame($ulid, \strtolower($ulid)); + } + + public function testGenerateMonotonicProducesStrictlyIncreasingValues(): void + { + Ulid::resetMonotonicState(); + $ulids = []; + + // Generate many ULIDs rapidly (within same millisecond) + for ($i = 0; $i < 100; $i++) { + $ulids[] = Ulid::generateMonotonic(); + } + + // Verify strict ordering + for ($i = 1; $i < count($ulids); $i++) { + $this->assertLessThan( + 0, + \strcmp($ulids[$i - 1], $ulids[$i]), + "ULID at index $i should be greater than ULID at index " . ($i - 1) + ); + } + } + + public function testGenerateMonotonicIncrementsRandomPortion(): void + { + // Retry until both ULIDs land in the same millisecond so we can + // verify the random portion was incremented rather than regenerated. + for ($attempt = 0; $attempt < 100; $attempt++) { + Ulid::resetMonotonicState(); + + $ulid1 = Ulid::generateMonotonic(); + $ulid2 = Ulid::generateMonotonic(); + + $time1 = \substr($ulid1, 0, 10); + $time2 = \substr($ulid2, 0, 10); + + if ($time1 === $time2) { + $rand1 = \substr($ulid1, 10); + $rand2 = \substr($ulid2, 10); + + // Same millisecond: random portion must be incremented + $this->assertNotSame($rand1, $rand2); + $this->assertLessThan(0, \strcmp($rand1, $rand2)); + return; + } + } + + $this->fail('Could not generate two monotonic ULIDs within the same millisecond after 100 attempts'); + } + + public function testGenerateMonotonicResetsOnNewMillisecond(): void + { + // Retry until the two ULIDs land in different milliseconds, + // so we can verify the timestamp actually advanced. + for ($attempt = 0; $attempt < 100; $attempt++) { + Ulid::resetMonotonicState(); + $ulid1 = Ulid::generateMonotonic(); + + // Wait for a new millisecond + \usleep(2000); + + $ulid2 = Ulid::generateMonotonic(); + + $time1 = Ulid::decodeTime($ulid1); + $time2 = Ulid::decodeTime($ulid2); + + if ($time1 !== $time2) { + // $time2 should be greater than $time1 + $this->assertGreaterThan($time1, $time2); + return; + } + } + + $this->fail('Could not generate two monotonic ULIDs in different milliseconds after 100 attempts'); + } + + public function testResetMonotonicState(): void + { + // Generate a ULID to set the state + Ulid::generateMonotonic(); + + // Reset + Ulid::resetMonotonicState(); + + // Generate another - should start fresh + $ulid = Ulid::generateMonotonic(); + + $this->assertTrue(Ulid::isValid($ulid)); + } + + public function testGenerateMonotonicUniqueness(): void + { + Ulid::resetMonotonicState(); + $ulids = []; + + for ($i = 0; $i < 1000; $i++) { + $ulids[] = Ulid::generateMonotonic(); + } + + $uniqueCount = \count(\array_unique($ulids)); + + $this->assertSame(1000, $uniqueCount, 'All monotonic ULIDs should be unique'); + } + + public function testGenerateMonotonicSortedArray(): void + { + Ulid::resetMonotonicState(); + $ulids = []; + + for ($i = 0; $i < 100; $i++) { + $ulids[] = Ulid::generateMonotonic(); + } + + $sortedUlids = $ulids; + \sort($sortedUlids, SORT_STRING); + + $this->assertSame($ulids, $sortedUlids, 'Monotonic ULIDs should already be in sorted order'); + } + + // ========================================================================= + // BINARY CONVERSION TESTS (require ext-bcmath) + // ========================================================================= + + #[RequiresPhpExtension('bcmath')] + public function testToBinaryReturns16Bytes(): void + { + $ulid = Ulid::generate(); + $binary = Ulid::toBinary($ulid); + + $this->assertSame(16, \strlen($binary)); + } + + #[RequiresPhpExtension('bcmath')] + public function testToBinaryProducesConsistentResult(): void + { + $ulid = Ulid::generate(); + $binary1 = Ulid::toBinary($ulid); + $binary2 = Ulid::toBinary($ulid); + + $this->assertSame($binary1, $binary2); + } + + #[RequiresPhpExtension('bcmath')] + public function testToBinaryThrowsExceptionForInvalidUlid(): void + { + $this->expectException(\InvalidArgumentException::class); + + Ulid::toBinary('invalid-ulid'); + } + + #[RequiresPhpExtension('bcmath')] + public function testFromBinaryReturnsValidUlid(): void + { + $originalUlid = Ulid::generate(); + $binary = Ulid::toBinary($originalUlid); + $ulid = Ulid::fromBinary($binary); + + $this->assertTrue(Ulid::isValid($ulid)); + $this->assertSame(26, \strlen($ulid)); + } + + #[RequiresPhpExtension('bcmath')] + public function testFromBinaryThrowsExceptionForInvalidLength(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/^Invalid binary length: expected 16, got \d+/'); + + Ulid::fromBinary('short'); + } + + #[RequiresPhpExtension('bcmath')] + public function testBinaryRoundTrip(): void + { + $originalUlid = Ulid::generate(); + $binary = Ulid::toBinary($originalUlid); + $convertedBack = Ulid::fromBinary($binary); + + $this->assertSame($originalUlid, $convertedBack); + } + + #[RequiresPhpExtension('bcmath')] + public function testBinaryRoundTripMultiple(): void + { + for ($i = 0; $i < 100; $i++) { + $originalUlid = Ulid::generate(); + $binary = Ulid::toBinary($originalUlid); + $convertedBack = Ulid::fromBinary($binary); + + $this->assertSame( + $originalUlid, + $convertedBack, + "Binary round-trip failed for ULID: $originalUlid" + ); + } + } + + /** + * Test known binary vector: all zeros + */ + #[RequiresPhpExtension('bcmath')] + public function testToBinaryKnownVectorZeros(): void + { + $ulid = '00000000000000000000000000'; + $binary = Ulid::toBinary($ulid); + + // All zeros ULID should be all zero bytes + $this->assertSame(\str_repeat("\x00", 16), $binary); + } + + /** + * Test known binary vector: all zeros + */ + #[RequiresPhpExtension('bcmath')] + public function testFromBinaryKnownVectorZeros(): void + { + $binary = \str_repeat("\x00", 16); + $ulid = Ulid::fromBinary($binary); + + $this->assertSame('00000000000000000000000000', $ulid); + } + + /** + * Test binary preserves lexicographic ordering + */ + #[RequiresPhpExtension('bcmath')] + public function testBinaryPreservesOrdering(): void + { + Ulid::resetMonotonicState(); + $ulid1 = Ulid::generateMonotonic(); + $ulid2 = Ulid::generateMonotonic(); + + $binary1 = Ulid::toBinary($ulid1); + $binary2 = Ulid::toBinary($ulid2); + + // Binary comparison should match string comparison + $this->assertLessThan(0, \strcmp($ulid1, $ulid2)); + $this->assertLessThan(0, \strcmp($binary1, $binary2)); + } + + /** + * Test binary storage efficiency + */ + #[RequiresPhpExtension('bcmath')] + public function testBinaryStorageEfficiency(): void + { + $ulid = Ulid::generate(); + + $stringLength = \strlen($ulid); // 26 bytes + $binaryLength = \strlen(Ulid::toBinary($ulid)); // 16 bytes + + $this->assertSame(26, $stringLength); + $this->assertSame(16, $binaryLength); + + // Binary is ~38% smaller + $savings = (1 - ($binaryLength / $stringLength)) * 100; + $this->assertGreaterThan(38, $savings); + } + + // ========================================================================= + // UUID AND BINARY INTEROPERABILITY TESTS (require ext-bcmath) + // ========================================================================= + + /** + * Test that ULID → Binary → ULID → UUID is consistent + */ + #[RequiresPhpExtension('bcmath')] + public function testBinaryUuidInteroperability(): void + { + $originalUlid = Ulid::generate(); + + // ULID → Binary → ULID + $binary = Ulid::toBinary($originalUlid); + $ulidFromBinary = Ulid::fromBinary($binary); + + // Both should produce the same UUID + $uuid1 = Ulid::toUuid($originalUlid); + $uuid2 = Ulid::toUuid($ulidFromBinary); + + $this->assertSame($uuid1, $uuid2); + } + + /** + * Test that Binary and UUID representations are equivalent + */ + #[RequiresPhpExtension('bcmath')] + public function testBinaryMatchesUuidHex(): void + { + $ulid = Ulid::generate(); + $binary = Ulid::toBinary($ulid); + $uuid = Ulid::toUuid($ulid); + + // Convert binary to hex and compare with UUID + $binaryHex = \bin2hex($binary); + $uuidHex = \str_replace('-', '', $uuid); + + $this->assertSame($binaryHex, $uuidHex); + } + + // ========================================================================= + // CONSTANT TESTS + // ========================================================================= + + /** + * Test that BINARY_LENGTH constant is correct + */ + public function testBinaryLengthConstant(): void + { + $this->assertSame(16, Ulid::BINARY_LENGTH); + } + + /** + * Test that MAX_TIME constant is correct (2^48 - 1) + */ + public function testMaxTimeConstant(): void + { + $expected = (2 ** 48) - 1; + $this->assertSame($expected, Ulid::MAX_TIME); + } +} diff --git a/xoops_lib/Xmf/Module/Admin.php b/xoops_lib/Xmf/Module/Admin.php index 7a2ba82a4..adc3fe132 100644 --- a/xoops_lib/Xmf/Module/Admin.php +++ b/xoops_lib/Xmf/Module/Admin.php @@ -347,7 +347,7 @@ public function addConfigWarning($value = '') * Check for installed module and version and do addConfigBoxLine() * * @param string $moddir - module directory name - * @param integer $minversion - minimum acceptable module version + * @param integer $minversion - minimum acceptable module version (100 = V1.00) * * @return bool true if requested version of the module is available */ @@ -357,15 +357,15 @@ public function addConfigModuleVersion($moddir, $minversion) $helper = Helper::getHelper($moddir); if (is_object($helper) && is_object($helper->getModule())) { $mod_modversion = $helper->getModule()->getVar('version'); - $mod_version_f = $mod_modversion; - $min_version_f = $minversion; + $mod_version_f = $mod_modversion / 100; + $min_version_f = $minversion / 100; $value = sprintf( _AM_XMF_MODULE_VERSION, strtoupper($moddir), $min_version_f, $mod_version_f ); - if ($helper->getModule()->versionCompare($min_version_f, $mod_version_f, '<=')) { + if ($mod_modversion >= $minversion) { $this->addConfigAccept($value); $return = true; } else { @@ -375,7 +375,7 @@ public function addConfigModuleVersion($moddir, $minversion) $value = sprintf( _AM_XMF_MODULE_NOTFOUND, strtoupper($moddir), - $minversion + $minversion / 100 ); $this->addConfigError($value); } diff --git a/xoops_lib/Xmf/Module/Helper/Session.php b/xoops_lib/Xmf/Module/Helper/Session.php index b70d98d9e..87393a8de 100644 --- a/xoops_lib/Xmf/Module/Helper/Session.php +++ b/xoops_lib/Xmf/Module/Helper/Session.php @@ -60,6 +60,9 @@ protected function prefix($name) /** * Sets a named session variable respecting our module prefix * + * Store scalars and arrays only: get() does not restore objects, which + * come back as __PHP_Incomplete_Class. + * * @param string $name name of variable * @param mixed $value value of variable * From b59ce051b322e8d4322ee718e37a12df0ecc4548 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Fri, 9 Oct 2026 04:24:41 -0400 Subject: [PATCH 3/6] Fix 2.6 integration issues in the XMF 1.3.2 port - Direction, ImageResolver: use the active 2.6 locale from Xoops\Locale::getCurrent() instead of falling back to English - BlockPosition: default sides follow XOOPS (0 = left, 1 = right) - Language: refuse to load files when the root path cannot be resolved - Tables: keep MariaDB current_timestamp() and precision forms unquoted in generated DDL - composer.json.dist: suggest ext-bcmath and ext-zlib - Tests: cover the above; isolate ProxyCheckTest and TranslatorTest from global state --- composer.json.dist | 4 +++ .../Xmf/Database/TablesDefaultClauseTest.php | 36 +++++++++++++++++++ .../xoopsLib/Xmf/I18n/BlockPositionTest.php | 20 +++++------ .../unit/xoopsLib/Xmf/I18n/DirectionTest.php | 12 +++++++ .../unit/xoopsLib/Xmf/I18n/TranslatorTest.php | 6 ++-- tests/unit/xoopsLib/Xmf/ProxyCheckTest.php | 6 ++-- xoops_lib/Xmf/Database/Tables.php | 5 +-- xoops_lib/Xmf/I18n/BlockPosition.php | 6 ++-- xoops_lib/Xmf/I18n/Direction.php | 3 ++ xoops_lib/Xmf/I18n/ImageResolver.php | 3 ++ xoops_lib/Xmf/Language.php | 11 +++--- 11 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 tests/unit/xoopsLib/Xmf/Database/TablesDefaultClauseTest.php diff --git a/composer.json.dist b/composer.json.dist index e36fb0db0..4b7e8167e 100644 --- a/composer.json.dist +++ b/composer.json.dist @@ -46,6 +46,10 @@ "wikimedia/less.php": "^5.0", "xoops/regdom": "^2.0" }, + "suggest": { + "ext-bcmath": "Needed by Xmf\\Ulid to convert ULIDs to and from UUIDs", + "ext-zlib": "Needed by Xmf\\Security\\Serializer for gzip-compressed payloads" + }, "require-dev": { "phpunit/phpunit": "^11.0", "roave/security-advisories": "dev-master" diff --git a/tests/unit/xoopsLib/Xmf/Database/TablesDefaultClauseTest.php b/tests/unit/xoopsLib/Xmf/Database/TablesDefaultClauseTest.php new file mode 100644 index 000000000..3051f5718 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/TablesDefaultClauseTest.php @@ -0,0 +1,36 @@ + [null, ''], + 'mysql timestamp' => ['CURRENT_TIMESTAMP', ' DEFAULT CURRENT_TIMESTAMP '], + 'mariadb timestamp' => ['current_timestamp()', ' DEFAULT CURRENT_TIMESTAMP() '], + 'timestamp precision' => ['CURRENT_TIMESTAMP(6)', ' DEFAULT CURRENT_TIMESTAMP(6) '], + 'plain string' => ['abc', " DEFAULT 'abc' "], + 'embedded quote' => ["it's", " DEFAULT 'it''s' "], + 'timestamp-like literal' => ['current_timestamp_x', " DEFAULT 'current_timestamp_x' "], + 'trailing newline' => ["CURRENT_TIMESTAMP\n", " DEFAULT 'CURRENT_TIMESTAMP\n' "], + ]; + } + + #[DataProvider('defaults')] + public function testQuoteDefaultClause(?string $default, string $expected): void + { + $tables = (new \ReflectionClass(Tables::class))->newInstanceWithoutConstructor(); + $method = new \ReflectionMethod(Tables::class, 'quoteDefaultClause'); + + $this->assertSame($expected, $method->invoke($tables, $default)); + } +} diff --git a/tests/unit/xoopsLib/Xmf/I18n/BlockPositionTest.php b/tests/unit/xoopsLib/Xmf/I18n/BlockPositionTest.php index 744aaa293..c9c09ea99 100644 --- a/tests/unit/xoopsLib/Xmf/I18n/BlockPositionTest.php +++ b/tests/unit/xoopsLib/Xmf/I18n/BlockPositionTest.php @@ -21,12 +21,12 @@ protected function tearDown(): void public function testToLogicalLeftReturnsStart(): void { - $this->assertSame('start', BlockPosition::toLogical(1)); + $this->assertSame('start', BlockPosition::toLogical(0)); } public function testToLogicalRightReturnsEnd(): void { - $this->assertSame('end', BlockPosition::toLogical(0)); + $this->assertSame('end', BlockPosition::toLogical(1)); } public function testToLogicalCenterForUnknownValue(): void @@ -43,12 +43,12 @@ public function testToLogicalWithCustomValues(): void public function testToCssClassLeft(): void { - $this->assertSame('block-start', BlockPosition::toCssClass(1)); + $this->assertSame('block-start', BlockPosition::toCssClass(0)); } public function testToCssClassRight(): void { - $this->assertSame('block-end', BlockPosition::toCssClass(0)); + $this->assertSame('block-end', BlockPosition::toCssClass(1)); } public function testToCssClassCenter(): void @@ -58,22 +58,22 @@ public function testToCssClassCenter(): void public function testGetStartValueLtr(): void { - $this->assertSame(1, BlockPosition::getStartValue(Direction::LTR)); + $this->assertSame(0, BlockPosition::getStartValue(Direction::LTR)); } public function testGetStartValueRtl(): void { - $this->assertSame(0, BlockPosition::getStartValue(Direction::RTL)); + $this->assertSame(1, BlockPosition::getStartValue(Direction::RTL)); } public function testGetEndValueLtr(): void { - $this->assertSame(0, BlockPosition::getEndValue(Direction::LTR)); + $this->assertSame(1, BlockPosition::getEndValue(Direction::LTR)); } public function testGetEndValueRtl(): void { - $this->assertSame(1, BlockPosition::getEndValue(Direction::RTL)); + $this->assertSame(0, BlockPosition::getEndValue(Direction::RTL)); } public function testGetStartValueWithCustomValues(): void @@ -90,7 +90,7 @@ public function testGetEndValueWithCustomValues(): void public function testDefaultConstants(): void { - $this->assertSame(1, BlockPosition::DEFAULT_LEFT); - $this->assertSame(0, BlockPosition::DEFAULT_RIGHT); + $this->assertSame(0, BlockPosition::DEFAULT_LEFT); + $this->assertSame(1, BlockPosition::DEFAULT_RIGHT); } } diff --git a/tests/unit/xoopsLib/Xmf/I18n/DirectionTest.php b/tests/unit/xoopsLib/Xmf/I18n/DirectionTest.php index 2fbd14f6b..702fb47f9 100644 --- a/tests/unit/xoopsLib/Xmf/I18n/DirectionTest.php +++ b/tests/unit/xoopsLib/Xmf/I18n/DirectionTest.php @@ -4,6 +4,8 @@ namespace Xmf\Test\I18n; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use Xmf\I18n\Direction; class DirectionTest extends \PHPUnit\Framework\TestCase @@ -28,6 +30,16 @@ public function testDirDetectsRtlFromArabic(): void $this->assertSame(Direction::RTL, Direction::dir('ar')); } + // loading the 2.6 \Xoops class would switch every later test off the legacy path + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testDirUsesXoops26CurrentLocale(): void + { + require_once \dirname(__DIR__, 5) . '/xoops_lib/Xoops.php'; + \Xoops\Locale::setCurrent('ar_SA'); + $this->assertSame(Direction::RTL, Direction::dir()); + } + public function testDirDetectsRtlFromHebrew(): void { $this->assertSame(Direction::RTL, Direction::dir('he')); diff --git a/tests/unit/xoopsLib/Xmf/I18n/TranslatorTest.php b/tests/unit/xoopsLib/Xmf/I18n/TranslatorTest.php index 315391351..725fcdc7a 100644 --- a/tests/unit/xoopsLib/Xmf/I18n/TranslatorTest.php +++ b/tests/unit/xoopsLib/Xmf/I18n/TranslatorTest.php @@ -46,9 +46,9 @@ public function testTReturnsLabelForNonStringConstant(): void public function testTHandlesValidPatternFormats(): void { - if (!\defined('_MI_MYMODULE_NAME')) { - \define('_MI_MYMODULE_NAME', 'My Module'); + if (!\defined('_MI_XMFTEST_TRANSLATOR_NAME')) { + \define('_MI_XMFTEST_TRANSLATOR_NAME', 'My Module'); } - $this->assertSame('My Module', Translator::t('_MI_MYMODULE_NAME')); + $this->assertSame('My Module', Translator::t('_MI_XMFTEST_TRANSLATOR_NAME')); } } diff --git a/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php b/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php index ada8885f4..694b977a1 100644 --- a/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php +++ b/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use Xmf\ProxyCheck; -class localProxyCheck extends ProxyCheck +class LocalProxyCheck extends ProxyCheck { public function __construct($name, $header) { @@ -28,6 +28,8 @@ class ProxyCheckTest extends \PHPUnit\Framework\TestCase */ protected function setUp(): void { + // the real constructor reads these globals; keep earlier tests from leaking into testGet() + unset($GLOBALS['xoopsConfig']['proxy_env']); $this->object = new ProxyCheck(); } @@ -61,7 +63,7 @@ public static function getProxyCheckTestData() #[DataProvider('getProxyCheckTestData')] public function testProxyCheck($name, $header, $expected) { - $obj = new localProxyCheck($name, $header); + $obj = new LocalProxyCheck($name, $header); $this->assertSame($expected, $obj->get()); } diff --git a/xoops_lib/Xmf/Database/Tables.php b/xoops_lib/Xmf/Database/Tables.php index ed683230d..1a9d3b590 100644 --- a/xoops_lib/Xmf/Database/Tables.php +++ b/xoops_lib/Xmf/Database/Tables.php @@ -890,8 +890,9 @@ protected function quoteDefaultClause($default) // functions should not be quoted // this section will need expanded when XOOPS minimum is no longer a mysql 5 version // Until mysql 8, only allowed function is CURRENT_TIMESTAMP - if ($default === 'CURRENT_TIMESTAMP') { - return ' DEFAULT CURRENT_TIMESTAMP '; + // MariaDB reports it as current_timestamp(), and either may carry a precision + if (preg_match('/^current_timestamp(\(\d*\))?\z/i', $default, $matches)) { + return ' DEFAULT CURRENT_TIMESTAMP' . ($matches[1] ?? '') . ' '; } // surround default with quotes — escape embedded single quotes for valid DDL diff --git a/xoops_lib/Xmf/I18n/BlockPosition.php b/xoops_lib/Xmf/I18n/BlockPosition.php index 05379ad66..2e16d4bbb 100644 --- a/xoops_lib/Xmf/I18n/BlockPosition.php +++ b/xoops_lib/Xmf/I18n/BlockPosition.php @@ -28,15 +28,15 @@ final class BlockPosition { /** XOOPS convention: integer representing the left/start side */ - public const DEFAULT_LEFT = 1; + public const DEFAULT_LEFT = 0; /** XOOPS convention: integer representing the right/end side */ - public const DEFAULT_RIGHT = 0; + public const DEFAULT_RIGHT = 1; /** * Map a side integer to logical class based on convention. * - * @param int $side Physical side (convention: 1=left, 0=right in LTR context) + * @param int $side Physical side (XOOPS convention: 0=left, 1=right) * @param int $leftValue What integer represents "left" in your CMS * @param int $rightValue What integer represents "right" in your CMS * diff --git a/xoops_lib/Xmf/I18n/Direction.php b/xoops_lib/Xmf/I18n/Direction.php index 9fe4bfb74..07caf8bba 100644 --- a/xoops_lib/Xmf/I18n/Direction.php +++ b/xoops_lib/Xmf/I18n/Direction.php @@ -93,6 +93,9 @@ public static function dir(?string $locale = null): string $resolved = $langCode; } } + if ($resolved === null && !$isLegacy && \class_exists('Xoops\Locale')) { + $resolved = (string) \Xoops\Locale::getCurrent(); + } if ($resolved === null) { $resolved = 'en'; } diff --git a/xoops_lib/Xmf/I18n/ImageResolver.php b/xoops_lib/Xmf/I18n/ImageResolver.php index fc6b28a2a..a33d66b4e 100644 --- a/xoops_lib/Xmf/I18n/ImageResolver.php +++ b/xoops_lib/Xmf/I18n/ImageResolver.php @@ -72,6 +72,9 @@ public static function resolve(string $basePath, ?string $lang = null, ?string $ $lang = $langCode; } } + if ($lang === null && !$isLegacy && \class_exists('Xoops\Locale')) { + $lang = (string) \Xoops\Locale::getCurrent(); + } if ($lang === null) { $lang = 'en'; } diff --git a/xoops_lib/Xmf/Language.php b/xoops_lib/Xmf/Language.php index 013d2a175..42989cb77 100644 --- a/xoops_lib/Xmf/Language.php +++ b/xoops_lib/Xmf/Language.php @@ -85,11 +85,12 @@ protected static function loadFile($filename) return false; } $allowedDir = realpath((string) \XoopsBaseConfig::get('root-path')); - if ($allowedDir !== false) { - $allowedDirWithSep = rtrim($allowedDir, '/\\') . DIRECTORY_SEPARATOR; - if (strpos($realPath, $allowedDirWithSep) !== 0 && $realPath !== $allowedDir) { - return false; - } + if ($allowedDir === false) { + return false; + } + $allowedDirWithSep = rtrim($allowedDir, '/\\') . DIRECTORY_SEPARATOR; + if (strpos($realPath, $allowedDirWithSep) !== 0 && $realPath !== $allowedDir) { + return false; } include_once $realPath; return true; From adfd625b6fd23512879b89867ce4a48d4a9b9442 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Fri, 9 Oct 2026 05:00:08 -0400 Subject: [PATCH 4/6] Add tests for Xmf\Security Serializer and SerializableTrait Cover the JSON, PHP and legacy formats, size limits, allowed-class handling, gzip limits, format detection, the typed helpers and debug stats. Five tests assert the correct behaviour for bugs in the bundled XMF 1.3.2 code and are marked incomplete until upstream fixes them: serialized null and short payloads in fromLegacy(), gzip decoding, the legacy logger call site, and migrateSerializedData() on corrupt input. ProxyCheckTest now restores the xoopsConfig global after each test. --- tests/unit/xoopsLib/Xmf/ProxyCheckTest.php | 16 +- .../Xmf/Security/SerializableTraitTest.php | 115 +++++ .../xoopsLib/Xmf/Security/SerializerTest.php | 443 ++++++++++++++++++ 3 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 tests/unit/xoopsLib/Xmf/Security/SerializableTraitTest.php create mode 100644 tests/unit/xoopsLib/Xmf/Security/SerializerTest.php diff --git a/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php b/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php index 694b977a1..ca07f022c 100644 --- a/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php +++ b/tests/unit/xoopsLib/Xmf/ProxyCheckTest.php @@ -22,13 +22,21 @@ class ProxyCheckTest extends \PHPUnit\Framework\TestCase */ protected $object; + /** @var array{0: bool, 1: bool, 2: mixed} whether xoopsConfig and proxy_env existed, and proxy_env's value */ + private $savedProxyEnv; + /** * Sets up the fixture, for example, opens a network connection. * This method is called before a test is executed. */ protected function setUp(): void { - // the real constructor reads these globals; keep earlier tests from leaking into testGet() + // the real constructor reads this global; keep other tests' settings out of testGet() + $this->savedProxyEnv = [ + \array_key_exists('xoopsConfig', $GLOBALS), + isset($GLOBALS['xoopsConfig']) && \array_key_exists('proxy_env', $GLOBALS['xoopsConfig']), + $GLOBALS['xoopsConfig']['proxy_env'] ?? null, + ]; unset($GLOBALS['xoopsConfig']['proxy_env']); $this->object = new ProxyCheck(); } @@ -39,6 +47,12 @@ protected function setUp(): void */ protected function tearDown(): void { + // ProxyCheck's `global $xoopsConfig` creates the global, so drop it if it was not there before + if (!$this->savedProxyEnv[0]) { + unset($GLOBALS['xoopsConfig']); + } elseif ($this->savedProxyEnv[1]) { + $GLOBALS['xoopsConfig']['proxy_env'] = $this->savedProxyEnv[2]; + } } public function testGet() diff --git a/tests/unit/xoopsLib/Xmf/Security/SerializableTraitTest.php b/tests/unit/xoopsLib/Xmf/Security/SerializableTraitTest.php new file mode 100644 index 000000000..cd98a019e --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Security/SerializableTraitTest.php @@ -0,0 +1,115 @@ + 1]; + public $broken; + /** @var array values written through setVar() */ + public $written = []; + + public function __construct() + { + $this->broken = new \stdClass(); + } + + public function setVar($key, $value) + { + $this->written[$key] = $value; + } + + protected function getSerializableProperties(): array + { + return [ + 'tags' => Format::JSON, + 'meta' => Format::PHP, + 'missing' => Format::JSON, + ]; + } + + public function serialize($value, string $format = Format::JSON): string + { + return $this->serializeProperty($value, $format); + } + + public function unserialize(string $data, $default = null) + { + return $this->unserializeProperty($data, $default); + } +} + +#[CoversClass(Serializer::class)] +class SerializableTraitTest extends \PHPUnit\Framework\TestCase +{ + public function testSerializeProperties(): void + { + $entity = new SerializableEntity(); + + $this->assertSame( + ['tags' => '["a","b"]', 'meta' => serialize(['n' => 1])], + $entity->serializeProperties() + ); + } + + public function testSerializePropertyRejectsUnsupportedFormat(): void + { + $this->expectException(\InvalidArgumentException::class); + (new SerializableEntity())->serialize([1], Format::LEGACY); + } + + public function testUnserializePropertyAutoDetectsAndFallsBack(): void + { + $entity = new SerializableEntity(); + + $this->assertSame([1], $entity->unserialize('[1]')); + $this->assertSame([1], $entity->unserialize(serialize([1]))); + $this->assertSame('fallback', $entity->unserialize('{bad', 'fallback')); + } + + public function testMigrateConvertsPhpPayloadToJson(): void + { + $entity = new SerializableEntity(); + + $this->assertTrue($entity->migrateSerializedData('tags', serialize(['x', 'y']))); + $this->assertSame(['tags' => '["x","y"]'], $entity->written); + } + + public function testMigrateConvertsLegacyPayloadToJson(): void + { + $entity = new SerializableEntity(); + + $this->assertTrue($entity->migrateSerializedData('meta', base64_encode(serialize(['k' => 'v'])))); + $this->assertSame(['meta' => '{"k":"v"}'], $entity->written); + } + + public function testMigrateLeavesJsonPayloadAlone(): void + { + $entity = new SerializableEntity(); + + $this->assertFalse($entity->migrateSerializedData('tags', '["already","json"]')); + $this->assertSame([], $entity->written); + } + + public function testMigrateKeepsDataWhenDecodingFails(): void + { + $this->markTestIncomplete( + 'Upstream XMF 1.3.2 bug: migrateSerializedData() decodes with tryFrom(), so a corrupt payload ' + . 'becomes null and is written back as the JSON string "null".' + ); + $entity = new SerializableEntity(); + + $this->assertFalse($entity->migrateSerializedData('tags', 'a:2:{i:0;s:1:"x";')); + $this->assertSame([], $entity->written); + } +} diff --git a/tests/unit/xoopsLib/Xmf/Security/SerializerTest.php b/tests/unit/xoopsLib/Xmf/Security/SerializerTest.php new file mode 100644 index 000000000..dfdd51762 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Security/SerializerTest.php @@ -0,0 +1,443 @@ + 'Ünïcode/path', 'list' => [1, 2.5, true, null]]; + $json = Serializer::toJson($data); + + $this->assertSame('{"name":"Ünïcode/path","list":[1,2.5,true,null]}', $json); + $this->assertSame($data, Serializer::fromJson($json)); + } + + public function testToJsonRejectsOversizedOutput(): void + { + $this->expectException(\RuntimeException::class); + Serializer::toJson(str_repeat('a', self::MAX_SIZE)); + } + + public function testToJsonRejectsUnencodableData(): void + { + $this->expectException(\JsonException::class); + Serializer::toJson("\xB1\x31"); + } + + public function testFromJsonRejectsEmptyString(): void + { + $this->expectException(\UnexpectedValueException::class); + Serializer::fromJson(''); + } + + public function testFromJsonRejectsOversizedPayload(): void + { + $this->expectException(\RuntimeException::class); + Serializer::fromJson(str_repeat(' ', self::MAX_SIZE + 1)); + } + + public function testFromJsonRejectsMalformedJson(): void + { + $this->expectException(\JsonException::class); + Serializer::fromJson('{"a":'); + } + + public function testJsonOnly(): void + { + $this->assertSame(['a' => 1], Serializer::jsonOnly('{"a":1}')); + $this->assertNull(Serializer::jsonOnly('')); + $this->assertNull(Serializer::jsonOnly(' ')); + $this->assertNull(Serializer::jsonOnly('{bad')); + $this->assertNull(Serializer::jsonOnly('a:1:{i:0;i:1;}')); + } + + // ----------------------------------------------------------------- PHP + + public static function phpValues(): array + { + return [ + 'null' => [null], + 'false' => [false], + 'true' => [true], + 'int' => [-42], + 'float' => [1.5], + 'string' => ['hello'], + 'array' => [['a' => [1, 2], 'b' => 'c']], + ]; + } + + #[DataProvider('phpValues')] + public function testPhpRoundTrip($value): void + { + $this->assertSame($value, Serializer::fromPhp(Serializer::toPhp($value))); + } + + public function testToPhpRejectsResource(): void + { + $handle = fopen('php://memory', 'r'); + try { + $this->expectException(\InvalidArgumentException::class); + Serializer::toPhp($handle); + } finally { + fclose($handle); + } + } + + public function testToPhpRejectsClosure(): void + { + $this->expectException(\InvalidArgumentException::class); + Serializer::toPhp(static function () { + }); + } + + public function testToPhpRejectsNestedClosure(): void + { + $this->expectException(\InvalidArgumentException::class); + Serializer::toPhp(['callback' => static function () { + }]); + } + + public function testToPhpRejectsOversizedOutput(): void + { + $this->expectException(\RuntimeException::class); + Serializer::toPhp(str_repeat('a', self::MAX_SIZE)); + } + + public function testFromPhpRejectsEmptyString(): void + { + $this->expectException(\UnexpectedValueException::class); + Serializer::fromPhp(''); + } + + public function testFromPhpRejectsOversizedPayload(): void + { + $this->expectException(\RuntimeException::class); + Serializer::fromPhp(str_repeat('a', self::MAX_SIZE + 1)); + } + + public function testFromPhpRejectsMalformedPayload(): void + { + $this->expectException(\UnexpectedValueException::class); + Serializer::fromPhp('a:1:{i:0;'); + } + + public function testFromPhpRejectsNulBytesWithoutAllowedClasses(): void + { + $this->expectException(\RuntimeException::class); + Serializer::fromPhp(serialize("a\0b")); + } + + public function testFromPhpDoesNotRestoreObjectsByDefault(): void + { + $result = Serializer::fromPhp(serialize(new \ArrayObject([1]))); + + $this->assertInstanceOf(\__PHP_Incomplete_Class::class, $result); + } + + public function testFromPhpRestoresAllowedClasses(): void + { + $object = new \stdClass(); + $object->name = 'x'; + + $result = Serializer::fromPhp(serialize($object), [\stdClass::class]); + + $this->assertInstanceOf(\stdClass::class, $result); + $this->assertSame('x', $result->name); + } + + // -------------------------------------------------------------- legacy + + public function testLegacyRoundTrip(): void + { + $data = ['legacy' => true, 'items' => [1, 2, 3]]; + + $this->assertSame($data, Serializer::fromLegacy(Serializer::toLegacy($data))); + } + + public function testFromLegacyAcceptsRawSerializedPayloadAndLogsIt(): void + { + $calls = []; + Serializer::setLegacyLogger(static function (string $file, int $line, string $preview) use (&$calls): void { + $calls[] = [$file, $line, $preview]; + }); + + $this->assertSame([1, 2], Serializer::fromLegacy(serialize([1, 2]))); + $this->assertCount(1, $calls); + // the logger reports the first caller outside Serializer + $this->assertStringNotContainsString('Serializer.php', $calls[0][0]); + $this->assertGreaterThan(0, $calls[0][1]); + $this->assertSame(serialize([1, 2]), $calls[0][2]); + } + + public function testLegacyLoggerReportsTheCallSite(): void + { + $this->markTestIncomplete( + 'Upstream XMF 1.3.2 bug: logLegacy() skips the frame that holds the call site (its class is Serializer), ' + . 'so it reports the caller of the calling function instead.' + ); + $location = null; + Serializer::setLegacyLogger(static function (string $file, int $line) use (&$location): void { + $location = [$file, $line]; + }); + + $line = __LINE__ + 1; + Serializer::fromLegacy(serialize([1, 2])); + + $this->assertSame([__FILE__, $line], $location); + } + + public function testLegacyLoggerTruncatesLongPreviews(): void + { + $preview = null; + Serializer::setLegacyLogger(static function (string $file, int $line, string $text) use (&$preview): void { + $preview = $text; + }); + + Serializer::fromLegacy(Serializer::toLegacy(str_repeat('x', 200))); + + $this->assertSame(53, \strlen((string) $preview)); + $this->assertStringEndsWith('...', (string) $preview); + } + + public function testFromLegacyRejectsNonBase64Garbage(): void + { + $this->expectException(\RuntimeException::class); + Serializer::fromLegacy('not a serialized or base64 value'); + } + + public function testFromLegacyRejectsNulBytesInDecodedPayload(): void + { + $this->expectException(\RuntimeException::class); + Serializer::fromLegacy(base64_encode(serialize("abc\0def-padding"))); + } + + #[RequiresPhpExtension('zlib')] + public function testFromLegacyDecodesGzipPayload(): void + { + $this->markTestIncomplete( + 'Upstream XMF 1.3.2 bug: safeGzipDecode() calls inflate_add(ZLIB_FINISH) after the stream has ended, ' + . 'which sets ZLIB_BUF_ERROR, so every valid gzip payload is rejected as truncated.' + ); + $data = ['compressed' => str_repeat('abc', 100)]; + + $this->assertSame($data, Serializer::fromLegacy(base64_encode(gzencode(serialize($data))))); + } + + #[RequiresPhpExtension('zlib')] + public function testFromLegacyRejectsTruncatedGzip(): void + { + $gzip = gzencode(serialize(['compressed' => str_repeat('abc', 100)])); + + $this->expectException(DecompressionException::class); + Serializer::fromLegacy(base64_encode(substr($gzip, 0, 40))); + } + + #[RequiresPhpExtension('zlib')] + public function testFromLegacyRejectsGzipBomb(): void + { + $bomb = gzencode(serialize(str_repeat('a', self::MAX_SIZE + 1)), 9); + + $this->expectException(DecompressionException::class); + $this->expectExceptionMessage('Decompressed payload exceeds'); + Serializer::fromLegacy(base64_encode($bomb)); + } + + public function testFromLegacyAcceptsSerializedNull(): void + { + $this->markTestIncomplete( + 'Upstream XMF 1.3.2 bug: fromLegacy() treats a valid serialized null (N;) as a parse failure.' + ); + $this->assertNull(Serializer::fromLegacy('N;')); + } + + public function testFromLegacyAcceptsShortToLegacyOutput(): void + { + $this->markTestIncomplete( + 'Upstream XMF 1.3.2 bug: base64 payloads under 16 characters, such as toLegacy(false), are rejected.' + ); + $this->assertFalse(Serializer::fromLegacy(Serializer::toLegacy(false))); + } + + // ------------------------------------------------------ detect / from + + public static function detectCases(): array + { + return [ + 'empty' => ['', Format::AUTO], + 'json object' => ['{"a":1}', Format::JSON], + 'json array' => [' [1,2]', Format::JSON], + 'broken json' => ['{"a":', Format::AUTO], + 'php array' => [serialize([1, 2]), Format::PHP], + 'php null' => ['N;', Format::PHP], + 'php double' => [serialize(1.5), Format::PHP], + 'php object' => [serialize(new \stdClass()), Format::PHP], + 'legacy' => [base64_encode(serialize(['a' => 'b'])), Format::LEGACY], + 'plain text' => ['hello world', Format::AUTO], + 'base64 text' => [base64_encode('just some plain text'), Format::AUTO], + ]; + } + + #[DataProvider('detectCases')] + public function testDetect(string $payload, string $expected): void + { + $this->assertSame($expected, Serializer::detect($payload)); + } + + public function testFromDispatchesOnDetectedFormat(): void + { + $data = ['k' => 'v']; + + $this->assertSame($data, Serializer::from(Serializer::toJson($data))); + $this->assertSame($data, Serializer::from(Serializer::toPhp($data))); + $this->assertSame($data, Serializer::from(Serializer::toLegacy($data))); + $this->assertNull(Serializer::from('N;')); + } + + public function testFromFallsBackToJsonForScalars(): void + { + $this->assertSame(42, Serializer::from('42')); + $this->assertNull(Serializer::from('null')); + } + + public function testFromRejectsGarbage(): void + { + $this->expectException(\RuntimeException::class); + Serializer::from('definitely not serialized'); + } + + // --------------------------------------------- typed helpers / tryFrom + + public function testToArrayForEachFormat(): void + { + $data = ['x' => 1]; + + $this->assertSame($data, Serializer::toArray(Serializer::toJson($data), Format::JSON)); + $this->assertSame($data, Serializer::toArray(Serializer::toPhp($data), Format::PHP)); + $this->assertSame($data, Serializer::toArray(Serializer::toLegacy($data), Format::LEGACY)); + $this->assertSame($data, Serializer::toArray(Serializer::toJson($data))); + } + + public function testToArrayRejectsNonArray(): void + { + $this->expectException(\UnexpectedValueException::class); + Serializer::toArray(Serializer::toPhp('scalar'), Format::PHP); + } + + public function testToArrayRejectsUnknownFormat(): void + { + $this->expectException(\InvalidArgumentException::class); + Serializer::toArray('{}', 'xml'); + } + + public function testToObjectRestoresRequestedClass(): void + { + $object = new \ArrayObject([1, 2]); + + $this->assertEquals($object, Serializer::toObject(serialize($object), \ArrayObject::class)); + $this->assertEquals( + $object, + Serializer::toObject(base64_encode(serialize($object)), \ArrayObject::class, Format::LEGACY) + ); + } + + public function testToObjectRejectsUnknownClass(): void + { + $this->expectException(\InvalidArgumentException::class); + Serializer::toObject(serialize(new \stdClass()), 'No\\Such\\ClassName'); + } + + public function testToObjectRejectsJsonFormat(): void + { + $this->expectException(\RuntimeException::class); + Serializer::toObject('{}', \stdClass::class, Format::JSON); + } + + public function testToObjectRejectsOtherClasses(): void + { + $this->expectException(\UnexpectedValueException::class); + Serializer::toObject(serialize(new \ArrayObject()), \stdClass::class); + } + + public function testTryFromReturnsDefaultOnFailure(): void + { + $this->assertSame(['a' => 1], Serializer::tryFrom('{"a":1}', 'd', Format::JSON)); + $this->assertSame([1], Serializer::tryFrom(serialize([1]), 'd', Format::PHP)); + $this->assertSame([1], Serializer::tryFrom(base64_encode(serialize([1])), 'd', Format::LEGACY)); + $this->assertSame([1], Serializer::tryFrom('[1]', 'd')); + $this->assertSame('d', Serializer::tryFrom('{bad', 'd', Format::JSON)); + $this->assertSame('d', Serializer::tryFrom('anything', 'd', 'xml')); + } + + public function testScalarsOnly(): void + { + $this->assertSame('x', Serializer::scalarsOnly(Serializer::toJson('x'), Format::JSON)); + $this->assertSame(3, Serializer::scalarsOnly(Serializer::toPhp(3), Format::PHP)); + $this->assertSame( + 'a longer legacy value', + Serializer::scalarsOnly(Serializer::toLegacy('a longer legacy value'), Format::LEGACY) + ); + $this->assertNull(Serializer::scalarsOnly('null')); + } + + public function testScalarsOnlyRejectsArrays(): void + { + $this->expectException(\UnexpectedValueException::class); + Serializer::scalarsOnly('[1]', Format::JSON); + } + + public function testScalarsOnlyRejectsUnknownFormat(): void + { + $this->expectException(\InvalidArgumentException::class); + Serializer::scalarsOnly('1', 'xml'); + } + + // --------------------------------------------------------------- debug + + public function testDebugStatsAreEmptyWhenDisabled(): void + { + $this->assertSame([], Serializer::getDebugStats()); + } + + public function testDebugStatsRecordOperationsAndErrors(): void + { + Serializer::enableDebug(); + Serializer::fromPhp(serialize([1])); + try { + Serializer::fromPhp('a:1:{'); + } catch (\UnexpectedValueException $e) { + // expected + } + + $stats = Serializer::getDebugStats(); + + $this->assertSame(2, $stats['total_operations']); + $this->assertSame([Format::PHP => 2], $stats['formats_detected']); + $this->assertCount(1, $stats['errors']); + $this->assertSame('fromPhp', $stats['errors'][0]['operation']); + $this->assertIsArray($stats['errors'][0]['trace']); + + Serializer::enableDebug(false); + $this->assertSame([], Serializer::getDebugStats()); + } +} From aa12ad3b59a27acb5f27456ffa4664ce90365351 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Fri, 9 Oct 2026 05:50:49 -0400 Subject: [PATCH 5/6] Cover the XMF 1.3.2 changes with unit tests - Port the upstream XMF 1.3.2 tests that are missing here for FilterInput, Yaml, Request, Metagen, IPAddress, TokenReader, Tables and Migrate (the Tables execSql routing tests are left out, 2.6 keeps its own execSql()) - Add database-free tests for the Migrate synchronisation flow and for loading table definitions through the 2.6 connection - Add tests for the legacy direction constants, the locale cache bound and the Request METHOD hash - Credit SerializableTrait coverage with #[CoversTrait] - Run session, proxy and header tests in separate processes - FilterInput::cleanVar(): accept a null type without a PHP 8.1 deprecation; Tables: pass str_getcsv()'s escape character explicitly (PHP 8.4 deprecation) One Tables test is marked incomplete for an existing 2.6 issue: Connection::safeQuery() returns null when a statement affects no rows, so executeQueue() reports a successful CREATE TABLE as failed. --- .../xoopsLib/Xmf/Database/MigrateFlowTest.php | 268 ++++++++++++++++++ .../xoopsLib/Xmf/Database/MigrateUnitTest.php | 234 +++++++++++++++ .../Xmf/Database/TablesSchemaTest.php | 235 +++++++++++++++ .../xoopsLib/Xmf/Database/TablesUnitTest.php | 227 +++++++++++++++ tests/unit/xoopsLib/Xmf/FilterInputTest.php | 137 +++++++++ .../unit/xoopsLib/Xmf/I18n/DirectionTest.php | 93 ++++++ tests/unit/xoopsLib/Xmf/IPAddressTest.php | 50 ++++ .../unit/xoopsLib/Xmf/Jwt/TokenReaderTest.php | 49 ++++ tests/unit/xoopsLib/Xmf/MetagenTest.php | 36 +++ tests/unit/xoopsLib/Xmf/RequestTest.php | 258 +++++++++++++++++ tests/unit/xoopsLib/Xmf/RequestXmf132Test.php | 89 ++++++ .../Xmf/Security/SerializableTraitTest.php | 2 + tests/unit/xoopsLib/Xmf/YamlTest.php | 99 +++++++ xoops_lib/Xmf/Database/Tables.php | 4 +- xoops_lib/Xmf/FilterInput.php | 4 +- 15 files changed, 1781 insertions(+), 4 deletions(-) create mode 100644 tests/unit/xoopsLib/Xmf/Database/MigrateFlowTest.php create mode 100644 tests/unit/xoopsLib/Xmf/Database/MigrateUnitTest.php create mode 100644 tests/unit/xoopsLib/Xmf/Database/TablesSchemaTest.php create mode 100644 tests/unit/xoopsLib/Xmf/Database/TablesUnitTest.php create mode 100644 tests/unit/xoopsLib/Xmf/RequestXmf132Test.php diff --git a/tests/unit/xoopsLib/Xmf/Database/MigrateFlowTest.php b/tests/unit/xoopsLib/Xmf/Database/MigrateFlowTest.php new file mode 100644 index 000000000..9adc668a7 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/MigrateFlowTest.php @@ -0,0 +1,268 @@ +yamlFile && file_exists($this->yamlFile)) { + unlink($this->yamlFile); + } + } + + private function migrate(array $tables, RecordingTableHandler $handler, ?array $target = null): FlowMigrate + { + $migrate = new FlowMigrate(); + $migrate->moduleTables = $tables; + $migrate->tableHandler = $handler; + $migrate->targetDefinitions = $target; + return $migrate; + } + + private static function target(): array + { + return [ + 'items' => [ + 'options' => 'ENGINE=InnoDB', + 'columns' => [ + ['name' => 'id', 'attributes' => 'int NOT NULL'], + ['name' => 'title', 'attributes' => 'varchar(255) NOT NULL'], + ['name' => 'added', 'attributes' => 'int NOT NULL DEFAULT 0'], + ], + 'keys' => [ + 'PRIMARY' => ['columns' => '`id`', 'unique' => true], + 'idx_title' => ['columns' => '`title`', 'unique' => true], + 'idx_new' => ['columns' => '`added`', 'unique' => false], + ], + ], + 'logs' => [ + 'options' => 'ENGINE=InnoDB', + 'columns' => [['name' => 'log_id', 'attributes' => 'int NOT NULL']], + ], + ]; + } + + public function testGetSynchronizeDdlSynchronizesExistingAndAddsMissingTables(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['items']; + $handler->existingColumns = ['id' => 'int NOT NULL', 'title' => 'varchar(100) NOT NULL']; + $handler->dumpTables = ['items' => ['columns' => [ + ['name' => 'id'], ['name' => 'title'], ['name' => 'obsolete'], + ]]]; + $handler->existingIndexes = [ + 'PRIMARY' => ['columns' => '`id`, `title`', 'unique' => true], + 'idx_title' => ['columns' => '`title`', 'unique' => false], + 'idx_gone' => ['columns' => '`obsolete`', 'unique' => false], + ]; + + $queue = $this->migrate(['items', 'logs'], $handler, self::target())->getSynchronizeDDL(); + + $this->assertSame(['queued'], $queue); + $this->assertSame([ + ['alterColumn', 'items', 'title', 'varchar(255) NOT NULL'], + ['addColumn', 'items', 'added', 'int NOT NULL DEFAULT 0'], + ['dropColumn', 'items', 'obsolete'], + ['dropPrimaryKey', 'items'], + ['addPrimaryKey', 'items', '`id`'], + ['dropIndex', 'idx_title', 'items'], + ['addIndex', 'idx_title', 'items', '`title`', true], + ['addIndex', 'idx_new', 'items', '`added`', false], + ['dropIndex', 'idx_gone', 'items'], + ['addTable', 'logs'], + ['setTableOptions', 'logs', 'ENGINE=InnoDB'], + ['addColumn', 'logs', 'log_id', 'int NOT NULL'], + ], $handler->calls); + } + + public function testSynchronizeAddsPrimaryKeyWhenMissing(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['items']; + $handler->existingColumns = [ + 'id' => 'int NOT NULL', 'title' => 'varchar(255) NOT NULL', 'added' => 'int NOT NULL DEFAULT 0', + ]; + $handler->existingIndexes = [ + 'idx_title' => ['columns' => '`title`', 'unique' => true], + 'idx_new' => ['columns' => '`added`', 'unique' => false], + ]; + + $this->migrate(['items'], $handler, self::target())->getSynchronizeDDL(); + + $this->assertSame([['addPrimaryKey', 'items', '`id`']], $handler->calls); + } + + public function testSynchronizeToleratesMissingIndexList(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['logs']; + $handler->existingColumns = ['log_id' => 'int NOT NULL']; + $handler->existingIndexes = false; + + $this->migrate(['logs'], $handler, self::target())->getSynchronizeDDL(); + + $this->assertSame([], $handler->calls); + } + + public function testSynchronizeRejectsTableWithoutTargetDefinition(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['unknown']; + + $this->expectException(SchemaDefinitionException::class); + $this->migrate(['unknown'], $handler, self::target())->getSynchronizeDDL(); + } + + public function testNonPrimaryKeyMustDeclareUniqueness(): void + { + $target = self::target(); + unset($target['items']['keys']['idx_new']['unique']); + + $this->expectException(SchemaDefinitionException::class); + $this->migrate(['items'], new RecordingTableHandler(), $target)->getSynchronizeDDL(); + } + + public function testNonStringKeyNamesAreIgnored(): void + { + $target = ['logs' => [ + 'options' => 'ENGINE=InnoDB', + 'columns' => [['name' => 'log_id', 'attributes' => 'int NOT NULL']], + 'keys' => [0 => ['columns' => '`log_id`', 'unique' => false]], + ]]; + $handler = new RecordingTableHandler(); + + $this->migrate(['logs'], $handler, $target)->getSynchronizeDDL(); + + $this->assertSame([ + ['addTable', 'logs'], + ['setTableOptions', 'logs', 'ENGINE=InnoDB'], + ['addColumn', 'logs', 'log_id', 'int NOT NULL'], + ], $handler->calls); + } + + public function testGetCurrentSchemaAddsTablesTheHandlerDoesNotKnow(): void + { + $handler = new RecordingTableHandler(); + $handler->existingTables = ['items']; + $handler->dumpTables = ['items' => ['name' => 'x_items']]; + + $schema = $this->migrate(['items', 'logs'], $handler)->getCurrentSchema(); + + $this->assertSame(['items' => ['name' => 'x_items'], 'logs' => ['name' => 'x_logs']], $schema); + $this->assertSame([['addTable', 'logs']], $handler->calls); + } + + public function testGetTargetDefinitionsReadsYamlFile(): void + { + $this->yamlFile = tempnam(sys_get_temp_dir(), 'xmfmig'); + Yaml::save(self::target(), $this->yamlFile); + $migrate = $this->migrate([], new RecordingTableHandler()); + $migrate->tableDefinitionFile = $this->yamlFile; + + $this->assertSame(self::target(), $migrate->getTargetDefinitions()); + } + + public function testGetTargetDefinitionsRejectsMissingFile(): void + { + $migrate = $this->migrate([], new RecordingTableHandler()); + $migrate->tableDefinitionFile = sys_get_temp_dir() . '/xmf-no-such-schema.yml'; + + $this->expectException(SchemaDefinitionException::class); + $migrate->getTargetDefinitions(); + } + + public function testLastErrorComesFromTheTableHandler(): void + { + $handler = new RecordingTableHandler(); + $migrate = $this->migrate([], $handler); + + $this->assertSame('handler error', $migrate->getLastError()); + $this->assertSame(1146, $migrate->getLastErrNo()); + } +} + +class FlowMigrate extends Migrate +{ + public $moduleTables; + public $tableHandler; + public $targetDefinitions; + public $tableDefinitionFile; + + public function __construct() + { + } +} + +class RecordingTableHandler +{ + public $calls = []; + public $existingTables = []; + public $existingColumns = []; + public $dumpTables = []; + /** @var array|false */ + public $existingIndexes = []; + + public function __call($name, $args) + { + $this->calls[] = array_merge([$name], $args); + return true; + } + + // like Tables::addTable(), a new table shows up in dumpTables() + public function addTable($table) + { + $this->calls[] = ['addTable', $table]; + $this->dumpTables[$table] = ['name' => 'x_' . $table]; + return true; + } + + public function useTable($table) + { + return in_array($table, $this->existingTables, true); + } + + public function getColumnAttributes($table, $column) + { + return $this->existingColumns[$column] ?? false; + } + + public function dumpTables() + { + return $this->dumpTables; + } + + public function getTableIndexes($table) + { + return $this->existingIndexes; + } + + public function dumpQueue() + { + return ['queued']; + } + + public function getLastError() + { + return 'handler error'; + } + + public function getLastErrNo() + { + return 1146; + } +} diff --git a/tests/unit/xoopsLib/Xmf/Database/MigrateUnitTest.php b/tests/unit/xoopsLib/Xmf/Database/MigrateUnitTest.php new file mode 100644 index 000000000..df37d5e1b --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/MigrateUnitTest.php @@ -0,0 +1,234 @@ +setTargetDefinitions(array( + 'demo' => array( + 'options' => 'ENGINE=InnoDB', + 'columns' => array( + array('attributes' => 'int NOT NULL'), + ), + ), + )); + $migrate->setTableHandler(new FakeMigrateTableHandler()); + + $this->expectException(SchemaDefinitionException::class); + $this->expectExceptionMessage('No schema definition for table demo'); + + $migrate->callAddMissingTable('demo'); + } + + public function testAddMissingTableAddsValidatedColumnsAndKeys() + { + $migrate = new TestableMigrate(); + $migrate->setTargetDefinitions(array( + 'demo' => array( + 'options' => 'ENGINE=InnoDB', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + array('name' => 'title', 'attributes' => 'varchar(255) NOT NULL'), + ), + 'keys' => array( + 'PRIMARY' => array('columns' => '`id`'), + 'idx_title' => array('columns' => '`title`', 'unique' => false), + ), + ), + )); + $handler = new FakeMigrateTableHandler(); + $migrate->setTableHandler($handler); + + $migrate->callAddMissingTable('demo'); + + $this->assertSame( + array( + array('addTable', 'demo'), + array('setTableOptions', 'demo', 'ENGINE=InnoDB'), + array('addColumn', 'demo', 'id', 'int NOT NULL'), + array('addColumn', 'demo', 'title', 'varchar(255) NOT NULL'), + array('addPrimaryKey', 'demo', '`id`'), + array('addIndex', 'idx_title', 'demo', '`title`', false), + ), + $handler->calls + ); + } + + public function testSynchronizeTableThrowsForMalformedKeyDefinition() + { + $migrate = new TestableMigrate(); + $migrate->setTargetDefinitions(array( + 'demo' => array( + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + ), + 'keys' => array( + 'idx_title' => array('columns' => '`title`', 'unique' => 'yes'), + ), + ), + )); + $handler = new FakeMigrateTableHandler(); + $handler->existingColumns = array('id' => 'int NOT NULL'); + $handler->dumpTables = array( + 'demo' => array( + 'columns' => array( + array('name' => 'id'), + ), + ), + ); + $handler->existingIndexes = array(); + $migrate->setTableHandler($handler); + + $this->expectException(SchemaDefinitionException::class); + $this->expectExceptionMessage('No schema definition for table demo'); + + $migrate->callSynchronizeTable('demo'); + } + + public function testSynchronizeTableDropsPrimaryKeyWhenTargetOmitsIt() + { + $migrate = new TestableMigrate(); + $migrate->setTargetDefinitions(array( + 'demo' => array( + 'columns' => array( + array('name' => 'id', 'attributes' => 'int unsigned NOT NULL'), + array('name' => 'title', 'attributes' => 'varchar(255) NOT NULL'), + ), + ), + )); + $handler = new FakeMigrateTableHandler(); + $handler->existingColumns = array('id' => 'int NOT NULL'); + $handler->dumpTables = array( + 'demo' => array( + 'columns' => array( + array('name' => 'id'), + array('name' => 'obsolete'), + ), + ), + ); + $handler->existingIndexes = array( + 'PRIMARY' => array('columns' => '`id`', 'unique' => true), + 'idx_old' => array('columns' => '`obsolete`', 'unique' => false), + ); + $migrate->setTableHandler($handler); + + $migrate->callSynchronizeTable('demo'); + + $this->assertContains(array('alterColumn', 'demo', 'id', 'int unsigned NOT NULL'), $handler->calls); + $this->assertContains(array('addColumn', 'demo', 'title', 'varchar(255) NOT NULL'), $handler->calls); + $this->assertContains(array('dropColumn', 'demo', 'obsolete'), $handler->calls); + $this->assertContains(array('dropPrimaryKey', 'demo'), $handler->calls); + $this->assertContains(array('dropIndex', 'idx_old', 'demo'), $handler->calls); + } +} + +class TestableMigrate extends Migrate +{ + public function __construct() + { + } + + public function setTargetDefinitions(array $targetDefinitions): void + { + $this->targetDefinitions = $targetDefinitions; + } + + public function setTableHandler(object $tableHandler): void + { + $this->tableHandler = $tableHandler; + } + + public function callAddMissingTable(string $tableName): void + { + $this->addMissingTable($tableName); + } + + public function callSynchronizeTable(string $tableName): void + { + $this->synchronizeTable($tableName); + } +} + +class FakeMigrateTableHandler +{ + public array $calls = array(); + public array $existingColumns = array(); + public array $dumpTables = array(); + public array|false $existingIndexes = array(); + + public function addTable(string $tableName): bool + { + $this->calls[] = array('addTable', $tableName); + return true; + } + + public function setTableOptions(string $tableName, string $options): bool + { + $this->calls[] = array('setTableOptions', $tableName, $options); + return true; + } + + public function addColumn(string $tableName, string $name, string $attributes): bool + { + $this->calls[] = array('addColumn', $tableName, $name, $attributes); + return true; + } + + public function addPrimaryKey(string $tableName, string $columns): bool + { + $this->calls[] = array('addPrimaryKey', $tableName, $columns); + return true; + } + + public function addIndex(string $name, string $tableName, string $columns, bool $unique): bool + { + $this->calls[] = array('addIndex', $name, $tableName, $columns, $unique); + return true; + } + + public function getColumnAttributes(string $tableName, string $name): string|false + { + return $this->existingColumns[$name] ?? false; + } + + public function alterColumn(string $tableName, string $name, string $attributes): bool + { + $this->calls[] = array('alterColumn', $tableName, $name, $attributes); + return true; + } + + public function dumpTables(): array + { + return $this->dumpTables; + } + + public function getTableIndexes(string $tableName): array|false + { + return $this->existingIndexes; + } + + public function dropPrimaryKey(string $tableName): bool + { + $this->calls[] = array('dropPrimaryKey', $tableName); + return true; + } + + public function dropIndex(string $name, string $tableName): bool + { + $this->calls[] = array('dropIndex', $name, $tableName); + return true; + } + + public function dropColumn(string $tableName, string $name): bool + { + $this->calls[] = array('dropColumn', $tableName, $name); + return true; + } +} diff --git a/tests/unit/xoopsLib/Xmf/Database/TablesSchemaTest.php b/tests/unit/xoopsLib/Xmf/Database/TablesSchemaTest.php new file mode 100644 index 000000000..92190ed01 --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/TablesSchemaTest.php @@ -0,0 +1,235 @@ +newInstanceWithoutConstructor(); + foreach (['db' => $db, 'databaseName' => 'xoops', 'tables' => [], 'queue' => []] as $name => $value) { + $property = new \ReflectionProperty(Tables::class, $name); + $property->setValue($tables, $value); + } + return $tables; + } + + private static function itemsSchema(): SchemaFakeConnection + { + $db = new SchemaFakeConnection(); + $db->results['TABLES'] = [ + ['TABLE_NAME' => 'x_items', 'ENGINE' => 'InnoDB', 'CHARACTER_SET_NAME' => 'utf8mb4'], + ]; + $db->results['COLUMNS'] = [ + ['COLUMN_NAME' => 'id', 'COLUMN_TYPE' => 'int unsigned', 'IS_NULLABLE' => 'NO', + 'COLUMN_DEFAULT' => null, 'EXTRA' => 'auto_increment'], + ['COLUMN_NAME' => 'title', 'COLUMN_TYPE' => 'varchar(255)', 'IS_NULLABLE' => 'YES', + 'COLUMN_DEFAULT' => "it's", 'EXTRA' => ''], + ['COLUMN_NAME' => 'created', 'COLUMN_TYPE' => 'timestamp', 'IS_NULLABLE' => 'NO', + 'COLUMN_DEFAULT' => 'current_timestamp()', 'EXTRA' => 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP'], + ]; + $db->results['STATISTICS'] = [ + ['INDEX_NAME' => 'PRIMARY', 'SEQ_IN_INDEX' => 1, 'NON_UNIQUE' => 0, 'COLUMN_NAME' => 'id', 'SUB_PART' => null], + ['INDEX_NAME' => 'idx_title', 'SEQ_IN_INDEX' => 1, 'NON_UNIQUE' => 1, 'COLUMN_NAME' => 'title', 'SUB_PART' => 20], + ['INDEX_NAME' => 'idx_title', 'SEQ_IN_INDEX' => 2, 'NON_UNIQUE' => 1, 'COLUMN_NAME' => 'created', 'SUB_PART' => null], + ['INDEX_NAME' => 'idx_func', 'SEQ_IN_INDEX' => 1, 'NON_UNIQUE' => 1, 'COLUMN_NAME' => null, 'SUB_PART' => null], + ]; + return $db; + } + + public function testUseTableLoadsColumnsAndKeys(): void + { + $tables = $this->tables(self::itemsSchema()); + + $this->assertTrue($tables->useTable('items')); + $definition = $tables->dumpTables()['items']; + + $this->assertSame('x_items', $definition['name']); + $this->assertSame('ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', $definition['options']); + $this->assertSame( + [ + ['name' => 'id', 'attributes' => ' int unsigned NOT NULL auto_increment'], + ['name' => 'title', 'attributes' => " varchar(255) DEFAULT 'it''s' "], + ['name' => 'created', 'attributes' => ' timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP() on update CURRENT_TIMESTAMP'], + ], + $definition['columns'] + ); + $this->assertSame( + [ + 'PRIMARY' => ['columns' => 'id', 'unique' => true], + 'idx_title' => ['columns' => 'title (20), created', 'unique' => false], + ], + $tables->getTableIndexes('items') + ); + $this->assertSame(' varchar(255) DEFAULT \'it\'\'s\' ', $tables->getColumnAttributes('items', 'TITLE')); + $this->assertFalse($tables->getColumnAttributes('items', 'missing')); + } + + public function testUseTableReturnsFalseForUnknownTable(): void + { + $db = new SchemaFakeConnection(); + $db->results['TABLES'] = []; + $tables = $this->tables($db); + + $this->assertFalse($tables->useTable('nothing')); + } + + public function testUseTableReportsQueryFailure(): void + { + $db = new SchemaFakeConnection(); + $db->fail = true; + $tables = $this->tables($db); + + $this->assertFalse($tables->useTable('items')); + $this->assertSame(['42S02', 1146, 'missing'], $tables->getLastError()); + $this->assertSame('42S02', $tables->getLastErrNo()); + } + + public function testAddIndexOnNewTableAndAlterOnExistingTable(): void + { + $tables = $this->tables(self::itemsSchema()); + $tables->addTable('fresh'); + $tables->addColumn('fresh', 'id', 'int NOT NULL'); + $tables->addIndex('idx_id', 'fresh', 'id', true); + + $this->assertSame(['columns' => '`id`', 'unique' => true], $tables->dumpTables()['fresh']['keys']['idx_id']); + + $tables->useTable('items'); + $tables->addIndex('idx_new', 'items', 'created'); + $this->assertContains('ALTER TABLE `x_items` ADD INDEX `idx_new` (`created`)', $tables->dumpQueue()); + } + + private function queueCreateTable(SchemaFakeConnection $db): Tables + { + $tables = $this->tables($db); + $tables->addTable('fresh'); + $tables->setTableOptions('fresh', 'ENGINE=InnoDB'); + $tables->addColumn('fresh', 'id', 'int NOT NULL'); + $tables->addPrimaryKey('fresh', 'id'); + return $tables; + } + + public function testExecuteQueueRendersCreateTableAndRunsItForced(): void + { + $db = self::itemsSchema(); + $db->affectedRows = 1; + $tables = $this->queueCreateTable($db); + + $this->assertTrue($tables->executeQueue(true)); + $this->assertTrue($db->forced); + $this->assertStringStartsWith('CREATE TABLE `x_fresh` (', end($db->queries)); + $this->assertStringContainsString('PRIMARY KEY (`id`)', end($db->queries)); + } + + public function testExecuteQueueAcceptsDdlThatAffectsNoRows(): void + { + $this->markTestIncomplete( + 'Existing 2.6 bug: Connection::safeQuery() returns null when a statement affects 0 rows, ' + . 'which every CREATE TABLE does, so Tables::executeQueue() reports a successful CREATE as failed.' + ); + $tables = $this->queueCreateTable(self::itemsSchema()); + + $this->assertTrue($tables->executeQueue(true)); + } + + public function testExecuteQueueStopsOnFailedStatement(): void + { + $db = self::itemsSchema(); + $tables = $this->tables($db); + $tables->useTable('items'); + $tables->dropColumn('items', 'title'); + $db->fail = true; + + $this->assertFalse($tables->executeQueue()); + $this->assertSame('42S02', $tables->getLastErrNo()); + } +} + +/** + * Minimal stand-in for Xoops\Core\Database\Connection as used by Tables + */ +class SchemaFakeConnection +{ + /** @var array>> rows keyed by INFORMATION_SCHEMA table */ + public $results = []; + public $queries = []; + public $fail = false; + public $force = false; + /** @var bool whether setForce(true) was called before the last statement */ + public $forced = false; + public $affectedRows = 0; + + public function prefix($table = '') + { + return 'x_' . $table; + } + + public function quote($value) + { + return "'" . str_replace("'", "''", (string) $value) . "'"; + } + + public function setForce($force) + { + $this->force = (bool) $force; + $this->forced = $this->forced || $this->force; + } + + public function query($sql) + { + $this->queries[] = $sql; + // like Connection::safeQuery(): force lasts for one statement, and a + // non-SELECT returns its affected-row count, or null when that is 0 + $this->force = false; + if ($this->fail) { + return false; + } + if (stripos(ltrim($sql), 'select') !== 0) { + return $this->affectedRows ?: null; + } + // only x_items exists; every other table comes back empty + if (strpos($sql, "TABLE_NAME = 'x_items'") === false) { + return new SchemaFakeResult([]); + } + foreach ($this->results as $schemaTable => $rows) { + if (strpos($sql, '`INFORMATION_SCHEMA`.`' . $schemaTable . '`') !== false) { + return new SchemaFakeResult($rows); + } + } + return new SchemaFakeResult([]); + } + + public function errorInfo() + { + return ['42S02', 1146, 'missing']; + } + + public function errorCode() + { + return '42S02'; + } +} + +class SchemaFakeResult +{ + private $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + } + + public function fetchAssociative() + { + return array_shift($this->rows) ?? false; + } +} diff --git a/tests/unit/xoopsLib/Xmf/Database/TablesUnitTest.php b/tests/unit/xoopsLib/Xmf/Database/TablesUnitTest.php new file mode 100644 index 000000000..7ba3dbd4e --- /dev/null +++ b/tests/unit/xoopsLib/Xmf/Database/TablesUnitTest.php @@ -0,0 +1,227 @@ +setTables( + array( + 'demo' => array( + 'options' => 'ENGINE=InnoDB', + 'columns' => array( + array('attributes' => 'int NOT NULL'), + ), + ), + ) + ); + + $this->assertFalse($tables->callRenderTableCreate('demo')); + } + + public function testRenderTableCreateIncludesPrimaryAndUniqueKeys() + { + $tables = new TestableTables(); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'options' => 'ENGINE=InnoDB', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + array('name' => 'title', 'attributes' => 'varchar(255) NOT NULL'), + ), + 'keys' => array( + 'PRIMARY' => array('columns' => '`id`'), + 'idx_title' => array('columns' => '`title`', 'unique' => true), + ), + ), + ) + ); + + $sql = $tables->callRenderTableCreate('demo', true); + + $this->assertStringContainsString('CREATE TABLE `xoops_demo`', $sql); + $this->assertStringContainsString('PRIMARY KEY (`id`)', $sql); + $this->assertStringContainsString('UNIQUE KEY idx_title (`title`)', $sql); + } + + public function testInsertSkipsMalformedColumnDefinitionWithWarning() + { + $tables = new TestableTables(); + $tables->setDb(new FakeTablesDatabase()); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + array('attributes' => 'varchar(255) NOT NULL'), + ), + ), + ) + ); + + $warning = $this->captureWarning(static function () use ($tables): void { + $tables->insert('demo', array('id' => 7)); + }); + + $this->assertStringContainsString('Skipping malformed column definition in Xmf\Database\Tables::insert', $warning); + $this->assertSame("INSERT INTO `xoops_demo` (`id`) VALUES('7')", $tables->dumpQueue()[0]); + } + + public function testUpdateSkipsMalformedColumnDefinitionWithWarning() + { + $tables = new TestableTables(); + $tables->setDb(new FakeTablesDatabase()); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'columns' => array( + array('name' => 'title', 'attributes' => 'varchar(255) NOT NULL'), + array('attributes' => 'varchar(255) NOT NULL'), + ), + ), + ) + ); + + $warning = $this->captureWarning(static function () use ($tables): void { + $tables->update('demo', array('title' => 'Updated'), 'WHERE id = 1'); + }); + + $this->assertStringContainsString('Skipping malformed column definition in Xmf\Database\Tables::update', $warning); + $this->assertSame("UPDATE `xoops_demo` SET `title` = 'Updated' WHERE id = 1", $tables->dumpQueue()[0]); + } + + public function testInsertReturnsFalseWhenNoValidColumnsMatch() + { + $tables = new TestableTables(); + $tables->setDb(new FakeTablesDatabase()); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + ), + ), + ) + ); + + $this->assertFalse($tables->insert('demo', array('title' => 'Ignored'))); + $this->assertSame('No valid columns supplied for insert', $tables->getLastError()); + $this->assertSame(-1, $tables->getLastErrNo()); + $this->assertSame(array(), $tables->dumpQueue()); + } + + public function testUpdateReturnsFalseWhenNoValidColumnsMatch() + { + $tables = new TestableTables(); + $tables->setDb(new FakeTablesDatabase()); + $tables->setTables( + array( + 'demo' => array( + 'name' => 'xoops_demo', + 'columns' => array( + array('name' => 'id', 'attributes' => 'int NOT NULL'), + ), + ), + ) + ); + + $this->assertFalse($tables->update('demo', array('title' => 'Ignored'), 'WHERE id = 1')); + $this->assertSame('No valid columns supplied for update', $tables->getLastError()); + $this->assertSame(-1, $tables->getLastErrNo()); + $this->assertSame(array(), $tables->dumpQueue()); + } + + public function testQuoteDefaultClauseEscapesSingleQuotes() + { + $tables = new TestableTables(); + $result = $tables->callQuoteDefaultClause("O'Reilly"); + $this->assertSame(" DEFAULT 'O''Reilly' ", $result); + } + + public function testQuoteDefaultClauseNullReturnsEmpty() + { + $tables = new TestableTables(); + $result = $tables->callQuoteDefaultClause(null); + $this->assertSame('', $result); + } + + public function testQuoteDefaultClauseCurrentTimestamp() + { + $tables = new TestableTables(); + $result = $tables->callQuoteDefaultClause('CURRENT_TIMESTAMP'); + $this->assertSame(' DEFAULT CURRENT_TIMESTAMP ', $result); + } + + + + + + private function captureWarning(callable $callback): string + { + $warning = ''; + + set_error_handler(static function (int $errno, string $errstr) use (&$warning): bool { + $warning = $errstr; + return true; + }); + + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $warning; + } +} + +class TestableTables extends Tables +{ + public function __construct() + { + } + + public function setDb(object $db): void + { + $this->db = $db; + } + + public function setTables(array $tables): void + { + $this->tables = $tables; + $this->queue = array(); + } + + public function callRenderTableCreate(string $table, bool $prefixed = false): string|false + { + return $this->renderTableCreate($table, $prefixed); + } + + public function callQuoteDefaultClause(?string $default): string + { + return $this->quoteDefaultClause($default); + } + + /** + * @return \mysqli_result|bool + */ +} + +class FakeTablesDatabase +{ + public function quote(mixed $value): string + { + return "'" . addslashes((string) $value) . "'"; + } +} diff --git a/tests/unit/xoopsLib/Xmf/FilterInputTest.php b/tests/unit/xoopsLib/Xmf/FilterInputTest.php index 77d6cc7ce..93e773cc7 100644 --- a/tests/unit/xoopsLib/Xmf/FilterInputTest.php +++ b/tests/unit/xoopsLib/Xmf/FilterInputTest.php @@ -110,4 +110,141 @@ public function testCleanVarTypes($value, $type, $expected) { $this->assertSame($expected, $this->object->cleanVar($value, $type)); } + + // ported from XMF 1.3.2 tests/unit + /** + * Tag names that are empty or do not start with a letter are rejected by the + * tag-name regex, so they are stripped. Locks in the removal of the redundant + * "!$tagName" test in filterTags(). + */ + public function testCleanStripsMalformedTagNames() + { + // "<>" is not recognised as a tag at all, so it is left untouched. + $this->assertEquals('<>', FilterInput::clean('<>', 'string')); + // A digit-led tag name fails the regex and is stripped. + $this->assertEquals('', FilterInput::clean('<0>', 'string')); + $this->assertEquals('hello', FilterInput::clean('<0>hello', 'string')); + $this->assertEquals('text', FilterInput::clean('<0img src=x>text', 'string')); + // A well-formed tag is still stripped, content preserved. + $this->assertEquals('keep', FilterInput::clean('keep', 'string')); + } + + public function testWeburlRejectsProtocolRelativeUrl() + { + $result = FilterInput::clean('//evil.example/path', 'WEBURL'); + $this->assertSame('', $result); + } + + public function testWeburlAllowsHttpUrl() + { + $result = FilterInput::clean('http://example.com/page', 'WEBURL'); + $this->assertSame('http://example.com/page', $result); + } + + public function testWeburlAllowsRelativeUrl() + { + $result = FilterInput::clean('/local/path', 'WEBURL'); + $this->assertSame('/local/path', $result); + } + + public function testWeburlRejectsProtocolRelativeUrlWithLeadingWhitespace() + { + $result = FilterInput::clean(' //evil.example/path', 'WEBURL'); + $this->assertSame('', $result); + } + + public function testWeburlRejectsJavascriptScheme() + { + $result = FilterInput::clean('javascript:alert(1)', 'WEBURL'); + $this->assertSame('', $result); + } + + public function testHexEntityDecode() + { + // A = 'A', B = 'B', C = 'C' + $input = 'ABC'; + $result = FilterInput::clean($input, 'string'); + $this->assertStringContainsString('ABC', $result); + } + + public function testDecimalEntityDecode() + { + // A = 'A', B = 'B' + $input = 'AB'; + $result = FilterInput::clean($input, 'string'); + $this->assertStringContainsString('AB', $result); + } + + public function testEmptyTagIsKeptWithoutGrowing() + { + // "<>" stays as text; it used to re-append the remainder on every pass, so remove() never settled + $this->assertSame('a<>b', FilterInput::clean('a<>b', 'string')); + $this->assertSame('x<> y<>', FilterInput::clean('x<> y<>', 'string')); + $this->assertSame('<>keep', FilterInput::clean('<>keep', 'string')); + } + + public function testIncompleteTagDoesNotSurviveAsTag() + { + // text before a nested or missing ">" is kept, but without a "<" that would open a tag + $this->assertSame('img src="<>" onerror=alert(1)>', FilterInput::clean('', 'string')); + $this->assertSame('img src=x onerror=alert(1) ', FilterInput::clean('', 'string')); + $this->assertSame('ximg src=x onerror=alert(1) ', FilterInput::clean('xassertSame('<>', FilterInput::clean('<>