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;