/*
* Copyright 2017 OmniFaces
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.omnifaces.resourcehandler;
import static java.lang.String.format;
import static java.lang.reflect.Modifier.isPublic;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonMap;
import static java.util.Collections.unmodifiableMap;
import static java.util.logging.Level.FINE;
import static org.omnifaces.util.Beans.getManager;
import static org.omnifaces.util.BeansLocal.getReference;
import static org.omnifaces.util.Faces.getContext;
import static org.omnifaces.util.Faces.getExternalContext;
import static org.omnifaces.util.Servlets.toQueryString;
import static org.omnifaces.util.Utils.coalesce;
import static org.omnifaces.util.Utils.isEmpty;
import static org.omnifaces.util.Utils.isNumber;
import static org.omnifaces.util.Utils.isOneAnnotationPresent;
import static org.omnifaces.util.Utils.isOneInstanceOf;
import static org.omnifaces.util.Utils.isOneOf;
import static org.omnifaces.util.Utils.toByteArray;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;
import javax.el.ValueExpression;
import javax.enterprise.inject.Any;
import javax.enterprise.inject.spi.Bean;
import javax.enterprise.inject.spi.BeanManager;
import javax.enterprise.util.AnnotationLiteral;
import javax.faces.FacesException;
import javax.faces.application.Application;
import javax.faces.application.Resource;
import javax.faces.component.UIComponent;
import javax.faces.component.UIOutput;
import javax.faces.context.FacesContext;
import javax.faces.convert.Converter;
import javax.xml.bind.DatatypeConverter;
import org.omnifaces.cdi.GraphicImageBean;
import org.omnifaces.el.ExpressionInspector;
import org.omnifaces.el.MethodReference;
/**
* <p>
* This {@link Resource} implementation is used by the {@link org.omnifaces.component.output.GraphicImage} component.
*
* @author Bauke Scholtz
* @since 2.0
*/
public class GraphicResource extends DynamicResource {
// Constants ------------------------------------------------------------------------------------------------------
private static final Logger logger = Logger.getLogger(GraphicResource.class.getName());
private static final String DEFAULT_CONTENT_TYPE = "image";
private static final Map<String, String> CONTENT_TYPES_BY_BASE64_HEADER = createContentTypesByBase64Header();
private static final Map<String, MethodReference> ALLOWED_METHODS = new ConcurrentHashMap<>();
private static final String[] EMPTY_PARAMS = new String[0];
@SuppressWarnings("unchecked")
private static final Class<? extends Annotation>[] REQUIRED_ANNOTATION_TYPES = new Class[] {
GraphicImageBean.class,
javax.faces.bean.ApplicationScoped.class,
javax.enterprise.context.ApplicationScoped.class
};
@SuppressWarnings("unchecked")
private static final Class<? extends Annotation>[] REQUIRED_RETURN_TYPES = new Class[] {
InputStream.class,
byte[].class
};
private static final AnnotationLiteral<Any> ANY = new AnnotationLiteral<Any>() {
private static final long serialVersionUID = 1L;
};
private static final String ERROR_MISSING_METHOD =
"@GraphicImageBean bean '%s' must have a method returning an InputStream or byte[].";
private static final String ERROR_INVALID_LASTMODIFIED =
"o:graphicImage 'lastModified' attribute must be an instance of Long or Date."
+ " Encountered an invalid value of '%s'.";
private static final String ERROR_INVALID_TYPE =
"o:graphicImage 'type' attribute must represent a valid file extension."
+ " Encountered an invalid value of '%s'.";
private static final String ERROR_UNKNOWN_METHOD =
"o:graphicImage 'value' attribute must refer an existing method."
+ " Encountered an unknown method of '%s'.";
private static final String ERROR_INVALID_SCOPE =
"o:graphicImage 'value' attribute must refer a @GraphicImageBean or @ApplicationScoped bean."
+ " Cannot find the right annotation on bean class '%s'.";
private static final String ERROR_INVALID_RETURNTYPE =
"o:graphicImage 'value' attribute must represent a method returning an InputStream or byte[]."
+ " Encountered an invalid return value of '%s'.";
private static final String ERROR_INVALID_PARAMS =
"o:graphicImage 'value' attribute must specify valid method parameters."
+ " Encountered invalid method parameters '%s'.";
// Variables ------------------------------------------------------------------------------------------------------
private String base64;
private String[] params;
// Constructors ---------------------------------------------------------------------------------------------------
/**
* Construct a new graphic resource which uses the given content as data URI.
* This constructor is called during render time of <code><o:graphicImage ... dataURI="true"></code>.
* @param content The graphic resource content, to be represented as data URI.
* @param contentType The graphic resource content type. If this is <code>null</code>, then it will be guessed
* based on the content type signature in the content header. So far, JPEG, PNG, GIF, ICO, SVG, BMP and TIFF are
* recognized. Else if this represents the file extension, then it will be resolved based on mime mappings.
*/
public GraphicResource(Object content, String contentType) {
super("", GraphicResourceHandler.LIBRARY_NAME, contentType);
base64 = convertToBase64(content);
if (contentType == null) {
setContentType(guessContentType(base64));
}
else if (!contentType.contains("/")) {
setContentType(getContentType("image." + contentType));
}
}
/**
* Construct a new graphic resource based on the given name, EL method parameters converted as string, and the
* "last modified" representation.
* This constructor is called during render time of <code><o:graphicImage value="..." dataURI="false"></code>
* and during handling the resource request by {@link GraphicResourceHandler}.
* @param name The graphic resource name, usually representing the base and method of EL method expression.
* @param params The graphic resource method parameters.
* @param lastModified The "last modified" representation of the graphic resource, can be {@link Long} or
* {@link Date}, or otherwise an attempt will be made to parse it as {@link Long}.
* @throws IllegalArgumentException If "last modified" can not be parsed to a timestamp.
*/
public GraphicResource(String name, String[] params, Object lastModified) {
super(name, GraphicResourceHandler.LIBRARY_NAME, getContentType(name));
this.params = coalesce(params, EMPTY_PARAMS);
if (lastModified instanceof Long) {
setLastModified((Long) lastModified);
}
else if (lastModified instanceof Date) {
setLastModified(((Date) lastModified).getTime());
}
else if (isNumber(String.valueOf(lastModified))) {
setLastModified(Long.valueOf(lastModified.toString()));
}
else if (lastModified != null) {
throw new IllegalArgumentException(format(ERROR_INVALID_LASTMODIFIED, lastModified));
}
}
/**
* Create a new graphic resource based on the given value expression.
* @param context The involved faces context.
* @param value The value expression representing content to create a new graphic resource for.
* @param type The image type, represented as file extension. E.g. "jpg", "png", "gif", "ico", "svg", "bmp",
* "tiff", etc.
* @param lastModified The "last modified" representation of the graphic resource, can be {@link Long} or
* {@link Date}, or otherwise an attempt will be made to parse it as {@link Long}.
* @return The new graphic resource.
* @throws IllegalArgumentException When the "value" attribute of the given component is absent or does not
* represent a method expression referring an existing method taking at least one argument. Or, when the "type"
* attribute does not represent a valid file extension (you can add unrecognized ones as
* <code><mime-mapping></code> in <code>web.xml</code>).
*/
public static GraphicResource create(FacesContext context, ValueExpression value, String type, Object lastModified) {
MethodReference methodReference = ExpressionInspector.getMethodReference(context.getELContext(), value);
Method beanMethod = methodReference.getMethod();
if (beanMethod == null) {
throw new IllegalArgumentException(format(ERROR_UNKNOWN_METHOD, value.getExpressionString()));
}
Class<?> beanClass = methodReference.getBase().getClass();
String name = getResourceBaseName(beanClass, beanMethod);
if (!ALLOWED_METHODS.containsKey(name)) { // No need to validate everytime when already known.
if (!isOneAnnotationPresent(beanClass, REQUIRED_ANNOTATION_TYPES)) {
throw new IllegalArgumentException(format(ERROR_INVALID_SCOPE, beanClass));
}
if (!isOneOf(beanMethod.getReturnType(), REQUIRED_RETURN_TYPES)) {
throw new IllegalArgumentException(format(ERROR_INVALID_RETURNTYPE, beanMethod.getReturnType()));
}
ALLOWED_METHODS.put(name, new MethodReference(methodReference.getBase(), beanMethod));
}
Object[] params = methodReference.getActualParameters();
String[] convertedParams = convertToStrings(context, params, beanMethod.getParameterTypes());
return new GraphicResource(name + (isEmpty(type) ? "" : "." + type), convertedParams, lastModified);
}
/**
* An override which either returns the data URI or appends the converted method parameters to the query string.
*/
@Override
public String getRequestPath() {
if (base64 != null) {
return "data:" + getContentType() + ";base64," + base64;
}
else {
String queryString = isEmpty(params) ? "" : ("&" + toQueryString(singletonMap("p", asList(params))));
return super.getRequestPath() + queryString;
}
}
@Override
public InputStream getInputStream() throws IOException {
MethodReference methodReference = ALLOWED_METHODS.get(getResourceName().split("\\.", 2)[0]);
Method method;
Object[] convertedParams;
try {
method = methodReference.getMethod();
convertedParams = convertToObjects(getContext(), params, method.getParameterTypes());
}
catch (Exception ignore) {
logger.log(FINE, "Ignoring thrown exception; this can only be a hacker attempt.", ignore);
return null; // I'd rather return 400 here, but JSF spec doesn't support it.
}
Object content;
try {
content = method.invoke(methodReference.getBase(), convertedParams);
}
catch (Exception e) {
throw new FacesException(e);
}
if (content instanceof InputStream) {
return (InputStream) content;
}
else if (content instanceof byte[]) {
return new ByteArrayInputStream((byte[]) content);
}
else {
return null;
}
}
// Helpers --------------------------------------------------------------------------------------------------------
/**
* Create mapping of content types by base64 header.
*/
private static Map<String, String> createContentTypesByBase64Header() {
Map<String, String> contentTypesByBase64Header = new HashMap<>();
contentTypesByBase64Header.put("/9j/", "image/jpeg");
contentTypesByBase64Header.put("iVBORw", "image/png");
contentTypesByBase64Header.put("R0lGOD", "image/gif");
contentTypesByBase64Header.put("AAABAA", "image/x-icon");
contentTypesByBase64Header.put("PD94bW", "image/svg+xml");
contentTypesByBase64Header.put("Qk0", "image/bmp");
contentTypesByBase64Header.put("SUkqAA", "image/tiff");
contentTypesByBase64Header.put("TU0AKg", "image/tiff");
return unmodifiableMap(contentTypesByBase64Header);
}
/**
* Register graphic image scoped beans discovered so far.
* @throws IllegalArgumentException When bean method is missing.
*/
public static void registerGraphicImageBeans() {
BeanManager beanManager = getManager();
for (Bean<?> bean : beanManager.getBeans(Object.class, ANY)) {
Class<?> beanClass = bean.getBeanClass();
if (!isOneAnnotationPresent(beanClass, GraphicImageBean.class)) {
continue;
}
Object instance = getReference(beanManager, bean);
boolean registered = false;
for (Method method : beanClass.getMethods()) {
if (isPublic(method.getModifiers()) && isOneInstanceOf(method.getReturnType(), REQUIRED_RETURN_TYPES)) {
String resourceBaseName = getResourceBaseName(beanClass, method);
MethodReference methodReference = new MethodReference(instance, method);
ALLOWED_METHODS.put(resourceBaseName, methodReference);
registered = true;
}
}
if (!registered) {
throw new IllegalArgumentException(format(ERROR_MISSING_METHOD, beanClass.getName()));
}
}
}
/**
* This must return an unique and URL-safe identifier of the bean+method without any periods.
*/
private static String getResourceBaseName(Class<?> beanClass, Method beanMethod) {
return beanClass.getSimpleName().replaceAll("\\W", "") + "_" + beanMethod.getName();
}
/**
* This must extract the content type from the resource name, if any, else return the default content type.
* @throws IllegalArgumentException When given type is unrecognized.
*/
private static String getContentType(String resourceName) {
if (!resourceName.contains(".")) {
return DEFAULT_CONTENT_TYPE;
}
String contentType = getExternalContext().getMimeType(resourceName);
if (contentType == null) {
throw new IllegalArgumentException(format(ERROR_INVALID_TYPE, resourceName.split("\\.", 2)[1]));
}
return contentType;
}
/**
* Guess the image content type based on given base64 encoded content for data URI.
*/
private static String guessContentType(String base64) {
for (Entry<String, String> contentTypeByBase64Header : CONTENT_TYPES_BY_BASE64_HEADER.entrySet()) {
if (base64.startsWith(contentTypeByBase64Header.getKey())) {
return contentTypeByBase64Header.getValue();
}
}
return DEFAULT_CONTENT_TYPE;
}
/**
* Convert the given resource content to base64 encoded string.
* @throws IllegalArgumentException When given content is unrecognized.
*/
private static String convertToBase64(Object content) {
byte[] bytes;
if (content instanceof InputStream) {
try {
bytes = toByteArray((InputStream) content);
}
catch (IOException e) {
throw new FacesException(e);
}
}
else if (content instanceof byte[]) {
bytes = (byte[]) content;
}
else {
throw new IllegalArgumentException(format(ERROR_INVALID_RETURNTYPE, content));
}
return DatatypeConverter.printBase64Binary(bytes);
}
/**
* Convert the given objects to strings using converters registered on given types.
* @throws IllegalArgumentException When the length of given params doesn't match those of given types.
*/
private static String[] convertToStrings(FacesContext context, Object[] values, Class<?>[] types) {
validateParamLength(values, types);
String[] strings = new String[values.length];
Application application = context.getApplication();
UIComponent dummyComponent = new UIOutput();
for (int i = 0; i < values.length; i++) {
Object value = values[i];
Converter converter = application.createConverter(types[i]);
strings[i] = (converter != null)
? converter.getAsString(context, dummyComponent, value)
: (value != null) ? value.toString() : "";
}
return strings;
}
/**
* Convert the given strings to objects using converters registered on given types.
* @throws IllegalArgumentException When the length of given params doesn't match those of given types.
*/
private static Object[] convertToObjects(FacesContext context, String[] values, Class<?>[] types) {
validateParamLength(values, types);
Object[] objects = new Object[values.length];
Application application = context.getApplication();
UIComponent dummyComponent = new UIOutput();
for (int i = 0; i < values.length; i++) {
String value = isEmpty(values[i]) ? null : values[i];
Converter converter = application.createConverter(types[i]);
objects[i] = (converter != null)
? converter.getAsObject(context, dummyComponent, value)
: value;
}
return objects;
}
private static void validateParamLength(Object[] params, Class<?>[] types) {
if (params.length != types.length) {
throw new IllegalArgumentException(format(ERROR_INVALID_PARAMS, Arrays.toString(params)));
}
}
}