diff --git a/XSD/menuItem.xsd b/XSD/menuItem.xsd
index e4c2860998b..83d9989dce3 100644
--- a/XSD/menuItem.xsd
+++ b/XSD/menuItem.xsd
@@ -43,6 +43,7 @@
+
diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php
index 9c2b9b3d872..5bb0d7c6451 100644
--- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php
+++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php
@@ -11,6 +11,7 @@
use wcf\system\database\table\column\IntDatabaseTableColumn;
use wcf\system\database\table\column\MediumtextDatabaseTableColumn;
use wcf\system\database\table\column\VarcharDatabaseTableColumn;
+use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn;
use wcf\system\database\table\index\DatabaseTableForeignKey;
use wcf\system\database\table\index\DatabaseTableIndex;
use wcf\system\database\table\PartialDatabaseTable;
@@ -74,4 +75,9 @@
->columns([
MediumtextDatabaseTableColumn::create('exifData'),
]),
+ PartialDatabaseTable::create('wcf1_menu_item')
+ ->columns([
+ NotNullVarchar255DatabaseTableColumn::create('urlParameters')
+ ->defaultValue(''),
+ ]),
];
diff --git a/wcfsetup/install/files/lib/acp/form/MenuItemAddForm.class.php b/wcfsetup/install/files/lib/acp/form/MenuItemAddForm.class.php
index 2ab89fc61dd..580dbe2d7e9 100644
--- a/wcfsetup/install/files/lib/acp/form/MenuItemAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/MenuItemAddForm.class.php
@@ -210,6 +210,15 @@ protected function createForm()
->fieldId('pageID')
->values(\array_keys($pageHandlers))
),
+ TextFormField::create('urlParameters')
+ ->label('wcf.acp.menu.item.urlParameters')
+ ->maximumLength(255)
+ ->addDependency(
+ ValueFormFieldDependency::create('isInternalLinkDependency')
+ ->fieldId('isInternalLink')
+ ->values([1])
+ )
+ ->addFieldClass('medium'),
TextFormField::create('externalURL')
->label('wcf.acp.menu.item.externalURL')
->maximumLength(255)
diff --git a/wcfsetup/install/files/lib/data/menu/item/MenuItem.class.php b/wcfsetup/install/files/lib/data/menu/item/MenuItem.class.php
index c845af84e6d..b7e60c6c808 100644
--- a/wcfsetup/install/files/lib/data/menu/item/MenuItem.class.php
+++ b/wcfsetup/install/files/lib/data/menu/item/MenuItem.class.php
@@ -11,6 +11,7 @@
use wcf\system\page\handler\ILookupPageHandler;
use wcf\system\page\handler\IMenuPageHandler;
use wcf\system\WCF;
+use wcf\util\Url;
/**
* Represents a menu item.
@@ -32,6 +33,7 @@
* @property-read int $isDisabled is `1` if the menu item is disabled and thus not shown in the menu, otherwise `0`
* @property-read int $originIsSystem is `1` if the menu item has been delivered by a package, otherwise `0` (if the menu item has been created by an admin in the ACP)
* @property-read int $packageID id of the package the which delivers the menu item or `1` if it has been created in the ACP
+ * @property-read string $urlParameters
*/
class MenuItem extends DatabaseObject implements ITitledObject
{
@@ -93,17 +95,26 @@ public function getURL()
if ($this->pageObjectID) {
$handler = $this->getMenuPageHandler();
if ($handler && $handler instanceof ILookupPageHandler) {
- return $handler->getLink($this->pageObjectID);
+ return $this->appendUrlParameters($handler->getLink($this->pageObjectID));
}
}
if ($this->pageID) {
- return $this->getPage()->getLink();
+ return $this->appendUrlParameters($this->getPage()->getLink());
} else {
return WCF::getLanguage()->get($this->externalURL);
}
}
+ private function appendUrlParameters(string $url): string
+ {
+ if (!$this->urlParameters) {
+ return $url;
+ }
+
+ return Url::withQueryString($url, $this->urlParameters);
+ }
+
/**
* Returns the page that is linked by this menu item.
*
diff --git a/wcfsetup/install/files/lib/data/menu/item/MenuItemNodeTree.class.php b/wcfsetup/install/files/lib/data/menu/item/MenuItemNodeTree.class.php
index eee399551f1..fa7cbba3324 100644
--- a/wcfsetup/install/files/lib/data/menu/item/MenuItemNodeTree.class.php
+++ b/wcfsetup/install/files/lib/data/menu/item/MenuItemNodeTree.class.php
@@ -4,6 +4,8 @@
use wcf\system\page\PageLocationManager;
use wcf\system\request\RequestHandler;
+use wcf\system\request\RouteHandler;
+use wcf\util\Url;
/**
* Represents a menu item node tree.
@@ -75,17 +77,28 @@ public function __construct($menuID, ?MenuItemList $menuItemList = null, $checkV
$activeMenuItems = [];
if (!RequestHandler::getInstance()->isACPRequest()) {
+ $requestParameters = Url::parseQueryString($_SERVER['QUERY_STRING']);
+
$possibleLocations = PageLocationManager::getInstance()->getLocations();
- $length = \count($possibleLocations);
- for ($i = 0; $i < $length; $i++) {
- foreach ($menuItemList as $menuItem) {
- if ($menuItem->pageID == $possibleLocations[$i]['pageID'] && $menuItem->pageObjectID == $possibleLocations[$i]['pageObjectID']) {
- if (!isset($activeMenuItems[$i])) {
- $activeMenuItems[$i] = [];
- }
+ for ($i = 0, $length = \count($possibleLocations); $i < $length; $i++) {
+ foreach ($menuItemList->getObjects() as $menuItem) {
+ if ($menuItem->pageID !== $possibleLocations[$i]['pageID']) {
+ continue;
+ }
- $activeMenuItems[$i][] = $menuItem->itemID;
+ if ($menuItem->pageObjectID !== $possibleLocations[$i]['pageObjectID']) {
+ continue;
}
+
+ if ($menuItem->urlParameters !== '') {
+ $expectedParameters = Url::parseQueryString($menuItem->urlParameters);
+ if (\array_diff($expectedParameters, $requestParameters) !== []) {
+ continue;
+ }
+ }
+
+ $activeMenuItems[$i] ??= [];
+ $activeMenuItems[$i][] = $menuItem->itemID;
}
}
}
diff --git a/wcfsetup/install/files/lib/system/package/plugin/MenuItemPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/MenuItemPackageInstallationPlugin.class.php
index 2b3376bd520..f533a27ef48 100644
--- a/wcfsetup/install/files/lib/system/package/plugin/MenuItemPackageInstallationPlugin.class.php
+++ b/wcfsetup/install/files/lib/system/package/plugin/MenuItemPackageInstallationPlugin.class.php
@@ -155,11 +155,14 @@ protected function prepareImport(array $data)
}
$externalURL = (!empty($data['elements']['externalURL'])) ? $data['elements']['externalURL'] : '';
+ $urlParameters = $data['elements']['urlParameters'] ?? '';
if ($pageID === null && empty($externalURL)) {
throw new SystemException("The menu item '" . $data['attributes']['identifier'] . "' must either have an associated page or an external url set.");
} elseif ($pageID !== null && !empty($externalURL)) {
throw new SystemException("The menu item '" . $data['attributes']['identifier'] . "' can either have an associated page or an external url, but not both.");
+ } elseif ($pageID === null && !empty($urlParameters)) {
+ throw new SystemException("The menu item '" . $data['attributes']['identifier'] . "' can not have an additional URL parameters set if it does not have an associated page.");
}
return [
@@ -171,6 +174,7 @@ protected function prepareImport(array $data)
'parentItemID' => $parentItemID,
'showOrder' => $this->getItemOrder($menuID, $parentItemID),
'title' => $this->getI18nValues($data['elements']['title']),
+ 'urlParameters' => $urlParameters,
];
}
@@ -390,6 +394,14 @@ protected function addFormFields(IFormDocument $form)
->values(['internal'])
),
+ TextFormField::create('urlParameters')
+ ->label('wcf.acp.pip.menuItem.urlParameters')
+ ->maximumLength(255)
+ ->addDependency(
+ ValueFormFieldDependency::create('linkType')
+ ->fieldId('linkType')
+ ->values(['internal'])
+ ),
TextFormField::create('externalURL')
->label('wcf.acp.pip.menuItem.externalURL')
->description('wcf.acp.pip.menuItem.externalURL.description')
@@ -469,7 +481,7 @@ protected function fetchElementData(\DOMElement $element, $saveData)
$data['title'][LanguageFactory::getInstance()->getLanguageByCode($title->getAttribute('language'))->languageID] = $title->nodeValue;
}
- foreach (['externalURL', 'menu', 'page', 'parent'] as $optionalElementName) {
+ foreach (['externalURL', 'menu', 'page', 'parent', 'urlParameters'] as $optionalElementName) {
$optionalElement = $element->getElementsByTagName($optionalElementName)->item(0);
if ($optionalElement !== null) {
$data[$optionalElementName] = $optionalElement->nodeValue;
@@ -594,6 +606,10 @@ protected function prepareXmlElement(\DOMDocument $document, IFormDocument $form
[
'page' => '',
'externalURL' => '',
+ 'additionalInternalURL' => [
+ 'defaultValue' => '',
+ 'cdata' => true,
+ ],
'showOrder' => null,
],
$form
diff --git a/wcfsetup/install/files/lib/util/Url.class.php b/wcfsetup/install/files/lib/util/Url.class.php
index 8c6cecc54ab..82a5d10d26e 100644
--- a/wcfsetup/install/files/lib/util/Url.class.php
+++ b/wcfsetup/install/files/lib/util/Url.class.php
@@ -2,6 +2,9 @@
namespace wcf\util;
+use GuzzleHttp\Psr7\Uri;
+use Psr\Http\Message\UriInterface;
+
/**
* Generic wrapper around `parse_url()`.
*
@@ -216,4 +219,71 @@ public static function getHostnameMatcher(array $hostnames): callable
return false;
};
}
+
+ /**
+ * Appends a query string and an optional fragment to an existing URI.
+ *
+ * @since 6.3
+ */
+ public static function withQueryString(string|Uri $uri, string $queryString): UriInterface
+ {
+ if (\is_string($uri)) {
+ $uri = new Uri($uri);
+ }
+
+ if ($queryString === '') {
+ return $uri;
+ }
+
+ $anchorPosition = \mb_strpos($queryString, '#');
+ if ($anchorPosition !== false) {
+ $anchor = \mb_substr($queryString, $anchorPosition + 1);
+ $queryString = \mb_substr($queryString, 0, $anchorPosition);
+
+ $uri = $uri->withFragment($anchor);
+ }
+
+ if ($queryString === '') {
+ return $uri;
+ }
+
+ $parts = self::parseQueryString($queryString);
+
+ return $uri->withQueryValues($uri, $parts);
+ }
+
+ /**
+ * Parses a query string into a one-dimensional key-value list.
+ *
+ * This is a replacement for PHP’s `parse_str()` that has two problems:
+ * 1. Dots inside the key are replaced by underscores.
+ * 2. Keys containing square brackets are converted into nested arrays.
+ *
+ * @return array
+ * @since 6.3
+ */
+ public static function parseQueryString(string $queryString): array
+ {
+ $components = \explode('&', $queryString);
+ $keyValueMap = [];
+ for ($i = 0, $length = \count($components); $i < $length; $i++) {
+ $component = $components[$i];
+ if (\str_contains($component, '=')) {
+ [$key, $value] = \explode('=', $component, 2);
+ } else {
+ $key = $component;
+ $value = '';
+ }
+
+ $key = \str_replace(
+ ['%5B', '%5D'],
+ ['[', ']'],
+ $key
+ );
+
+ $keyValueMap[$key] = $value;
+ }
+
+ return $keyValueMap;
+ }
}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index f335878ed0d..9c16733261e 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -1230,6 +1230,7 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE
+
@@ -2547,6 +2548,7 @@ Die Datenbestände werden sorgfältig gepflegt, aber es ist nicht ausgeschlossen
- {$tableName}-Datenbanktabelle, der für diese Bedingung verwendet wird.]]>
- {$tableName} ist keine Spalte vom Typ INT.]]>
- {$tableName}.]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index 19a6382374d..56f6939d4cd 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -1206,6 +1206,7 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru
+
@@ -2347,6 +2348,7 @@ If you have already bought the licenses for the listed apps, th
+
- [A-z0-9-_]. In general, the first part of the menu identifier is the package identifier and the second part is the unqualified controller class without the controller type suffixes Form and Page. Example: com.foo.bar.package.Baz]]>
diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql
index 0ce54b4e9a9..26acc92cdd3 100644
--- a/wcfsetup/setup/db/install.sql
+++ b/wcfsetup/setup/db/install.sql
@@ -835,7 +835,8 @@ CREATE TABLE wcf1_menu_item (
showOrder INT(10) NOT NULL DEFAULT 0,
isDisabled TINYINT(1) NOT NULL DEFAULT 0,
originIsSystem TINYINT(1) NOT NULL DEFAULT 0,
- packageID INT(10) NOT NULL
+ packageID INT(10) NOT NULL,
+ urlParameters VARCHAR(255) NOT NULL DEFAULT ''
);
DROP TABLE IF EXISTS wcf1_message_embedded_object;