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); ?>
-
escape($this->source->filename); ?>
+ type === 'file') : ?>
+
escape($this->source->filename); ?>
+
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') . '_FILTER_LABEL'); ?>
-
- 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']);
+ }
+}