LCOV - code coverage report
Current view: top level - httpd/raw - ResourceServlet.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 128 158 81.0 %
Date: 2022-11-19 15:00:39 Functions: 17 20 85.0 %

          Line data    Source code
       1             : // Copyright (C) 2015 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.httpd.raw;
      16             : 
      17             : import static com.google.common.net.HttpHeaders.CONTENT_ENCODING;
      18             : import static com.google.common.net.HttpHeaders.ETAG;
      19             : import static com.google.common.net.HttpHeaders.IF_MODIFIED_SINCE;
      20             : import static com.google.common.net.HttpHeaders.IF_NONE_MATCH;
      21             : import static com.google.common.net.HttpHeaders.LAST_MODIFIED;
      22             : import static java.util.Objects.requireNonNull;
      23             : import static java.util.concurrent.TimeUnit.DAYS;
      24             : import static java.util.concurrent.TimeUnit.MINUTES;
      25             : import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
      26             : import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
      27             : import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
      28             : 
      29             : import com.google.common.annotations.VisibleForTesting;
      30             : import com.google.common.base.CharMatcher;
      31             : import com.google.common.cache.Cache;
      32             : import com.google.common.collect.ImmutableMap;
      33             : import com.google.common.flogger.FluentLogger;
      34             : import com.google.common.hash.Hashing;
      35             : import com.google.gerrit.common.Nullable;
      36             : import com.google.gerrit.common.UsedAt;
      37             : import com.google.gerrit.httpd.HtmlDomUtil;
      38             : import com.google.gerrit.util.http.CacheHeaders;
      39             : import com.google.gerrit.util.http.RequestUtil;
      40             : import java.io.IOException;
      41             : import java.io.OutputStream;
      42             : import java.nio.file.Files;
      43             : import java.nio.file.NoSuchFileException;
      44             : import java.nio.file.Path;
      45             : import java.nio.file.attribute.FileTime;
      46             : import java.util.concurrent.Callable;
      47             : import java.util.concurrent.ExecutionException;
      48             : import java.util.zip.GZIPOutputStream;
      49             : import javax.servlet.http.HttpServlet;
      50             : import javax.servlet.http.HttpServletRequest;
      51             : import javax.servlet.http.HttpServletResponse;
      52             : 
      53             : /**
      54             :  * Base class for serving static resources.
      55             :  *
      56             :  * <p>Supports caching, ETags, basic content type detection, and limited gzip compression.
      57             :  */
      58             : public abstract class ResourceServlet extends HttpServlet {
      59             :   private static final long serialVersionUID = 1L;
      60             : 
      61         100 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      62             : 
      63             :   private static final int CACHE_FILE_SIZE_LIMIT_BYTES = 100 << 10;
      64             : 
      65             :   private static final String JS = "application/x-javascript";
      66         100 :   private static final ImmutableMap<String, String> MIME_TYPES =
      67         100 :       ImmutableMap.<String, String>builder()
      68         100 :           .put("css", "text/css")
      69         100 :           .put("gif", "image/gif")
      70         100 :           .put("htm", "text/html")
      71         100 :           .put("html", "text/html")
      72         100 :           .put("ico", "image/x-icon")
      73         100 :           .put("jpeg", "image/jpeg")
      74         100 :           .put("jpg", "image/jpeg")
      75         100 :           .put("js", JS)
      76         100 :           .put("pdf", "application/pdf")
      77         100 :           .put("png", "image/png")
      78         100 :           .put("rtf", "text/rtf")
      79         100 :           .put("svg", "image/svg+xml")
      80         100 :           .put("text", "text/plain")
      81         100 :           .put("tif", "image/tiff")
      82         100 :           .put("tiff", "image/tiff")
      83         100 :           .put("txt", "text/plain")
      84         100 :           .put("woff", "font/woff")
      85         100 :           .put("woff2", "font/woff2")
      86         100 :           .build();
      87             : 
      88             :   protected static String contentType(String name) {
      89           1 :     int dot = name.lastIndexOf('.');
      90           1 :     String ext = 0 < dot ? name.substring(dot + 1) : "";
      91           1 :     String type = MIME_TYPES.get(ext);
      92           1 :     return type != null ? type : "application/octet-stream";
      93             :   }
      94             : 
      95             :   private final Cache<Path, Resource> cache;
      96             :   private final boolean refresh;
      97             :   private final boolean cacheOnClient;
      98             :   private final int cacheFileSizeLimitBytes;
      99             : 
     100             :   protected ResourceServlet(Cache<Path, Resource> cache, boolean refresh) {
     101         100 :     this(cache, refresh, true, CACHE_FILE_SIZE_LIMIT_BYTES);
     102         100 :   }
     103             : 
     104             :   protected ResourceServlet(Cache<Path, Resource> cache, boolean refresh, boolean cacheOnClient) {
     105           1 :     this(cache, refresh, cacheOnClient, CACHE_FILE_SIZE_LIMIT_BYTES);
     106           1 :   }
     107             : 
     108             :   @VisibleForTesting
     109             :   ResourceServlet(
     110             :       Cache<Path, Resource> cache,
     111             :       boolean refresh,
     112             :       boolean cacheOnClient,
     113         100 :       int cacheFileSizeLimitBytes) {
     114         100 :     this.cache = requireNonNull(cache, "cache");
     115         100 :     this.refresh = refresh;
     116         100 :     this.cacheOnClient = cacheOnClient;
     117         100 :     this.cacheFileSizeLimitBytes = cacheFileSizeLimitBytes;
     118         100 :   }
     119             : 
     120             :   /**
     121             :    * Get the resource path on the filesystem that should be served for this request.
     122             :    *
     123             :    * @param pathInfo result of {@link HttpServletRequest#getPathInfo()}.
     124             :    * @return path where static content can be found.
     125             :    * @throws IOException if an error occurred resolving the resource.
     126             :    */
     127             :   protected abstract Path getResourcePath(String pathInfo) throws IOException;
     128             : 
     129             :   /**
     130             :    * Indicates that resource requires some processing before being served.
     131             :    *
     132             :    * <p>If true, the caching headers in response are set to not cache. Additionally, streaming
     133             :    * option is disabled.
     134             :    *
     135             :    * @param req the HTTP servlet request
     136             :    * @param rsp the HTTP servlet response
     137             :    * @param p URL path
     138             :    * @return true if the {@link #processResourceBeforeServe(HttpServletRequest, HttpServletResponse,
     139             :    *     Resource)} should be called.
     140             :    */
     141             :   protected boolean shouldProcessResourceBeforeServe(
     142             :       HttpServletRequest req, HttpServletResponse rsp, Path p) {
     143           1 :     return false;
     144             :   }
     145             : 
     146             :   /**
     147             :    * Edits the resource before adding it to the response.
     148             :    *
     149             :    * @param req the HTTP servlet request
     150             :    * @param rsp the HTTP servlet response
     151             :    */
     152             :   protected Resource processResourceBeforeServe(
     153             :       HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
     154           0 :     return resource;
     155             :   }
     156             : 
     157             :   protected FileTime getLastModifiedTime(Path p) throws IOException {
     158           1 :     return Files.getLastModifiedTime(p);
     159             :   }
     160             : 
     161             :   @Override
     162             :   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     163             :     String name;
     164           1 :     if (req.getPathInfo() == null) {
     165           0 :       name = "/";
     166             :     } else {
     167           1 :       name = CharMatcher.is('/').trimFrom(req.getPathInfo());
     168             :     }
     169           1 :     if (isUnreasonableName(name)) {
     170           0 :       notFound(rsp);
     171           0 :       return;
     172             :     }
     173           1 :     Path p = getResourcePath(name);
     174           1 :     if (p == null) {
     175           0 :       notFound(rsp);
     176           0 :       return;
     177             :     }
     178             : 
     179           1 :     boolean requiresPostProcess = shouldProcessResourceBeforeServe(req, rsp, p);
     180           1 :     Resource r = cache.getIfPresent(p);
     181             :     try {
     182           1 :       if (r == null) {
     183           1 :         if (!requiresPostProcess && maybeStream(p, req, rsp)) {
     184           1 :           return; // Bypass cache for large resource.
     185             :         }
     186           1 :         r = cache.get(p, newLoader(p));
     187             :       }
     188           1 :       if (refresh && r.isStale(p, this)) {
     189           1 :         cache.invalidate(p);
     190           1 :         r = cache.get(p, newLoader(p));
     191             :       }
     192           0 :     } catch (ExecutionException e) {
     193           0 :       logger.atWarning().withCause(e).log("Cannot load static resource %s", req.getPathInfo());
     194           0 :       CacheHeaders.setNotCacheable(rsp);
     195           0 :       rsp.setStatus(SC_INTERNAL_SERVER_ERROR);
     196           0 :       return;
     197           1 :     }
     198           1 :     if (r == Resource.NOT_FOUND) {
     199           1 :       notFound(rsp); // Cached not found response.
     200           1 :       return;
     201             :     }
     202             : 
     203           1 :     String e = req.getParameter("e");
     204           1 :     if (e != null && !r.etag.equals(e)) {
     205           0 :       CacheHeaders.setNotCacheable(rsp);
     206           0 :       rsp.setStatus(SC_NOT_FOUND);
     207           0 :       return;
     208           1 :     } else if (!requiresPostProcess
     209             :         && cacheOnClient
     210           1 :         && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
     211           0 :       rsp.setStatus(SC_NOT_MODIFIED);
     212           0 :       return;
     213             :     }
     214             : 
     215           1 :     if (requiresPostProcess) {
     216           1 :       r = processResourceBeforeServe(req, rsp, r);
     217             :     }
     218           1 :     byte[] tosend = r.raw;
     219           1 :     if (!r.contentType.equals(JS) && RequestUtil.acceptsGzipEncoding(req)) {
     220           1 :       byte[] gz = HtmlDomUtil.compress(tosend);
     221           1 :       if ((gz.length + 24) < tosend.length) {
     222           1 :         rsp.setHeader(CONTENT_ENCODING, "gzip");
     223           1 :         tosend = gz;
     224             :       }
     225             :     }
     226             : 
     227           1 :     if (!requiresPostProcess && cacheOnClient) {
     228           1 :       rsp.setHeader(ETAG, r.etag);
     229             :     } else {
     230           1 :       CacheHeaders.setNotCacheable(rsp);
     231             :     }
     232           1 :     if (!CacheHeaders.hasCacheHeader(rsp)) {
     233           1 :       if (e != null && r.etag.equals(e)) {
     234           0 :         CacheHeaders.setCacheable(req, rsp, 360, DAYS, false);
     235             :       } else {
     236           1 :         CacheHeaders.setCacheable(req, rsp, 15, MINUTES, refresh);
     237             :       }
     238             :     }
     239           1 :     rsp.setContentType(r.contentType);
     240           1 :     rsp.setContentLength(tosend.length);
     241           1 :     try (OutputStream out = rsp.getOutputStream()) {
     242           1 :       out.write(tosend);
     243             :     }
     244           1 :   }
     245             : 
     246             :   @Nullable
     247             :   Resource getResource(String name) {
     248             :     try {
     249           0 :       Path p = getResourcePath(name);
     250           0 :       if (p == null) {
     251           0 :         logger.atWarning().log("Path doesn't exist %s", name);
     252           0 :         return null;
     253             :       }
     254           0 :       return cache.get(p, newLoader(p));
     255           0 :     } catch (ExecutionException | IOException e) {
     256           0 :       logger.atWarning().withCause(e).log("Cannot load static resource %s", name);
     257           0 :       return null;
     258             :     }
     259             :   }
     260             : 
     261             :   private static void notFound(HttpServletResponse rsp) {
     262           1 :     rsp.setStatus(SC_NOT_FOUND);
     263           1 :     CacheHeaders.setNotCacheable(rsp);
     264           1 :   }
     265             : 
     266             :   /**
     267             :    * Maybe stream a path to the response, depending on the properties of the file and cache headers
     268             :    * in the request.
     269             :    *
     270             :    * @param p path to stream
     271             :    * @param req HTTP request.
     272             :    * @param rsp HTTP response.
     273             :    * @return true if the response was written (either the file contents or an error); false if the
     274             :    *     path is too small to stream and should be cached.
     275             :    */
     276             :   private boolean maybeStream(Path p, HttpServletRequest req, HttpServletResponse rsp)
     277             :       throws IOException {
     278             :     try {
     279           1 :       if (Files.size(p) < cacheFileSizeLimitBytes) {
     280           1 :         return false;
     281             :       }
     282           1 :     } catch (NoSuchFileException e) {
     283           1 :       cache.put(p, Resource.NOT_FOUND);
     284           1 :       notFound(rsp);
     285           1 :       return true;
     286           1 :     }
     287             : 
     288           1 :     long lastModified = getLastModifiedTime(p).toMillis();
     289           1 :     if (req.getDateHeader(IF_MODIFIED_SINCE) >= lastModified) {
     290           0 :       rsp.setStatus(SC_NOT_MODIFIED);
     291           0 :       return true;
     292             :     }
     293             : 
     294           1 :     if (lastModified > 0) {
     295           1 :       rsp.setDateHeader(LAST_MODIFIED, lastModified);
     296             :     }
     297           1 :     if (!CacheHeaders.hasCacheHeader(rsp)) {
     298           1 :       CacheHeaders.setCacheable(req, rsp, 15, MINUTES, refresh);
     299             :     }
     300           1 :     rsp.setContentType(contentType(p.toString()));
     301             : 
     302           1 :     OutputStream out = rsp.getOutputStream();
     303           1 :     GZIPOutputStream gz = null;
     304           1 :     if (RequestUtil.acceptsGzipEncoding(req)) {
     305           1 :       rsp.setHeader(CONTENT_ENCODING, "gzip");
     306           1 :       gz = new GZIPOutputStream(out);
     307           1 :       out = gz;
     308             :     }
     309           1 :     Files.copy(p, out);
     310           1 :     if (gz != null) {
     311           1 :       gz.finish();
     312             :     }
     313           1 :     return true;
     314             :   }
     315             : 
     316             :   private static boolean isUnreasonableName(String name) {
     317           1 :     return name.length() < 1
     318           1 :         || name.contains("\\") // no windows/dos style paths
     319           1 :         || name.startsWith("../") // no "../etc/passwd"
     320           1 :         || name.contains("/../") // no "foo/../etc/passwd"
     321           1 :         || name.contains("/./") // "foo/./foo" is insane to ask
     322           1 :         || name.contains("//"); // windows UNC path can be "//..."
     323             :   }
     324             : 
     325             :   private Callable<Resource> newLoader(Path p) {
     326           1 :     return () -> {
     327             :       try {
     328           1 :         return new Resource(
     329           1 :             getLastModifiedTime(p), contentType(p.toString()), Files.readAllBytes(p));
     330           0 :       } catch (NoSuchFileException e) {
     331           0 :         return Resource.NOT_FOUND;
     332             :       }
     333             :     };
     334             :   }
     335             : 
     336             :   public static class Resource {
     337           1 :     static final Resource NOT_FOUND = new Resource(FileTime.fromMillis(0), "", new byte[] {});
     338             : 
     339             :     final FileTime lastModified;
     340             :     final String contentType;
     341             :     final String etag;
     342             :     final byte[] raw;
     343             : 
     344           1 :     Resource(FileTime lastModified, String contentType, byte[] raw) {
     345           1 :       this.lastModified = requireNonNull(lastModified, "lastModified");
     346           1 :       this.contentType = requireNonNull(contentType, "contentType");
     347           1 :       this.raw = requireNonNull(raw, "raw");
     348           1 :       this.etag = Hashing.murmur3_128().hashBytes(raw).toString();
     349           1 :     }
     350             : 
     351             :     boolean isStale(Path p, ResourceServlet rs) throws IOException {
     352             :       FileTime t;
     353             :       try {
     354           1 :         t = rs.getLastModifiedTime(p);
     355           1 :       } catch (NoSuchFileException e) {
     356           1 :         return this != NOT_FOUND;
     357           1 :       }
     358           1 :       return t.toMillis() == 0 || lastModified.toMillis() == 0 || !lastModified.equals(t);
     359             :     }
     360             :   }
     361             : 
     362             :   @UsedAt(UsedAt.Project.GOOGLE)
     363          99 :   public static class Weigher implements com.google.common.cache.Weigher<Path, Resource> {
     364             :     @Override
     365             :     public int weigh(Path p, Resource r) {
     366           0 :       return 2 * p.toString().length() + r.raw.length;
     367             :     }
     368             :   }
     369             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750