All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.vertx.ext.web.handler.impl.StaticHandlerImpl Maven / Gradle / Ivy

/*
 * Copyright 2014 Red Hat, Inc.
 *
 *  All rights reserved. This program and the accompanying materials
 *  are made available under the terms of the Eclipse Public License v1.0
 *  and Apache License v2.0 which accompanies this distribution.
 *
 *  The Eclipse Public License is available at
 *  http://www.eclipse.org/legal/epl-v10.html
 *
 *  The Apache License v2.0 is available at
 *  http://www.opensource.org/licenses/apache2.0.php
 *
 *  You may elect to redistribute this code under either of these licenses.
 */

package io.vertx.ext.web.handler.impl;

import io.vertx.core.*;
import io.vertx.core.file.FileProps;
import io.vertx.core.file.FileSystem;
import io.vertx.core.file.FileSystemException;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.json.JsonArray;
import io.vertx.core.logging.Logger;
import io.vertx.core.logging.LoggerFactory;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.impl.LRUCache;
import io.vertx.ext.web.impl.Utils;

import java.io.File;
import java.nio.file.NoSuchFileException;
import java.text.DateFormat;
import java.text.ParseException;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static io.netty.handler.codec.http.HttpResponseStatus.*;

/**
 * Static web server
 * Parts derived from Yoke
 *
 * @author Tim Fox
 * @author Paulo Lopes
 */
public class StaticHandlerImpl implements StaticHandler {

  private static final Logger log = LoggerFactory.getLogger(StaticHandlerImpl.class);

  private final DateFormat dateTimeFormatter = Utils.createRFC1123DateTimeFormatter();
  private Map propsCache;
  private String webRoot = DEFAULT_WEB_ROOT;
  private long maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS; // One day
  private boolean directoryListing = DEFAULT_DIRECTORY_LISTING;
  private String directoryTemplateResource = DEFAULT_DIRECTORY_TEMPLATE;
  private String directoryTemplate;
  private boolean includeHidden = DEFAULT_INCLUDE_HIDDEN;
  private boolean filesReadOnly = DEFAULT_FILES_READ_ONLY;
  private boolean cachingEnabled = DEFAULT_CACHING_ENABLED;
  private long cacheEntryTimeout = DEFAULT_CACHE_ENTRY_TIMEOUT;
  private String indexPage = DEFAULT_INDEX_PAGE;
  private int maxCacheSize = DEFAULT_MAX_CACHE_SIZE;
  private boolean rangeSupport = DEFAULT_RANGE_SUPPORT;
  private boolean allowRootFileSystemAccess = DEFAULT_ROOT_FILESYSTEM_ACCESS;

  // These members are all related to auto tuning of synchronous vs asynchronous file system access
  private static int NUM_SERVES_TUNING_FS_ACCESS = 1000;
  private boolean alwaysAsyncFS = DEFAULT_ALWAYS_ASYNC_FS;
  private long maxAvgServeTimeNanoSeconds = DEFAULT_MAX_AVG_SERVE_TIME_NS;
  private boolean tuning = DEFAULT_ENABLE_FS_TUNING;
  private long totalTime;
  private long numServesBlocking;
  private boolean useAsyncFS;
  private long nextAvgCheck = NUM_SERVES_TUNING_FS_ACCESS;

  private final ClassLoader classLoader;

  public StaticHandlerImpl(String root, ClassLoader classLoader) {
    this.classLoader = classLoader;
    setRoot(root);
  }

  public StaticHandlerImpl() {
    classLoader = null;
  }

  private String directoryTemplate(Vertx vertx) {
    if (directoryTemplate == null) {
      directoryTemplate = Utils.readFileToString(vertx, directoryTemplateResource);
    }
    return directoryTemplate;
  }

  /**
   * Create all required header so content can be cache by Caching servers or Browsers
   *
   * @param request base HttpServerRequest
   * @param props   file properties
   */
  private void writeCacheHeaders(HttpServerRequest request, FileProps props) {

    MultiMap headers = request.response().headers();

    if (cachingEnabled) {
      // We use cache-control and last-modified
      // We *do not use* etags and expires (since they do the same thing - redundant)
      headers.set("cache-control", "public, max-age=" + maxAgeSeconds);
      headers.set("last-modified", dateTimeFormatter.format(props.lastModifiedTime()));
    }

    // date header is mandatory
    headers.set("date", dateTimeFormatter.format(new Date()));
  }

  @Override
  public void handle(RoutingContext context) {
    HttpServerRequest request = context.request();
    if (request.method() != HttpMethod.GET && request.method() != HttpMethod.HEAD) {
      if (log.isTraceEnabled()) log.trace("Not GET or HEAD so ignoring request");
      context.next();
    } else {
      String path = context.normalisedPath();
      // if the normalized path is null it cannot be resolved
      if (path == null) {
        log.warn("Invalid path: " + context.request().path() + " so returning 404");
        context.fail(NOT_FOUND.code());
        return;
      }

      // only root is known for sure to be a directory. all other directories must be identified as such.
      if (!directoryListing && "/".equals(path)) {
        path = indexPage;
      }

      // can be called recursive for index pages
      sendStatic(context, path);

    }
  }

  private void sendStatic(RoutingContext context, String path) {

    String file = null;

    if (!includeHidden) {
      file = getFile(path, context);
      int idx = file.lastIndexOf('/');
      String name = file.substring(idx + 1);
      if (name.length() > 0 && name.charAt(0) == '.') {
        context.fail(NOT_FOUND.code());
        return;
      }
    }

    // Look in cache
    CacheEntry entry = null;
    if (cachingEnabled) {
      entry = propsCache().get(path);
      if (entry != null) {
        HttpServerRequest request = context.request();
        if ((filesReadOnly || !entry.isOutOfDate()) && entry.shouldUseCached(request)) {
          context.response().setStatusCode(NOT_MODIFIED.code()).end();
          return;
        }
      }
    }

    if (file == null) {
      file = getFile(path, context);
    }

    FileProps props;
    if (filesReadOnly && entry != null) {
      props = entry.props;
      sendFile(context, file, props);
    } else {
      // Need to read the props from the filesystem
      String sfile = file;
      getFileProps(context, file, res -> {
        if (res.succeeded()) {
          FileProps fprops = res.result();
          if (fprops == null) {
            // File does not exist
            context.fail(NOT_FOUND.code());
          } else if (fprops.isDirectory()) {
            sendDirectory(context, path, sfile);
          } else {
            propsCache().put(path, new CacheEntry(fprops, System.currentTimeMillis()));
            sendFile(context, sfile, fprops);
          }
        } else {
          if (res.cause() instanceof NoSuchFileException || (res.cause().getCause() != null && res.cause().getCause() instanceof NoSuchFileException)) {
            context.fail(NOT_FOUND.code());
          } else {
            context.fail(res.cause());
          }
        }
      });

    }
  }

  private void sendDirectory(RoutingContext context, String path, String file) {
    if (directoryListing) {
      sendDirectoryListing(file, context);
    } else if (indexPage != null) {
      // send index page
      String indexPath;
      if (path.endsWith("/") && indexPage.startsWith("/")) {
        indexPath = path + indexPage.substring(1);
      } else if (!path.endsWith("/") && !indexPage.startsWith("/")) {
        indexPath = path + "/" + indexPage.substring(1);
      } else {
        indexPath = path + indexPage;
      }
      // recursive call
      sendStatic(context, indexPath);

    } else {
      // Directory listing denied
      context.fail(FORBIDDEN.code());
    }
  }

  private  T wrapInTCCLSwitch(Callable callable, Handler> resultHandler) {
    try {
      if (classLoader == null) {
        return callable.call();
      } else {
        final ClassLoader original = Thread.currentThread().getContextClassLoader();
        try {
          Thread.currentThread().setContextClassLoader(classLoader);
          return callable.call();
        } finally {
          Thread.currentThread().setContextClassLoader(original);
        }
      }
    } catch (Exception e) {
      if (resultHandler != null) {
        resultHandler.handle(Future.failedFuture(e.getCause()));
        return null;
      } else {
        throw new RuntimeException(e);
      }
    }
  }

  private synchronized void getFileProps(RoutingContext context, String file, Handler> resultHandler) {
    FileSystem fs = context.vertx().fileSystem();
    if (alwaysAsyncFS || useAsyncFS) {
      wrapInTCCLSwitch(() -> fs.props(file, resultHandler), resultHandler);
    } else {
      // Use synchronous access - it might well be faster!
      long start = 0;
      if (tuning) {
        start = System.nanoTime();
      }
      try {
        FileProps props = wrapInTCCLSwitch(() -> fs.propsBlocking(file), resultHandler);

        if (tuning) {
          long end = System.nanoTime();
          long dur = end - start;
          totalTime += dur;
          numServesBlocking++;
          if (numServesBlocking == Long.MAX_VALUE) {
            // Unlikely.. but...
            resetTuning();
          } else if (numServesBlocking == nextAvgCheck) {
            double avg = (double) totalTime / numServesBlocking;
            if (avg > maxAvgServeTimeNanoSeconds) {
              useAsyncFS = true;
              log.info("Switching to async file system access in static file server as fs access is slow! (Average access time of " + avg + " ns)");
              tuning = false;
            }
            nextAvgCheck += NUM_SERVES_TUNING_FS_ACCESS;
          }
        }
        resultHandler.handle(Future.succeededFuture(props));
      } catch (FileSystemException e) {
        resultHandler.handle(Future.failedFuture(e.getCause()));
      }
    }
  }

  private void resetTuning() {
    // Reset
    nextAvgCheck = NUM_SERVES_TUNING_FS_ACCESS;
    totalTime = 0;
    numServesBlocking = 0;
  }

  private static final Pattern RANGE = Pattern.compile("^bytes=(\\d+)-(\\d*)$");

  private void sendFile(RoutingContext context, String file, FileProps fileProps) {
    HttpServerRequest request = context.request();

    Long offset = null;
    Long end = null;
    MultiMap headers = null;

    if (rangeSupport) {
      // check if the client is making a range request
      String range = request.getHeader("Range");
      // end byte is length - 1
      end = fileProps.size() - 1;

      if (range != null) {
        Matcher m = RANGE.matcher(range);
        if (m.matches()) {
          try {
            String part = m.group(1);
            // offset cannot be empty
            offset = Long.parseLong(part);
            // offset must fall inside the limits of the file
            if (offset < 0 || offset >= fileProps.size()) {
              throw new IndexOutOfBoundsException();
            }
            // length can be empty
            part = m.group(2);
            if (part != null && part.length() > 0) {
              // ranges are inclusive
              end = Long.parseLong(part);
              // offset must fall inside the limits of the file
              if (end < offset || end >= fileProps.size()) {
                throw new IndexOutOfBoundsException();
              }
            }
          } catch (NumberFormatException | IndexOutOfBoundsException e) {
            context.fail(REQUESTED_RANGE_NOT_SATISFIABLE.code());
            return;
          }
        }
      }

      // notify client we support range requests
      headers = request.response().headers();
      headers.set("Accept-Ranges", "bytes");
      // send the content length even for HEAD requests
      headers.set("Content-Length", Long.toString(end + 1 - (offset == null ? 0 : offset)));
    }

    writeCacheHeaders(request, fileProps);

    if (request.method() == HttpMethod.HEAD) {
      request.response().end();
    } else {
      if (rangeSupport && offset != null) {
        // must return content range
        headers.set("Content-Range", "bytes " + offset + "-" + end + "/" + fileProps.size());
        // return a partial response
        request.response().setStatusCode(PARTIAL_CONTENT.code());

        // Wrap the sendFile operation into a TCCL switch, so the file resolver would find the file from the set
        // classloader (if any).
        final Long finalOffset = offset;
        final Long finalEnd = end;
        wrapInTCCLSwitch(() ->
            request.response().sendFile(file, finalOffset, finalEnd + 1, res2 -> {
              if (res2.failed()) {
                context.fail(res2.cause());
              }
            }), null);
      } else {
        // Wrap the sendFile operation into a TCCL switch, so the file resolver would find the file from the set
        // classloader (if any).
        wrapInTCCLSwitch(() ->
            request.response().sendFile(file, res2 -> {
              if (res2.failed()) {
                context.fail(res2.cause());
              }
            }
        ), null);
      }
    }
  }

  @Override
  public StaticHandler setAllowRootFileSystemAccess(boolean allowRootFileSystemAccess) {
    this.allowRootFileSystemAccess = allowRootFileSystemAccess;
    return this;
  }

  @Override
  public StaticHandler setWebRoot(String webRoot) {
    setRoot(webRoot);
    return this;
  }

  @Override
  public StaticHandler setFilesReadOnly(boolean readOnly) {
    this.filesReadOnly = readOnly;
    return this;
  }

  @Override
  public StaticHandler setMaxAgeSeconds(long maxAgeSeconds) {
    if (maxAgeSeconds < 0) {
      throw new IllegalArgumentException("timeout must be >= 0");
    }
    this.maxAgeSeconds = maxAgeSeconds;
    return this;
  }

  @Override
  public StaticHandler setMaxCacheSize(int maxCacheSize) {
    if (maxCacheSize < 1) {
      throw new IllegalArgumentException("maxCacheSize must be >= 1");
    }
    this.maxCacheSize = maxCacheSize;
    return this;
  }

  @Override
  public StaticHandler setCachingEnabled(boolean enabled) {
    this.cachingEnabled = enabled;
    return this;
  }

  @Override
  public StaticHandler setDirectoryListing(boolean directoryListing) {
    this.directoryListing = directoryListing;
    return this;
  }

  @Override
  public StaticHandler setDirectoryTemplate(String directoryTemplate) {
    this.directoryTemplateResource = directoryTemplate;
    this.directoryTemplate = null;
    return this;
  }

  @Override
  public StaticHandler setEnableRangeSupport(boolean enableRangeSupport) {
    this.rangeSupport = enableRangeSupport;
    return this;
  }

  @Override
  public StaticHandler setIncludeHidden(boolean includeHidden) {
    this.includeHidden = includeHidden;
    return this;
  }

  @Override
  public StaticHandler setCacheEntryTimeout(long timeout) {
    if (timeout < 1) {
      throw new IllegalArgumentException("timeout must be >= 1");
    }
    this.cacheEntryTimeout = timeout;
    return this;
  }

  @Override
  public StaticHandler setIndexPage(String indexPage) {
    Objects.requireNonNull(indexPage);
    if (!indexPage.startsWith("/")) {
      indexPage = "/" + indexPage;
    }
    this.indexPage = indexPage;
    return this;
  }

  @Override
  public StaticHandler setAlwaysAsyncFS(boolean alwaysAsyncFS) {
    this.alwaysAsyncFS = alwaysAsyncFS;
    return this;
  }

  @Override
  public synchronized StaticHandler setEnableFSTuning(boolean enableFSTuning) {
    this.tuning = enableFSTuning;
    if (!tuning) {
      resetTuning();
    }
    return this;
  }

  @Override
  public StaticHandler setMaxAvgServeTimeNs(long maxAvgServeTimeNanoSeconds) {
    this.maxAvgServeTimeNanoSeconds = maxAvgServeTimeNanoSeconds;
    return this;
  }

  private Map propsCache() {
    if (propsCache == null) {
      propsCache = new LRUCache<>(maxCacheSize);
    }
    return propsCache;
  }

  private Date parseDate(String header) {
    try {
      return dateTimeFormatter.parse(header);
    } catch (ParseException e) {
      throw new VertxException(e);
    }
  }

  private String getFile(String path, RoutingContext context) {
    String file = webRoot + Utils.pathOffset(path, context);
    if (log.isTraceEnabled()) log.trace("File to serve is " + file);
    return file;
  }

  private void setRoot(String webRoot) {
    Objects.requireNonNull(webRoot);
    if (webRoot.startsWith("/") && !allowRootFileSystemAccess) {
      throw new IllegalArgumentException("root cannot start with '/'");
    }
    this.webRoot = webRoot;
  }

  private void sendDirectoryListing(String dir, RoutingContext context) {
    FileSystem fileSystem = context.vertx().fileSystem();
    HttpServerRequest request = context.request();

    fileSystem.readDir(dir, asyncResult -> {
      if (asyncResult.failed()) {
        context.fail(asyncResult.cause());
      } else {

        String accept = request.headers().get("accept");
        if (accept == null) {
          accept = "text/plain";
        }

        if (accept.contains("html")) {
          String normalizedDir = context.normalisedPath();
          if (!normalizedDir.endsWith("/")) {
            normalizedDir += "/";
          }

          String file;
          StringBuilder files = new StringBuilder("
    "); List list = asyncResult.result(); Collections.sort(list); for (String s : list) { file = s.substring(s.lastIndexOf(File.separatorChar) + 1); // skip dot files if (!includeHidden && file.charAt(0) == '.') { continue; } files.append("
  • "); files.append(file); files.append("
  • "); } files.append("
"); // link to parent dir int slashPos = 0; for (int i = normalizedDir.length() - 2; i > 0; i--) { if (normalizedDir.charAt(i) == '/') { slashPos = i; break; } } String parent = ".."; request.response().putHeader("content-type", "text/html"); request.response().end( directoryTemplate(context.vertx()).replace("{directory}", normalizedDir) .replace("{parent}", parent) .replace("{files}", files.toString())); } else if (accept.contains("json")) { String file; JsonArray json = new JsonArray(); for (String s : asyncResult.result()) { file = s.substring(s.lastIndexOf(File.separatorChar) + 1); // skip dot files if (!includeHidden && file.charAt(0) == '.') { continue; } json.add(file); } request.response().putHeader("content-type", "application/json"); request.response().end(json.encode()); } else { String file; StringBuilder buffer = new StringBuilder(); for (String s : asyncResult.result()) { file = s.substring(s.lastIndexOf(File.separatorChar) + 1); // skip dot files if (!includeHidden && file.charAt(0) == '.') { continue; } buffer.append(file); buffer.append('\n'); } request.response().putHeader("content-type", "text/plain"); request.response().end(buffer.toString()); } } }); } // TODO make this static and use Java8 DateTimeFormatter private final class CacheEntry { final FileProps props; long createDate; private CacheEntry(FileProps props, long createDate) { this.props = props; this.createDate = createDate; } // return true if there are conditional headers present and they match what is in the entry boolean shouldUseCached(HttpServerRequest request) { String ifModifiedSince = request.headers().get("if-modified-since"); if (ifModifiedSince == null) { // Not a conditional request return false; } Date ifModifiedSinceDate = parseDate(ifModifiedSince); boolean modifiedSince = Utils.secondsFactor(props.lastModifiedTime()) > ifModifiedSinceDate.getTime(); return !modifiedSince; } boolean isOutOfDate() { boolean outOfDate = System.currentTimeMillis() - createDate > cacheEntryTimeout; return outOfDate; } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy