password.pwm.http.servlet.resource.ResourceFileServlet.java Source code

Java tutorial

Introduction

Here is the source code for password.pwm.http.servlet.resource.ResourceFileServlet.java

Source

/*
 * 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)) {
            // This allows us to override a webjar file, if needed.  Mostly helpful during development.
            final File file = new File(servletContext.getRealPath(resourcePathUri));
            if (file.exists()) {
                return new RealFileResource(file);
            }

            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;
    }
}