diff --git a/README.txt b/README.txt index fc7895f946d04..47744e44b2090 100644 --- a/README.txt +++ b/README.txt @@ -41,7 +41,7 @@ Joomla! CMS™ 8- Ready to install Joomla? * Check the minimum requirements here: https://downloads.joomla.org/technical-requirements - * How do you install Joomla - https://docs.joomla.org/Special:MyLanguage/J5.x:Installing_Joomla + * How do you install Joomla - https://guide.joomla.org/user-manual/getting-started/getting-started-installing-joomla * You could start your Joomla! experience building your site on a local test server. When ready it can be moved to an online hosting account of your choice. See the tutorial: https://docs.joomla.org/Special:MyLanguage/Installing_Joomla_locally diff --git a/administrator/components/com_admin/sql/updates/mysql/6.1.4-2026-08-27.sql b/administrator/components/com_admin/sql/updates/mysql/6.1.4-2026-08-27.sql new file mode 100644 index 0000000000000..0f3ee94db4d88 --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/6.1.4-2026-08-27.sql @@ -0,0 +1,5 @@ +-- +-- Add security column to updates table +-- + +ALTER TABLE `#__updates` ADD COLUMN `security` TINYINT NULL DEFAULT NULL AFTER `extra_query` /** CAN FAIL **/; diff --git a/administrator/components/com_admin/sql/updates/postgresql/6.1.4-2026-08-27.sql b/administrator/components/com_admin/sql/updates/postgresql/6.1.4-2026-08-27.sql new file mode 100644 index 0000000000000..52bef3ceb85dd --- /dev/null +++ b/administrator/components/com_admin/sql/updates/postgresql/6.1.4-2026-08-27.sql @@ -0,0 +1,5 @@ +-- +-- Add security column to updates table +-- + +ALTER TABLE "#__updates" ADD COLUMN "security" smallint NULL DEFAULT NULL /** CAN FAIL **/; diff --git a/administrator/components/com_admin/src/Model/SysinfoModel.php b/administrator/components/com_admin/src/Model/SysinfoModel.php index 9ebd648c6176a..4d389f461da6d 100644 --- a/administrator/components/com_admin/src/Model/SysinfoModel.php +++ b/administrator/components/com_admin/src/Model/SysinfoModel.php @@ -317,7 +317,7 @@ public function &getInfo(): array 'compatpluginenabled' => PluginHelper::isEnabled('behaviour', 'compat6'), 'compatpluginparameters' => $this->getCompatPluginParameters(), 'phpversion' => PHP_VERSION, - 'php' => php_uname(), + 'php' => \function_exists('php_uname') ? php_uname() : '', 'dbserver' => $db->getServerType(), 'dbversion' => $db->getVersion(), 'dbcollation' => $db->getCollation(), diff --git a/administrator/components/com_categories/src/Model/CategoryModel.php b/administrator/components/com_categories/src/Model/CategoryModel.php index b60f7c73dc15b..b380ae1614305 100644 --- a/administrator/components/com_categories/src/Model/CategoryModel.php +++ b/administrator/components/com_categories/src/Model/CategoryModel.php @@ -28,7 +28,6 @@ use Joomla\CMS\UCM\UCMType; use Joomla\CMS\Versioning\VersionableModelInterface; use Joomla\CMS\Versioning\VersionableModelTrait; -use Joomla\Component\Categories\Administrator\Helper\CategoriesHelper; use Joomla\Component\Categories\Administrator\Table\CategoryTable; use Joomla\Database\ParameterType; use Joomla\Filesystem\Path; @@ -228,10 +227,14 @@ public function getItem($pk = null) $assoc = $this->getAssoc(); if ($assoc) { + $result->associations = []; + if ($result->id != null) { - $result->associations = ArrayHelper::toInteger(CategoriesHelper::getAssociations($result->id, $result->extension)); - } else { - $result->associations = []; + $associations = Associations::getAssociations($result->extension, '#__categories', 'com_categories.item', $result->id, 'id', 'alias', ''); + + foreach ($associations as $tag => $association) { + $result->associations[$tag] = (int) $association->id; + } } } diff --git a/administrator/components/com_categories/src/Service/HTML/AdministratorService.php b/administrator/components/com_categories/src/Service/HTML/AdministratorService.php index cddb935cf5517..3c297bc7b797a 100644 --- a/administrator/components/com_categories/src/Service/HTML/AdministratorService.php +++ b/administrator/components/com_categories/src/Service/HTML/AdministratorService.php @@ -11,13 +11,12 @@ namespace Joomla\Component\Categories\Administrator\Service\HTML; use Joomla\CMS\Factory; +use Joomla\CMS\Language\Associations; use Joomla\CMS\Language\LanguageHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\Layout\LayoutHelper; use Joomla\CMS\Router\Route; -use Joomla\Component\Categories\Administrator\Helper\CategoriesHelper; use Joomla\Database\ParameterType; -use Joomla\Utilities\ArrayHelper; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; @@ -47,8 +46,10 @@ public function association($catid, $extension = 'com_content') $html = ''; // Get the associations - if ($associations = CategoriesHelper::getAssociations($catid, $extension)) { - $associations = ArrayHelper::toInteger($associations); + if ($associations = Associations::getAssociations($extension, '#__categories', 'com_categories.item', $catid, 'id', 'alias', '')) { + foreach ($associations as $tag => $associated) { + $associations[$tag] = (int) $associated->id; + } // Get the associated categories $db = Factory::getDbo(); diff --git a/administrator/components/com_fields/src/Model/FieldModel.php b/administrator/components/com_fields/src/Model/FieldModel.php index 3ab7d2a0930f4..c7d5c3de19efb 100644 --- a/administrator/components/com_fields/src/Model/FieldModel.php +++ b/administrator/components/com_fields/src/Model/FieldModel.php @@ -996,6 +996,21 @@ protected function loadFormData() */ public function validate($form, $data, $group = null) { + // Check if the values are unique. This should only apply for list, checkboxes and radio custom field types + if (\in_array($data['type'], ['list', 'checkboxes', 'radio']) + && !empty($data['fieldparams']['options']) + && \is_array($data['fieldparams']['options'])) { + $tmpValues = []; + foreach ($data['fieldparams']['options'] as $option) { + $tmpValues[] = $option['value']; + } + + if (\count($tmpValues) !== \count(array_unique($tmpValues))) { + Factory::getApplication()->enqueueMessage(Text::_('COM_FIELDS_FIELD_INVALID_DUPLICATE_VALUES'), 'error'); + + return false; + } + } if (!$this->getCurrentUser()->authorise('core.admin', 'com_fields')) { if (isset($data['rules'])) { unset($data['rules']); diff --git a/administrator/components/com_finder/src/Indexer/Indexer.php b/administrator/components/com_finder/src/Indexer/Indexer.php index c95339736b587..cd5997ff24f0d 100644 --- a/administrator/components/com_finder/src/Indexer/Indexer.php +++ b/administrator/components/com_finder/src/Indexer/Indexer.php @@ -734,8 +734,13 @@ public function optimize() $query2->select('COUNT(lt.link_id)') ->from($db->quoteName('#__finder_links_terms', 'lt')) ->where($db->quoteName('lt.term_id') . ' = ' . $db->quoteName('t.term_id')); - $query->update($db->quoteName('#__finder_terms', 't')) - ->set($db->quoteName('t.links') . ' = (' . $query2 . ')'); + if ($serverType === 'mysql') { + $query->update($db->quoteName('#__finder_terms', 't')) + ->set($db->quoteName('t.links') . ' = (' . $query2 . ')'); + } else { + $query->update($db->quoteName('#__finder_terms', 't')) + ->set($db->quoteName('links') . ' = (' . $query2 . ')'); + } $db->setQuery($query); $db->execute(); diff --git a/administrator/components/com_installer/tmpl/update/default.php b/administrator/components/com_installer/tmpl/update/default.php index 290c122b0279f..e493e91f3b0da 100644 --- a/administrator/components/com_installer/tmpl/update/default.php +++ b/administrator/components/com_installer/tmpl/update/default.php @@ -105,6 +105,11 @@ update_site_id; ?> + security)) : ?> +
+ security); ?> +
+ client_translated; ?> diff --git a/administrator/components/com_tags/src/Model/TagModel.php b/administrator/components/com_tags/src/Model/TagModel.php index 7295efa317cfc..3bc4b63112f8d 100644 --- a/administrator/components/com_tags/src/Model/TagModel.php +++ b/administrator/components/com_tags/src/Model/TagModel.php @@ -13,6 +13,7 @@ use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Date\Date; use Joomla\CMS\Factory; +use Joomla\CMS\Filter\OutputFilter; use Joomla\CMS\Form\Form; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Model\AdminModel; @@ -275,6 +276,30 @@ public function save($data) $data['published'] = 0; } + // Automatic handling of alias for empty fields + if (\in_array($input->get('task'), ['apply', 'save', 'save2new']) && (!isset($data['id']) || (int) $data['id'] == 0)) { + if (empty($data['alias'])) { + if (Factory::getApplication()->get('unicodeslugs') == 1) { + $data['alias'] = OutputFilter::stringUrlUnicodeSlug($data['title']); + } else { + $data['alias'] = OutputFilter::stringURLSafe($data['title']); + } + + $tagTable = $this->getTable(); + + if ($tagTable->load(['alias' => $data['alias'], 'parent_id' => $data['parent_id']])) { + $msg = Text::_('COM_TAGS_SAVE_WARNING'); + } + + [$title, $alias] = $this->generateNewTitle($data['parent_id'], $data['alias'], $data['title']); + $data['alias'] = $alias; + + if (isset($msg)) { + Factory::getApplication()->enqueueMessage($msg, 'warning'); + } + } + } + // Bind the data. if (!$table->bind($data)) { $this->setError($table->getError()); diff --git a/administrator/components/com_tags/src/Table/TagTable.php b/administrator/components/com_tags/src/Table/TagTable.php index 307e0a647c4ac..de02bd2c8b6e6 100644 --- a/administrator/components/com_tags/src/Table/TagTable.php +++ b/administrator/components/com_tags/src/Table/TagTable.php @@ -198,7 +198,7 @@ public function store($updateNulls = true) // Verify that the alias is unique $table = new static($this->getDatabase()); - if ($table->load(['alias' => $this->alias]) && ($table->id != $this->id || $this->id == 0)) { + if ($table->load(['alias' => $this->alias, 'parent_id' => (int) $this->parent_id]) && ($table->id != $this->id || $this->id == 0)) { $this->setError(Text::_('COM_TAGS_ERROR_UNIQUE_ALIAS')); // Is the existing tag trashed? diff --git a/administrator/components/com_templates/src/Helper/TemplateHelper.php b/administrator/components/com_templates/src/Helper/TemplateHelper.php index 4ca8774123724..14e384921a009 100644 --- a/administrator/components/com_templates/src/Helper/TemplateHelper.php +++ b/administrator/components/com_templates/src/Helper/TemplateHelper.php @@ -64,10 +64,10 @@ public static function canUpload($file, $err = '') // Media file names should never have executable extensions buried in them. $executable = [ - 'exe', 'phtml','java', 'perl', 'py', 'asp','dll', 'go', 'jar', - 'ade', 'adp', 'bat', 'chm', 'cmd', 'com', 'cpl', 'hta', 'ins', 'isp', - 'jse', 'lib', 'mde', 'msc', 'msp', 'mst', 'pif', 'scr', 'sct', 'shb', - 'sys', 'vb', 'vbe', 'vbs', 'vxd', 'wsc', 'wsf', 'wsh', 'shtml', 'shtm', 'sht', 'stm', + 'ade', 'adp', 'asp', 'bat', 'chm', 'cmd', 'com', 'cpl', 'dll', 'exe', 'go', + 'hta', 'ins', 'isp', 'jar', 'java', 'jse', 'lib', 'mde', 'msc', 'msp', 'mst', + 'perl', 'phtml', 'pif', 'py', 'scr', 'sct', 'shb', 'sht', 'shtm', 'shtml', + 'stm', 'sys', 'vb', 'vbe', 'vbs', 'vxd', 'wsc', 'wsf', 'wsh', ]; $explodedFileName = explode('.', $file['name']); diff --git a/administrator/components/com_templates/src/Model/TemplateModel.php b/administrator/components/com_templates/src/Model/TemplateModel.php index b8641c897d55b..026bbbcec161e 100644 --- a/administrator/components/com_templates/src/Model/TemplateModel.php +++ b/administrator/components/com_templates/src/Model/TemplateModel.php @@ -181,16 +181,23 @@ public function getUpdatedList($state = false, $all = false, $cleanup = false) $template = $this->getTemplate(); - $query->from($db->quoteName('#__template_overrides', 'a')); + $query->from($db->quoteName('#__template_overrides', 'a')) + ->join( + 'INNER', + $db->quoteName('#__extensions', 'e') + . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('a.extension_id') + ) + ->where($db->quoteName('e.enabled') . ' = 1') + ->where($db->quoteName('e.type') . ' = ' . $db->quote('template')); if (!$all) { $teid = (int) $template->extension_id; - $query->where($db->quoteName('extension_id') . ' = :teid') + $query->where($db->quoteName('a.extension_id') . ' = :teid') ->bind(':teid', $teid, ParameterType::INTEGER); } if ($state) { - $query->where($db->quoteName('state') . ' = 0'); + $query->where($db->quoteName('a.state') . ' = 0'); } $query->order($db->quoteName('a.modified_date') . ' DESC'); diff --git a/administrator/components/com_templates/tmpl/template/default.php b/administrator/components/com_templates/tmpl/template/default.php index 4474b252c0f10..881f5cc9c2d93 100644 --- a/administrator/components/com_templates/tmpl/template/default.php +++ b/administrator/components/com_templates/tmpl/template/default.php @@ -68,7 +68,9 @@
type != 'home') : ?>

get('isMedia', 0) ? '/media/templates/' . ((int) $this->template->client_id === 0 ? 'site' : 'administrator') . '/' . $this->template->element . str_replace('//', '/', $this->escape(base64_decode($this->file))) : '/' . ((int) $this->template->client_id === 0 ? '' : 'administrator/') . 'templates/' . $this->template->element . str_replace('//', '/', $this->escape(base64_decode($this->file)))), $this->template->element); ?>

- + type === 'file') : ?> + +
type == 'file' && !empty($this->source->coreFile)) : ?> diff --git a/administrator/language/en-GB/com_fields.ini b/administrator/language/en-GB/com_fields.ini index d262ead0d9af4..a2e02d3d3e372 100644 --- a/administrator/language/en-GB/com_fields.ini +++ b/administrator/language/en-GB/com_fields.ini @@ -29,6 +29,7 @@ COM_FIELDS_FIELD_IMAGE_ALT_LABEL="Alt Text" COM_FIELDS_FIELD_IMAGE_DESC="Image label." COM_FIELDS_FIELD_IMAGE_LABEL="Image" COM_FIELDS_FIELD_INVALID_DEFAULT_VALUE="The default value is invalid." +COM_FIELDS_FIELD_INVALID_DUPLICATE_VALUES="The option values must be unique." COM_FIELDS_FIELD_LABEL_FORM_CLASS_LABEL="Label Class (Form)" COM_FIELDS_FIELD_LABEL_LABEL="Label" COM_FIELDS_FIELD_LABEL_RENDER_CLASS_LABEL="Label Class (Output)" diff --git a/administrator/language/en-GB/com_installer.ini b/administrator/language/en-GB/com_installer.ini index e7b455b3d250d..b01a65f8bec95 100644 --- a/administrator/language/en-GB/com_installer.ini +++ b/administrator/language/en-GB/com_installer.ini @@ -222,6 +222,11 @@ COM_INSTALLER_PREFERENCES_DESCRIPTION="Fine-tune how extensions installation and COM_INSTALLER_PREFERENCES_LABEL="Preferences" COM_INSTALLER_REINSTALL_ARIA="Reinstall %s" COM_INSTALLER_REINSTALL_BUTTON="Reinstall" +COM_INSTALLER_SECURITY_SEVERITY_0="Security release level: None" +COM_INSTALLER_SECURITY_SEVERITY_1="Security release level: Low" +COM_INSTALLER_SECURITY_SEVERITY_2="Security release level: Medium" +COM_INSTALLER_SECURITY_SEVERITY_3="Security release level: High" +COM_INSTALLER_SECURITY_SEVERITY_4="Security release level: Critical" COM_INSTALLER_SUBMENU_WARNINGS="Warnings" COM_INSTALLER_TOOLBAR_DATABASE_FIX="Update Structure" COM_INSTALLER_TOOLBAR_DISCOVER="Discover" diff --git a/administrator/language/en-GB/com_tags.ini b/administrator/language/en-GB/com_tags.ini index 4a4cf96e1d3fa..746148f3edf66 100644 --- a/administrator/language/en-GB/com_tags.ini +++ b/administrator/language/en-GB/com_tags.ini @@ -40,7 +40,7 @@ COM_TAGS_COUNT_UNPUBLISHED_ITEMS="Unpublished items" COM_TAGS_EMPTYSTATE_BUTTON_ADD="Add your first tag" COM_TAGS_EMPTYSTATE_CONTENT="Tags in Joomla! provide a flexible way of organizing content. The same tag can be applied to many different content items across content types." COM_TAGS_EMPTYSTATE_TITLE="No Tags have been created yet." -COM_TAGS_ERROR_UNIQUE_ALIAS="Another Tag has the same alias." +COM_TAGS_ERROR_UNIQUE_ALIAS="Another Tag with the same parent tag has the same alias." COM_TAGS_ERROR_UNIQUE_ALIAS_TRASHED="A trashed Tag with the same parent tag has the same alias." COM_TAGS_EXCLUDE="Exclude" COM_TAGS_FIELD_CONTENT_TYPE_LABEL="Content types" @@ -115,6 +115,7 @@ COM_TAGS_REBUILD_FAILURE="Failed rebuilding Tags tree data." COM_TAGS_REBUILD_SUCCESS="Tags tree data rebuilt." COM_TAGS_RIGHT="Right" COM_TAGS_SAVE_SUCCESS="Tag saved." +COM_TAGS_SAVE_WARNING="Alias already existed so a number was added at the end. You can re-edit the tag to customise the alias." COM_TAGS_SEARCH_TYPE_LABEL="Match Type" COM_TAGS_SELECT_TAGTYPE="- Select Tag Type -" COM_TAGS_SHOW_ALL_TAGS_IMAGE_LABEL="Show Heading Image" diff --git a/administrator/language/en-GB/plg_quickicon_extensionupdate.ini b/administrator/language/en-GB/plg_quickicon_extensionupdate.ini index 84398346ff8aa..7341a9ad11ebb 100644 --- a/administrator/language/en-GB/plg_quickicon_extensionupdate.ini +++ b/administrator/language/en-GB/plg_quickicon_extensionupdate.ini @@ -8,6 +8,7 @@ PLG_QUICKICON_EXTENSIONUPDATE_CHECKING="Checking extensions …" PLG_QUICKICON_EXTENSIONUPDATE_ERROR="Unknown extensions …" PLG_QUICKICON_EXTENSIONUPDATE_GROUP_DESC="The group of this plugin (this value is compared with the group value used in Quick Icons modules to inject icons)." PLG_QUICKICON_EXTENSIONUPDATE_GROUP_LABEL="Group" +PLG_QUICKICON_EXTENSIONUPDATE_SECURITY_UPDATEFOUND="* Security update is available for %s!" PLG_QUICKICON_EXTENSIONUPDATE_UPDATEFOUND="Updates are available! %s" PLG_QUICKICON_EXTENSIONUPDATE_UPTODATE="Extensions are up to date." PLG_QUICKICON_EXTENSIONUPDATE_XML_DESCRIPTION="Checks for updates of your installed third-party extensions and notifies you when you visit the Home Dashboard page." diff --git a/administrator/modules/mod_popular/tmpl/default.php b/administrator/modules/mod_popular/tmpl/default.php index bbec31bf7f940..13fbfbb9e1a34 100644 --- a/administrator/modules/mod_popular/tmpl/default.php +++ b/administrator/modules/mod_popular/tmpl/default.php @@ -55,7 +55,7 @@ hits; ?> - publish_up, Text::_('DATE_FORMAT_LC4')); ?> + publish_up) ? $item->created : $item->publish_up, Text::_('DATE_FORMAT_LC4')); ?> diff --git a/api/components/com_joomlaupdate/src/View/Healthcheck/JsonapiView.php b/api/components/com_joomlaupdate/src/View/Healthcheck/JsonapiView.php index e6aaa886f370a..66af708e3588e 100644 --- a/api/components/com_joomlaupdate/src/View/Healthcheck/JsonapiView.php +++ b/api/components/com_joomlaupdate/src/View/Healthcheck/JsonapiView.php @@ -70,7 +70,7 @@ protected function getStatsData() 'db_type' => $db->name, 'db_version' => $db->getVersion(), 'cms_version' => JVERSION, - 'server_os' => php_uname('s') . ' ' . php_uname('r'), + 'server_os' => \function_exists('php_uname') ? php_uname('s') . ' ' . php_uname('r') : (getenv('OSTYPE') ?: \PHP_OS), 'update_requirement_state' => $updateModel->getAutoUpdateRequirementsState(), ]; diff --git a/build/media_source/plg_quickicon_extensionupdate/js/extensionupdatecheck.es6.js b/build/media_source/plg_quickicon_extensionupdate/js/extensionupdatecheck.es6.js index c7dc64c1a4d79..84a53a44de846 100644 --- a/build/media_source/plg_quickicon_extensionupdate/js/extensionupdatecheck.es6.js +++ b/build/media_source/plg_quickicon_extensionupdate/js/extensionupdatecheck.es6.js @@ -40,7 +40,11 @@ // No updates update('success', Joomla.Text._('PLG_QUICKICON_EXTENSIONUPDATE_UPTODATE')); } else { - update('danger', Joomla.Text._('PLG_QUICKICON_EXTENSIONUPDATE_UPDATEFOUND').replace('%s', `${updateInfoList.length}`)); + let text = Joomla.Text._('PLG_QUICKICON_EXTENSIONUPDATE_UPDATEFOUND').replace('%s', `${updateInfoList.length}`); + updateInfoList.filter((u) => u.security > 0).forEach((update) => + text += '
' + Joomla.Text._('PLG_QUICKICON_EXTENSIONUPDATE_SECURITY_UPDATEFOUND').replace('%s', update.name) + '
', + ); + update('danger', text); } } else { // An error occurred diff --git a/build/media_source/system/js/fields/calendar.es5.js b/build/media_source/system/js/fields/calendar.es5.js index 4935230ef3061..1f9c8c8062ee4 100644 --- a/build/media_source/system/js/fields/calendar.es5.js +++ b/build/media_source/system/js/fields/calendar.es5.js @@ -157,6 +157,9 @@ this._documentClick = function(event) { return self._handleDocumentClick(event); }; + this._focusOut = function (event) { + return self._handleFocusOut(event); + }; // Set it up this.checkInputs(); @@ -233,7 +236,10 @@ JoomlaCalendar.prototype.moveCursorBy = function (step) { var date = new Date(this.date); date.setDate(date.getDate() - step); - this.setDate(date); + if (!date.equalsTo(this.date)) { + this.date = date; + this.callHandler(); + } }; /** Reset select element */ @@ -288,8 +294,8 @@ this.hidden = false; document.addEventListener("keydown", this._calKeyEvent, true); - document.addEventListener("keypress", this._calKeyEvent, true); document.addEventListener("mousedown", this._documentClick, true); + document.addEventListener("focusout", this._focusOut, true); /** Move the calendar to top position if it doesn't fit below. */ var containerTmp = this.element.querySelector('.js-calendar'); @@ -306,8 +312,8 @@ /** Method to hide the calendar. */ JoomlaCalendar.prototype.hide = function () { document.removeEventListener("keydown", this._calKeyEvent, true); - document.removeEventListener("keypress", this._calKeyEvent, true); document.removeEventListener("mousedown", this._documentClick, true); + document.removeEventListener("focusout", this._focusOut, true); this.dropdownElement.classList.remove('open'); this.dropdownElement.setAttribute('hidden', ''); @@ -329,6 +335,17 @@ } }; + JoomlaCalendar.prototype._handleFocusOut = function (ev) { + var relatedTarget = ev.relatedTarget; + + // Focus is still somewhere inside the calendar field. + if (relatedTarget && this.element.contains(relatedTarget)) { + return; + } + + this.hide(); + }; + /** Method to handle mouse click events (menus, buttons) **/ JoomlaCalendar.prototype._handleDayMouseDown = function (ev) { var self = this, @@ -511,11 +528,45 @@ var self = this, code = ev.code; - // Get value from input + // Tab from the calendar input moves into the time controls. if (ev.target === this.inputField && (code === 'Enter' || code === 'Tab')) { + if (code === 'Tab' && !ev.shiftKey && self.params.showsTime) { + var hoursEl = this.table.querySelector('.time-hours'); + + if (hoursEl) { + ev.preventDefault(); + hoursEl.focus(); + return; + } + } + this.close(); } + // Handle keyboard navigation and focus within the time controls. + if ( + ev.target.matches && + ev.target.matches('.time-hours, .time-minutes') + ) { + // Shift+Tab from Hours returns to the calendar input. + if (code === 'Tab' && ev.shiftKey && ev.target.matches('.time-hours')) { + ev.preventDefault(); + this.inputField.focus(); + return; + } + + // Let the time selects handle arrow keys and Tab natively. + if ( + code === 'ArrowUp' || + code === 'ArrowDown' || + code === 'ArrowLeft' || + code === 'ArrowRight' || + code === 'Tab' + ) { + return; + } + } + if (self.params.direction === 'rtl') { if (code === 'ArrowLeft') { code = 'ArrowRight'; @@ -524,23 +575,34 @@ } } + // Select the currently highlighted date. + if (code === 'Enter' && self.currentDateEl) { + self.cellClick(self.currentDateEl, ev); + return; + } + if (ev.shiftKey && code === 'Space') { ev.preventDefault(); this.cellClick(self._nav_now, ev); self.close(); } + if (code === 'Escape') { this.close(); } + if (code === 'ArrowUp') { this.moveCursorBy(7); } + if (code === 'ArrowDown') { this.moveCursorBy(-7); } + if (code === 'ArrowLeft') { this.moveCursorBy(1); } + if (code === 'ArrowRight') { this.moveCursorBy(-1); } @@ -936,7 +998,7 @@ // while setMonth() (the Date constructor) expects 0-indexed. // Forgetting either conversion causes an off-by-one day (or month) error. var ndate = Date.localCalToGregorian(date.rawYear, date.rawMonth + 1, date.rawDay); - + // Rebuild the Date atomically (year/month/day in one constructor call) instead of calling // setFullYear()/setMonth()/setDate() in sequence: // mutating month before day meant the OLD day (e.g. 31) could briefly clash with diff --git a/build/media_source/system/js/joomla-toolbar-button.w-c.es6.js b/build/media_source/system/js/joomla-toolbar-button.w-c.es6.js index 19b9c06e72b29..b43d67d63706c 100644 --- a/build/media_source/system/js/joomla-toolbar-button.w-c.es6.js +++ b/build/media_source/system/js/joomla-toolbar-button.w-c.es6.js @@ -58,6 +58,11 @@ window.customElements.define('joomla-toolbar-button', class extends HTMLElement // Watch on list selection this.formElement.boxchecked.addEventListener('change', this.onChange); } + + this.onHideDropdown = this.onHideDropdown.bind(this); + if (this.buttonElement.classList.contains('dropdown-toggle')) { + this.buttonElement.addEventListener('hide.bs.dropdown', this.onHideDropdown); + } } /** @@ -69,6 +74,21 @@ window.customElements.define('joomla-toolbar-button', class extends HTMLElement } this.buttonElement.removeEventListener('click', this.executeTask); + + if (this.buttonElement.classList.contains('dropdown-toggle')) { + this.buttonElement.removeEventListener('hide.bs.dropdown', this.onHideDropdown); + } + } + + onHideDropdown(e) { + if (e.clickEvent && e.clickEvent.target instanceof Element) { + const target = e.clickEvent.target; + if (this.formElement && this.formElement.contains(target) && target.closest('input[type="checkbox"], label')) { + if (this.formElement.boxchecked && this.formElement.boxchecked.value > 0) { + e.preventDefault(); + } + } + } } onChange({ target }) { @@ -84,6 +104,16 @@ window.customElements.define('joomla-toolbar-button', class extends HTMLElement // An anchor does not support "disabled" attribute, so use class if (this.buttonElement) { if (this.disabled) { + // If it's a dropdown toggle and it's currently showing, hide it before disabling + if (this.buttonElement.classList.contains('dropdown-toggle') && this.buttonElement.classList.contains('show')) { + if (window.bootstrap && window.bootstrap.Dropdown) { + const dropdown = window.bootstrap.Dropdown.getInstance(this.buttonElement); + if (dropdown) { + dropdown.hide(); + } + } + } + if (this.buttonElement.nodeName === 'BUTTON') { this.buttonElement.disabled = true; } else { diff --git a/components/com_content/tmpl/category/default_articles.php b/components/com_content/tmpl/category/default_articles.php index 2088fc2e2c84c..cd5d6abaf778d 100644 --- a/components/com_content/tmpl/category/default_articles.php +++ b/components/com_content/tmpl/category/default_articles.php @@ -93,18 +93,16 @@ - - params->get('filter_field') !== 'tag' && $this->params->get('filter_field') !== 'month') : ?> - - + diff --git a/components/com_tags/src/Service/Router.php b/components/com_tags/src/Service/Router.php index b7c7db667319f..8349445c22e59 100644 --- a/components/com_tags/src/Service/Router.php +++ b/components/com_tags/src/Service/Router.php @@ -250,6 +250,18 @@ public function build(&$query) unset($query['id']); } + // Strip 'types' from the URL if they match the menu item's own configured types + if (isset($query['types'], $menuItem->query['types'])) { + $queryTypes = ArrayHelper::toInteger((array) $query['types']); + $menuTypes = ArrayHelper::toInteger((array) $menuItem->query['types']); + sort($queryTypes); + sort($menuTypes); + + if ($queryTypes === $menuTypes) { + unset($query['types']); + } + } + unset($query['view']); } else { if (isset($query['view'])) { diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql index 30f4512ce59c0..e81a7561b902a 100644 --- a/installation/sql/mysql/base.sql +++ b/installation/sql/mysql/base.sql @@ -875,6 +875,7 @@ CREATE TABLE IF NOT EXISTS `#__updates` ( `infourl` text NOT NULL, `changelogurl` text, `extra_query` varchar(1000) DEFAULT '', + `security` tinyint DEFAULT NULL, PRIMARY KEY (`update_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci COMMENT='Available Updates'; diff --git a/installation/sql/postgresql/base.sql b/installation/sql/postgresql/base.sql index 0d8786e809902..fecbc882a10b4 100644 --- a/installation/sql/postgresql/base.sql +++ b/installation/sql/postgresql/base.sql @@ -890,6 +890,7 @@ CREATE TABLE IF NOT EXISTS "#__updates" ( "infourl" text NOT NULL, "changelogurl" text, "extra_query" varchar(1000) DEFAULT '', + "security" smallint DEFAULT NULL, PRIMARY KEY ("update_id") ); diff --git a/libraries/src/Encrypt/Totp.php b/libraries/src/Encrypt/Totp.php index 395e378ac8eef..7706ecd0b74bc 100644 --- a/libraries/src/Encrypt/Totp.php +++ b/libraries/src/Encrypt/Totp.php @@ -118,7 +118,7 @@ public function checkCode($secret, $code) $time = $this->getPeriod(); for ($i = -1; $i <= 1; $i++) { - if ($this->getCode($secret, ($time + $i) * $this->_timeStep) == $code) { + if (hash_equals($this->getCode($secret, ($time + $i) * $this->_timeStep), (string) $code)) { return true; } } diff --git a/libraries/src/Filter/InputFilter.php b/libraries/src/Filter/InputFilter.php index fcdf648c472c5..69e5ca86d1d4b 100644 --- a/libraries/src/Filter/InputFilter.php +++ b/libraries/src/Filter/InputFilter.php @@ -34,8 +34,8 @@ class InputFilter extends BaseInputFilter * @since 4.0.0 */ public const FORBIDDEN_FILE_EXTENSIONS = [ - 'asp', 'php', 'phps', 'pht', 'phtml', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', 'phar', - 'inc', 'pl', 'cgi', 'fcgi', 'java', 'jar', 'py', 'aspx', 'shtml', 'shtm', 'sht', 'stm', + 'asp', 'aspx', 'cgi', 'fcgi', 'inc', 'jar', 'java', 'phar', 'php', 'php3', 'php4', 'php5', 'php6', 'php7', + 'php8', 'php9', 'phps', 'pht', 'phtml', 'pl', 'py', 'sht', 'shtm', 'shtml', 'stm', ]; /** diff --git a/libraries/src/Form/Field/ComponentlayoutField.php b/libraries/src/Form/Field/ComponentlayoutField.php index fb5ec948c485e..df08bdb1cb587 100644 --- a/libraries/src/Form/Field/ComponentlayoutField.php +++ b/libraries/src/Form/Field/ComponentlayoutField.php @@ -212,21 +212,24 @@ protected function getInput() foreach ($files as $file) { // Add an option to the template group $value = basename($file, '.php'); - $text = $lang - ->hasKey( - $key = strtoupper( - 'TPL_' - . $template->name - . '_' - . $extension - . '_' - . $view - . '_LAYOUT_' - . $value - ) - ) - ? Text::_($key) : $value; - $groups[$template->name]['items'][] = HTMLHelper::_('select.option', $template->element . ':' . $value, $text); + $text = $value; + $path = str_replace('.php', '.xml', $file); + + if (is_file($path) && $xml = simplexml_load_file($path)) { + $layout = $xml->xpath('//layout[1]'); + + if (isset($layout[0])) { + $attributes = $layout[0]; + + if (isset($attributes['option']) && (string) $attributes['option'] !== '') { + $text = Text::_((string)$attributes['option']); + } elseif (isset($attributes['title']) && (string) $attributes['title'] !== '') { + $text = Text::_((string)$attributes['title']); + } + } + } + + $groups[$template->name]['items'][] = HTMLHelper::_('select.option', $template->element . ':' . $view . ':' . $value, $text); } } } diff --git a/libraries/src/Form/Form.php b/libraries/src/Form/Form.php index b57e009668de0..9fc1fa4d2071f 100644 --- a/libraries/src/Form/Form.php +++ b/libraries/src/Form/Form.php @@ -656,7 +656,7 @@ public function load($data, $replace = true, $xpath = null) $olddom->parentNode->replaceChild($addeddom, $olddom); $loadeddom->parentNode->removeChild($loadeddom); } else { - unset($field); + unset($field[0]); } } } diff --git a/libraries/src/Updater/Adapter/ExtensionAdapter.php b/libraries/src/Updater/Adapter/ExtensionAdapter.php index 4853e05ba32f7..745966c4aea5c 100644 --- a/libraries/src/Updater/Adapter/ExtensionAdapter.php +++ b/libraries/src/Updater/Adapter/ExtensionAdapter.php @@ -32,6 +32,15 @@ class ExtensionAdapter extends UpdateAdapter protected $currentUpdate; protected $latest; + /** + * The updates with security fixes + * + * @var array + * + * @since __DEPLOY_VERSION__ + */ + protected array $security = []; + /** * Start element parser callback. * @@ -219,6 +228,10 @@ protected function _endElement($parser, $name) // We don't have any possible updates yet, assume this is an available update. $this->latest = $this->currentUpdate; } + + if (isset($this->currentUpdate->security)) { + $this->security[] = $this->currentUpdate; + } } } break; @@ -263,7 +276,7 @@ protected function _characterData($parser, $data) * * @param array $options Update options. * - * @return array|boolean Array containing the array of update sites and array of updates. False on failure + * @return array|boolean Array containing the array of update sites, an array of updates and an array of security updates. False on failure * * @since 1.7.0 */ @@ -281,6 +294,8 @@ public function findUpdate($options) */ unset($this->latest); + $this->security = []; + if (\array_key_exists('minimum_stability', $options)) { $this->minimum_stability = $options['minimum_stability']; } @@ -323,7 +338,7 @@ public function findUpdate($options) $updates = []; } - return ['update_sites' => [], 'updates' => $updates]; + return ['update_sites' => [], 'updates' => $updates, 'security' => $this->security]; } /** diff --git a/libraries/src/Updater/UpdateAdapter.php b/libraries/src/Updater/UpdateAdapter.php index 54c7cb1b7463b..6b061aa0ead09 100644 --- a/libraries/src/Updater/UpdateAdapter.php +++ b/libraries/src/Updater/UpdateAdapter.php @@ -72,7 +72,7 @@ abstract class UpdateAdapter * @var array * @since 3.0.0 */ - protected $updatecols = ['NAME', 'ELEMENT', 'TYPE', 'FOLDER', 'CLIENT', 'VERSION', 'DESCRIPTION', 'INFOURL', 'CHANGELOGURL', 'EXTRA_QUERY']; + protected $updatecols = ['NAME', 'ELEMENT', 'TYPE', 'FOLDER', 'CLIENT', 'VERSION', 'DESCRIPTION', 'INFOURL', 'CHANGELOGURL', 'EXTRA_QUERY', 'SECURITY']; /** * Should we try appending a .xml extension to the update site's URL? diff --git a/libraries/src/Updater/Updater.php b/libraries/src/Updater/Updater.php index 37135575bacb6..ff19baec3dcef 100644 --- a/libraries/src/Updater/Updater.php +++ b/libraries/src/Updater/Updater.php @@ -9,6 +9,7 @@ namespace Joomla\CMS\Updater; +use Joomla\CMS\Adapter\Adapter; use Joomla\CMS\Factory; use Joomla\CMS\Object\LegacyPropertyManagementTrait; use Joomla\CMS\Table\Extension; @@ -388,6 +389,7 @@ private function getUpdateObjectsForSite($updateSite, $minimumStability = self:: if (version_compare($current_update->version, $data['version'], $operator) == 1) { $current_update->extension_id = $eid; + $current_update->security = $this->findHighestSeverity($data['version'], $current_update, $update_result['security'] ?? []); $retVal[] = $current_update; } } else { @@ -406,6 +408,17 @@ private function getUpdateObjectsForSite($updateSite, $minimumStability = self:: // If there is an update, check that the version is newer then replaces if (version_compare($current_update->version, $update->version, $operator) == 1) { $retVal[] = $current_update; + if (!$eid) { + continue; + } + + $extension->load($eid); + $data = json_decode($extension->manifest_cache ?? '', true); + if (empty($data['version'])) { + continue; + } + + $current_update->security = $this->findHighestSeverity($data['version'], $current_update, $update_result['security'] ?? []); } } } @@ -415,6 +428,31 @@ private function getUpdateObjectsForSite($updateSite, $minimumStability = self:: return $retVal; } + /** + * Searches for the highest severity in security updates since the installed version. + * + * @param string $installedVersion The installed version + * @param UpdateTable $update The latest compatible update + * @param array $securityUpdates The found security updates + * + * @return int|null + * + * @since __DEPLOY_VERSION__ + */ + private function findHighestSeverity(string $installedVersion, UpdateTable $update, array $securityUpdates): ?int + { + $securityLevel = (int) $update->security; + + /** @var \Joomla\CMS\Table\Update $securityUpdate */ + foreach ($securityUpdates as $securityUpdate) { + if (version_compare($securityUpdate->version, $installedVersion, '>') && $securityLevel < (int) $securityUpdate->security) { + $securityLevel = (int) $securityUpdate->security; + } + } + + return $securityLevel; + } + /** * Returns the IDs of the update sites with cached updates * diff --git a/modules/mod_articles/src/Helper/ArticlesHelper.php b/modules/mod_articles/src/Helper/ArticlesHelper.php index 8f2c0bbfef797..994c8542a8589 100644 --- a/modules/mod_articles/src/Helper/ArticlesHelper.php +++ b/modules/mod_articles/src/Helper/ArticlesHelper.php @@ -389,6 +389,8 @@ public function getArticles(Registry $params, SiteApplication $app) $item->displayDate = $show_date ? $item->displayDate : ''; $item->displayHits = $show_hits ? $item->hits : ''; + $item->introTextTruncated = false; + if ($show_introtext) { $item->displayIntrotext = HTMLHelper::_('content.prepare', $item->introtext, '', 'mod_articles.content'); @@ -399,7 +401,9 @@ public function getArticles(Registry $params, SiteApplication $app) } if ($introtext_limit != 0) { - $item->displayIntrotext = HTMLHelper::_('string.truncateComplex', $item->displayIntrotext, $introtext_limit); + $truncated = HTMLHelper::_('string.truncateComplex', $item->displayIntrotext, $introtext_limit); + $item->introTextTruncated = $truncated !== $item->displayIntrotext; + $item->displayIntrotext = $truncated; } } diff --git a/modules/mod_articles/tmpl/default_items.php b/modules/mod_articles/tmpl/default_items.php index 57d84087c43af..58729019cb8fe 100644 --- a/modules/mod_articles/tmpl/default_items.php +++ b/modules/mod_articles/tmpl/default_items.php @@ -124,7 +124,7 @@ event->afterDisplayContent; ?> - get('show_readmore') && !empty($item->fulltext)) : ?> + get('show_readmore') && (!empty($item->fulltext) || $item->introTextTruncated)) : ?> get('show_readmore_title', '') !== '') : ?> params->set('show_readmore_title', $params->get('show_readmore_title')); ?> params->set('readmore_limit', $params->get('readmore_limit')); ?> diff --git a/modules/mod_stats/src/Helper/StatsHelper.php b/modules/mod_stats/src/Helper/StatsHelper.php index 93a9d0782860b..5e8dc944a5f02 100644 --- a/modules/mod_stats/src/Helper/StatsHelper.php +++ b/modules/mod_stats/src/Helper/StatsHelper.php @@ -58,7 +58,7 @@ public function getStats(Registry &$params, CMSApplicationInterface $app) if ($serverinfo) { $rows[$i] = new \stdClass(); $rows[$i]->title = Text::_('MOD_STATS_OS'); - $rows[$i]->data = substr(php_uname(), 0, 7); + $rows[$i]->data = \function_exists('php_uname') ? substr(php_uname(), 0, 7) : ''; $i++; $rows[$i] = new \stdClass(); diff --git a/plugins/fields/imagelist/src/Extension/Imagelist.php b/plugins/fields/imagelist/src/Extension/Imagelist.php index 706dcf6f8d7fb..303081430da78 100644 --- a/plugins/fields/imagelist/src/Extension/Imagelist.php +++ b/plugins/fields/imagelist/src/Extension/Imagelist.php @@ -45,7 +45,8 @@ public function onCustomFieldsPrepareDom($field, \DOMElement $parent, Form $form } $fieldNode->setAttribute('hide_default', 'true'); - $fieldNode->setAttribute('directory', '/images/' . $fieldNode->getAttribute('directory')); + $directory = trim((string) $fieldNode->getAttribute('directory'), '/'); + $fieldNode->setAttribute('directory', $directory === '' ? 'images' : 'images/' . $directory); return $fieldNode; } diff --git a/plugins/filesystem/local/src/Adapter/LocalAdapter.php b/plugins/filesystem/local/src/Adapter/LocalAdapter.php index aa2bcb86e9220..95479b451d918 100644 --- a/plugins/filesystem/local/src/Adapter/LocalAdapter.php +++ b/plugins/filesystem/local/src/Adapter/LocalAdapter.php @@ -869,7 +869,14 @@ private function checkContent(string $localPath, string $mediaContent) throw new \Exception(Text::_('JLIB_MEDIA_ERROR_UPLOAD_INPUT'), 500); } - $can = $helper->canUpload(['name' => $name, 'size' => \strlen($mediaContent), 'tmp_name' => $tmpFile], 'com_media'); + /* + * Pass the normalised name so the size, MIME and executable-extension + * checks still run without re-validating the existing filename, which may + * contain non-ASCII characters preserved from earlier uploads. + */ + $safeName = File::makeSafe($name) ?: $name; + + $can = $helper->canUpload(['name' => $safeName, 'size' => \strlen($mediaContent), 'tmp_name' => $tmpFile], 'com_media'); try { File::delete($tmpFile); diff --git a/plugins/multifactorauth/totp/src/Extension/Totp.php b/plugins/multifactorauth/totp/src/Extension/Totp.php index 578eb08d9eae8..dda1d631e6a89 100644 --- a/plugins/multifactorauth/totp/src/Extension/Totp.php +++ b/plugins/multifactorauth/totp/src/Extension/Totp.php @@ -293,7 +293,7 @@ public function onUserMultifactorSaveSetup(SaveSetup $event): void * If the code is empty but the key already existed in $options someone is simply changing the title / default * Method status. We can allow this and stop checking anything else now. */ - $code = $input->getInt('code'); + $code = $input->getCmd('code'); if (empty($code) && !empty($optionsKey)) { $event->addResult($options); diff --git a/plugins/quickicon/extensionupdate/src/Extension/Extensionupdate.php b/plugins/quickicon/extensionupdate/src/Extension/Extensionupdate.php index 66a0f1a8b9f8e..cb2b1d6bc5cd7 100644 --- a/plugins/quickicon/extensionupdate/src/Extension/Extensionupdate.php +++ b/plugins/quickicon/extensionupdate/src/Extension/Extensionupdate.php @@ -83,6 +83,7 @@ public function onGetIcons(QuickIconsEvent $event): void Text::script('PLG_QUICKICON_EXTENSIONUPDATE_UPTODATE'); Text::script('PLG_QUICKICON_EXTENSIONUPDATE_UPDATEFOUND'); + Text::script('PLG_QUICKICON_EXTENSIONUPDATE_SECURITY_UPDATEFOUND'); Text::script('PLG_QUICKICON_EXTENSIONUPDATE_ERROR'); Text::script('MESSAGE'); Text::script('ERROR'); diff --git a/plugins/schemaorg/jobposting/src/Extension/JobPosting.php b/plugins/schemaorg/jobposting/src/Extension/JobPosting.php index 55eb9c9137c79..32543b44bf964 100644 --- a/plugins/schemaorg/jobposting/src/Extension/JobPosting.php +++ b/plugins/schemaorg/jobposting/src/Extension/JobPosting.php @@ -89,6 +89,23 @@ public function onSchemaBeforeCompileHead(BeforeCompileHeadEvent $event) if (!empty($entry['validThrough'])) { $entry['validThrough'] = $this->prepareDate($entry['validThrough']); } + + /** + * Search engines only accept TELECOMMUTE as jobLocationType. A remote job is + * marked TELECOMMUTE, a hybrid job is marked TELECOMMUTE combined with the + * physical jobLocation and an on-site job must not emit a jobLocationType at + * all and relies on jobLocation alone. + */ + switch ($entry['jobLocationType'] ?? '') { + case 'Telecommute': + case 'Hybrid': + $entry['jobLocationType'] = 'TELECOMMUTE'; + break; + + case 'Onsite': + unset($entry['jobLocationType']); + break; + } } $schema->set('@graph', $graph); diff --git a/plugins/system/schemaorg/src/Extension/Schemaorg.php b/plugins/system/schemaorg/src/Extension/Schemaorg.php index 366fc16502c46..8af09bb76109c 100644 --- a/plugins/system/schemaorg/src/Extension/Schemaorg.php +++ b/plugins/system/schemaorg/src/Extension/Schemaorg.php @@ -321,14 +321,21 @@ public function onBeforeCompileHead(BeforeCompileHeadApplicationEvent $event): v $siteSchema['url'] = $domain; // Image - $image = $this->params->get('image') ? HTMLHelper::_('cleanimageUrl', $this->params->get('image')) : false; + $image = $this->params->get('image') ? HTMLHelper::_('cleanImageUrl', $this->params->get('image')) : false; if ($image !== false) { + $logoUrl = $image->url; + + // Ensure absolute URL for schema logo + if (!preg_match('#^(https?:)?//#i', $logoUrl)) { + $logoUrl = Uri::root() . ltrim($logoUrl, '/'); + } + $siteSchema['logo'] = [ '@type' => 'ImageObject', '@id' => $domain . '#/schema/ImageObject/logo', - 'url' => $image->url, - 'contentUrl' => $image->url, + 'url' => $logoUrl, + 'contentUrl' => $logoUrl, 'width' => $image->attributes['width'] ?? 0, 'height' => $image->attributes['height'] ?? 0, ]; diff --git a/plugins/system/stats/src/Extension/Stats.php b/plugins/system/stats/src/Extension/Stats.php index a8f94b3870a32..d0dc6a0f99fbb 100644 --- a/plugins/system/stats/src/Extension/Stats.php +++ b/plugins/system/stats/src/Extension/Stats.php @@ -356,7 +356,7 @@ private function getStatsData() 'db_type' => $this->getDatabase()->name, 'db_version' => $this->getDatabase()->getVersion(), 'cms_version' => JVERSION, - 'server_os' => php_uname('s') . ' ' . php_uname('r'), + 'server_os' => \function_exists('php_uname') ? php_uname('s') . ' ' . php_uname('r') : (getenv('OSTYPE') ?: \PHP_OS), ]; // Check if we have a MariaDB version string and extract the proper version from it diff --git a/tests/System/integration/administrator/components/com_finder/Index.cy.js b/tests/System/integration/administrator/components/com_finder/Index.cy.js index f6decc45d1b20..72b79bd65be9d 100644 --- a/tests/System/integration/administrator/components/com_finder/Index.cy.js +++ b/tests/System/integration/administrator/components/com_finder/Index.cy.js @@ -16,6 +16,19 @@ describe('Test in backend that the Smart Search', () => { cy.contains('Test article').should('exist'); }); + it('can index content', () => { + // Create a new article + cy.visit('/administrator/index.php?option=com_content&task=article.add'); + cy.get('#jform_title').clear().type('Test article'); + cy.clickToolbarButton('Save & Close'); + // Visit the smart search page + cy.visit('/administrator/index.php?option=com_finder&view=index'); + // Click the "Index" button. Note: with JDEBUG enabled the button is moved + // into the #toolbar-indexing-group dropdown, see com_finder Index HtmlView. + cy.get('#toolbar-index > button').click(); + cy.contains('Test article').should('exist'); + }); + it('can purge the index', () => { // Visit the smart search page cy.visit('/administrator/index.php?option=com_finder&view=index'); diff --git a/tests/Unit/Libraries/Cms/Encrypt/TotpTest.php b/tests/Unit/Libraries/Cms/Encrypt/TotpTest.php new file mode 100644 index 0000000000000..2b3a1ce23448c --- /dev/null +++ b/tests/Unit/Libraries/Cms/Encrypt/TotpTest.php @@ -0,0 +1,52 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Encrypt; + +use Joomla\CMS\Encrypt\Totp; +use Joomla\Tests\Unit\UnitTestCase; + +/** + * Test class for \Joomla\CMS\Encrypt\Totp + * + * @since __DEPLOY_VERSION__ + */ +class TotpTest extends UnitTestCase +{ + /** + * The currently valid passcode is accepted. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function testCheckCodeAcceptsValidPasscode() + { + $totp = new Totp(); + $secret = $totp->generateSecret(); + + $this->assertTrue($totp->checkCode($secret, $totp->getCode($secret))); + } + + /** + * A numeric string that only equals the passcode under type juggling must be rejected. + * + * @return void + * @since __DEPLOY_VERSION__ + */ + public function testCheckCodeRejectsTypeJuggledPasscode() + { + $totp = new Totp(); + $secret = $totp->generateSecret(); + $code = $totp->getCode($secret); + + $this->assertFalse($totp->checkCode($secret, '+' . $code)); + $this->assertFalse($totp->checkCode($secret, ' ' . $code)); + } +} diff --git a/tests/Unit/Plugin/Filesystem/Local/Adapter/LocalAdapterTest.php b/tests/Unit/Plugin/Filesystem/Local/Adapter/LocalAdapterTest.php new file mode 100644 index 0000000000000..86f8263d9206d --- /dev/null +++ b/tests/Unit/Plugin/Filesystem/Local/Adapter/LocalAdapterTest.php @@ -0,0 +1,194 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Plugin\Filesystem\Local\Adapter; + +use Joomla\CMS\Application\CMSApplicationInterface; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Component\ComponentRecord; +use Joomla\CMS\Factory; +use Joomla\Filesystem\File; +use Joomla\Plugin\Filesystem\Local\Adapter\LocalAdapter; +use Joomla\Registry\Registry; +use Joomla\Tests\Unit\UnitTestCase; + +/** + * Test class for the Local filesystem adapter. + * + * @package Joomla.UnitTest + * @subpackage Local + * + * @testdox The Local adapter + * + * @since __DEPLOY_VERSION__ + */ +class LocalAdapterTest extends UnitTestCase +{ + /** + * Absolute path of the throw-away media root used by a single test. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + private $workDir; + + /** + * Creates an isolated media root and the minimal global state that + * MediaHelper::canUpload() reaches for (the application and the com_media + * component parameters), so the adapter can run as a pure unit test. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function setUp(): void + { + parent::setUp(); + + $this->workDir = sys_get_temp_dir() . '/joomla-localadapter-' . uniqid(); + mkdir($this->workDir); + + // canUpload() calls Factory::getApplication() unconditionally. + Factory::$application = $this->createStub(CMSApplicationInterface::class); + + // Seed com_media with empty params so ComponentHelper never queries the database. + $record = new ComponentRecord(['option' => 'com_media', 'enabled' => 1]); + $record->setParams(new Registry()); + + $components = new \ReflectionProperty(ComponentHelper::class, 'components'); + $components->setValue(null, ['com_media' => $record]); + } + + /** + * Removes the temporary media root and resets the global state touched in setUp(). + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function tearDown(): void + { + foreach (glob($this->workDir . '/*') ?: [] as $file) { + unlink($file); + } + + if (is_dir($this->workDir)) { + rmdir($this->workDir); + } + + Factory::$application = null; + + $components = new \ReflectionProperty(ComponentHelper::class, 'components'); + $components->setValue(null, []); + + parent::tearDown(); + } + + /** + * Returns the bytes of a tiny, valid single-colour JPEG so that the + * MIME/type checks in canUpload() see a real image. + * + * @param array $rgb The fill colour as [red, green, blue]. + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function jpegBytes(array $rgb): string + { + $image = imagecreatetruecolor(4, 4); + $color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]); + imagefilledrectangle($image, 0, 0, 3, 3, $color); + + ob_start(); + imagejpeg($image); + $bytes = ob_get_clean(); + + + return $bytes; + } + + /** + * @testdox saves an edited file back to its original non-ASCII name + * + * This is the behaviour PR #47935 restores: a file whose on-disk name + * contains non-ASCII characters (uploaded via FTP, sample data, a + * migration, ...) can be re-saved by the image editor instead of being + * rejected by canUpload(). + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function testUpdateFileSavesBackToNonAsciiName() + { + $name = 'erik_schön.jpg'; + file_put_contents($this->workDir . '/' . $name, $this->jpegBytes([255, 0, 0])); + + $newContent = $this->jpegBytes([0, 0, 255]); + + $adapter = new LocalAdapter($this->workDir, 'test-media'); + $adapter->updateFile($name, '/', $newContent); + + $this->assertSame( + $newContent, + file_get_contents($this->workDir . '/' . $name), + 'The non-ASCII named file should be updated in place.' + ); + } + + /** + * @testdox never touches a pre-existing file that already has the safe name + * + * Answers @brianteeman's review question: the "safe" name derived in + * checkContent() is only handed to canUpload() as a validation string. It + * is never used to name a file on disk (the probe file uses uniqid(), the + * real write targets the original path), so a file that already exists at + * File::makeSafe($name) is left completely untouched. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function testUpdateFileDoesNotTouchExistingSafeNamedNeighbour() + { + $name = 'erik_schön.jpg'; + $safeName = File::makeSafe($name); + + // Guard the premise of the test: the two names really are different. + $this->assertNotSame($name, $safeName); + + file_put_contents($this->workDir . '/' . $name, $this->jpegBytes([255, 0, 0])); + + // A pre-existing, unrelated file that happens to sit at the safe name. + $neighbourContent = 'PRE-EXISTING-SAFE-NAME-CONTENT'; + file_put_contents($this->workDir . '/' . $safeName, $neighbourContent); + + $adapter = new LocalAdapter($this->workDir, 'test-media'); + $adapter->updateFile($name, '/', $this->jpegBytes([0, 0, 255])); + + // The neighbour is byte-for-byte unchanged. + $this->assertSame( + $neighbourContent, + file_get_contents($this->workDir . '/' . $safeName), + 'The pre-existing safe-named file must not be overwritten.' + ); + + // Only the two original files remain: the uniqid() probe file was + // cleaned up and no new safe-named file was created. + $remaining = array_values(array_diff(scandir($this->workDir), ['.', '..'])); + sort($remaining); + + $expected = [$name, $safeName]; + sort($expected); + + $this->assertSame($expected, $remaining, 'No stray files should be left behind.'); + } +} diff --git a/tests/Unit/Plugin/Schemaorg/JobPosting/Extension/JobPostingPluginTest.php b/tests/Unit/Plugin/Schemaorg/JobPosting/Extension/JobPostingPluginTest.php new file mode 100644 index 0000000000000..f60ef6a955a64 --- /dev/null +++ b/tests/Unit/Plugin/Schemaorg/JobPosting/Extension/JobPostingPluginTest.php @@ -0,0 +1,158 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Plugin\Schemaorg\JobPosting\Extension; + +use Joomla\CMS\Event\Plugin\System\Schemaorg\BeforeCompileHeadEvent; +use Joomla\Plugin\Schemaorg\JobPosting\Extension\JobPosting; +use Joomla\Registry\Registry; +use Joomla\Tests\Unit\UnitTestCase; + +/** + * Test class for JobPosting plugin + * + * @package Joomla.UnitTest + * @subpackage JobPosting + * + * @testdox The JobPosting plugin + * + * @since __DEPLOY_VERSION__ + */ +class JobPostingPluginTest extends UnitTestCase +{ + /** + * Dispatches the schema through the plugin and returns the resulting graph. + * + * @param array $entry The JobPosting graph entry to compile + * + * @return array The compiled graph entry + * + * @since __DEPLOY_VERSION__ + */ + private function compileGraphEntry(array $entry): array + { + $plugin = new JobPosting(['name' => 'jobposting', 'type' => 'schemaorg']); + + $schema = new Registry(); + $schema->loadArray(['@graph' => [$entry]]); + + $event = new BeforeCompileHeadEvent('onSchemaBeforeCompileHead', [ + 'subject' => $schema, + 'context' => 'com_content.article.1', + ]); + + $plugin->onSchemaBeforeCompileHead($event); + + return $schema->get('@graph')[0]; + } + + /** + * @testdox maps a telecommute job location type to the TELECOMMUTE value expected by search engines + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function testTelecommuteIsMappedToUppercaseTelecommute() + { + $entry = $this->compileGraphEntry([ + '@type' => 'JobPosting', + 'title' => 'Remote Developer', + 'jobLocationType' => 'Telecommute', + ]); + + $this->assertSame('TELECOMMUTE', $entry['jobLocationType']); + } + + /** + * @testdox maps a hybrid job location type to TELECOMMUTE while keeping the physical job location + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function testHybridIsMappedToTelecommuteAndKeepsJobLocation() + { + $jobLocation = [ + '@type' => 'Place', + 'address' => ['@type' => 'PostalAddress', 'addressLocality' => 'Berlin'], + ]; + + $entry = $this->compileGraphEntry([ + '@type' => 'JobPosting', + 'title' => 'Hybrid Developer', + 'jobLocationType' => 'Hybrid', + 'jobLocation' => $jobLocation, + ]); + + $this->assertSame('TELECOMMUTE', $entry['jobLocationType']); + $this->assertSame($jobLocation, $entry['jobLocation']); + } + + /** + * @testdox omits the job location type for an on-site job so only the job location is emitted + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function testOnsiteJobLocationTypeIsOmitted() + { + $jobLocation = [ + '@type' => 'Place', + 'address' => ['@type' => 'PostalAddress', 'addressLocality' => 'Berlin'], + ]; + + $entry = $this->compileGraphEntry([ + '@type' => 'JobPosting', + 'title' => 'Onsite Developer', + 'jobLocationType' => 'Onsite', + 'jobLocation' => $jobLocation, + ]); + + $this->assertArrayNotHasKey('jobLocationType', $entry); + $this->assertSame($jobLocation, $entry['jobLocation']); + } + + /** + * @testdox leaves an entry without a job location type untouched + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function testEntryWithoutJobLocationTypeIsUntouched() + { + $entry = $this->compileGraphEntry([ + '@type' => 'JobPosting', + 'title' => 'Developer', + ]); + + $this->assertArrayNotHasKey('jobLocationType', $entry); + $this->assertSame('Developer', $entry['title']); + } + + /** + * @testdox does not touch graph entries of other schema types + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function testOtherSchemaTypesAreUntouched() + { + $entry = $this->compileGraphEntry([ + '@type' => 'Event', + 'jobLocationType' => 'Onsite', + ]); + + $this->assertSame('Onsite', $entry['jobLocationType']); + } +}