Allow projects to specify themes

Gerrit administrators can put per-project themes in
$site_path/themes/{project-name}; the same header/footer/CSS filenames
are supported as for sitewide themes. These are inherited and cached
per-project and exposed via GET /projects/X/config.

Themes must be provided by a site admin rather than arbitrary project
admins, so the site admins can be responsible for making sure themes
do not introduce XSS vulnerabilities.

Change-Id: I065d9e6d4df9275b963bb142ec11f66b5604678b
This commit is contained in:
Dave Borowitz
2013-04-09 12:14:57 -07:00
parent 9779c416d5
commit 1e49e143ec
11 changed files with 130 additions and 15 deletions

View File

@@ -2760,7 +2760,7 @@ Files in this directory provide additional configuration.
+
Other files support site customization.
+
* link:config-headerfooter.html[Site Header/Footer]
* link:config-themes.html[Themes]
GERRIT
------

View File

@@ -1,29 +1,39 @@
Gerrit Code Review - Site Customization
=======================================
Gerrit Code Review - Themes
===========================
Gerrit supports some customization of the HTML it sends to
the browser, allowing organizations to alter the look and
feel of the application to fit with their general scheme.
Configuration can either be sitewide or per-project. Projects without a
specified theme inherit from their parents, or from the sitewide theme
for `All-Projects`.
Sitewide themes are stored in `'$site_path'/etc`, and per-project
themes are stored in `'$site_path'/themes/{project-name}`. Files are
only served from a single theme directory; if you want to modify or
extend an inherited theme, you must copy it into the appropriate
per-project directory.
HTML Header/Footer
------------------
At startup Gerrit reads the following files (if they exist) and
uses them to customize the HTML page it sends to clients:
* `'$site_path'/etc/GerritSiteHeader.html`
* `<theme-dir>/GerritSiteHeader.html`
+
HTML is inserted below the menu bar, but above any page content.
This is a good location for an organizational logo, or links to
other systems like bug tracking.
* `'$site_path'/etc/GerritSiteFooter.html`
* `<theme-dir>/GerritSiteFooter.html`
+
HTML is inserted at the bottom of the page, below all other content,
but just above the footer rule and the "Powered by Gerrit Code
Review (v....)" message shown at the extreme bottom.
* `'$site_path'/etc/GerritSite.css`
* `<theme-dir>/GerritSite.css`
+
The CSS rules are inlined into the top of the HTML page, inside
of a `<style>` tag. These rules can be used to support styling

View File

@@ -39,7 +39,7 @@ Configuration
* link:config-gerrit.html[System Settings]
* link:config-contact.html[User Contact Information]
* link:config-gitweb.html[Gitweb Integration]
* link:config-headerfooter.html[Site Header/Footer]
* link:config-themes.html[Themes]
* link:config-sso.html[Single Sign-On Systems]
* link:config-reverseproxy.html[Reverse Proxy]
* link:config-hooks.html[Hooks]

View File

@@ -143,7 +143,7 @@ For more information, see the related topics in this manual:
* link:config-reverseproxy.html[Reverse Proxy]
* link:config-sso.html[Single Sign-On Systems]
* link:config-headerfooter.html[Site Header/Footer]
* link:config-themes.html[Themes]
* link:config-gitweb.html[Gitweb Integration]
* link:config-gerrit.html[Other System Settings]

View File

@@ -113,6 +113,8 @@ public final class Project {
protected String localDefaultDashboardId;
protected String themeName;
protected Project() {
}
@@ -206,6 +208,14 @@ public final class Project {
this.localDefaultDashboardId = localDefaultDashboardId;
}
public String getThemeName() {
return themeName;
}
public void setThemeName(final String themeName) {
this.themeName = themeName;
}
public void copySettingsFrom(final Project update) {
description = update.description;
useContributorAgreements = update.useContributorAgreements;

View File

@@ -24,6 +24,10 @@ import java.io.IOException;
/** Important paths within a {@link SitePath}. */
@Singleton
public final class SitePaths {
public static final String CSS_FILENAME = "GerritSite.css";
public static final String HEADER_FILENAME = "GerritSiteHeader.html";
public static final String FOOTER_FILENAME = "GerritSiteFooter.html";
public final File site_path;
public final File bin_dir;
public final File etc_dir;
@@ -35,6 +39,7 @@ public final class SitePaths {
public final File mail_dir;
public final File hooks_dir;
public final File static_dir;
public final File themes_dir;
public final File gerrit_sh;
public final File gerrit_war;
@@ -71,6 +76,7 @@ public final class SitePaths {
mail_dir = new File(etc_dir, "mail");
hooks_dir = new File(site_path, "hooks");
static_dir = new File(site_path, "static");
themes_dir = new File(site_path, "themes");
gerrit_sh = new File(bin_dir, "gerrit.sh");
gerrit_war = new File(bin_dir, "gerrit.war");
@@ -85,9 +91,9 @@ public final class SitePaths {
ssh_dsa = new File(etc_dir, "ssh_host_dsa_key");
peer_keys = new File(etc_dir, "peer_keys");
site_css = new File(etc_dir, "GerritSite.css");
site_header = new File(etc_dir, "GerritSiteHeader.html");
site_footer = new File(etc_dir, "GerritSiteFooter.html");
site_css = new File(etc_dir, CSS_FILENAME);
site_header = new File(etc_dir, HEADER_FILENAME);
site_footer = new File(etc_dir, FOOTER_FILENAME);
site_gitweb = new File(etc_dir, "gitweb_config.perl");
if (site_path.exists()) {

View File

@@ -30,6 +30,7 @@ public class GetConfig implements RestReadView<ProjectResource> {
public Boolean requireChangeId;
public Map<String, CommentLinkInfo> commentlinks;
public ThemeInfo theme;
}
@Override
@@ -51,6 +52,9 @@ public class GetConfig implements RestReadView<ProjectResource> {
for (CommentLinkInfo cl : project.getCommentLinks()) {
result.commentlinks.put(cl.name, cl);
}
// Themes are visible to anyone, as they are rendered client-side.
result.theme = project.getTheme();
return result;
}
}

View File

@@ -14,12 +14,14 @@
package com.google.gerrit.server.project;
import com.google.common.base.Charsets;
import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.io.Files;
import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GroupReference;
import com.google.gerrit.common.data.LabelType;
@@ -35,6 +37,7 @@ import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.CapabilityCollection;
import com.google.gerrit.server.account.GroupMembership;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.SitePaths;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.ProjectConfig;
import com.google.inject.Inject;
@@ -45,7 +48,10 @@ import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
@@ -59,11 +65,15 @@ import java.util.Set;
/** Cached information on a project. */
public class ProjectState {
private static final Logger log =
LoggerFactory.getLogger(ProjectState.class);
public interface Factory {
ProjectState create(ProjectConfig config);
}
private final boolean isAllProjects;
private final SitePaths sitePaths;
private final AllProjectsName allProjectsName;
private final ProjectCache projectCache;
private final ProjectControl.AssistedFactory projectControlFactory;
@@ -84,11 +94,15 @@ public class ProjectState {
/** Local access sections, wrapped in SectionMatchers for faster evaluation. */
private volatile List<SectionMatcher> localAccessSections;
/** Theme information loaded from site_path/themes. */
private volatile ThemeInfo theme;
/** If this is all projects, the capabilities used by the server. */
private final CapabilityCollection capabilities;
@Inject
public ProjectState(
final SitePaths sitePaths,
final ProjectCache projectCache,
final AllProjectsName allProjectsName,
final ProjectControl.AssistedFactory projectControlFactory,
@@ -97,6 +111,7 @@ public class ProjectState {
final RulesCache rulesCache,
final List<CommentLinkInfo> commentLinks,
@Assisted final ProjectConfig config) {
this.sitePaths = sitePaths;
this.projectCache = projectCache;
this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
this.allProjectsName = allProjectsName;
@@ -397,6 +412,47 @@ public class ProjectState {
return ImmutableList.copyOf(cls.values());
}
public ThemeInfo getTheme() {
ThemeInfo theme = this.theme;
if (theme == null) {
synchronized (this) {
theme = this.theme;
if (theme == null) {
theme = loadTheme();
this.theme = theme;
}
}
}
if (theme == ThemeInfo.INHERIT) {
ProjectState parent = Iterables.getFirst(parents(), null);
return parent != null ? parent.getTheme() : null;
}
return theme;
}
private ThemeInfo loadTheme() {
String name = getConfig().getProject().getName();
File dir = new File(sitePaths.themes_dir, name);
if (!dir.exists()) {
return ThemeInfo.INHERIT;
} else if (!dir.isDirectory()) {
log.warn("Bad theme for {}: not a directory", name);
return ThemeInfo.INHERIT;
}
try {
return new ThemeInfo(readFile(new File(dir, SitePaths.CSS_FILENAME)),
readFile(new File(dir, SitePaths.HEADER_FILENAME)),
readFile(new File(dir, SitePaths.FOOTER_FILENAME)));
} catch (IOException e) {
log.error("Error reading theme for " + name, e);
return ThemeInfo.INHERIT;
}
}
private String readFile(File f) throws IOException {
return f.exists() ? Files.toString(f, Charsets.UTF_8) : null;
}
private boolean getInheritableBoolean(Function<Project, InheritableBoolean> func) {
for (ProjectState s : tree()) {
switch (func.apply(s.getProject())) {

View File

@@ -0,0 +1,29 @@
// Copyright (C) 2013 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.server.git;
package com.google.gerrit.server.project;
public class ThemeInfo {
static final ThemeInfo INHERIT = new ThemeInfo(null, null, null);
public final String css;
public final String header;
public final String footer;
ThemeInfo(String css, String header, String footer) {
this.css = css;
this.header = header;
this.footer = footer;
}
}

View File

@@ -80,8 +80,8 @@ public class GerritCommonTest extends PrologTestCase {
for (LabelType label : labelTypes.getLabelTypes()) {
config.getLabelSections().put(label.getName(), label);
}
allProjects = new ProjectState(this, allProjectsName, null, null, null,
null, null, config);
allProjects = new ProjectState(null, this, allProjectsName, null, null,
null, null, null, config);
}
@Override

View File

@@ -540,10 +540,10 @@ public class RefControlTest extends TestCase {
ProjectControl.AssistedFactory projectControlFactory = null;
RulesCache rulesCache = null;
all.put(local.getProject().getNameKey(), new ProjectState(
projectCache, allProjectsName, projectControlFactory,
null, projectCache, allProjectsName, projectControlFactory,
envFactory, mgr, rulesCache, null, local));
all.put(parent.getProject().getNameKey(), new ProjectState(
projectCache, allProjectsName, projectControlFactory,
null, projectCache, allProjectsName, projectControlFactory,
envFactory, mgr, rulesCache, null, parent));
return all.get(local.getProject().getNameKey());
}