bf111a0c44acd90a9094784eae7e4fec207588d6
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / request / LinkHandler.class.php
1 <?php
2
3 namespace wcf\system\request;
4
5 use wcf\data\DatabaseObjectDecorator;
6 use wcf\data\IIDObject;
7 use wcf\data\page\PageCache;
8 use wcf\system\application\ApplicationHandler;
9 use wcf\system\language\LanguageFactory;
10 use wcf\system\Regex;
11 use wcf\system\SingletonFactory;
12 use wcf\system\WCF;
13 use wcf\util\StringUtil;
14
15 /**
16 * Handles relative links within the wcf.
17 *
18 * @author Marcel Werk
19 * @copyright 2001-2019 WoltLab GmbH
20 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
21 */
22 final class LinkHandler extends SingletonFactory
23 {
24 /**
25 * regex object to extract controller data from controller class name
26 * @var Regex
27 * @since 5.2
28 */
29 protected $controllerRegex;
30
31 /**
32 * title search strings
33 * @var string[]
34 */
35 protected $titleSearch = [];
36
37 /**
38 * title replacement strings
39 * @var string[]
40 */
41 protected $titleReplace = [];
42
43 /**
44 * @inheritDoc
45 */
46 protected function init()
47 {
48 $this->controllerRegex = new Regex(
49 '^(?P<application>[a-z][a-z0-9]*)\\\\(?P<isAcp>acp\\\\)?.+\\\\(?P<controller>[^\\\\]+)(?:Action|Form|Page)$'
50 );
51
52 if (\defined('URL_TITLE_COMPONENT_REPLACEMENT') && URL_TITLE_COMPONENT_REPLACEMENT) {
53 $replacements = \explode(
54 "\n",
55 StringUtil::unifyNewlines(StringUtil::trim(URL_TITLE_COMPONENT_REPLACEMENT))
56 );
57 foreach ($replacements as $replacement) {
58 if (\strpos($replacement, '=') === false) {
59 continue;
60 }
61 $components = \explode('=', $replacement);
62 $this->titleSearch[] = $components[0];
63 $this->titleReplace[] = $components[1];
64 }
65 }
66 }
67
68 /**
69 * Returns in internal link based on the given fully qualified controller
70 * class name.
71 *
72 * Important: The controller class is not checked if it actually exists.
73 * That check happens during the runtime.
74 *
75 * @throws \InvalidArgumentException if the passed string is no controller class name
76 * @since 5.2
77 */
78 public function getControllerLink(string $controllerClass, array $parameters = [], string $url = ''): string
79 {
80 if (!$this->controllerRegex->match($controllerClass)) {
81 throw new \InvalidArgumentException("Invalid controller '{$controllerClass}' passed.");
82 }
83
84 $matches = $this->controllerRegex->getMatches();
85
86 // important: matches cannot overwrite explicitly set parameters
87 $parameters['application'] = $parameters['application'] ?? $matches['application'];
88 $parameters['isACP'] = $parameters['isACP'] ?? $matches['isAcp'];
89 $parameters['forceFrontend'] = $parameters['forceFrontend'] ?? !$matches['isAcp'];
90
91 return $this->getLink($matches['controller'], $parameters, $url);
92 }
93
94 /**
95 * Returns a relative link.
96 */
97 public function getLink(?string $controller = null, array $parameters = [], string $url = ''): string
98 {
99 $abbreviation = 'wcf';
100 $anchor = '';
101 $isACP = RequestHandler::getInstance()->isACPRequest();
102 $isRaw = false;
103 $encodeTitle = true;
104
105 // enforce a certain level of sanitation and protection for links embedded in emails
106 if (isset($parameters['isEmail'])) {
107 if ((bool)$parameters['isEmail']) {
108 if (!isset($parameters['isACP']) || !(bool)$parameters['isACP']) {
109 $parameters['forceFrontend'] = true;
110 }
111 }
112
113 unset($parameters['isEmail']);
114 }
115
116 if (isset($parameters['application'])) {
117 $abbreviation = $parameters['application'];
118 }
119 if (isset($parameters['isRaw'])) {
120 $isRaw = $parameters['isRaw'];
121 unset($parameters['isRaw']);
122 }
123 if (isset($parameters['isACP'])) {
124 $isACP = (bool)$parameters['isACP'];
125 unset($parameters['isACP']);
126 }
127 if (isset($parameters['forceFrontend'])) {
128 if ($parameters['forceFrontend'] && $isACP) {
129 $isACP = false;
130 }
131 unset($parameters['forceFrontend']);
132 }
133 if (isset($parameters['encodeTitle'])) {
134 $encodeTitle = $parameters['encodeTitle'];
135 unset($parameters['encodeTitle']);
136 }
137
138 /** @deprecated 3.0 */
139 unset($parameters['appendSession']);
140 /** @deprecated 3.0 */
141 unset($parameters['forceWCF']);
142
143 // remove anchor before parsing
144 if (($pos = \strpos($url, '#')) !== false) {
145 $anchor = \substr($url, $pos);
146 $url = \substr($url, 0, $pos);
147 }
148
149 // build route
150 if ($controller === null) {
151 if ($isACP) {
152 $controller = 'Index';
153 if ($abbreviation !== 'wcf') {
154 throw new \InvalidArgumentException("A 'controller' must be specified for non-'wcf' links in ACP.");
155 }
156 } else {
157 if ($abbreviation !== 'wcf') {
158 $application = ApplicationHandler::getInstance()->getApplication($abbreviation);
159 if ($application === null) {
160 throw new \RuntimeException("Unknown abbreviation '" . $abbreviation . "'.");
161 }
162
163 $landingPage = PageCache::getInstance()->getPage($application->landingPageID);
164 if ($landingPage === null) {
165 $landingPage = PageCache::getInstance()
166 ->getPageByController(WCF::getApplicationObject($application)->getPrimaryController());
167 }
168
169 if ($landingPage !== null) {
170 return $landingPage->getLink();
171 }
172 }
173
174 return PageCache::getInstance()->getLandingPage()->getLink();
175 }
176 }
177
178 // handle object
179 if (isset($parameters['object'])) {
180 if (
181 !($parameters['object'] instanceof IRouteController)
182 && $parameters['object'] instanceof DatabaseObjectDecorator
183 && $parameters['object']->getDecoratedObject() instanceof IRouteController
184 ) {
185 $parameters['object'] = $parameters['object']->getDecoratedObject();
186 }
187
188 if ($parameters['object'] instanceof IRouteController) {
189 $parameters['id'] = $parameters['object']->getObjectID();
190 $parameters['title'] = $parameters['object']->getTitle();
191 } elseif ($parameters['object'] instanceof IIDObject) {
192 $parameters['id'] = $parameters['object']->getObjectID();
193 }
194 }
195 unset($parameters['object']);
196
197 if (isset($parameters['title'])) {
198 // component replacement
199 if ($this->titleSearch !== []) {
200 $parameters['title'] = \str_replace($this->titleSearch, $this->titleReplace, $parameters['title']);
201 }
202
203 // remove illegal characters
204 $parameters['title'] = \trim(
205 \preg_replace('/[^\p{L}\p{N}]+/u', '-', $parameters['title']),
206 '-'
207 );
208
209 // trim to 80 characters
210 $parameters['title'] = \rtrim(\mb_substr($parameters['title'], 0, 80), '-');
211 $parameters['title'] = \mb_strtolower($parameters['title']);
212
213 // encode title
214 if ($encodeTitle) {
215 $parameters['title'] = \rawurlencode($parameters['title']);
216 }
217 }
218
219 $parameters['controller'] = $controller;
220 if (!$isACP) {
221 $abbreviation = ControllerMap::getInstance()->getApplicationOverride($abbreviation, $controller);
222 }
223 $routeURL = RouteHandler::getInstance()->buildRoute($abbreviation, $parameters, $isACP);
224 if (!$isRaw && $url !== '') {
225 $routeURL .= \str_contains($routeURL, '?') ? '&' : '?';
226 }
227
228 // encode certain characters
229 if ($url !== '') {
230 $url = \str_replace(['[', ']'], ['%5B', '%5D'], $url);
231 }
232
233 $url = $routeURL . $url;
234
235 // handle applications
236 if (!PACKAGE_ID) {
237 $url = RouteHandler::getHost() . RouteHandler::getPath(['acp']) . ($isACP ? 'acp/' : '') . $url;
238 } else {
239 $application = ApplicationHandler::getInstance()->getApplication($abbreviation);
240 if ($application === null) {
241 throw new \InvalidArgumentException("Unknown application identifier '{$abbreviation}'.");
242 }
243
244 $pageURL = $application->getPageURL();
245
246 $url = $pageURL . ($isACP ? 'acp/' : '') . $url;
247 }
248
249 // append previously removed anchor
250 $url .= $anchor;
251
252 return $url;
253 }
254
255 /**
256 * Returns the full URL to a CMS page. The `$languageID` parameter is optional and if not
257 * present (or the integer value `-1` is given) will cause the handler to pick the correct
258 * language version based upon the user's language.
259 *
260 * Passing in an illegal page id will cause this method to fail silently, returning an
261 * empty string.
262 *
263 * @param int $pageID page id
264 * @param int $languageID language id, optional
265 * @return string full URL of empty string if `$pageID` is invalid
266 * @since 3.0
267 */
268 public function getCmsLink($pageID, $languageID = -1): string
269 {
270 $data = null;
271
272 // use current language
273 if ($languageID === -1) {
274 $data = ControllerMap::getInstance()->lookupCmsPage($pageID, WCF::getLanguage()->languageID);
275
276 // no result, attempt to use the default language instead
277 $defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID();
278 if ($data === null && $defaultLanguageID != WCF::getLanguage()->languageID) {
279 $data = ControllerMap::getInstance()->lookupCmsPage($pageID, $defaultLanguageID);
280 }
281
282 // no result, possibly this is a non-multilingual page
283 if ($data === null) {
284 $data = ControllerMap::getInstance()->lookupCmsPage($pageID, null);
285 }
286 } else {
287 $data = ControllerMap::getInstance()->lookupCmsPage($pageID, $languageID);
288 }
289
290 // no result, page does not exist or at least not in the given language
291 if ($data === null) {
292 return '';
293 }
294
295 return $this->getLink($data['controller'], [
296 'application' => $data['application'],
297 'forceFrontend' => true,
298 ]);
299 }
300 }