com.google.gerrit.httpd.plugins.HttpPluginServlet Maven / Gradle / Ivy
The newest version!
// Copyright (C) 2012 The Android Open Source Project
//
// 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.gerrit.httpd.plugins;
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
import static com.google.common.net.HttpHeaders.ORIGIN;
import static com.google.common.net.HttpHeaders.VARY;
import static com.google.gerrit.common.FileUtil.lastModified;
import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CHARACTER_ENCODING;
import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CONTENT_TYPE;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toList;
import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.httpd.resources.Resource;
import com.google.gerrit.httpd.resources.ResourceKey;
import com.google.gerrit.httpd.resources.SmallResource;
import com.google.gerrit.httpd.restapi.RestApiServlet;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.documentation.MarkdownFormatter;
import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
import com.google.gerrit.server.plugins.Plugin;
import com.google.gerrit.server.plugins.Plugin.ApiType;
import com.google.gerrit.server.plugins.PluginContentScanner;
import com.google.gerrit.server.plugins.PluginEntry;
import com.google.gerrit.server.plugins.PluginsCollection;
import com.google.gerrit.server.plugins.ReloadPluginListener;
import com.google.gerrit.server.plugins.StartPluginListener;
import com.google.gerrit.server.ssh.SshInfo;
import com.google.gerrit.util.http.CacheHeaders;
import com.google.gerrit.util.http.RequestUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import com.google.inject.servlet.GuiceFilter;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Predicate;
import java.util.jar.Attributes;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.FilterChain;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.util.IO;
import org.eclipse.jgit.util.RawParseUtils;
@Singleton
class HttpPluginServlet extends HttpServlet implements StartPluginListener, ReloadPluginListener {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final int SMALL_RESOURCE = 128 * 1024;
private static final long serialVersionUID = 1L;
private final MimeUtilFileTypeRegistry mimeUtil;
private final Provider webUrl;
private final Cache resourceCache;
private final String sshHost;
private final int sshPort;
private final RestApiServlet managerApi;
private List pending = new ArrayList<>();
private ContextMapper wrapper;
private final ConcurrentMap plugins = Maps.newConcurrentMap();
private final Pattern allowOrigin;
@Inject
HttpPluginServlet(
MimeUtilFileTypeRegistry mimeUtil,
@CanonicalWebUrl Provider webUrl,
@Named(HttpPluginModule.PLUGIN_RESOURCES) Cache cache,
SshInfo sshInfo,
RestApiServlet.Globals globals,
PluginsCollection plugins,
@GerritServerConfig Config cfg) {
this.mimeUtil = mimeUtil;
this.webUrl = webUrl;
this.resourceCache = cache;
this.managerApi = new RestApiServlet(globals, plugins);
String sshHost = "review.example.com";
int sshPort = 29418;
if (!sshInfo.getHostKeys().isEmpty()) {
String host = sshInfo.getHostKeys().get(0).getHost();
int c = host.lastIndexOf(':');
if (0 <= c) {
sshHost = host.substring(0, c);
sshPort = Integer.parseInt(host.substring(c + 1));
} else {
sshHost = host;
sshPort = 22;
}
}
this.sshHost = sshHost;
this.sshPort = sshPort;
this.allowOrigin = makeAllowOrigin(cfg);
}
@Override
public synchronized void init(ServletConfig config) throws ServletException {
super.init(config);
wrapper = new ContextMapper(config.getServletContext().getContextPath());
for (Plugin plugin : pending) {
install(plugin);
}
pending = null;
}
@Override
public synchronized void onStartPlugin(Plugin plugin) {
if (pending != null) {
pending.add(plugin);
} else {
install(plugin);
}
}
@Override
public void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin) {
install(newPlugin);
}
private void install(Plugin plugin) {
GuiceFilter filter = load(plugin);
final String name = plugin.getName();
final PluginHolder holder = new PluginHolder(plugin, filter);
plugin.add(() -> plugins.remove(name, holder));
plugins.put(name, holder);
}
@Nullable
private GuiceFilter load(Plugin plugin) {
if (plugin.getHttpInjector() != null) {
final String name = plugin.getName();
final GuiceFilter filter;
try {
filter = plugin.getHttpInjector().getInstance(GuiceFilter.class);
} catch (RuntimeException e) {
logger.atWarning().withCause(e).log("Plugin %s cannot load GuiceFilter", name);
return null;
}
try {
ServletContext ctx = PluginServletContext.create(plugin, wrapper.getFullPath(name));
filter.init(new WrappedFilterConfig(ctx));
} catch (ServletException e) {
logger.atWarning().withCause(e).log("Plugin %s failed to initialize HTTP", name);
return null;
}
plugin.add(filter::destroy);
return filter;
}
return null;
}
@Override
public void service(HttpServletRequest req, HttpServletResponse res)
throws IOException, ServletException {
List parts =
Lists.newArrayList(
Splitter.on('/')
.limit(3)
.omitEmptyStrings()
.split(Strings.nullToEmpty(RequestUtil.getEncodedPathInfo(req))));
if (isApiCall(req, parts)) {
managerApi.service(req, res);
return;
}
String name = parts.get(0);
final PluginHolder holder = plugins.get(name);
if (holder == null) {
CacheHeaders.setNotCacheable(res);
res.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
HttpServletRequest wr = wrapper.create(req, name);
FilterChain chain =
(sreq, sres) -> onDefault(holder, (HttpServletRequest) sreq, (HttpServletResponse) sres);
if (holder.filter != null) {
holder.filter.doFilter(wr, res, chain);
} else {
chain.doFilter(wr, res);
}
}
private static boolean isApiCall(HttpServletRequest req, List parts) {
String method = req.getMethod();
int cnt = parts.size();
return cnt == 0
|| (cnt == 1 && ("PUT".equals(method) || "DELETE".equals(method)))
|| (cnt == 2 && parts.get(1).startsWith("gerrit~"));
}
private void onDefault(PluginHolder holder, HttpServletRequest req, HttpServletResponse res)
throws IOException {
if (!"GET".equals(req.getMethod()) && !"HEAD".equals(req.getMethod())) {
CacheHeaders.setNotCacheable(res);
res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
String pathInfo = RequestUtil.getEncodedPathInfo(req);
if (pathInfo.length() < 1) {
Resource.NOT_FOUND.send(req, res);
return;
}
checkCors(req, res);
String file = pathInfo.substring(1);
PluginResourceKey key = PluginResourceKey.create(holder.plugin, file);
Resource rsc = resourceCache.getIfPresent(key);
if (rsc != null && req.getHeader(HttpHeaders.IF_MODIFIED_SINCE) == null) {
rsc.send(req, res);
return;
}
String uri = req.getRequestURI();
if ("".equals(file)) {
res.sendRedirect(uri + holder.docPrefix + "index.html");
return;
}
if (file.startsWith(holder.staticPrefix)) {
if (holder.plugin.getApiType() == ApiType.JS) {
sendJsPlugin(holder.plugin, key, req, res);
} else {
PluginContentScanner scanner = holder.plugin.getContentScanner();
Optional entry = scanner.getEntry(file);
if (entry.isPresent()) {
if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
rsc.send(req, res);
} else {
sendResource(scanner, entry.get(), key, res);
}
} else {
resourceCache.put(key, Resource.NOT_FOUND);
Resource.NOT_FOUND.send(req, res);
}
}
} else if (file.equals(holder.docPrefix.substring(0, holder.docPrefix.length() - 1))) {
res.sendRedirect(uri + "/index.html");
} else if (file.startsWith(holder.docPrefix) && file.endsWith("/")) {
res.sendRedirect(uri + "index.html");
} else if (file.startsWith(holder.docPrefix)) {
PluginContentScanner scanner = holder.plugin.getContentScanner();
Optional entry = scanner.getEntry(file);
if (!entry.isPresent()) {
entry = findSource(scanner, file);
}
if (!entry.isPresent() && file.endsWith("/index.html")) {
String pfx = file.substring(0, file.length() - "index.html".length());
long pluginLastModified = lastModified(holder.plugin.getSrcFile());
if (hasUpToDateCachedResource(rsc, pluginLastModified)) {
rsc.send(req, res);
} else {
sendAutoIndex(scanner, pfx, holder.plugin.getName(), key, res, pluginLastModified);
}
} else if (entry.isPresent() && entry.get().getName().endsWith(".md")) {
if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
rsc.send(req, res);
} else {
sendMarkdownAsHtml(scanner, entry.get(), holder.plugin.getName(), key, res);
}
} else if (entry.isPresent()) {
if (hasUpToDateCachedResource(rsc, entry.get().getTime())) {
rsc.send(req, res);
} else {
sendResource(scanner, entry.get(), key, res);
}
} else {
resourceCache.put(key, Resource.NOT_FOUND);
Resource.NOT_FOUND.send(req, res);
}
} else {
resourceCache.put(key, Resource.NOT_FOUND);
Resource.NOT_FOUND.send(req, res);
}
}
@Nullable
private static Pattern makeAllowOrigin(Config cfg) {
String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
if (allow.length > 0) {
return Pattern.compile(Joiner.on('|').join(allow));
}
return null;
}
private void checkCors(HttpServletRequest req, HttpServletResponse res) {
String origin = req.getHeader(ORIGIN);
if (!Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) {
res.addHeader(VARY, ORIGIN);
setCorsHeaders(res, origin);
}
}
private void setCorsHeaders(HttpServletResponse res, String origin) {
res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD");
}
private boolean isOriginAllowed(String origin) {
return allowOrigin != null && allowOrigin.matcher(origin).matches();
}
private boolean hasUpToDateCachedResource(Resource cachedResource, long lastUpdateTime) {
return cachedResource != null && cachedResource.isUnchanged(lastUpdateTime);
}
private void appendPageAsSection(
PluginContentScanner scanner, PluginEntry pluginEntry, String sectionTitle, StringBuilder md)
throws IOException {
InputStreamReader isr = new InputStreamReader(scanner.getInputStream(pluginEntry), UTF_8);
StringBuilder content = new StringBuilder();
try (BufferedReader reader = new BufferedReader(isr)) {
String line;
while ((line = reader.readLine()) != null) {
line = StringUtils.stripEnd(line, null);
if (line.isEmpty()) {
content.append("\n");
} else {
content.append(line).append("\n");
}
}
}
// Only append the section if there was anything in it
if (content.toString().trim().length() > 0) {
md.append("## ");
md.append(sectionTitle);
md.append(" ##\n");
md.append("\n").append(content);
md.append("\n");
}
}
private void appendEntriesSection(
PluginContentScanner scanner,
List entries,
String sectionTitle,
StringBuilder md,
String prefix,
int nameOffset)
throws IOException {
if (!entries.isEmpty()) {
md.append("## ").append(sectionTitle).append(" ##\n");
for (PluginEntry entry : entries) {
String rsrc = entry.getName().substring(prefix.length());
String entryTitle;
if (rsrc.endsWith(".html")) {
entryTitle = rsrc.substring(nameOffset, rsrc.length() - 5).replace('-', ' ');
} else if (rsrc.endsWith(".md")) {
entryTitle = extractTitleFromMarkdown(scanner, entry);
if (Strings.isNullOrEmpty(entryTitle)) {
entryTitle = rsrc.substring(nameOffset, rsrc.length() - 3).replace('-', ' ');
}
} else {
entryTitle = rsrc.substring(nameOffset).replace('-', ' ');
}
md.append(String.format("* [%s](%s)\n", entryTitle, rsrc));
}
md.append("\n");
}
}
private void sendAutoIndex(
PluginContentScanner scanner,
final String prefix,
final String pluginName,
PluginResourceKey cacheKey,
HttpServletResponse res,
long lastModifiedTime)
throws IOException {
List cmds = new ArrayList<>();
List servlets = new ArrayList<>();
List restApis = new ArrayList<>();
List docs = new ArrayList<>();
PluginEntry about = null;
PluginEntry toc = null;
Predicate filter =
entry -> {
String name = entry.getName();
Optional size = entry.getSize();
if (name.startsWith(prefix)
&& (name.endsWith(".md") || name.endsWith(".html"))
&& size.isPresent()) {
if (size.get() <= 0 || size.get() > SMALL_RESOURCE) {
logger.atWarning().log(
"Plugin %s: %s omitted from document index. " + "Size %d out of range (0,%d).",
pluginName, name.substring(prefix.length()), size.get(), SMALL_RESOURCE);
return false;
}
return true;
}
return false;
};
List entries = scanner.entries().filter(filter).collect(toList());
for (PluginEntry entry : entries) {
String name = entry.getName().substring(prefix.length());
if (name.startsWith("cmd-")) {
cmds.add(entry);
} else if (name.startsWith("servlet-")) {
servlets.add(entry);
} else if (name.startsWith("rest-api-")) {
restApis.add(entry);
} else if (name.startsWith("about.")) {
if (about == null) {
about = entry;
} else {
logger.atWarning().log(
"Plugin %s: Multiple 'about' documents found; using %s",
pluginName, about.getName().substring(prefix.length()));
}
} else if (name.startsWith("toc.")) {
if (toc == null) {
toc = entry;
} else {
logger.atWarning().log(
"Plugin %s: Multiple 'toc' documents found; using %s",
pluginName, toc.getName().substring(prefix.length()));
}
} else {
docs.add(entry);
}
}
cmds.sort(PluginEntry.COMPARATOR_BY_NAME);
docs.sort(PluginEntry.COMPARATOR_BY_NAME);
StringBuilder md = new StringBuilder();
md.append(String.format("# Plugin %s #\n", pluginName));
md.append("\n");
appendPluginInfoTable(md, scanner.getManifest().getMainAttributes());
if (about != null) {
appendPageAsSection(scanner, about, "About", md);
}
if (toc != null) {
appendPageAsSection(scanner, toc, "Documentation", md);
} else {
appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
appendEntriesSection(scanner, restApis, "REST APIs", md, prefix, "rest-api-".length());
appendEntriesSection(scanner, cmds, "Commands", md, prefix, "cmd-".length());
}
sendMarkdownAsHtml(md.toString(), pluginName, cacheKey, res, lastModifiedTime);
}
private void sendMarkdownAsHtml(
String md,
String pluginName,
PluginResourceKey cacheKey,
HttpServletResponse res,
long lastModifiedTime)
throws UnsupportedEncodingException, IOException {
Map macros = new HashMap<>();
macros.put("PLUGIN", pluginName);
macros.put("SSH_HOST", sshHost);
macros.put("SSH_PORT", "" + sshPort);
String url = webUrl.get();
if (Strings.isNullOrEmpty(url)) {
url = "http://review.example.com/";
}
macros.put("URL", url);
Matcher m = Pattern.compile("(\\\\)?@([A-Z_]+)@").matcher(md);
StringBuilder sb = new StringBuilder();
while (m.find()) {
String key = m.group(2);
String val = macros.get(key);
if (m.group(1) != null) {
m.appendReplacement(sb, "@" + key + "@");
} else if (val != null) {
m.appendReplacement(sb, val);
} else {
m.appendReplacement(sb, "@" + key + "@");
}
}
m.appendTail(sb);
byte[] html = new MarkdownFormatter().markdownToDocHtml(sb.toString(), UTF_8.name());
resourceCache.put(
cacheKey,
new SmallResource(html)
.setContentType("text/html")
.setCharacterEncoding(UTF_8.name())
.setLastModified(lastModifiedTime));
res.setContentType("text/html");
res.setCharacterEncoding(UTF_8.name());
res.setContentLength(html.length);
res.setDateHeader("Last-Modified", lastModifiedTime);
res.getOutputStream().write(html);
}
private static void appendPluginInfoTable(StringBuilder html, Attributes main) {
if (main != null) {
String t = main.getValue(Attributes.Name.IMPLEMENTATION_TITLE);
String n = main.getValue(Attributes.Name.IMPLEMENTATION_VENDOR);
String v = main.getValue(Attributes.Name.IMPLEMENTATION_VERSION);
String a = main.getValue("Gerrit-ApiVersion");
html.append("");
if (!Strings.isNullOrEmpty(t)) {
html.append("Name ").append(t).append(" \n");
}
if (!Strings.isNullOrEmpty(n)) {
html.append("Vendor ").append(n).append(" \n");
}
if (!Strings.isNullOrEmpty(v)) {
html.append("Version ").append(v).append(" \n");
}
if (!Strings.isNullOrEmpty(a)) {
html.append("API Version ").append(a).append(" \n");
}
html.append("
\n");
}
}
private static String extractTitleFromMarkdown(PluginContentScanner scanner, PluginEntry entry)
throws IOException {
String charEnc = null;
Map