| // 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.Preconditions.checkArgument; | |
| import static com.google.common.base.Preconditions.checkNotNull; | |
| import static com.google.common.base.Preconditions.checkState; | |
| import static java.nio.charset.StandardCharsets.UTF_8; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.collect.ImmutableMap; | |
| import com.google.common.collect.Maps; | |
| import com.google.common.hash.Funnels; | |
| import com.google.common.hash.HashCode; | |
| import com.google.common.hash.Hasher; | |
| import com.google.common.hash.Hashing; | |
| import com.google.common.html.types.LegacyConversions; | |
| import com.google.common.io.ByteStreams; | |
| import com.google.common.net.HttpHeaders; | |
| import com.google.template.soy.data.SanitizedContent; | |
| import com.google.template.soy.data.SanitizedContent.ContentKind; | |
| import com.google.template.soy.data.SanitizedContents; | |
| import com.google.template.soy.jbcsrc.api.SoySauce; | |
| import java.io.File; | |
| import java.io.IOException; | |
| import java.io.InputStream; | |
| import java.io.OutputStream; | |
| import java.net.MalformedURLException; | |
| import java.net.URL; | |
| import java.util.Map; | |
| import java.util.Optional; | |
| import java.util.concurrent.ConcurrentHashMap; | |
| import java.util.concurrent.ConcurrentMap; | |
| import java.util.function.Function; | |
| import java.util.zip.GZIPOutputStream; | |
| import javax.servlet.http.HttpServletRequest; | |
| import javax.servlet.http.HttpServletResponse; | |
| /** | |
| * Renderer for Soy templates used by Gitiles. | |
| * | |
| * <p>Most callers should not use the methods in this class directly, and instead use one of the | |
| * HTML methods in {@link BaseServlet}. | |
| */ | |
| public abstract class Renderer { | |
| // Must match .streamingPlaceholder. | |
| private static final String PLACEHOLDER = "id=\"STREAMED-OUTPUT-BLOCK\""; | |
| private static final SanitizedContent THEME_INIT_SCRIPT; | |
| private static final SanitizedContent THEME_TOGGLE_SCRIPT; | |
| static { | |
| try { | |
| THEME_INIT_SCRIPT = | |
| SanitizedContents.fromResource( | |
| Renderer.class, "static/theme-init.js", UTF_8, ContentKind.JS); | |
| THEME_TOGGLE_SCRIPT = | |
| SanitizedContents.fromResource( | |
| Renderer.class, "static/theme-toggle.js", UTF_8, ContentKind.JS); | |
| } catch (IOException e) { | |
| throw new ExceptionInInitializerError(e); | |
| } | |
| } | |
| private static final ImmutableList<String> SOY_FILENAMES = | |
| ImmutableList.of( | |
| "BlameDetail.soy", | |
| "Common.soy", | |
| "DiffDetail.soy", | |
| "Doc.soy", | |
| "Error.soy", | |
| "GrepDetail.soy", | |
| "HostIndex.soy", | |
| "LogDetail.soy", | |
| "ObjectDetail.soy", | |
| "PathDetail.soy", | |
| "RefList.soy", | |
| "RevisionDetail.soy", | |
| "RepositoryIndex.soy"); | |
| public static final ImmutableMap<String, String> STATIC_URL_GLOBALS = | |
| ImmutableMap.<String, String>builder() | |
| .put("gitiles.BASE_CSS_URL", "base.css") | |
| .put("gitiles.DOC_CSS_URL", "doc.css") | |
| .put("gitiles.PRETTIFY_CSS_URL", "prettify/prettify.css") | |
| .put("gitiles.FAVICON_ICO_URL", "favicon.ico") | |
| .put("gitiles.FAVICON_SVG_URL", "favicon.svg") | |
| .put("gitiles.FAVICON_32_URL", "favicon-32x32.png") | |
| .put("gitiles.FAVICON_16_URL", "favicon-16x16.png") | |
| .put("gitiles.APPLE_TOUCH_ICON_URL", "apple-touch-icon.png") | |
| .buildOrThrow(); | |
| protected static Function<String, URL> fileUrlMapper() { | |
| return fileUrlMapper(""); | |
| } | |
| protected static Function<String, URL> fileUrlMapper(String prefix) { | |
| checkNotNull(prefix); | |
| return filename -> { | |
| if (filename == null) { | |
| return null; | |
| } | |
| try { | |
| return new File(prefix + filename).toURI().toURL(); | |
| } catch (MalformedURLException e) { | |
| throw new IllegalArgumentException(e); | |
| } | |
| }; | |
| } | |
| protected ImmutableMap<String, URL> templates; | |
| protected ImmutableMap<String, String> globals; | |
| protected final String siteTitle; | |
| private final ConcurrentMap<String, HashCode> hashes = | |
| new ConcurrentHashMap<>(SOY_FILENAMES.size()); | |
| protected Renderer( | |
| Function<String, URL> resourceMapper, | |
| Map<String, String> globals, | |
| String staticPrefix, | |
| Iterable<URL> customTemplates, | |
| String siteTitle) { | |
| checkNotNull(staticPrefix, "staticPrefix"); | |
| ImmutableMap.Builder<String, URL> b = ImmutableMap.builder(); | |
| for (String name : SOY_FILENAMES) { | |
| b.put(name, resourceMapper.apply(name)); | |
| } | |
| for (URL u : customTemplates) { | |
| b.put(u.toString(), u); | |
| } | |
| templates = b.buildOrThrow(); | |
| Map<String, String> allGlobals = Maps.newHashMap(); | |
| for (Map.Entry<String, String> e : STATIC_URL_GLOBALS.entrySet()) { | |
| allGlobals.put(e.getKey(), staticPrefix + e.getValue()); | |
| } | |
| allGlobals.putAll(globals); | |
| this.globals = ImmutableMap.copyOf(allGlobals); | |
| this.siteTitle = siteTitle; | |
| } | |
| public HashCode getTemplateHash(String soyFile) { | |
| return hashes.computeIfAbsent(soyFile, this::computeTemplateHash); | |
| } | |
| HashCode computeTemplateHash(String soyFile) { | |
| URL u = templates.get(soyFile); | |
| checkState(u != null, "Missing Soy template %s", soyFile); | |
| Hasher h = Hashing.murmur3_128().newHasher(); | |
| try (InputStream is = u.openStream(); | |
| OutputStream os = Funnels.asOutputStream(h)) { | |
| ByteStreams.copy(is, os); | |
| } catch (IOException e) { | |
| throw new IllegalStateException("Missing Soy template " + soyFile, e); | |
| } | |
| return h.hash(); | |
| } | |
| void renderHtml( | |
| HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData) | |
| throws IOException { | |
| res.setContentType("text/html"); | |
| res.setCharacterEncoding("UTF-8"); | |
| byte[] data = | |
| newRenderer(templateName, Optional.of(req)) | |
| .setData(soyData) | |
| .renderHtml() | |
| .get() | |
| .toString() | |
| .getBytes(UTF_8); | |
| if (BaseServlet.acceptsGzipEncoding(req)) { | |
| res.addHeader(HttpHeaders.VARY, HttpHeaders.ACCEPT_ENCODING); | |
| res.setHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); | |
| data = BaseServlet.gzip(data); | |
| } | |
| res.setContentLength(data.length); | |
| res.getOutputStream().write(data); | |
| } | |
| OutputStream renderHtmlStreaming( | |
| HttpServletRequest req, HttpServletResponse res, String templateName, Map<String, ?> soyData) | |
| throws IOException { | |
| return renderHtmlStreaming(req, res, false, templateName, soyData); | |
| } | |
| OutputStream renderHtmlStreaming( | |
| HttpServletRequest req, | |
| HttpServletResponse res, | |
| boolean gzip, | |
| String templateName, | |
| Map<String, ?> soyData) | |
| throws IOException { | |
| String html = | |
| newRenderer(templateName, Optional.of(req)).setData(soyData).renderHtml().get().toString(); | |
| int id = html.indexOf(PLACEHOLDER); | |
| checkArgument(id >= 0, "Template must contain %s", PLACEHOLDER); | |
| int lt = html.lastIndexOf('<', id); | |
| int gt = html.indexOf('>', id + PLACEHOLDER.length()); | |
| OutputStream out = gzip ? new GZIPOutputStream(res.getOutputStream()) : res.getOutputStream(); | |
| out.write(html.substring(0, lt).getBytes(UTF_8)); | |
| out.flush(); | |
| byte[] tail = html.substring(gt + 1).getBytes(UTF_8); | |
| return new OutputStream() { | |
| @Override | |
| public void write(byte[] b, int off, int len) throws IOException { | |
| out.write(b, off, len); | |
| } | |
| @Override | |
| public void write(int b) throws IOException { | |
| out.write(b); | |
| } | |
| @Override | |
| public void flush() throws IOException { | |
| out.flush(); | |
| } | |
| @Override | |
| public void close() throws IOException { | |
| try (out) { | |
| out.write(tail); | |
| } | |
| } | |
| }; | |
| } | |
| SoySauce.Renderer newRenderer(String templateName) { | |
| return newRenderer(templateName, Optional.empty()); | |
| } | |
| SoySauce.Renderer newRenderer(String templateName, Optional<HttpServletRequest> req) { | |
| ImmutableMap.Builder<String, Object> staticUrls = ImmutableMap.builder(); | |
| for (String key : STATIC_URL_GLOBALS.keySet()) { | |
| staticUrls.put( | |
| key.replaceFirst("^gitiles\\.", ""), | |
| LegacyConversions.riskilyAssumeTrustedResourceUrl(globals.get(key))); | |
| } | |
| ImmutableMap.Builder<String, Object> ij = | |
| ImmutableMap.<String, Object>builder() | |
| .put("staticUrls", staticUrls.buildOrThrow()) | |
| .put("SITE_TITLE", siteTitle) | |
| .put("THEME_INIT_SCRIPT", THEME_INIT_SCRIPT) | |
| .put("THEME_TOGGLE_SCRIPT", THEME_TOGGLE_SCRIPT); | |
| Optional<String> nonce = req.map((r) -> (String) r.getAttribute("nonce")); | |
| if (nonce.isPresent()) { | |
| ij.put("csp_nonce", nonce.get()); | |
| } | |
| return getSauce().renderTemplate(templateName).setIj(ij.buildOrThrow()); | |
| } | |
| protected abstract SoySauce getSauce(); | |
| /** | |
| * Give a resource URL of a soy template file, returns the import path for use in a Soy import | |
| * statement. | |
| */ | |
| protected String toSoySrcPath(URL templateUrl) { | |
| String filePath = templateUrl.getPath(); | |
| String fileName = filePath.substring(filePath.lastIndexOf('/') + 1); | |
| return "com/google/gitiles/templates/" + fileName; | |
| } | |
| } |