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