| // Copyright 2012 Google Inc. All Rights Reserved. | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| package com.google.gitiles; | |
| import static com.google.common.base.MoreObjects.firstNonNull; | |
| import static com.google.gitiles.FormatType.DEFAULT; | |
| import static com.google.gitiles.FormatType.HTML; | |
| import static com.google.gitiles.FormatType.JSON; | |
| import static com.google.gitiles.FormatType.TEXT; | |
| import static java.nio.charset.StandardCharsets.UTF_8; | |
| import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; | |
| import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; | |
| import static javax.servlet.http.HttpServletResponse.SC_OK; | |
| import static org.eclipse.jgit.util.HttpSupport.ENCODING_GZIP; | |
| import com.google.common.base.Joiner; | |
| import com.google.common.base.Strings; | |
| import com.google.common.collect.ImmutableMap; | |
| import com.google.common.collect.Maps; | |
| import com.google.common.net.HttpHeaders; | |
| import com.google.gitiles.GitilesRequestFailureException.FailureReason; | |
| import com.google.gson.FieldNamingPolicy; | |
| import com.google.gson.GsonBuilder; | |
| import java.io.BufferedWriter; | |
| import java.io.ByteArrayOutputStream; | |
| import java.io.IOException; | |
| import java.io.OutputStream; | |
| import java.io.OutputStreamWriter; | |
| import java.io.Writer; | |
| import java.lang.reflect.Type; | |
| import java.nio.charset.Charset; | |
| import java.time.Instant; | |
| import java.util.Map; | |
| import java.util.Optional; | |
| import java.util.regex.Pattern; | |
| import java.util.zip.GZIPOutputStream; | |
| import javax.servlet.ServletException; | |
| import javax.servlet.http.HttpServlet; | |
| import javax.servlet.http.HttpServletRequest; | |
| import javax.servlet.http.HttpServletResponse; | |
| /** Base servlet class for Gitiles servlets that serve Soy templates. */ | |
| public abstract class BaseServlet extends HttpServlet { | |
| private static final long serialVersionUID = 1L; | |
| private static final String DATA_ATTRIBUTE = BaseServlet.class.getName() + "/Data"; | |
| private static final String STREAMING_ATTRIBUTE = BaseServlet.class.getName() + "/Streaming"; | |
| static void setNotCacheable(HttpServletResponse res) { | |
| res.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, max-age=0, must-revalidate"); | |
| res.setHeader(HttpHeaders.PRAGMA, "no-cache"); | |
| res.setHeader(HttpHeaders.EXPIRES, "Mon, 01 Jan 1990 00:00:00 GMT"); | |
| res.setDateHeader(HttpHeaders.DATE, Instant.now().toEpochMilli()); | |
| } | |
| public static BaseServlet notFoundServlet() { | |
| return new BaseServlet(null, null) { | |
| private static final long serialVersionUID = 1L; | |
| @Override | |
| public void service(HttpServletRequest req, HttpServletResponse res) { | |
| res.setStatus(SC_NOT_FOUND); | |
| } | |
| }; | |
| } | |
| public static Map<String, String> menuEntry(String text, String url) { | |
| if (url != null) { | |
| return ImmutableMap.of("text", text, "url", url); | |
| } | |
| return ImmutableMap.of("text", text); | |
| } | |
| public static boolean isStreamingResponse(HttpServletRequest req) { | |
| return firstNonNull((Boolean) req.getAttribute(STREAMING_ATTRIBUTE), false); | |
| } | |
| protected static ArchiveFormat getArchiveFormat(GitilesAccess access) throws IOException { | |
| return ArchiveFormat.getDefault(access.getConfig()); | |
| } | |
| /** | |
| * Put a value into a request's Soy data map. | |
| * | |
| * @param req in-progress request. | |
| * @param key key. | |
| * @param value Soy data value. | |
| */ | |
| public static void putSoyData(HttpServletRequest req, String key, Object value) { | |
| getData(req).put(key, value); | |
| } | |
| @Override | |
| protected void doGet(HttpServletRequest req, HttpServletResponse res) | |
| throws IOException, ServletException { | |
| Optional<FormatType> format = getFormat(req); | |
| if (!format.isPresent()) { | |
| res.sendError(SC_BAD_REQUEST); | |
| return; | |
| } | |
| switch (format.get()) { | |
| case HTML: | |
| doGetHtml(req, res); | |
| break; | |
| case TEXT: | |
| doGetText(req, res); | |
| break; | |
| case JSON: | |
| doGetJson(req, res); | |
| break; | |
| case DEFAULT: | |
| default: | |
| throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT); | |
| } | |
| } | |
| protected Optional<FormatType> getFormat(HttpServletRequest req) { | |
| Optional<FormatType> format = FormatType.getFormatType(req); | |
| if (format.isPresent() && format.get() == DEFAULT) { | |
| return Optional.of(getDefaultFormat(req)); | |
| } | |
| return format; | |
| } | |
| /** | |
| * Get default format | |
| * | |
| * @param req in-progress request. | |
| * @return the default {@link FormatType} used when {@code ?format=} is not specified. | |
| */ | |
| protected FormatType getDefaultFormat(HttpServletRequest req) { | |
| return HTML; | |
| } | |
| /** | |
| * Handle a GET request when the requested format type was HTML. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @throws IOException if there was an error rendering the result. | |
| */ | |
| protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException { | |
| throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT); | |
| } | |
| /** | |
| * Handle a GET request when the requested format type was plain text. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @throws IOException if there was an error rendering the result. | |
| */ | |
| protected void doGetText(HttpServletRequest req, HttpServletResponse res) throws IOException { | |
| throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT); | |
| } | |
| /** | |
| * Handle a GET request when the requested format type was JSON. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @throws IOException if there was an error rendering the result. | |
| */ | |
| protected void doGetJson(HttpServletRequest req, HttpServletResponse res) throws IOException { | |
| throw new GitilesRequestFailureException(FailureReason.UNSUPPORTED_RESPONSE_FORMAT); | |
| } | |
| protected static Map<String, Object> getData(HttpServletRequest req) { | |
| @SuppressWarnings("unchecked") | |
| Map<String, Object> data = (Map<String, Object>) req.getAttribute(DATA_ATTRIBUTE); | |
| if (data == null) { | |
| data = Maps.newHashMap(); | |
| req.setAttribute(DATA_ATTRIBUTE, data); | |
| } | |
| return data; | |
| } | |
| protected final Renderer renderer; | |
| private final GitilesAccess.Factory accessFactory; | |
| protected BaseServlet(Renderer renderer, GitilesAccess.Factory accessFactory) { | |
| this.renderer = renderer; | |
| this.accessFactory = accessFactory; | |
| } | |
| /** | |
| * Render data to HTML using Soy. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @param templateName Soy template name; must be in one of the template files defined in {@link | |
| * Renderer}. | |
| * @param soyData data for Soy. | |
| * @throws IOException an error occurred during rendering. | |
| */ | |
| protected void renderHtml( | |
| HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData) | |
| throws IOException { | |
| renderer.renderHtml(req, res, templateName, startHtmlResponse(req, res, soyData)); | |
| } | |
| /** | |
| * Start a streaming HTML response with header and footer rendered by Soy. | |
| * | |
| * <p>A streaming template includes the special template {@code gitiles.streamingPlaceholder} at | |
| * the point where data is to be streamed. The template before and after this placeholder is | |
| * rendered using the provided data map. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @param templateName Soy template name; must be in one of the template files defined in {@link | |
| * Renderer}. | |
| * @param soyData data for Soy. | |
| * @return output stream to render to. The portion of the template before the placeholder is | |
| * already written and flushed; the portion after is written only on calling {@code close()}. | |
| * @throws IOException an error occurred during rendering the header. | |
| */ | |
| protected OutputStream startRenderStreamingHtml( | |
| HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData) | |
| throws IOException { | |
| req.setAttribute(STREAMING_ATTRIBUTE, true); | |
| return renderer.renderHtmlStreaming( | |
| req, res, false, templateName, startHtmlResponse(req, res, soyData)); | |
| } | |
| /** | |
| * Start a compressed, streaming HTML response with header and footer rendered by Soy. | |
| * | |
| * <p>A streaming template includes the special template {@code gitiles.streamingPlaceholder} at | |
| * the point where data is to be streamed. The template before and after this placeholder is | |
| * rendered using the provided data map. | |
| * | |
| * <p>The response will be gzip compressed (if the user agent supports it) to reduce bandwidth. | |
| * This may delay rendering in the browser. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @param templateName Soy template name; must be in one of the template files defined in {@link | |
| * Renderer}. | |
| * @param soyData data for Soy. | |
| * @return output stream to render to. The portion of the template before the placeholder is | |
| * already written and flushed; the portion after is written only on calling {@code close()}. | |
| * @throws IOException an error occurred during rendering the header. | |
| */ | |
| protected OutputStream startRenderCompressedStreamingHtml( | |
| HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData) | |
| throws IOException { | |
| req.setAttribute(STREAMING_ATTRIBUTE, true); | |
| boolean gzip = false; | |
| if (acceptsGzipEncoding(req)) { | |
| res.addHeader(HttpHeaders.VARY, HttpHeaders.ACCEPT_ENCODING); | |
| res.setHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); | |
| gzip = true; | |
| } | |
| return renderer.renderHtmlStreaming( | |
| req, res, gzip, templateName, startHtmlResponse(req, res, soyData)); | |
| } | |
| private Map<String, ?> startHtmlResponse( | |
| HttpServletRequest req, HttpServletResponse res, Map<String, ?> soyData) throws IOException { | |
| res.setContentType(FormatType.HTML.getMimeType()); | |
| res.setCharacterEncoding(UTF_8.name()); | |
| setCacheHeaders(req, res); | |
| Map<String, Object> allData = getData(req); | |
| // for backwards compatibility, first try to access the old customHeader config var, | |
| // then read the new customVariant variable. | |
| GitilesConfig.putVariant(getAccess(req).getConfig(), "customHeader", "customVariant", allData); | |
| GitilesConfig.putVariant(getAccess(req).getConfig(), "customVariant", "customVariant", allData); | |
| allData.putAll(soyData); | |
| GitilesView view = ViewFilter.getView(req); | |
| if (!allData.containsKey("repositoryName") && view.getRepositoryName() != null) { | |
| allData.put("repositoryName", view.getRepositoryName()); | |
| } | |
| if (!allData.containsKey("breadcrumbs") && view.getRepositoryName() != null) { | |
| allData.put("breadcrumbs", view.getBreadcrumbs()); | |
| } | |
| res.setStatus(HttpServletResponse.SC_OK); | |
| return allData; | |
| } | |
| /** | |
| * Render data to JSON using GSON. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @param src source, @see com.google.gson.Gson#toJson(Object, Type, Appendable) | |
| * @param typeOfSrc type of source, @see com.google.gson.Gson#toJson(Object, Type, Appendable) | |
| */ | |
| protected void renderJson( | |
| HttpServletRequest req, HttpServletResponse res, Object src, Type typeOfSrc) | |
| throws IOException { | |
| setApiHeaders(req, res, JSON); | |
| res.setStatus(SC_OK); | |
| try (Writer writer = newWriter(req, res)) { | |
| newGsonBuilder(req).create().toJson(src, typeOfSrc, writer); | |
| writer.write('\n'); | |
| } | |
| } | |
| @SuppressWarnings("unused") // Used in subclasses. | |
| protected GsonBuilder newGsonBuilder(HttpServletRequest req) throws IOException { | |
| return new GsonBuilder() | |
| .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) | |
| .setPrettyPrinting() | |
| .generateNonExecutableJson(); | |
| } | |
| /** | |
| * Start to render text. | |
| * | |
| * @see #startRenderText(HttpServletRequest, HttpServletResponse) | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @param contentType contentType to set. | |
| * @return the response's writer. | |
| */ | |
| protected Writer startRenderText( | |
| HttpServletRequest req, HttpServletResponse res, String contentType) throws IOException { | |
| setApiHeaders(req, res, contentType); | |
| return newWriter(req, res); | |
| } | |
| /** | |
| * Prepare the response to render plain text. | |
| * | |
| * <p>Unlike {@link #renderHtml(HttpServletRequest, HttpServletResponse, String, Map)} and {@link | |
| * #renderJson(HttpServletRequest, HttpServletResponse, Object, Type)}, which assume the data to | |
| * render is already completely prepared, this method does not write any data, only headers, and | |
| * returns the response's ready-to-use writer. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @return the response's writer. | |
| */ | |
| protected Writer startRenderText(HttpServletRequest req, HttpServletResponse res) | |
| throws IOException { | |
| return startRenderText(req, res, TEXT.getMimeType()); | |
| } | |
| /** | |
| * Render an error as plain text. | |
| * | |
| * @param req in-progress request. | |
| * @param res in-progress response. | |
| * @param statusCode HTTP status code. | |
| * @param message full message text. | |
| * @throws IOException if an IO error occurred. | |
| */ | |
| protected void renderTextError( | |
| HttpServletRequest req, HttpServletResponse res, int statusCode, String message) | |
| throws IOException { | |
| res.setStatus(statusCode); | |
| setApiHeaders(req, res, TEXT); | |
| setCacheHeaders(req, res); | |
| try (Writer out = newWriter(req, res)) { | |
| out.write(message); | |
| } | |
| } | |
| protected GitilesAccess getAccess(HttpServletRequest req) { | |
| return GitilesAccess.getAccess(req, accessFactory); | |
| } | |
| protected void setCacheHeaders(HttpServletRequest req, HttpServletResponse res) { | |
| if (Strings.nullToEmpty(req.getHeader(HttpHeaders.PRAGMA)).equalsIgnoreCase("no-cache") | |
| || Strings.nullToEmpty(req.getHeader(HttpHeaders.CACHE_CONTROL)) | |
| .equalsIgnoreCase("no-cache")) { | |
| setNotCacheable(res); | |
| return; | |
| } | |
| GitilesView view = ViewFilter.getView(req); | |
| Revision rev = view.getRevision(); | |
| if (rev.nameIsId()) { | |
| res.setHeader( | |
| HttpHeaders.CACHE_CONTROL, "private, max-age=7200, stale-while-revalidate=604800"); | |
| return; | |
| } | |
| setNotCacheable(res); | |
| } | |
| protected void setApiHeaders(HttpServletRequest req, HttpServletResponse res, String contentType) | |
| throws IOException { | |
| if (!Strings.isNullOrEmpty(contentType)) { | |
| res.setContentType(contentType); | |
| } | |
| res.setCharacterEncoding(UTF_8.name()); | |
| res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment"); | |
| GitilesAccess access = getAccess(req); | |
| String[] allowOrigin = access.getConfig().getStringList("gitiles", null, "allowOriginRegex"); | |
| if (allowOrigin.length > 0) { | |
| String origin = req.getHeader(HttpHeaders.ORIGIN); | |
| Pattern allowOriginPattern = Pattern.compile(Joiner.on("|").join(allowOrigin)); | |
| if (!Strings.isNullOrEmpty(origin) && allowOriginPattern.matcher(origin).matches()) { | |
| res.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin); | |
| res.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "X-Requested-With"); | |
| res.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); | |
| res.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET"); | |
| } | |
| } else { | |
| res.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); | |
| } | |
| setCacheHeaders(req, res); | |
| } | |
| protected void setApiHeaders(HttpServletRequest req, HttpServletResponse res, FormatType type) | |
| throws IOException { | |
| setApiHeaders(req, res, type.getMimeType()); | |
| } | |
| protected void setDownloadHeaders( | |
| HttpServletRequest req, HttpServletResponse res, String filename, String contentType) { | |
| res.setContentType(contentType); | |
| res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename); | |
| setCacheHeaders(req, res); | |
| } | |
| protected static Writer newWriter(OutputStream os, HttpServletResponse res) throws IOException { | |
| // StreamEncoder#write(int) is wasteful with its allocations, and we don't have much control | |
| // over whether library code calls that variant as opposed to the saner write(char[], int, int). | |
| // Protect against this by buffering. | |
| return new BufferedWriter( | |
| new OutputStreamWriter(os, Charset.forName(res.getCharacterEncoding()))); | |
| } | |
| private Writer newWriter(HttpServletRequest req, HttpServletResponse res) throws IOException { | |
| OutputStream out; | |
| if (acceptsGzipEncoding(req)) { | |
| res.addHeader(HttpHeaders.VARY, HttpHeaders.ACCEPT_ENCODING); | |
| res.setHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); | |
| out = new GZIPOutputStream(res.getOutputStream()); | |
| } else { | |
| out = res.getOutputStream(); | |
| } | |
| return newWriter(out, res); | |
| } | |
| protected static boolean acceptsGzipEncoding(HttpServletRequest req) { | |
| String accepts = req.getHeader(HttpHeaders.ACCEPT_ENCODING); | |
| if (accepts == null) { | |
| return false; | |
| } | |
| for (int b = 0; b < accepts.length(); ) { | |
| int comma = accepts.indexOf(',', b); | |
| int e = 0 <= comma ? comma : accepts.length(); | |
| String term = accepts.substring(b, e).trim(); | |
| if (term.equals(ENCODING_GZIP)) { | |
| return true; | |
| } | |
| b = e + 1; | |
| } | |
| return false; | |
| } | |
| protected static byte[] gzip(byte[] raw) throws IOException { | |
| ByteArrayOutputStream out = new ByteArrayOutputStream(); | |
| try (GZIPOutputStream gz = new GZIPOutputStream(out)) { | |
| gz.write(raw); | |
| } | |
| return out.toByteArray(); | |
| } | |
| } |