Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTEXT-PARAMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ An `org.glassfish.mojarra.*` name which this page does not list is not recognize
<tr><td><code>String[]</code></td><td><em>(none)</em></td><td>2.0.0</td><td>request</td><td>Comma separated list of mime types of resources which are GZIP compressed when served. A trailing <code>/*</code> acts as a wildcard, as in <code>text/*</code>. Compression spends CPU to save bandwidth, so list the text based types only.</td></tr>
<tr><th colspan="5" align="left"><br/><code>org.glassfish.mojarra.defaultResourceMaxAge</code></th></tr>
<tr><td><code>long</code></td><td><code>604800000</code></td><td>2.0.0</td><td>request</td><td>Amount of milliseconds a resource may be cached by the client, as expressed in the <code>Expires</code> response header. A higher value means fewer resource requests reach the server.</td></tr>
<tr><th colspan="5" align="left"><br/><code>org.glassfish.mojarra.disableEarlyHints</code></th></tr>
<tr><td><code>boolean</code></td><td><code>false</code></td><td>5.0.0-M13</td><td>request</td><td>Stops announcing the stylesheets, scripts and fonts of the view as <code>Link</code> preload headers. By default, every such resource rendered while the response is not yet committed gets a <code>Link</code> header on the final response, and the ones rendered in the head are also sent ahead in a <code>103 Early Hints</code> interim response, so the browser can fetch them while the body is still being rendered. The 103 is only sent over HTTP/2 or later, because an HTTP/1.1 client or intermediary may take it for the final response. Set it to <code>true</code> when a client or intermediary breaks on the 103 or on the <code>Link</code> headers.</td></tr>
<tr><th colspan="5" align="left"><br/><code>org.glassfish.mojarra.enableMissingResourceLibraryDetection</code></th></tr>
<tr><td><code>boolean</code></td><td><code>false</code></td><td>2.0.5</td><td>request</td><td>Fails the view when a composite component refers to a resource library which does not exist, instead of rendering the tag as unresolved markup. <code>false</code> is faster as the check resolves the library on every composite component tag.</td></tr>
<tr><th colspan="5" align="left"><br/><code>org.glassfish.mojarra.resourceBufferSize</code></th></tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ public enum MojarraContextParam implements ContextParam {
@Deprecated(since = "5.0", forRemoval = true)
DISABLE_CLIENT_STATE_ENCRYPTION("disableClientStateEncryption", false, Deprecation.replacedBy("enableClientStateDebugging")),

DISABLE_EARLY_HINTS("disableEarlyHints", false),

DISABLE_EL_RESOLVER_SHORTCUTS("disableELResolverShortcuts", false),

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@

package org.glassfish.mojarra.context;

import static jakarta.faces.application.ResourceHandler.RESOURCE_IDENTIFIER;
import static jakarta.servlet.http.MappingMatch.EXACT;
import static jakarta.servlet.http.MappingMatch.EXTENSION;
import static java.util.Locale.ROOT;
import static java.util.function.Predicate.not;
import static org.glassfish.mojarra.RIConstants.EARLY_HINTS_RESOURCE_URLS_KEY_NAME;
import static org.glassfish.mojarra.util.Util.isEmpty;

Expand All @@ -30,7 +35,7 @@
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.HashSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
Expand All @@ -55,6 +60,7 @@
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
Expand Down Expand Up @@ -133,6 +139,21 @@ static PREDEFINED_COOKIE_PROPERTIES of(String key) {
"properties", "text/plain"
);

private static final String NO_PRELOAD = "";
private static final String FONT_PRELOAD_PARAMETERS = ";as=font;crossorigin";

private static final Map<String, String> PRELOAD_PARAMETERS = Map.of(
"css", ";as=style",
"js", ";as=script",
"woff2", FONT_PRELOAD_PARAMETERS,
"woff", FONT_PRELOAD_PARAMETERS,
"ttf", FONT_PRELOAD_PARAMETERS,
"otf", FONT_PRELOAD_PARAMETERS
);

private static final String HTTP_PROTOCOL_PREFIX = "HTTP/";
private static final char HTTP_2_MAJOR_VERSION = '2';

// ------------------------------------------------------------ Constructors

public ExternalContextImpl(ServletContext sc, ServletRequest request, ServletResponse response) {
Expand Down Expand Up @@ -1064,11 +1085,15 @@ public void release() {
flash = null;
}

/**
* Adds a {@code Link} preload header for the given resource URL, provided the resource has a preload destination as per
* {@link #getPreloadParameters(String)} and early hints are not disabled by {@link MojarraContextParam#DISABLE_EARLY_HINTS}.
*/
@SuppressWarnings("unchecked")
private void addEarlyHintIfPossibleAndNecessary(String encodedURL) {
FacesContext context = FacesContext.getCurrentInstance();

if (!context.getApplication().getResourceHandler().isResourceURL(encodedURL)) {
if (!context.getApplication().getResourceHandler().isResourceURL(encodedURL) || MojarraContextParam.DISABLE_EARLY_HINTS.isEnabled(servletContext)) {
return;
}

Expand All @@ -1078,30 +1103,85 @@ private void addEarlyHintIfPossibleAndNecessary(String encodedURL) {
}

if (response instanceof HttpServletResponse hres && !hres.isCommitted()) {
Set<String> resourceUrls = (Set<String>) context.getAttributes().computeIfAbsent(EARLY_HINTS_RESOURCE_URLS_KEY_NAME, k -> new HashSet<>());
Map<String, String> preloads = (Map<String, String>) context.getAttributes()
.computeIfAbsent(EARLY_HINTS_RESOURCE_URLS_KEY_NAME, k -> new HashMap<>());

if (resourceUrls.add(encodedURL)) {
hres.addHeader("Link", "<" + encodedURL + ">;rel=preload");
if (!preloads.containsKey(encodedURL)) {
String preloadParameters = getPreloadParameters(encodedURL);
preloads.put(encodedURL, preloadParameters != null ? preloadParameters : NO_PRELOAD);

if (preloadParameters != null) {
hres.addHeader("Link", "<" + encodedURL + ">;rel=preload" + preloadParameters);
}
}
}
}

/**
* Returns the {@code Link} parameters which give a preload of the given resource URL its destination, or {@code null} when the resource is not worth a
* preload. A preload without a matching {@code as} destination is never used by the browser, and a font is always fetched in CORS mode, so its preload must
* be too.
*/
private String getPreloadParameters(String resourceURL) {
String resourceName = Util.removeQueryString(resourceURL.substring(resourceURL.indexOf(RESOURCE_IDENTIFIER) + RESOURCE_IDENTIFIER.length()));
int pathParameters = resourceName.indexOf(';');

if (pathParameters != -1) {
resourceName = resourceName.substring(0, pathParameters);
}

String mappingSuffix = getResourceMappingSuffix();

if (mappingSuffix != null && resourceName.endsWith(mappingSuffix)) {
resourceName = resourceName.substring(0, resourceName.length() - mappingSuffix.length());
}

String extension = Util.fileExtension(resourceName);
return extension != null ? PRELOAD_PARAMETERS.get(extension.toLowerCase(ROOT)) : null;
}

/**
* Returns the suffix which the {@link jakarta.faces.webapp.FacesServlet} mapping appends to a resource URL, or {@code null} when it appends none. The
* mapping is selected the same way as {@link jakarta.faces.application.Resource#getRequestPath()} does.
*/
private String getResourceMappingSuffix() {
HttpServletMapping mapping = ((HttpServletRequest) request).getHttpServletMapping();

if (mapping != null && mapping.getMappingMatch() == EXACT) {
mapping = Util.getFirstWildCardMappingToFacesServlet(this);
}

return mapping != null && mapping.getMappingMatch() == EXTENSION ? mapping.getPattern().substring(1) : null;
}

/**
* Sends a single HTTP 103 (Early Hints) interim response with the {@code Link} preload headers collected so far, provided the response is not yet
* committed. Invoked once, right after the head resources have been rendered, so the client can start fetching the render-blocking resources while the body
* is still being rendered.
* is still being rendered. It is only sent over HTTP/2 or later, because an HTTP/1.1 client or intermediary may take it for the final response, as warned
* in section 3 of RFC 8297. The {@code Link} headers stay on the final response regardless.
*/
public static void sendEarlyHints(FacesContext context) {
Object resourceUrls = context.getAttributes().get(EARLY_HINTS_RESOURCE_URLS_KEY_NAME);
ExternalContext externalContext = context.getExternalContext();
Object preloads = context.getAttributes().get(EARLY_HINTS_RESOURCE_URLS_KEY_NAME);

if (
resourceUrls instanceof Set<?> set && !set.isEmpty() && context.getExternalContext().getResponse() instanceof HttpServletResponse hres
preloads instanceof Map<?, ?> map && map.values().stream().anyMatch(not(NO_PRELOAD::equals))
&& externalContext.getResponse() instanceof HttpServletResponse hres
&& !hres.isCommitted()
&& isHttp2OrLater(((HttpServletRequest) externalContext.getRequest()).getProtocol())
) {
hres.sendEarlyHints();
}
}

/**
* @return whether the protocol, formatted as {@code HTTP/major.minor} as per {@link ServletRequest#getProtocol()}, is HTTP/2 or later.
*/
private static boolean isHttp2OrLater(String protocol) {
return protocol != null && protocol.startsWith(HTTP_PROTOCOL_PREFIX) && protocol.length() > HTTP_PROTOCOL_PREFIX.length()
&& protocol.charAt(HTTP_PROTOCOL_PREFIX.length()) >= HTTP_2_MAJOR_VERSION;
}

private void doLastPhaseActions(FacesContext context, boolean outgoingResponseIsRedirect) {
Map<Object, Object> attrs = context.getAttributes();
try {
Expand Down Expand Up @@ -1129,7 +1209,7 @@ private static String getFallbackMimeType(String file) {
* @param path Relative path to be normalized
* @return normalized path
*/
public static String normalize(String path) {
private static String normalize(String path) {
return normalize(path, true);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,22 @@

package org.glassfish.mojarra.context;

import static org.glassfish.mojarra.RIConstants.EARLY_HINTS_RESOURCE_URLS_KEY_NAME;
import static org.glassfish.mojarra.RIConstants.FACES_SERVLET_MAPPINGS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.IOException;
Expand All @@ -36,6 +42,7 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.function.Consumer;
Expand All @@ -46,9 +53,12 @@
import jakarta.faces.push.PushContext;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletMapping;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.MappingMatch;

import org.glassfish.mojarra.config.MojarraContextParam;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
Expand All @@ -61,6 +71,7 @@
public class ExternalContextImplTest {

private static final String WEBSOCKET_PATH = "/ctx/jakarta.faces.push/x";
private static final String LINK = "Link";

/**
* Test getRequestCookieMap method.
Expand Down Expand Up @@ -348,6 +359,124 @@ private String encodeWebsocketURL(String requestURL, String configuredPort, Stri
}
}

/**
* Test that encodeResourceURL announces a stylesheet, script or font in a {@code Link} preload header with the {@code as} destination the browser needs to
* use the preload, whatever the FacesServlet mapping appends to the resource URL, and that it announces no other resource.
*/
@ParameterizedTest
@CsvSource(
delimiter = '|', value = {
"/ctx/jakarta.faces.resource/css/style.css.xhtml?ln=lib&v=1 | EXTENSION | *.xhtml | ;as=style",
"/ctx/faces/jakarta.faces.resource/js/script.js?ln=lib | PATH | /faces/* | ;as=script",
"/ctx/jakarta.faces.resource/css/style.css.jsf | EXACT | /page.xhtml | ;as=style",
"/ctx/jakarta.faces.resource/fonts/font.woff2.xhtml | EXTENSION | *.xhtml | ;as=font;crossorigin",
"/ctx/jakarta.faces.resource/fonts/font.ttf.xhtml | EXTENSION | *.xhtml | ;as=font;crossorigin",
"/ctx/jakarta.faces.resource/css/STYLE.CSS.xhtml | EXTENSION | *.xhtml | ;as=style",
"/ctx/jakarta.faces.resource/css/style.css.xhtml;jsessionid=abc?ln=lib | EXTENSION | *.xhtml | ;as=style",
"/ctx/jakarta.faces.resource/img/logo.png.xhtml | EXTENSION | *.xhtml | ",
"/ctx/jakarta.faces.resource/js/script.xhtml | EXTENSION | *.xhtml | ",
"/ctx/jakarta.faces.resource/css/style.css | EXTENSION | *.xhtml | ;as=style",
"/ctx/jakarta.faces.resource/css/style.css | | | ;as=style",
}
)
public void testEncodeResourceURLAddsPreloadWithDestination(String url, MappingMatch mappingMatch, String pattern, String preloadParameters) {
HttpServletResponse response = mock(HttpServletResponse.class);
Map<Object, Object> attributes = encodeResourceURL(url, mappingMatch, pattern, null, response);

if (preloadParameters != null) {
verify(response).addHeader(LINK, "<" + url + ">;rel=preload" + preloadParameters);
}
else {
verify(response, never()).addHeader(eq(LINK), anyString());
}

assertEquals(preloadParameters != null ? preloadParameters : "", earlyHintsPreloads(attributes).get(url));
}

/**
* Test that encodeResourceURL announces nothing when {@code org.glassfish.mojarra.disableEarlyHints} is set, so no 103 is sent either.
*/
@Test
public void testEncodeResourceURLAddsNoPreloadWhenEarlyHintsDisabled() {
HttpServletResponse response = mock(HttpServletResponse.class);
Map<Object, Object> attributes = encodeResourceURL(
"/ctx/jakarta.faces.resource/css/style.css.xhtml", MappingMatch.EXTENSION, "*.xhtml", "true", response
);

verify(response, never()).addHeader(eq(LINK), anyString());
assertTrue(earlyHintsPreloads(attributes).isEmpty());
}

/**
* Test that sendEarlyHints sends the 103 only over HTTP/2 or later, because an HTTP/1.x client or intermediary may take it for the final response.
*/
@ParameterizedTest
@CsvSource({ "HTTP/1.0, false", "HTTP/1.1, false", "HTTP/2.0, true", "HTTP/3, true", "HTTP/, false", ", false" })
public void testSendEarlyHintsOnlyOverHttp2OrLater(String protocol, boolean expected) {
HttpServletResponse response = sendEarlyHints(protocol, Map.of("/ctx/jakarta.faces.resource/style.css", ";as=style"));

verify(response, times(expected ? 1 : 0)).sendEarlyHints();
}

/**
* Test that sendEarlyHints sends no 103 when none of the resource URLs encoded so far was worth a preload.
*/
@Test
public void testSendEarlyHintsNotWithoutPreloads() {
HttpServletResponse response = sendEarlyHints("HTTP/2.0", Map.of("/ctx/jakarta.faces.resource/logo.png", ""));

verify(response, never()).sendEarlyHints();
}

private HttpServletResponse sendEarlyHints(String protocol, Map<String, String> preloads) {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
when(request.getProtocol()).thenReturn(protocol);
FacesContext context = mock(FacesContext.class, RETURNS_DEEP_STUBS);
when(context.getAttributes()).thenReturn(new HashMap<>(Map.of(EARLY_HINTS_RESOURCE_URLS_KEY_NAME, preloads)));
when(context.getExternalContext().getRequest()).thenReturn(request);
when(context.getExternalContext().getResponse()).thenReturn(response);

ExternalContextImpl.sendEarlyHints(context);

return response;
}

private Map<Object, Object> encodeResourceURL(
String url, MappingMatch mappingMatch, String pattern, String disableEarlyHints, HttpServletResponse response
)
{
ServletContext servletContext = mockServletContext();
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletMapping mapping = mappingMatch != null ? mock(HttpServletMapping.class) : null;
when(servletContext.getInitParameter(MojarraContextParam.DISABLE_EARLY_HINTS.getName())).thenReturn(disableEarlyHints);
when(request.getHttpServletMapping()).thenReturn(mapping);

if (mapping != null) {
when(servletContext.getAttribute(FACES_SERVLET_MAPPINGS)).thenReturn(List.of(pattern, "*.jsf"));
when(mapping.getMappingMatch()).thenReturn(mappingMatch);
when(mapping.getPattern()).thenReturn(pattern);
}
when(response.encodeURL(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
Map<Object, Object> attributes = new HashMap<>();

try (MockedStatic<FacesContext> facesContext = mockStatic(FacesContext.class)) {
FacesContext context = mock(FacesContext.class, RETURNS_DEEP_STUBS);
when(context.getExternalContext().getContext()).thenReturn(servletContext);
when(context.getApplication().getResourceHandler().isResourceURL(anyString())).thenReturn(true);
when(context.getAttributes()).thenReturn(attributes);
facesContext.when(FacesContext::getCurrentInstance).thenReturn(context);
new ExternalContextImpl(servletContext, request, response).encodeResourceURL(url);
}

return attributes;
}

@SuppressWarnings("unchecked")
private static Map<String, String> earlyHintsPreloads(Map<Object, Object> attributes) {
return (Map<String, String>) attributes.getOrDefault(EARLY_HINTS_RESOURCE_URLS_KEY_NAME, Map.of());
}

private static class FlushRecordingWriter extends StringWriter {

private boolean flushed;
Expand Down
Loading
Loading