Cookie-based PolyGerrit/GWT UI switch

To configure the UI that is used on the server side, there are now two
distinct options in gerrit.config:
* gerrit.enableGwtUi controls whether the GWT UI is enabled at all,
  defaulting to true.
* gerrit.enablePolyGerrit controls whether PolyGerrit is enabled at
  all.

If both options are false, the server is headless, same as if
--headless was passed to Daemon. If only one is true, that UI is used
regardless of user preferences. If both are true, users can switch
between the UIs at runtime.

Because gerrit.enableGwtUi defaults to true, this is a change from the
previous behavior of gerrit.enablePolyGerrit. Formerly, setting
enablePolyGerrit was enough to turn on PolyGerrit site-side. To
achieve that behavior now, admins need to set both enabePolyGerrit
true and enableGwtUi false.

There are two ways to toggle between UIs:
* Add a request parameter, ?polygerrit=1 (or 0 to disable).
* Add a cookie GERRIT_UI=polygerrit (or gwt to disable); this method
  is used by the existing Gerrit UI Switcher Chrome extension[1].

Guice Servlet doesn't support different filter phases, so that server
side forwarding cannot be used, because there is no way to
differentiate between REQUEST and FORWARDING phase filter.

Dispatching takes place in a new filter that handles all of /* when
the PolyGerrit UI is enabled. This requires doing some path matching
during doFilter to only process PolyGerrit-related paths. We do this
instead of filtering only specific paths with Guice to keep all the
dispatching code in one place, and to preserve the full path info
before passing to the ResourceServlet.

Due to the differences in the implementations: [2] of
ServletDefinition and FilterDefinition, path info in Filter#doFilter()
method is always null. To overcome this limitation request wrapper is
used.

Test Plan

Toggle the UI using request parameter mode:

* clear browser cache and cookies
* start gerrit and access default GWT UI
* switch to PolyGerrit UI: <URL>?polygerrit=1
* switch back to GWT UI: <URL>?polygerrit=0

Toggle the UI using Chrome Gerrit UI Switcher Extension:

* clear browser cache and cookies
* install adjusted Chrome Gerrit UI Switcher: [3]
* start gerrit and access default GWT UI
* toggle the UI by clicking on PG icon

[1] https://chrome.google.com/webstore/detail/fdjpjnlhaohkkbjnglcfehbmcmpojklf
[2] https://github.com/google/guice/issues/807
[3] https://gerrit-review.googlesource.com/76674

Bug: Issue 4047
Change-Id: Ib2788f86b2601b08e298b9e911523e65d7434961
This commit is contained in:
David Ostrovsky
2016-04-15 06:38:59 +02:00
committed by Dave Borowitz
parent fe91cbb399
commit dac0dcb853
4 changed files with 260 additions and 37 deletions

View File

@@ -20,6 +20,7 @@ public class GerritOptions {
private final boolean headless;
private final boolean slave;
private final boolean enablePolyGerrit;
private final boolean enableGwtUi;
private final boolean forcePolyGerritDev;
public GerritOptions(Config cfg, boolean headless, boolean slave,
@@ -28,11 +29,16 @@ public class GerritOptions {
this.slave = slave;
this.enablePolyGerrit = forcePolyGerritDev
|| cfg.getBoolean("gerrit", null, "enablePolyGerrit", false);
this.enableGwtUi = cfg.getBoolean("gerrit", null, "enableGwtUi", true);
this.forcePolyGerritDev = forcePolyGerritDev;
}
public boolean enableDefaultUi() {
return !headless && !enablePolyGerrit;
public boolean headless() {
return headless;
}
public boolean enableGwtUi() {
return !headless && enableGwtUi;
}
public boolean enableMasterFeatures() {

View File

@@ -62,7 +62,7 @@ class UrlModule extends ServletModule {
filter("/*").through(Key.get(CacheControlFilter.class));
bind(Key.get(CacheControlFilter.class)).in(SINGLETON);
if (options.enableDefaultUi()) {
if (options.enableGwtUi()) {
filter("/").through(XsrfCookieFilter.class);
filter("/accounts/self/detail").through(XsrfCookieFilter.class);
serve("/").with(HostPageServlet.class);

View File

@@ -36,7 +36,7 @@ class BowerComponentsServlet extends ResourceServlet {
} else {
bowerComponents = GerritLauncher
.newZipFileSystem(zip)
.getPath("bower_components/");
.getPath("/");
}
}

View File

@@ -15,9 +15,11 @@
package com.google.gerrit.httpd.raw;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static java.nio.file.Files.exists;
import static java.nio.file.Files.isReadable;
import com.google.common.base.Enums;
import com.google.common.cache.Cache;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.httpd.GerritOptions;
@@ -46,8 +48,16 @@ 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.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
public class StaticModule extends ServletModule {
@@ -55,20 +65,42 @@ public class StaticModule extends ServletModule {
LoggerFactory.getLogger(StaticModule.class);
public static final String CACHE = "static_content";
public static final String GERRIT_UI_COOKIE = "GERRIT_UI";
/**
* 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/*",
"/q/*",
"/x/*",
"/admin/*",
"/dashboard/*",
"/settings/*",
// TODO(dborowitz): These fragments conflict with the REST API
// namespace, so they will need to use a different path.
"/groups/*",
"/projects/*");
"/",
"/c/*",
"/q/*",
"/x/*",
"/admin/*",
"/dashboard/*",
"/settings/*");
// TODO(dborowitz): These fragments conflict with the REST API
// namespace, so they will need to use a different path.
//"/groups/*",
//"/projects/*");
//
/**
* 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";
@@ -77,6 +109,14 @@ public class StaticModule extends ServletModule {
"PolyGerritUiIndexServlet";
private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
private static final int GERRIT_UI_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
enum UiPreference {
NONE,
GWT,
POLYGERRIT;
}
private final GerritOptions options;
private Paths paths;
@@ -85,9 +125,11 @@ public class StaticModule extends ServletModule {
this.options = options;
}
@Provides
@Singleton
private Paths getPaths() {
if (paths == null) {
paths = new Paths();
paths = new Paths(options);
}
return paths;
}
@@ -104,11 +146,13 @@ public class StaticModule extends ServletModule {
.weigher(ResourceServlet.Weigher.class);
}
});
if (!options.headless()) {
install(new CoreStaticModule());
}
if (options.enablePolyGerrit()) {
install(new CoreStaticModule());
install(new PolyGerritUiModule());
} else if (options.enableDefaultUi()) {
install(new CoreStaticModule());
install(new PolyGerritModule());
}
if (options.enableGwtUi()) {
install(new GwtUiModule());
}
}
@@ -211,25 +255,17 @@ public class StaticModule extends ServletModule {
}
}
private class PolyGerritUiModule extends ServletModule {
private class PolyGerritModule extends ServletModule {
@Override
public void configureServlets() {
Path buckOut = getPaths().buckOut;
if (buckOut != null) {
serve("/bower_components/*").with(BowerComponentsServlet.class);
serve("/fonts/*").with(FontsServlet.class);
} else {
// In the war case, bower_components and fonts are either inlined
// by vulcanize, or live under /polygerrit_ui in the war file,
// so we don't need a separate servlet.
}
Key<HttpServlet> indexKey = named(POLYGERRIT_INDEX_SERVLET);
for (String p : POLYGERRIT_INDEX_PATHS) {
filter(p).through(XsrfCookieFilter.class);
serve(p).with(indexKey);
// Skip XsrfCookieFilter for /, since that is already done in the GWT UI
// path (UrlModule).
if (!p.equals("/")) {
filter(p).through(XsrfCookieFilter.class);
}
}
serve("/*").with(PolyGerritUiServlet.class);
filter("/*").through(PolyGerritFilter.class);
}
@Provides
@@ -281,13 +317,13 @@ public class StaticModule extends ServletModule {
}
}
private class Paths {
private static class Paths {
private final FileSystem warFs;
private final Path buckOut;
private final Path unpackedWar;
private final boolean development;
private Paths() {
private Paths(GerritOptions options) {
try {
File launcherLoadedFrom = getLauncherLoadedFrom();
if (launcherLoadedFrom != null
@@ -393,4 +429,185 @@ public class StaticModule extends ServletModule {
private static Key<HttpServlet> named(String name) {
return Key.get(HttpServlet.class, Names.named(name));
}
@Singleton
private static class PolyGerritFilter implements Filter {
private final GerritOptions options;
private final Paths paths;
private final HttpServlet polyGerritIndex;
private final PolyGerritUiServlet polygerritUI;
private final BowerComponentsServlet bowerComponentServlet;
private final FontsServlet fontServlet;
@Inject
PolyGerritFilter(GerritOptions options,
Paths paths,
@Named(POLYGERRIT_INDEX_SERVLET) HttpServlet polyGerritIndex,
PolyGerritUiServlet polygerritUI,
BowerComponentsServlet bowerComponentServlet,
FontsServlet fontServlet) {
this.paths = paths;
this.options = options;
this.polyGerritIndex = polyGerritIndex;
this.polygerritUI = polygerritUI;
this.bowerComponentServlet = bowerComponentServlet;
this.fontServlet = fontServlet;
checkState(options.enablePolyGerrit(),
"can't install PolyGerritFilter when PolyGerrit is disabled");
}
@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;
if (!isPolyGerritEnabled(req, res)) {
chain.doFilter(req, res);
return;
}
GuiceFilterRequestWrapper reqWrapper =
new GuiceFilterRequestWrapper(req);
String path = pathInfo(req);
// Special case assets during development that are built by Buck and not
// served out of the source tree.
//
// In the war case, these are either inlined by vulcanize, 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 boolean isPolyGerritEnabled(HttpServletRequest req,
HttpServletResponse res) {
if (!options.enableGwtUi()) {
return true;
}
String param = req.getParameter("polygerrit");
if ("1".equals(param)) {
return setPolyGerritCookie(req, res, UiPreference.POLYGERRIT);
} else if ("0".equals(param)) {
return setPolyGerritCookie(req, res, UiPreference.GWT);
} else {
return isPolyGerritCookie(req);
}
}
private boolean isPolyGerritCookie(HttpServletRequest req) {
UiPreference pref = UiPreference.GWT;
if (options.enablePolyGerrit() && !options.enableGwtUi()) {
pref = UiPreference.POLYGERRIT;
}
Cookie[] all = req.getCookies();
if (all != null) {
for (Cookie c : all) {
if (GERRIT_UI_COOKIE.equals(c.getName())) {
String v = c.getValue().toUpperCase();
pref = Enums.getIfPresent(UiPreference.class, v).or(pref);
break;
}
}
}
return pref == UiPreference.POLYGERRIT;
}
private boolean setPolyGerritCookie(HttpServletRequest req,
HttpServletResponse res, UiPreference pref) {
// Only actually set a cookie if both UIs are enabled in the server;
// otherwise clear it.
Cookie cookie = new Cookie(GERRIT_UI_COOKIE, pref.name().toLowerCase());
if (options.enablePolyGerrit() && options.enableGwtUi()) {
cookie.setPath("/");
cookie.setSecure(isSecure(req));
cookie.setMaxAge(GERRIT_UI_COOKIE_MAX_AGE);
} else {
cookie.setValue("");
cookie.setMaxAge(0);
}
res.addCookie(cookie);
return pref == UiPreference.POLYGERRIT;
}
private static boolean isSecure(HttpServletRequest req) {
return req.isSecure() || "https".equals(req.getScheme());
}
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;
}
}
}