blob: 0d4c67e8eecb03d3e1ec1239d87f966ba6919b0c [file] [log] [blame]
// Copyright (C) 2015 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.raw;
import static com.google.common.base.Preconditions.checkArgument;
import static java.nio.file.Files.exists;
import static java.nio.file.Files.isReadable;
import com.google.common.cache.Cache;
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.api.GerritApi;
import com.google.gerrit.httpd.XsrfCookieFilter;
import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
import com.google.gerrit.launcher.GerritLauncher;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.GerritOptions;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
import com.google.inject.Inject;
import com.google.inject.Key;
import com.google.inject.Provides;
import com.google.inject.ProvisionException;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import com.google.inject.name.Names;
import com.google.inject.servlet.ServletModule;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jgit.lib.Config;
public class StaticModule extends ServletModule {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public static final String CACHE = "static_content";
/**
* Paths at which we should serve the main PolyGerrit application {@code index.html}.
*
* <p>Supports {@code "/*"} as a trailing wildcard.
*/
public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
ImmutableList.of(
"/",
"/c/*",
"/p/*",
"/q/*",
"/x/*",
"/admin/*",
"/dashboard/*",
"/groups/self",
"/settings/*",
"/Documentation/q/*");
/**
* Paths that should be treated as static assets when serving PolyGerrit.
*
* <p>Supports {@code "/*"} as a trailing wildcard.
*/
private static final ImmutableList<String> POLYGERRIT_ASSET_PATHS =
ImmutableList.of(
"/behaviors/*",
"/bower_components/*",
"/elements/*",
"/fonts/*",
"/scripts/*",
"/styles/*");
private static final String DOC_SERVLET = "DocServlet";
private static final String FAVICON_SERVLET = "FaviconServlet";
private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
private final GerritOptions options;
private Paths paths;
@Inject
public StaticModule(GerritOptions options) {
this.options = options;
}
@Provides
@Singleton
private Paths getPaths() {
if (paths == null) {
paths = new Paths(options);
}
return paths;
}
@Override
protected void configureServlets() {
serveRegex("^/Documentation$").with(named(DOC_SERVLET));
serveRegex("^/Documentation/$").with(named(DOC_SERVLET));
serveRegex("^/Documentation/(.+)$").with(named(DOC_SERVLET));
serve("/static/*").with(SiteStaticDirectoryServlet.class);
install(
new CacheModule() {
@Override
protected void configure() {
cache(CACHE, Path.class, Resource.class)
.maximumWeight(1 << 20)
.weigher(ResourceServlet.Weigher.class);
}
});
if (!options.headless()) {
install(new CoreStaticModule());
install(new PolyGerritModule());
}
}
@Provides
@Singleton
@Named(DOC_SERVLET)
HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
Paths p = getPaths();
if (p.warFs != null) {
return new WarDocServlet(cache, p.warFs);
} else if (p.unpackedWar != null && !p.isDev()) {
return new DirectoryDocServlet(cache, p.unpackedWar);
} else {
return new HttpServlet() {
private static final long serialVersionUID = 1L;
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
}
};
}
}
private class CoreStaticModule extends ServletModule {
@Override
public void configureServlets() {
serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
serve("/favicon.ico").with(named(FAVICON_SERVLET));
}
@Provides
@Singleton
@Named(ROBOTS_TXT_SERVLET)
HttpServlet getRobotsTxtServlet(
@GerritServerConfig Config cfg,
SitePaths sitePaths,
@Named(CACHE) Cache<Path, Resource> cache) {
Path configPath = sitePaths.resolve(cfg.getString("httpd", null, "robotsFile"));
if (configPath != null) {
if (exists(configPath) && isReadable(configPath)) {
return new SingleFileServlet(cache, configPath, true);
}
logger.atWarning().log("Cannot read httpd.robotsFile, using default");
}
Paths p = getPaths();
if (p.warFs != null) {
return new SingleFileServlet(cache, p.warFs.getPath("/robots.txt"), false);
}
return new SingleFileServlet(cache, webappSourcePath("robots.txt"), true);
}
@Provides
@Singleton
@Named(FAVICON_SERVLET)
HttpServlet getFaviconServlet(@Named(CACHE) Cache<Path, Resource> cache) {
Paths p = getPaths();
if (p.warFs != null) {
return new SingleFileServlet(cache, p.warFs.getPath("/favicon.ico"), false);
}
return new SingleFileServlet(cache, webappSourcePath("favicon.ico"), true);
}
private Path webappSourcePath(String name) {
Paths p = getPaths();
if (p.unpackedWar != null) {
return p.unpackedWar.resolve(name);
}
return p.sourceRoot.resolve("webapp/" + name);
}
}
private class PolyGerritModule extends ServletModule {
@Override
public void configureServlets() {
for (String p : POLYGERRIT_INDEX_PATHS) {
filter(p).through(XsrfCookieFilter.class);
}
filter("/*").through(PolyGerritFilter.class);
}
@Provides
@Singleton
@Named(POLYGERRIT_INDEX_SERVLET)
HttpServlet getPolyGerritUiIndexServlet(
@CanonicalWebUrl @Nullable String canonicalUrl,
@GerritServerConfig Config cfg,
GerritApi gerritApi) {
String cdnPath = cfg.getString("gerrit", null, "cdnPath");
String faviconPath = cfg.getString("gerrit", null, "faviconPath");
return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi);
}
@Provides
@Singleton
PolyGerritUiServlet getPolyGerritUiServlet(@Named(CACHE) Cache<Path, Resource> cache) {
return new PolyGerritUiServlet(cache, polyGerritBasePath());
}
@Provides
@Singleton
BowerComponentsDevServlet getBowerComponentsServlet(@Named(CACHE) Cache<Path, Resource> cache)
throws IOException {
return getPaths().isDev() ? new BowerComponentsDevServlet(cache, getPaths().builder) : null;
}
@Provides
@Singleton
FontsDevServlet getFontsServlet(@Named(CACHE) Cache<Path, Resource> cache) throws IOException {
return getPaths().isDev() ? new FontsDevServlet(cache, getPaths().builder) : null;
}
private Path polyGerritBasePath() {
Paths p = getPaths();
if (options.forcePolyGerritDev()) {
checkArgument(
p.sourceRoot != null, "no source root directory found for PolyGerrit developer mode");
}
if (p.isDev()) {
return p.sourceRoot.resolve("polygerrit-ui").resolve("app");
}
return p.warFs != null
? p.warFs.getPath("/polygerrit_ui")
: p.unpackedWar.resolve("polygerrit_ui");
}
}
private static class Paths {
private final FileSystem warFs;
private final BazelBuild builder;
private final Path sourceRoot;
private final Path unpackedWar;
private final boolean development;
private Paths(GerritOptions options) {
try {
File launcherLoadedFrom = getLauncherLoadedFrom();
if (launcherLoadedFrom != null && launcherLoadedFrom.getName().endsWith(".jar")) {
// Special case: unpacked war archive deployed in container.
// The path is something like:
// <container>/<gerrit>/WEB-INF/lib/launcher.jar
// Switch to exploded war case with <container>/webapp>/<gerrit>
// root directory
warFs = null;
unpackedWar =
java.nio.file.Paths.get(
launcherLoadedFrom.getParentFile().getParentFile().getParentFile().toURI());
sourceRoot = null;
development = false;
builder = null;
return;
}
warFs = getDistributionArchive(launcherLoadedFrom);
if (warFs == null) {
unpackedWar = makeWarTempDir();
development = true;
} else if (options.forcePolyGerritDev()) {
unpackedWar = null;
development = true;
} else {
unpackedWar = null;
development = false;
sourceRoot = null;
builder = null;
return;
}
} catch (IOException e) {
throw new ProvisionException("Error initializing static content paths", e);
}
sourceRoot = getSourceRootOrNull();
builder = new BazelBuild(sourceRoot);
}
private static Path getSourceRootOrNull() {
try {
return GerritLauncher.resolveInSourceRoot(".");
} catch (FileNotFoundException e) {
return null;
}
}
private FileSystem getDistributionArchive(File war) throws IOException {
if (war == null) {
return null;
}
return GerritLauncher.getZipFileSystem(war.toPath());
}
private File getLauncherLoadedFrom() {
File war;
try {
war = GerritLauncher.getDistributionArchive();
} catch (IOException e) {
if ((e instanceof FileNotFoundException)
&& GerritLauncher.NOT_ARCHIVED.equals(e.getMessage())) {
return null;
}
throw new ProvisionException("Error reading gerrit.war", e);
}
return war;
}
private boolean isDev() {
return development;
}
private Path makeWarTempDir() {
// Obtain our local temporary directory, but it comes back as a file
// so we have to switch it to be a directory post creation.
//
try {
File dstwar = GerritLauncher.createTempFile("gerrit_", "war");
if (!dstwar.delete() || !dstwar.mkdir()) {
throw new IOException("Cannot mkdir " + dstwar.getAbsolutePath());
}
// Jetty normally refuses to serve out of a symlinked directory, as
// a security feature. Try to resolve out any symlinks in the path.
//
try {
return dstwar.getCanonicalFile().toPath();
} catch (IOException e) {
return dstwar.getAbsoluteFile().toPath();
}
} catch (IOException e) {
throw new ProvisionException("Cannot create war tempdir", e);
}
}
}
private static Key<HttpServlet> named(String name) {
return Key.get(HttpServlet.class, Names.named(name));
}
@Singleton
private static class PolyGerritFilter implements Filter {
private final Paths paths;
private final HttpServlet polyGerritIndex;
private final PolyGerritUiServlet polygerritUI;
private final BowerComponentsDevServlet bowerComponentServlet;
private final FontsDevServlet fontServlet;
@Inject
PolyGerritFilter(
Paths paths,
@Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
PolyGerritUiServlet polygerritUI,
@Nullable BowerComponentsDevServlet bowerComponentServlet,
@Nullable FontsDevServlet fontServlet) {
this.paths = paths;
this.polyGerritIndex = polyGerritIndex;
this.polygerritUI = polygerritUI;
this.bowerComponentServlet = bowerComponentServlet;
this.fontServlet = fontServlet;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void destroy() {}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
GuiceFilterRequestWrapper reqWrapper = new GuiceFilterRequestWrapper(req);
String path = pathInfo(req);
// Special case assets during development that are built by Bazel and not
// served out of the source tree.
//
// In the war case, these are either inlined, or live under
// /polygerrit_ui in the war file, so we can just treat them as normal
// assets.
if (paths.isDev()) {
if (path.startsWith("/bower_components/")) {
bowerComponentServlet.service(reqWrapper, res);
return;
} else if (path.startsWith("/fonts/")) {
fontServlet.service(reqWrapper, res);
return;
}
}
if (isPolyGerritIndex(path)) {
polyGerritIndex.service(reqWrapper, res);
return;
}
if (isPolyGerritAsset(path)) {
polygerritUI.service(reqWrapper, res);
return;
}
chain.doFilter(req, res);
}
private static String pathInfo(HttpServletRequest req) {
String uri = req.getRequestURI();
String ctx = req.getContextPath();
return uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
}
private static boolean isPolyGerritAsset(String path) {
return matchPath(POLYGERRIT_ASSET_PATHS, path);
}
private static boolean isPolyGerritIndex(String path) {
return matchPath(POLYGERRIT_INDEX_PATHS, path);
}
private static boolean matchPath(Iterable<String> paths, String path) {
for (String p : paths) {
if (p.endsWith("/*")) {
if (path.regionMatches(0, p, 0, p.length() - 1)) {
return true;
}
} else if (p.equals(path)) {
return true;
}
}
return false;
}
}
private static class GuiceFilterRequestWrapper extends HttpServletRequestWrapper {
GuiceFilterRequestWrapper(HttpServletRequest req) {
super(req);
}
@Override
public String getPathInfo() {
String uri = getRequestURI();
String ctx = getContextPath();
// This is a workaround for long standing guice filter bug:
// https://github.com/google/guice/issues/807
String res = uri.startsWith(ctx) ? uri.substring(ctx.length()) : uri;
// Match the logic in the ResourceServlet, that re-add "/"
// for null path info
if ("/".equals(res)) {
return null;
}
return res;
}
}
}