/*
* Password Management Servlets (PWM)
* http://www.pwm-project.org
*
* Copyright (c) 2006-2009 Novell, Inc.
* Copyright (c) 2009-2017 The PWM Project
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package password.pwm.http.servlet.resource;
import com.github.benmanes.caffeine.cache.Cache;
import org.apache.commons.io.IOUtils;
import org.webjars.WebJarAssetLocator;
import password.pwm.PwmApplication;
import password.pwm.PwmConstants;
import password.pwm.config.PwmSetting;
import password.pwm.error.ErrorInformation;
import password.pwm.error.PwmError;
import password.pwm.error.PwmUnrecoverableException;
import password.pwm.http.HttpHeader;
import password.pwm.http.HttpMethod;
import password.pwm.http.PwmRequest;
import password.pwm.http.servlet.PwmServlet;
import password.pwm.svc.stats.EventRateMeter;
import password.pwm.svc.stats.Statistic;
import password.pwm.svc.stats.StatisticsManager;
import password.pwm.util.java.StringUtil;
import password.pwm.util.logging.PwmLogger;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.zip.GZIPOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
@WebServlet(
name="ResourceFileServlet",
urlPatterns = {
PwmConstants.URL_PREFIX_PUBLIC + "/resources/*"
}
)
public class ResourceFileServlet extends HttpServlet implements PwmServlet {
private static final PwmLogger LOGGER = PwmLogger.forClass(ResourceFileServlet.class);
public static final String RESOURCE_PATH = "/public/resources";
public static final String THEME_CSS_PATH = "/themes/%THEME%/style.css";
public static final String THEME_CSS_MOBILE_PATH = "/themes/%THEME%/mobileStyle.css";
public static final String THEME_CSS_CONFIG_PATH = "/themes/%THEME%/configStyle.css";
public static final String TOKEN_THEME = "%THEME%";
public static final String EMBED_THEME = "embed";
private static final String WEBJAR_BASE_FILE_PATH = "META-INF/resources/webjars";
private static final String WEBJAR_BASE_URL_PATH = RESOURCE_PATH + "/webjars/";
private static final Map<String,String> WEB_JAR_VERSION_MAP = Collections.unmodifiableMap(new HashMap<>(new WebJarAssetLocator().getWebJars()));
private static final Collection<String> WEB_JAR_ASSET_LIST = Collections.unmodifiableCollection(new ArrayList<>(new WebJarAssetLocator().getFullPathIndex().values()));
@Override
protected void doGet(final HttpServletRequest req, final HttpServletResponse resp)
throws ServletException, IOException
{
PwmRequest pwmRequest = null;
try {
pwmRequest = PwmRequest.forRequest(req, resp);
} catch (PwmUnrecoverableException e) {
LOGGER.error("unable to satisfy request using standard mechanism, reverting to raw resource server");
}
if (pwmRequest != null) {
try {
processAction(pwmRequest);
} catch (PwmUnrecoverableException e) {
LOGGER.error(pwmRequest,"error during resource servlet request processing: " + e.getMessage());
}
} else {
try {
rawRequestProcessor(req,resp);
} catch (PwmUnrecoverableException e) {
LOGGER.error("error serving raw resource request: " + e.getMessage());
}
}
}
private void rawRequestProcessor(final HttpServletRequest req, final HttpServletResponse resp)
throws IOException, PwmUnrecoverableException
{
final FileResource file = resolveRequestedFile(
req.getServletContext(),
figureRequestPathMinusContext(req),
ResourceServletConfiguration.defaultConfiguration()
);
if (file == null || !file.exists()) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
handleUncachedResponse(resp, file, false);
}
protected void processAction(final PwmRequest pwmRequest)
throws ServletException, IOException, PwmUnrecoverableException
{
if (pwmRequest.getMethod() != HttpMethod.GET) {
throw new PwmUnrecoverableException(new ErrorInformation(PwmError.ERROR_SERVICE_NOT_AVAILABLE,"unable to process resource request for request method " + pwmRequest.getMethod()));
}
final PwmApplication pwmApplication = pwmRequest.getPwmApplication();
final ResourceServletService resourceService = pwmApplication.getResourceServletService();
final ResourceServletConfiguration resourceConfiguration = resourceService.getResourceServletConfiguration();
final String requestURI = stripNonceFromURI(resourceConfiguration, figureRequestPathMinusContext(pwmRequest.getHttpServletRequest()));
try {
if ( handleEmbeddedURIs(pwmApplication, requestURI, pwmRequest.getPwmResponse().getHttpServletResponse(), resourceConfiguration)) {
return;
}
} catch (Exception e) {
LOGGER.error(pwmRequest, "unexpected error detecting/handling special request uri: " + e.getMessage());
}
final FileResource file;
try {
file = resolveRequestedFile(this.getServletContext(), requestURI, resourceConfiguration);
} catch (PwmUnrecoverableException e) {
pwmRequest.getPwmResponse().getHttpServletResponse().sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
try {
pwmRequest.debugHttpRequestToLog("returning HTTP 500 status");
} catch (PwmUnrecoverableException e2) { /* noop */ }
return;
}
if (file == null || !file.exists()) {
pwmRequest.getPwmResponse().getHttpServletResponse().sendError(HttpServletResponse.SC_NOT_FOUND);
try {
pwmRequest.debugHttpRequestToLog("returning HTTP 404 status");
} catch (PwmUnrecoverableException e) { /* noop */ }
return;
}
// Get content type by file name and set default GZIP support and content disposition.
String contentType = getMimeType(file.getName());
boolean acceptsGzip = false;
// If content type is unknown, then set the default value.
// For all content types, see: http://www.w3schools.com/media/media_mimeref.asp
// To add new content types, add new mime-mapping entry in web.xml.
if (contentType == null) {
contentType = "application/octet-stream";
}
// If content type is text, then determine whether GZIP content encoding is supported by
// the browser and expand content type with the one and right character encoding.
if (resourceConfiguration.isEnableGzip()) {
if (contentType.startsWith("text") || contentType.contains("javascript")) {
final String acceptEncoding = pwmRequest.readHeaderValueAsString(HttpHeader.Accept_Encoding);
acceptsGzip = acceptEncoding != null && accepts(acceptEncoding, "gzip");
contentType += ";charset=UTF-8";
}
}
final HttpServletResponse response = pwmRequest.getPwmResponse().getHttpServletResponse();
final String eTagValue = resourceConfiguration.getNonceValue();
{ // reply back with etag.
final String ifNoneMatchValue = pwmRequest.readHeaderValueAsString(HttpHeader.If_None_Match);
if (ifNoneMatchValue != null && ifNoneMatchValue.equals(eTagValue)) {
response.reset();
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
try {
pwmRequest.debugHttpRequestToLog("returning HTTP 304 status");
} catch (PwmUnrecoverableException e2) { /* noop */ }
return;
}
}
// Initialize response.
addExpirationHeaders(resourceConfiguration, response);
response.setHeader("ETag", resourceConfiguration.getNonceValue());
response.setContentType(contentType);
try {
boolean fromCache = false;
StringBuilder debugText = new StringBuilder();
try {
fromCache = handleCacheableResponse(resourceConfiguration, response, file, acceptsGzip, resourceService.getCacheMap());
if (fromCache || acceptsGzip) {
debugText.append("(");
if (fromCache) {
debugText.append("cached");
}
if (fromCache && acceptsGzip) {
debugText.append(", ");
}
if (acceptsGzip) {
debugText.append("gzip");
}
debugText.append(")");
} else {
debugText = new StringBuilder("(not cached)");
}
StatisticsManager.incrementStat(pwmApplication, Statistic.HTTP_RESOURCE_REQUESTS);
} catch (UncacheableResourceException e) {
handleUncachedResponse(response, file, acceptsGzip);
debugText = new StringBuilder();
debugText.append("(uncacheable");
if (acceptsGzip) {
debugText.append(", gzip");
}
debugText.append(")");
}
try {
pwmRequest.debugHttpRequestToLog(debugText.toString());
} catch (PwmUnrecoverableException e) {
/* noop */
}
final EventRateMeter.MovingAverage cacheHitRatio = resourceService.getCacheHitRatio();
cacheHitRatio.update(fromCache ? 1 : 0);
} catch (Exception e) {
LOGGER.error(pwmRequest, "error fulfilling response for url '" + requestURI + "', error: " + e.getMessage());
}
}
private boolean handleCacheableResponse(
final ResourceServletConfiguration resourceServletConfiguration,
final HttpServletResponse response,
final FileResource file,
final boolean acceptsGzip,
final Cache<CacheKey, CacheEntry> responseCache
)
throws UncacheableResourceException, IOException
{
if (file.length() > resourceServletConfiguration.getMaxCacheBytes()) {
throw new UncacheableResourceException("file to large to cache");
}
boolean fromCache = false;
final CacheKey cacheKey = new CacheKey(file, acceptsGzip);
CacheEntry cacheEntry = responseCache.getIfPresent(cacheKey);
if (cacheEntry == null) {
final Map<String, String> headers = new HashMap<>();
final ByteArrayOutputStream tempOutputStream = new ByteArrayOutputStream();
final InputStream input = file.getInputStream();
try {
if (acceptsGzip) {
final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(tempOutputStream);
headers.put("Content-Encoding", "gzip");
copy(input, gzipOutputStream);
close(gzipOutputStream);
} else {
copy(input, tempOutputStream);
}
} finally {
close(input);
close(tempOutputStream);
}
final byte[] entity = tempOutputStream.toByteArray();
headers.put("Content-Length", String.valueOf(entity.length));
cacheEntry = new CacheEntry(entity, headers);
} else {
fromCache = true;
}
responseCache.put(cacheKey, cacheEntry);
for (final String key : cacheEntry.getHeaderStrings().keySet()) {
response.setHeader(key, cacheEntry.getHeaderStrings().get(key));
}
final OutputStream responseOutputStream = response.getOutputStream();
try {
copy(new ByteArrayInputStream(cacheEntry.getEntity()), responseOutputStream);
} finally {
close(responseOutputStream);
}
return fromCache;
}
private static void handleUncachedResponse(
final HttpServletResponse response,
final FileResource file,
final boolean acceptsGzip
) throws IOException {
// Prepare streams.
OutputStream output = null;
InputStream input = null;
try {
// Open streams.
input = new BufferedInputStream(file.getInputStream());
output = new BufferedOutputStream(response.getOutputStream());
if (acceptsGzip) {
// The browser accepts GZIP, so GZIP the content.
response.setHeader("Content-Encoding", "gzip");
output = new GZIPOutputStream(output);
} else {
// Content length is not directly predictable in case of GZIP.
// So only add it if there is no means of GZIP, else browser will hang.
if (file.length() > 0) {
response.setHeader("Content-Length", String.valueOf(file.length()));
}
}
// Copy full range.
copy(input, output);
} finally {
// Gently close streams.
close(output);
close(input);
}
}
/**
* Returns true if the given accept header accepts the given value.
*
* @param acceptHeader The accept header.
* @param toAccept The value to be accepted.
* @return True if the given accept header accepts the given value.
*/
private static boolean accepts(final String acceptHeader, final String toAccept) {
final String[] acceptValues = acceptHeader.split("\\s*(,|;)\\s*");
Arrays.sort(acceptValues);
return Arrays.binarySearch(acceptValues, toAccept) > -1
|| Arrays.binarySearch(acceptValues, toAccept.replaceAll("/.*$", "/*")) > -1
|| Arrays.binarySearch(acceptValues, "*/*") > -1;
}
/**
* Copy the given byte range of the given input to the given output.
*
* @param input The input to copy the given range to the given output for.
* @param output The output to copy the given range from the given input for.
* @throws IOException If something fails at I/O level.
*/
private static void copy(final InputStream input, final OutputStream output)
throws IOException
{
IOUtils.copy(input,output);
}
/**
* Close the given resource.
*
* @param resource The resource to be closed.
*/
private static void close(final Closeable resource) {
IOUtils.closeQuietly(resource);
}
static FileResource resolveRequestedFile(
final ServletContext servletContext,
final String resourcePathUri,
final ResourceServletConfiguration resourceServletConfiguration
)
throws PwmUnrecoverableException
{
// URL-decode the file name (might contain spaces and on) and prepare file object.
String filename = StringUtil.urlDecode(resourcePathUri);
// parse out the session key...
if (filename.contains(";")) {
filename = filename.substring(0, filename.indexOf(";"));
}
if (!filename.startsWith(RESOURCE_PATH)) {
LOGGER.warn("illegal url request to " + filename);
throw new PwmUnrecoverableException(new ErrorInformation(PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal url request"));
}
{
final FileResource resource = handleWebjarURIs(servletContext, resourcePathUri);
if (resource != null) {
return resource;
}
}
{// check files system zip files.
final Map<String,ZipFile> zipResources = resourceServletConfiguration.getZipResources();
for (final String path : zipResources.keySet()) {
if (filename.startsWith(path)) {
final String zipSubPath = filename.substring(path.length() + 1, filename.length());
final ZipFile zipFile = zipResources.get(path);
final ZipEntry zipEntry = zipFile.getEntry(zipSubPath);
if (zipEntry != null) {
return new ZipFileResource(zipFile, zipEntry);
}
}
if (filename.startsWith(zipResources.get(path).getName())) {
LOGGER.warn("illegal url request to " + filename + " zip resource");
throw new PwmUnrecoverableException(new ErrorInformation(PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal url request"));
}
}
}
// convert to file.
final String filePath = servletContext.getRealPath(filename);
final File file = new File(filePath);
// figure top-most path allowed by request
final String parentDirectoryPath = servletContext.getRealPath(RESOURCE_PATH);
final File parentDirectory = new File(parentDirectoryPath);
FileResource fileSystemResource = null;
{ //verify the requested page is a child of the servlet resource path.
int recursions = 0;
File recurseFile = file.getParentFile();
while (recurseFile != null && recursions < 100) {
if (parentDirectory.equals(recurseFile)) {
fileSystemResource = new RealFileResource(file);
break;
}
recurseFile = recurseFile.getParentFile();
recursions++;
}
}
if (fileSystemResource == null) {
LOGGER.warn("attempt to access file outside of servlet path " + file.getAbsolutePath());
throw new PwmUnrecoverableException(new ErrorInformation(PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal file path request"));
}
if (!fileSystemResource.exists()) { // check custom (configuration defined) zip file bundles
final Map<String,FileResource> customResources = resourceServletConfiguration.getCustomFileBundle();
for (final String customFileName : customResources.keySet()) {
final String testName = RESOURCE_PATH + "/" + customFileName;
if (testName.equals(resourcePathUri)) {
return customResources.get(customFileName);
}
}
}
return fileSystemResource;
}
private boolean handleEmbeddedURIs(
final PwmApplication pwmApplication,
final String requestURI,
final HttpServletResponse response,
final ResourceServletConfiguration resourceServletConfiguration
)
throws PwmUnrecoverableException, IOException, ServletException
{
if (requestURI != null) {
final String embedThemeUrl = RESOURCE_PATH + THEME_CSS_PATH.replace(TOKEN_THEME,EMBED_THEME);
final String embedThemeMobileUrl = RESOURCE_PATH + THEME_CSS_MOBILE_PATH.replace(TOKEN_THEME,EMBED_THEME);
if (requestURI.equalsIgnoreCase(embedThemeUrl)) {
writeConfigSettingToBody(pwmApplication, PwmSetting.DISPLAY_CSS_EMBED, response, resourceServletConfiguration);
return true;
} else if (requestURI.equalsIgnoreCase(embedThemeMobileUrl)) {
writeConfigSettingToBody(pwmApplication, PwmSetting.DISPLAY_CSS_MOBILE_EMBED, response, resourceServletConfiguration);
return true;
}
}
return false;
}
private void writeConfigSettingToBody(
final PwmApplication pwmApplication,
final PwmSetting pwmSetting,
final HttpServletResponse response,
final ResourceServletConfiguration resourceServletConfiguration
)
throws PwmUnrecoverableException, IOException
{
final String bodyText = pwmApplication.getConfig().readSettingAsString(pwmSetting);
try {
response.setContentType("text/css");
addExpirationHeaders(resourceServletConfiguration, response);
if (bodyText != null && bodyText.length() > 0) {
response.setIntHeader("Content-Length", bodyText.length());
copy(new ByteArrayInputStream(bodyText.getBytes()), response.getOutputStream());
} else {
response.setIntHeader("Content-Length", 0);
}
} finally {
close(response.getOutputStream());
}
}
private String stripNonceFromURI(
final ResourceServletConfiguration resourceServletConfiguration,
final String uriString
)
{
if (!resourceServletConfiguration.isEnablePathNonce()) {
return uriString;
}
final Matcher theMatcher = resourceServletConfiguration.getNoncePattern().matcher(uriString);
if (theMatcher.find()) {
return theMatcher.replaceFirst("");
}
return uriString;
}
private String figureRequestPathMinusContext(final HttpServletRequest httpServletRequest) {
final String requestURI = httpServletRequest.getRequestURI();
return requestURI.substring(httpServletRequest.getContextPath().length(), requestURI.length());
}
private static FileResource handleWebjarURIs(
final ServletContext servletContext,
final String resourcePathUri
)
throws PwmUnrecoverableException
{
if (resourcePathUri.startsWith(WEBJAR_BASE_URL_PATH)) {
final String remainingPath = resourcePathUri.substring(WEBJAR_BASE_URL_PATH.length(), resourcePathUri.length());
final String webJarName;
final String webJarPath;
{
final int slashIndex = remainingPath.indexOf("/");
if (slashIndex < 0) {
return null;
}
webJarName = remainingPath.substring(0, slashIndex);
webJarPath = remainingPath.substring(slashIndex + 1, remainingPath.length());
}
final String versionString = WEB_JAR_VERSION_MAP.get(webJarName);
if (versionString == null) {
return null;
}
final String fullPath = WEBJAR_BASE_FILE_PATH + "/" + webJarName + "/" + versionString+ "/" + webJarPath;
if (WEB_JAR_ASSET_LIST.contains(fullPath)) {
final ClassLoader classLoader = servletContext.getClassLoader();
final InputStream inputStream = classLoader.getResourceAsStream(fullPath);
if (inputStream != null) {
return new InputStreamFileResource(inputStream, fullPath);
}
}
}
return null;
}
private static class InputStreamFileResource implements FileResource {
private final InputStream inputStream;
private final String fullPath;
InputStreamFileResource(final InputStream inputStream, final String fullPath) {
this.inputStream = inputStream;
this.fullPath = fullPath;
}
@Override
public InputStream getInputStream() throws IOException {
return inputStream;
}
@Override
public long length() {
return 0;
}
@Override
public long lastModified() {
return 0;
}
@Override
public boolean exists() {
return true;
}
@Override
public String getName() {
return fullPath;
}
}
private void addExpirationHeaders(final ResourceServletConfiguration resourceServletConfiguration, final HttpServletResponse httpResponse) {
httpResponse.setDateHeader("Expires", System.currentTimeMillis() + (resourceServletConfiguration.getCacheExpireSeconds() * 1000));
httpResponse.setHeader("Cache-Control", "public, max-age=" + resourceServletConfiguration.getCacheExpireSeconds());
httpResponse.setHeader("Vary", "Accept-Encoding");
}
private String getMimeType(final String filename) {
final String contentType = getServletContext().getMimeType(filename);
if (contentType == null) {
if (filename.endsWith(".woff2")) {
return "font/woff2";
}
}
return contentType;
}
}