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}