com.sun.faces.application.resource.ResourceHelper Maven / Gradle / Ivy
Show all versions of jakarta.faces Show documentation
/*
* Copyright (c) 2023 Contributors to Eclipse Foundation.
* Copyright (c) 1997, 2020 Oracle and/or its affiliates. All rights reserved.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0, which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the
* Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
* version 2 with the GNU Classpath Exception, which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
*/
package com.sun.faces.application.resource;
import static jakarta.faces.application.ResourceHandler.FACES_SCRIPT_LIBRARY_NAME;
import static jakarta.faces.application.ResourceHandler.FACES_SCRIPT_RESOURCE_NAME;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;
import com.sun.faces.renderkit.html_basic.StylesheetRenderer;
import com.sun.faces.util.FacesLogger;
import com.sun.faces.util.MessageUtils;
import com.sun.faces.util.Util;
import jakarta.el.ELContext;
import jakarta.el.ELException;
import jakarta.el.ValueExpression;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.servlet.http.HttpServletResponse;
/**
* Implementations of this class contain the knowledge for finding and serving web application resources.
*
* @since 2.0
*/
public abstract class ResourceHelper {
private static final Logger LOGGER = FacesLogger.RESOURCE.getLogger();
/**
* This pattern represents a version for a library. Examples: 1_1 1_11 1_11_1 1_11_1_2
*/
private static final Pattern LIBRARY_VERSION_PATTERN = Pattern.compile("^(\\d+)(_\\d+)+");
/**
* This pattern represents a version for a resource. Examples: 1_1.jpg 1_11.323 1_11_1.gif 1_11_1_2.txt 1_1 1_11 1_11_1
* 1_11_1_2
*
* The extension is optional.
*/
private static final Pattern RESOURCE_VERSION_PATTERN = Pattern.compile("^((?:\\d+)(?:_\\d+)+)[\\.]?(\\w+)?");
/**
* Arbitrary file name to write the compressed bits to.
*/
private static final String COMPRESSED_CONTENT_FILENAME = "compressed-content";
private static final String[] EL_CONTENT_TYPES = { StylesheetRenderer.DEFAULT_CONTENT_TYPE, };
static {
Arrays.sort(EL_CONTENT_TYPES);
}
// ---------------------------------------------------------- Public Methods
/**
* @return the base path in which resources will be stored
*/
public abstract String getBaseResourcePath();
public abstract String getBaseContractsPath();
protected String getBasePath(String contract) {
if (contract == null) {
return getBaseResourcePath();
}
return getBaseContractsPath() + '/' + contract;
}
/**
*
* If the resource is compressable, return an InputStream to read the compressed content, otherwise, call
* {@link #getNonCompressedInputStream(ResourceInfo, jakarta.faces.context.FacesContext)} to return the content of the
* original resource.
*
*
* Implementation Note: If any exception occurs trying to return a stream to the compressed content, log the exception,
* and instead try to return a stream to the original content.
*
*
* @param toStream the resource to obtain an InputStream to
* @param ctx the {@link jakarta.faces.context.FacesContext} for the current request
* @return an InputStream
to the resource, or null
if no resource is found
* @throws IOException if an error occurs obtaining the stream
*/
public InputStream getInputStream(ResourceInfo toStream, FacesContext ctx) throws IOException {
// PENDING(edburns): this is a sub-optimal implementation choice
// done in the interest of prototyping. It's never a good idea
// to do a switch statement based on the type of an object.
InputStream in = null;
if (toStream instanceof ClientResourceInfo) {
ClientResourceInfo resource = (ClientResourceInfo) toStream;
in = getInputStreamFromClientInfo(resource, ctx);
if (null == in) {
ClientResourceInfo resourceWithoutLocalePrefix = new ClientResourceInfo(resource, false);
in = getInputStreamFromClientInfo(resourceWithoutLocalePrefix, ctx);
if (null != in) {
resource.copy(resourceWithoutLocalePrefix);
}
}
}
// else {
// // PENDING(edburns): get the input stream from the facelet ResourceInfo.
// }
return in;
}
private InputStream getInputStreamFromClientInfo(ClientResourceInfo resource, FacesContext ctx) throws IOException {
InputStream in = null;
if (resource.isCompressable() && clientAcceptsCompression(ctx)) {
if (!resource.supportsEL()) {
try {
String path = resource.getCompressedPath();
in = new BufferedInputStream(new FileInputStream(path + File.separatorChar + COMPRESSED_CONTENT_FILENAME));
} catch (IOException ioe) {
if (LOGGER.isLoggable(Level.SEVERE)) {
LOGGER.log(Level.SEVERE, ioe.getMessage(), ioe);
}
// return null so that the override code will try to serve
// the non-compressed content
in = null;
}
} else {
byte[] buf = new byte[512];
try (InputStream temp = new BufferedInputStream(new ELEvaluatingInputStream(ctx, resource, getNonCompressedInputStream(resource, ctx)));
ByteArrayOutputStream baos = new ByteArrayOutputStream(512);
OutputStream out = new GZIPOutputStream(baos);) {
// using dynamic compression here
for (int read = temp.read(buf); read != -1; read = temp.read(buf)) {
out.write(buf, 0, read);
}
in = new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray()));
} catch (IOException ioe) {
if (LOGGER.isLoggable(Level.SEVERE)) {
LOGGER.log(Level.SEVERE, ioe.getMessage(), ioe);
}
}
}
}
if (in == null) {
if (resource.supportsEL()) {
return new BufferedInputStream(new ELEvaluatingInputStream(ctx, resource, getNonCompressedInputStream(resource, ctx)));
} else {
in = getNonCompressedInputStream(resource, ctx);
}
}
return in;
}
/**
* @param resource the resource to obtain a URL reference to
* @param ctx the {@link FacesContext} for the current request
* @return a URL to the specified resource, otherwise null
if no resource is found
*/
public abstract URL getURL(ResourceInfo resource, FacesContext ctx);
/**
* Search for the specified library/localPrefix combination in an implementation dependent manner.
*
* @param libraryName the name of the library
* @param localePrefix the logicial identifier for a locale specific library. if no localePrefix is configured, pass
* null
* @param contract the name of the contract
* @param ctx the {@link jakarta.faces.context.FacesContext} for the current request @return a {@link LibraryInfo} if a
* matching library based off the inputs can be found, otherwise returns null
* @return library info
*/
public abstract LibraryInfo findLibrary(String libraryName, String localePrefix, String contract, FacesContext ctx);
/**
*
* Search for the specified resource based in the library/localePrefix/resourceName combination in an implementation
* dependent manner.
*
*
* If the resource is found, and is compressable, call
* {@link #handleCompression(com.sun.faces.application.resource.ClientResourceInfo)} to compress the content.
*
*
* @param library the library this resource should be a part of. If the the resource that is being searched for isn't
* part of a library, then pass null
* @param resourceName the name of the resource that is being searched for
* @param localePrefix the logicial identifier for a locale specific library. if no localePrefix is configured, pass
* null
* @param compressable true
if the resource can be compressed
* @param ctx the {@link jakarta.faces.context.FacesContext} for the current request
* @return a {@link ResourceInfo} if a matching resource based off the inputs can be found, otherwise returns
* null
*/
public abstract ResourceInfo findResource(LibraryInfo library, String resourceName, String localePrefix, boolean compressable, FacesContext ctx);
/**
*
* The default implementation of this method will call through to
* {@link ResourceHelper#getURL(ResourceInfo, jakarta.faces.context.FacesContext)} and leverage the URL to obtain the
* date information of the resource and return the value of URLConnection.getLastModified()
*
*
* @param resource the resource in question
* @param ctx the {@link FacesContext} for the current request
* @return the date of the resource in milliseconds (since epoch), or 0
if the date cannot be determined
*/
public long getLastModified(ResourceInfo resource, FacesContext ctx) {
URL url = getURL(resource, ctx);
// resource may have been deleted.
if (url == null) {
return 0;
}
return Util.getLastModified(url);
}
// ------------------------------------------------------- Protected Methods
/**
* If a {@link ResourceInfo} is not compressable,
* {@link #getInputStream(ResourceInfo, jakarta.faces.context.FacesContext)} will call this method to return a stream to
* the actual resource.
*
* @param info the resource to obtain an InputStream to
* @param ctx the {@link FacesContext} for the current request
* @return an InputStream to the resource
* @throws IOException if an error occurs obtaining the stream
*/
protected abstract InputStream getNonCompressedInputStream(ResourceInfo info, FacesContext ctx) throws IOException;
/**
*
* Given a collection of path names:
*
*
*
* 1.1, scripts, images, 1.2
*
*
* this method will pick out the directories that represent a library or resource version and return the latest version
* found, if any.
*
*
* @param resourcePaths a collection of paths (consisting of single path elements)
* @param isResource true
if the version being looked up is for a reource, otherwise, pass
* false
if the version is a library version
* @return the latest version or if no version can be detected, otherwise this method returns null
*/
protected VersionInfo getVersion(Collection resourcePaths, boolean isResource) {
List versionedPaths = new ArrayList<>(resourcePaths.size());
for (String p : resourcePaths) {
VersionInfo vp = getVersion(p, isResource);
if (vp != null) {
versionedPaths.add(vp);
}
}
VersionInfo version = null;
if (!versionedPaths.isEmpty()) {
Collections.sort(versionedPaths);
version = versionedPaths.get(versionedPaths.size() - 1);
}
return version;
}
/**
* Utility method to compress the content of the original resource to the temporary directory specified by
* {@link com.sun.faces.application.resource.ClientResourceInfo#getCompressedPath()}.
*
* @param info the resource to be compressed
* @return true
if compression succeeded and the compressed result is smaller than the original
* content, otherwise false
* @throws IOException if any error occur reading/writing
*/
protected boolean compressContent(ClientResourceInfo info) throws IOException {
InputStream source = null;
OutputStream dest = null;
try {
URL url = info.getHelper().getURL(info, FacesContext.getCurrentInstance());
URLConnection conn = url.openConnection();
conn.setUseCaches(false);
conn.connect();
source = conn.getInputStream();
byte[] buf = new byte[512];
ByteArrayOutputStream baos = new ByteArrayOutputStream();
dest = new GZIPOutputStream(baos);
int totalRead = 0;
for (int len = source.read(buf); len != -1; len = source.read(buf)) {
dest.write(buf, 0, len);
totalRead += len;
}
dest.flush();
try {
dest.close();
} catch (IOException ioe) {
if (LOGGER.isLoggable(Level.FINEST)) {
LOGGER.log(Level.FINEST, "Closing stream", ioe);
}
}
if (baos.size() < totalRead) {
String outputFile = info.getCompressedPath() + File.separatorChar + COMPRESSED_CONTENT_FILENAME;
dest = new FileOutputStream(outputFile);
dest.write(baos.toByteArray());
dest.flush();
return true;
}
return false;
} finally {
if (source != null) {
try {
source.close();
} catch (IOException ioe) {
if (LOGGER.isLoggable(Level.FINEST)) {
LOGGER.log(Level.FINEST, "Closing stream", ioe);
}
}
}
if (dest != null) {
try {
dest.close();
} catch (IOException ioe) {
if (LOGGER.isLoggable(Level.FINEST)) {
LOGGER.log(Level.FINEST, "Closing stream", ioe);
}
}
}
}
}
/**
*
* This method attempt to verify that the user agent can accept a gzip encoded response by interrogating the
* Accept-Encoding
requester header. If it is determined safe to send a gzip encoded response, send the
* Content-Encoding
header with a value of gzip
.
*
*
*
* See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html RFC 2616, sec. 14 for details on the
* accept-encoding header.
*
*
*
* Implementation Note: It is safe to cast to a HttpServletResponse
as this method will only be called when
* handling a resource request. Resource serving is outside of the Faces and Portlet lifecycle.
*
*
* @param ctx the {@link FacesContext} for the current request
* @return true
if compressed content can be sent to the client, otherwise false
*/
protected boolean clientAcceptsCompression(FacesContext ctx) {
ExternalContext extCtx = ctx.getExternalContext();
Object response = extCtx.getResponse();
if (response instanceof HttpServletResponse) {
String[] values = extCtx.getRequestHeaderValuesMap().get("accept-encoding");
boolean gzipFound = false;
for (String value : values) {
if (value.contains("gzip;q=0")) {
// gzip compression not accepted by the user-agent
return false;
}
if (value.contains("gzip")) {
// gzip compression explicitly listed as supported
// by the user agent. Break here as we don't need to continue.
gzipFound = true;
break;
}
if (value.contains("*") && !value.contains("*;q=0,") && !value.endsWith("*;q=0")) {
// gzip not explictly listed, but client sent *
// meaning gzip is implicitly acceptable
// keep looping to ensure we don't come across a
// *;q=0 value.
gzipFound = true;
}
}
if (gzipFound) {
((HttpServletResponse) response).setHeader("Content-Encoding", "gzip");
return true;
}
}
return false;
}
/**
*
* Utility method to peform the necessary actions to compress content.
*
*
*
* Implmentation Note: If an exception occurs while compressing the content, log the IOException and rebuilt the
* {@link ResourceInfo} as non-compressable.
*
*
* @param resource the resource to compression
* @return the ResourceInfo after compression is complete. If compression was successful, this should be the same
* instance. If compression was not successful, it will be a different instance than what was passed.
*/
protected ClientResourceInfo handleCompression(ClientResourceInfo resource) {
try {
if (!resource.supportsEL() && !compressContent(resource)) {
resource = rebuildAsNonCompressed(resource);
}
} catch (IOException ioe) {
if (LOGGER.isLoggable(Level.SEVERE)) {
LOGGER.log(Level.SEVERE, ioe.getMessage(), ioe);
}
resource = rebuildAsNonCompressed(resource);
}
return resource;
}
protected boolean resourceSupportsEL(String resourceName, String libraryName, FacesContext ctx) {
ExternalContext extContext = ctx.getExternalContext();
String contentType = extContext.getMimeType(resourceName);
boolean result = contentType != null && Arrays.binarySearch(EL_CONTENT_TYPES, contentType) >= 0 || null != resourceName && null != libraryName
&& FACES_SCRIPT_LIBRARY_NAME.equals(libraryName) && FACES_SCRIPT_RESOURCE_NAME.equals(resourceName);
return result;
}
/**
* @param s input String
* @return the String without a leading slash if it has one.
*/
protected String trimLeadingSlash(String s) {
if (s.charAt(0) == '/') {
return s.substring(1);
} else {
return s;
}
}
// --------------------------------------------------------- Private Methods
private ClientResourceInfo rebuildAsNonCompressed(ClientResourceInfo resource) {
LibraryInfo library = resource.getLibraryInfo();
ClientResourceInfo ret;
if (library != null) {
ret = new ClientResourceInfo(resource.library, resource.contract, resource.name, resource.version, false, resource.supportsEL, resource.isDevStage,
resource.cacheTimestamp);
} else {
ret = new ClientResourceInfo(resource.contract, resource.name, resource.version, resource.localePrefix, this, false, resource.supportsEL,
resource.isDevStage, resource.cacheTimestamp);
}
return ret;
}
/**
* @param pathElement the path element to verify
* @param isResource true
if the version being looked up is for a reource, otherwise, pass
* false
if the version is a library version
* @return true
if this path element represents a version (i.e. matches {@link #LIBRARY_VERSION_PATTERN}),
* otherwise returns false
*/
private VersionInfo getVersion(String pathElement, boolean isResource) {
Map appMap = FacesContext.getCurrentInstance().getExternalContext().getApplicationMap();
String[] pathElements = Util.split(appMap, pathElement, "/");
String path = pathElements[pathElements.length - 1];
String extension = null;
if (isResource) {
Matcher m = RESOURCE_VERSION_PATTERN.matcher(path);
return m.matches() ? new VersionInfo(m.group(1), m.group(2)) : null;
} else {
return LIBRARY_VERSION_PATTERN.matcher(path).matches() ? new VersionInfo(path, extension) : null;
}
}
// ---------------------------------------------------------- Nested Classes
private static final class ELEvaluatingInputStream extends InputStream {
// Premature optimization is the root of all evil. Blah blah.
private final List buf = new ArrayList<>(1024);
private boolean failedExpressionTest = false;
private boolean writingExpression = false;
private final InputStream inner;
private final ClientResourceInfo info;
private final FacesContext ctx;
private boolean expressionEvaluated;
private boolean endOfStreamReached;
// ---------------------------------------------------- Constructors
public ELEvaluatingInputStream(FacesContext ctx, ClientResourceInfo info, InputStream inner) {
this.inner = inner;
this.info = info;
this.ctx = ctx;
}
// ------------------------------------------------ Methods from InputStream
@Override
public int read() throws IOException {
if (null == inner) {
return -1;
}
int i;
char c;
if (failedExpressionTest) {
i = nextRead;
nextRead = -1;
failedExpressionTest = false;
} else if (writingExpression) {
if (0 < buf.size()) {
i = buf.remove(0);
} else {
writingExpression = false;
i = inner.read();
}
} else {
// Read a character.
i = inner.read();
c = (char) i;
// If it *might* be an expression...
if (c == '#') {
// read another character.
i = inner.read();
c = (char) i;
// If it's '{', assume we have an expression.
if (c == '{') {
// read it into the buffer, and evaluate it into the
// same buffer.
readExpressionIntoBufferAndEvaluateIntoBuffer();
// set the flag so that we need to return content
// from the buffer.
writingExpression = true;
// Make sure to swallow the '{'.
i = this.read();
} else {
// It's not an expression, we need to return '#',
i = '#';
// then return whatever we just read, on the
// *next* read;
nextRead = c;
failedExpressionTest = true;
}
}
}
if (i == -1) {
endOfStreamReached = true;
}
return i;
}
private int nextRead = -1;
private void readExpressionIntoBufferAndEvaluateIntoBuffer() throws IOException {
int i;
char c;
do {
i = inner.read();
c = (char) i;
if (c == '}') {
evaluateExpressionIntoBuffer();
} else {
buf.add(i);
}
} while (c != '}' && i != -1);
}
/*
* At this point, we know that getBuf() returns a List that contains the bytes of the expression. Turn it into
* a String, turn the String into a ValueExpression, evaluate it, store the toString() of it in expressionResult;
*/
private void evaluateExpressionIntoBuffer() {
char[] chars = new char[buf.size()];
for (int i = 0, len = buf.size(); i < len; i++) {
chars[i] = (char) (int) buf.get(i);
}
String expressionBody = new String(chars);
int colon;
// If this expression contains a ":"
if (-1 != (colon = expressionBody.indexOf(":"))) {
// Make sure it contains only one ":"
if (!isPropertyValid(expressionBody)) {
String message = MessageUtils.getExceptionMessageString(MessageUtils.INVALID_RESOURCE_FORMAT_COLON_ERROR, expressionBody);
throw new ELException(message);
}
Map appMap = FacesContext.getCurrentInstance().getExternalContext().getApplicationMap();
String[] parts = Util.split(appMap, expressionBody, ":");
if (null == parts[0] || null == parts[1]) {
String message = MessageUtils.getExceptionMessageString(MessageUtils.INVALID_RESOURCE_FORMAT_NO_LIBRARY_NAME_ERROR, expressionBody);
throw new ELException(message);
}
try {
int mark = parts[0].indexOf("[") + 2;
char quoteMark = parts[0].charAt(mark - 1);
parts[0] = parts[0].substring(mark, colon);
if (parts[0].equals("this")) {
LibraryInfo libInfo = info.getLibraryInfo();
if (null != libInfo) {
parts[0] = libInfo.getName();
} else if (null != info.getContract()) {
parts[0] = info.getContract();
} else {
throw new NullPointerException("Resource expression is not a library or resource library contract");
}
mark = parts[1].indexOf("]") - 1;
parts[1] = parts[1].substring(0, mark);
expressionBody = "resource[" + quoteMark + parts[0] + ":" + parts[1] + quoteMark + "]";
}
} catch (Exception e) {
String message = MessageUtils.getExceptionMessageString(MessageUtils.INVALID_RESOURCE_FORMAT_ERROR, expressionBody);
throw new ELException(message);
}
}
ELContext elContext = ctx.getELContext();
expressionEvaluated = true;
ValueExpression ve = ctx.getApplication().getExpressionFactory().createValueExpression(elContext, "#{" + expressionBody + "}", String.class);
Object value = ve.getValue(elContext);
String expressionResult = value != null ? value.toString() : "";
buf.clear();
for (int i = 0, len = expressionResult.length(); i < len; i++) {
buf.add((int) expressionResult.charAt(i));
}
}
@Override
public void close() throws IOException {
if (endOfStreamReached && !expressionEvaluated) {
info.disableEL();
}
inner.close();
super.close();
}
private boolean isPropertyValid(String property) {
int idx = property.indexOf(':');
return property.indexOf(':', idx + 1) == -1;
}
} // END ELEvaluatingInputStream
protected List getLocalizedPaths(String path, FacesContext ctx) {
Locale loc = (ctx != null && ctx.getViewRoot() != null) ? ctx.getViewRoot().getLocale() : null;
if (!path.endsWith(".properties") || loc == null) {
return Collections.singletonList(path);
}
List list = new ArrayList<>();
String base = path.substring(0, path.lastIndexOf(".properties"));
if (!loc.getVariant().isEmpty()) {
list.add(String.format("%s_%s_%s_%s.properties", base, loc.getLanguage(), loc.getCountry(), loc.getVariant()));
}
if (!loc.getCountry().isEmpty()) {
list.add(String.format("%s_%s_%s.properties", base, loc.getLanguage(), loc.getCountry()));
}
if (!loc.getLanguage().isEmpty()) {
list.add(String.format("%s_%s.properties", base, loc.getLanguage()));
}
list.add(path);
return list;
}
}