001// Copyright 2009-2012, 2026 The Apache Software Foundation 002// 003// Licensed under the Apache License, Version 2.0 (the "License"); 004// you may not use this file except in compliance with the License. 005// You may obtain a copy of the License at 006// 007// http://www.apache.org/licenses/LICENSE-2.0 008// 009// Unless required by applicable law or agreed to in writing, software 010// distributed under the License is distributed on an "AS IS" BASIS, 011// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 012// See the License for the specific language governing permissions and 013// limitations under the License. 014 015package org.apache.tapestry5.internal.services; 016 017import org.apache.tapestry5.*; 018import org.apache.tapestry5.commons.util.CollectionFactory; 019import org.apache.tapestry5.http.Link; 020import org.apache.tapestry5.http.TapestryHttpSymbolConstants; 021import org.apache.tapestry5.http.services.BaseURLSource; 022import org.apache.tapestry5.http.services.Request; 023import org.apache.tapestry5.http.services.Response; 024import org.apache.tapestry5.internal.InternalConstants; 025import org.apache.tapestry5.internal.TapestryInternalUtils; 026import org.apache.tapestry5.ioc.annotations.Symbol; 027import org.apache.tapestry5.ioc.internal.util.InternalUtils; 028import org.apache.tapestry5.services.ComponentClassResolver; 029import org.apache.tapestry5.services.ComponentEventLinkEncoder; 030import org.apache.tapestry5.services.ComponentEventRequestParameters; 031import org.apache.tapestry5.services.ContextPathEncoder; 032import org.apache.tapestry5.services.LocalizationSetter; 033import org.apache.tapestry5.services.MetaDataLocator; 034import org.apache.tapestry5.services.PageRenderRequestParameters; 035import org.apache.tapestry5.services.PersistentLocale; 036import org.apache.tapestry5.services.security.ClientWhitelist; 037 038import java.util.List; 039import java.util.Locale; 040 041public class ComponentEventLinkEncoderImpl implements ComponentEventLinkEncoder 042{ 043 private final ComponentClassResolver componentClassResolver; 044 045 private final ContextPathEncoder contextPathEncoder; 046 047 private final LocalizationSetter localizationSetter; 048 049 private final Response response; 050 051 private final RequestSecurityManager requestSecurityManager; 052 053 private final BaseURLSource baseURLSource; 054 055 private final PersistentLocale persistentLocale; 056 057 private final boolean encodeLocaleIntoPath; 058 059 private final MetaDataLocator metaDataLocator; 060 061 private final ClientWhitelist clientWhitelist; 062 063 private final String contextPath; 064 065 private final String applicationFolder; 066 067 private final String applicationFolderPrefix; 068 069 private static final int BUFFER_SIZE = 100; 070 071 private static final char SLASH = '/'; 072 073 public ComponentEventLinkEncoderImpl(ComponentClassResolver componentClassResolver, 074 ContextPathEncoder contextPathEncoder, LocalizationSetter localizationSetter, 075 Response response, RequestSecurityManager requestSecurityManager, BaseURLSource baseURLSource, 076 PersistentLocale persistentLocale, 077 @Symbol(SymbolConstants.ENCODE_LOCALE_INTO_PATH) 078 boolean encodeLocaleIntoPath, 079 @Symbol(TapestryHttpSymbolConstants.CONTEXT_PATH) 080 String contextPath, 081 @Symbol(SymbolConstants.APPLICATION_FOLDER) String applicationFolder, 082 MetaDataLocator metaDataLocator, 083 ClientWhitelist clientWhitelist) 084 { 085 this.componentClassResolver = componentClassResolver; 086 this.contextPathEncoder = contextPathEncoder; 087 this.localizationSetter = localizationSetter; 088 this.response = response; 089 this.requestSecurityManager = requestSecurityManager; 090 this.baseURLSource = baseURLSource; 091 this.persistentLocale = persistentLocale; 092 this.encodeLocaleIntoPath = encodeLocaleIntoPath; 093 this.contextPath = contextPath; 094 this.applicationFolder = applicationFolder; 095 this.metaDataLocator = metaDataLocator; 096 this.clientWhitelist = clientWhitelist; 097 098 boolean hasAppFolder = applicationFolder.equals(""); 099 100 applicationFolderPrefix = hasAppFolder ? null : SLASH + applicationFolder; 101 } 102 103 public Link createPageRenderLink(PageRenderRequestParameters parameters) 104 { 105 StringBuilder builder = new StringBuilder(BUFFER_SIZE); 106 107 // Build up the absolute URI. 108 109 String activePageName = parameters.getLogicalPageName(); 110 111 builder.append(contextPath); 112 113 encodeAppFolderAndLocale(builder); 114 115 builder.append(SLASH); 116 117 String encodedPageName = encodePageName(activePageName); 118 119 builder.append(encodedPageName); 120 121 appendContext(encodedPageName.length() > 0, parameters.getActivationContext(), builder); 122 123 Link link = new LinkImpl(builder.toString(), false, requestSecurityManager.checkPageSecurity(activePageName), 124 response, contextPathEncoder, baseURLSource); 125 126 if (parameters.isLoopback()) 127 { 128 link.addParameter(TapestryConstants.PAGE_LOOPBACK_PARAMETER_NAME, "t"); 129 } 130 131 return link; 132 } 133 134 private void encodeAppFolderAndLocale(StringBuilder builder) 135 { 136 if (!applicationFolder.equals("")) 137 { 138 builder.append(SLASH).append(applicationFolder); 139 } 140 141 if (encodeLocaleIntoPath) 142 { 143 Locale locale = persistentLocale.get(); 144 145 if (locale != null) 146 { 147 builder.append(SLASH); 148 builder.append(locale.toString()); 149 } 150 } 151 } 152 153 private String encodePageName(String pageName) 154 { 155 if (pageName.equalsIgnoreCase("index")) 156 return ""; 157 158 String encoded = pageName.toLowerCase(); 159 160 if (!encoded.endsWith("/index")) 161 return encoded; 162 163 return encoded.substring(0, encoded.length() - 6); 164 } 165 166 public Link createComponentEventLink(ComponentEventRequestParameters parameters, boolean forForm) 167 { 168 StringBuilder builder = new StringBuilder(BUFFER_SIZE); 169 170 // Build up the absolute URI. 171 172 String activePageName = parameters.getActivePageName(); 173 String containingPageName = parameters.getContainingPageName(); 174 String eventType = parameters.getEventType(); 175 176 String nestedComponentId = parameters.getNestedComponentId(); 177 boolean hasComponentId = InternalUtils.isNonBlank(nestedComponentId); 178 179 builder.append(contextPath); 180 181 encodeAppFolderAndLocale(builder); 182 183 builder.append(SLASH); 184 builder.append(activePageName.toLowerCase()); 185 186 if (hasComponentId) 187 { 188 builder.append('.'); 189 builder.append(nestedComponentId); 190 } 191 192 if (!hasComponentId || !eventType.equals(EventConstants.ACTION)) 193 { 194 builder.append(':'); 195 builder.append(encodePageName(eventType)); 196 } 197 198 appendContext(true, parameters.getEventContext(), builder); 199 200 Link result = new LinkImpl(builder.toString(), forForm, 201 requestSecurityManager.checkPageSecurity(activePageName), response, contextPathEncoder, baseURLSource); 202 203 EventContext pageActivationContext = parameters.getPageActivationContext(); 204 205 if (pageActivationContext.getCount() != 0) 206 { 207 // Reuse the builder 208 builder.setLength(0); 209 appendContext(true, pageActivationContext, builder); 210 211 // Omit that first slash 212 result.addParameter(InternalConstants.PAGE_CONTEXT_NAME, builder.substring(1)); 213 } 214 215 // TAPESTRY-2044: Sometimes the active page drags in components from another page and we 216 // need to differentiate that. 217 218 if (!containingPageName.equalsIgnoreCase(activePageName)) 219 result.addParameter(InternalConstants.CONTAINER_PAGE_NAME, encodePageName(containingPageName)); 220 221 return result; 222 } 223 224 /** 225 * Splits path at slashes into a <em>mutable</em> list of strings. Empty terms, including the 226 * expected leading term (paths start with a '/') are dropped. 227 * 228 * @param path 229 * @return mutable list of path elements 230 */ 231 private List<String> splitPath(String path) 232 { 233 String[] split = TapestryInternalUtils.splitPath(path); 234 235 List<String> result = CollectionFactory.newList(); 236 237 for (String name : split) 238 { 239 if (name.length() > 0) 240 { 241 result.add(name); 242 } 243 } 244 245 return result; 246 } 247 248 private String joinPath(List<String> path) 249 { 250 if (path.isEmpty()) 251 { 252 return ""; 253 } 254 255 StringBuilder builder = new StringBuilder(100); 256 String sep = ""; 257 258 for (String term : path) 259 { 260 builder.append(sep).append(term); 261 sep = "/"; 262 } 263 264 return builder.toString(); 265 } 266 267 public ComponentEventRequestParameters decodeComponentEventRequest(Request request) 268 { 269 String explicitLocale = null; 270 271 // Split the path around slashes into a mutable list of terms, which will be consumed term by term. 272 273 String requestPath = request.getPath(); 274 275 if (applicationFolderPrefix != null) 276 { 277 requestPath = removeApplicationPrefix(requestPath); 278 } 279 280 List<String> path = splitPath(requestPath); 281 282 283 284 if (path.isEmpty()) 285 { 286 return null; 287 } 288 289 // Next up: the locale (which is optional) 290 291 String potentialLocale = path.get(0); 292 293 if (localizationSetter.isSupportedLocaleName(potentialLocale)) 294 { 295 explicitLocale = potentialLocale; 296 path.remove(0); 297 } 298 299 StringBuilder pageName = new StringBuilder(100); 300 String sep = ""; 301 302 while (!path.isEmpty()) 303 { 304 String name = path.remove(0); 305 String eventType = EventConstants.ACTION; 306 String nestedComponentId = ""; 307 308 boolean found = false; 309 310 // First, look for an explicit action name. 311 312 int colonx = name.lastIndexOf(':'); 313 314 if (colonx > 0) 315 { 316 found = true; 317 eventType = name.substring(colonx + 1); 318 name = name.substring(0, colonx); 319 } 320 321 int dotx = name.indexOf('.'); 322 323 if (dotx > 0) 324 { 325 found = true; 326 nestedComponentId = name.substring(dotx + 1); 327 name = name.substring(0, dotx); 328 } 329 330 pageName.append(sep).append(name); 331 332 if (found) 333 { 334 ComponentEventRequestParameters result = validateAndConstructComponentEventRequest(request, pageName.toString(), nestedComponentId, eventType, path); 335 336 if (result == null) 337 { 338 return result; 339 } 340 341 if (explicitLocale == null) 342 { 343 localizationSetter.setNonPersistentLocaleFromRequest(request); 344 } else 345 { 346 localizationSetter.setLocaleFromLocaleName(explicitLocale); 347 } 348 349 return result; 350 } 351 352 // Continue on to the next name in the path 353 sep = "/"; 354 } 355 356 // Path empty before finding something that looks like a component id or event name, so 357 // it is not a component event request. 358 359 return null; 360 } 361 362 private ComponentEventRequestParameters validateAndConstructComponentEventRequest(Request request, String pageName, String nestedComponentId, String eventType, List<String> remainingPath) 363 { 364 if (!componentClassResolver.isPageName(pageName)) 365 { 366 return null; 367 } 368 369 String activePageName = componentClassResolver.canonicalizePageName(pageName); 370 371 if (isWhitelistOnlyAndNotValid(activePageName)) 372 { 373 return null; 374 } 375 376 String value = request.getParameter(InternalConstants.CONTAINER_PAGE_NAME); 377 378 String containingPageName; 379 380 if (value == null) 381 { 382 containingPageName = activePageName; 383 } else 384 { 385 // The event handler method executes on a component of the containing page, so the 386 // containing page's code runs for this request. 387 // It must pass the same gates as the active page. 388 if (!componentClassResolver.isPageName(value)) 389 { 390 return null; 391 } 392 393 containingPageName = componentClassResolver.canonicalizePageName(value); 394 395 if (isWhitelistOnlyAndNotValid(containingPageName)) 396 { 397 return null; 398 } 399 } 400 401 try 402 { 403 EventContext eventContext = contextPathEncoder.decodePath(joinPath(remainingPath)); 404 EventContext activationContext = contextPathEncoder.decodePath(request.getParameter(InternalConstants.PAGE_CONTEXT_NAME)); 405 406 return new ComponentEventRequestParameters(activePageName, containingPageName, nestedComponentId, eventType, 407 activationContext, eventContext); 408 } catch (IllegalArgumentException e) 409 { 410 // TAP5-2436 (parity with checkIfPage()): a malformed context is a request for a 411 // resource that doesn't exist (404), not an exception report. 412 return null; 413 } 414 } 415 416 public PageRenderRequestParameters decodePageRenderRequest(Request request) 417 { 418 boolean explicitLocale = false; 419 420 // The extended name may include a page activation context. The trick is 421 // to figure out where the logical page name stops and where the 422 // activation context begins. Further, strip out the leading slash. 423 424 String path = request.getPath(); 425 426 if (applicationFolderPrefix != null) 427 { 428 path = removeApplicationPrefix(path); 429 } 430 431 432 // TAPESTRY-1343: Sometimes path is the empty string (it should always be at least a slash, 433 // but Tomcat may return the empty string for a root context request). 434 435 String extendedName = path.length() == 0 ? path : path.substring(1); 436 437 // Ignore trailing slashes in the path. 438 while (extendedName.endsWith("/")) 439 { 440 extendedName = extendedName.substring(0, extendedName.length() - 1); 441 } 442 443 int slashx = extendedName.indexOf('/'); 444 445 // So, what can we have left? 446 // 1. A page name 447 // 2. A locale followed by a page name 448 // 3. A page name followed by activation context 449 // 4. A locale name, page name, activation context 450 // 5. Just activation context (for root Index page) 451 // 6. A locale name followed by activation context 452 453 String possibleLocaleName = slashx > 0 ? extendedName.substring(0, slashx) : extendedName; 454 455 if (localizationSetter.setLocaleFromLocaleName(possibleLocaleName)) 456 { 457 extendedName = slashx > 0 ? extendedName.substring(slashx + 1) : ""; 458 explicitLocale = true; 459 } 460 461 slashx = extendedName.length(); 462 boolean atEnd = true; 463 464 while (slashx > 0) 465 { 466 String pageName = extendedName.substring(0, slashx); 467 String pageActivationContext = atEnd ? "" : extendedName.substring(slashx + 1); 468 469 PageRenderRequestParameters parameters = checkIfPage(request, pageName, pageActivationContext); 470 471 if (parameters != null) 472 { 473 return parameters; 474 } 475 476 // Work backwards, splitting at the next slash. 477 slashx = extendedName.lastIndexOf('/', slashx - 1); 478 479 atEnd = false; 480 } 481 482 // OK, maybe its all page activation context for the root Index page. 483 484 PageRenderRequestParameters result = checkIfPage(request, "", extendedName); 485 486 if (result != null && !explicitLocale) 487 { 488 localizationSetter.setNonPersistentLocaleFromRequest(request); 489 } 490 491 return result; 492 } 493 494 private String removeApplicationPrefix(String path) { 495 int prefixLength = applicationFolderPrefix.length(); 496 497 assert path.substring(0, prefixLength).equalsIgnoreCase(applicationFolderPrefix); 498 499 // This checks that the character after the prefix is a slash ... the extra complexity 500 // only seems to occur in Selenium. There's some ambiguity about what to do with a request for 501 // the application folder that doesn't end with a slash. Manuyal with Chrome and IE 8 shows that such 502 // requests are passed through with a training slash, automated testing with Selenium and FireFox 503 // can include requests for the folder without the trailing slash. 504 505 assert path.length() <= prefixLength || path.charAt(prefixLength) == '/'; 506 507 // Strip off the folder prefix (i.e., "/foldername"), leaving the rest of the path (i.e., "/en/pagename"). 508 509 path = path.substring(prefixLength); 510 return path; 511 } 512 513 private PageRenderRequestParameters checkIfPage(Request request, String pageName, String pageActivationContext) 514 { 515 if (!componentClassResolver.isPageName(pageName)) 516 { 517 return null; 518 } 519 String canonicalized = componentClassResolver.canonicalizePageName(pageName); 520 521 // If the page is only visible to the whitelist, but the request is not on the whitelist, then 522 // pretend the page doesn't exist! 523 if (isWhitelistOnlyAndNotValid(canonicalized)) 524 { 525 return null; 526 } 527 try 528 { 529 EventContext activationContext = contextPathEncoder.decodePath(pageActivationContext); 530 531 boolean loopback = request.getMethod().equals("GET") && 532 request.getParameter(TapestryConstants.PAGE_LOOPBACK_PARAMETER_NAME) != null; 533 534 return new PageRenderRequestParameters(canonicalized, activationContext, loopback); 535 } catch (IllegalArgumentException e) 536 { 537 // TAP5-2436 538 return null; 539 } 540 } 541 542 private boolean isWhitelistOnlyAndNotValid(String canonicalized) 543 { 544 return metaDataLocator.findMeta(MetaDataConstants.WHITELIST_ONLY_PAGE, canonicalized, boolean.class) && 545 !clientWhitelist.isClientRequestOnWhitelist(); 546 } 547 548 public void appendContext(boolean seperatorRequired, EventContext context, StringBuilder builder) 549 { 550 String encoded = contextPathEncoder.encodeIntoPath(context); 551 552 if (encoded.length() > 0) 553 { 554 if (seperatorRequired) 555 { 556 builder.append(SLASH); 557 } 558 559 builder.append(encoded); 560 } 561 } 562}