From 06ae63123c94038b90153f4847de2c57c0193db8 Mon Sep 17 00:00:00 2001
From: Rafael Cavazin <rafaelcavazin@gmail.com>
Date: Sun, 27 Jan 2013 09:46:50 -0500
Subject: [PATCH] updating current development

---
 src/com/gitblit/GitBlit.java |  642 ++++++++++++++++++++++++++++++++++++++++++++++------------
 1 files changed, 508 insertions(+), 134 deletions(-)

diff --git a/src/com/gitblit/GitBlit.java b/src/com/gitblit/GitBlit.java
index 8355c03..6bf75d7 100644
--- a/src/com/gitblit/GitBlit.java
+++ b/src/com/gitblit/GitBlit.java
@@ -24,6 +24,8 @@
 import java.lang.reflect.Field;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.nio.charset.Charset;
+import java.security.Principal;
 import java.text.MessageFormat;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
@@ -58,6 +60,7 @@
 import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 
+import org.apache.wicket.RequestCycle;
 import org.apache.wicket.protocol.http.WebResponse;
 import org.apache.wicket.resource.ContextRelativeResource;
 import org.apache.wicket.util.resource.ResourceStreamNotFoundException;
@@ -75,12 +78,16 @@
 
 import com.gitblit.Constants.AccessPermission;
 import com.gitblit.Constants.AccessRestrictionType;
+import com.gitblit.Constants.AuthenticationType;
 import com.gitblit.Constants.AuthorizationControl;
 import com.gitblit.Constants.FederationRequest;
 import com.gitblit.Constants.FederationStrategy;
 import com.gitblit.Constants.FederationToken;
 import com.gitblit.Constants.PermissionType;
 import com.gitblit.Constants.RegistrantType;
+import com.gitblit.fanout.FanoutNioService;
+import com.gitblit.fanout.FanoutService;
+import com.gitblit.fanout.FanoutSocketService;
 import com.gitblit.models.FederationModel;
 import com.gitblit.models.FederationProposal;
 import com.gitblit.models.FederationSet;
@@ -96,16 +103,20 @@
 import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
 import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.Base64;
 import com.gitblit.utils.ByteFormat;
 import com.gitblit.utils.ContainerUtils;
 import com.gitblit.utils.DeepCopier;
 import com.gitblit.utils.FederationUtils;
+import com.gitblit.utils.HttpUtils;
 import com.gitblit.utils.JGitUtils;
 import com.gitblit.utils.JsonUtils;
 import com.gitblit.utils.MetricUtils;
 import com.gitblit.utils.ObjectCache;
 import com.gitblit.utils.StringUtils;
 import com.gitblit.utils.TimeUtils;
+import com.gitblit.utils.X509Utils.X509Metadata;
+import com.gitblit.wicket.GitBlitWebSession;
 import com.gitblit.wicket.WicketUtils;
 
 /**
@@ -146,8 +157,14 @@
 	private final Map<String, ProjectModel> projectCache = new ConcurrentHashMap<String, ProjectModel>();
 	
 	private final AtomicReference<String> repositoryListSettingsChecksum = new AtomicReference<String>("");
+	
+	private final ObjectCache<String> projectMarkdownCache = new ObjectCache<String>();
+	
+	private final ObjectCache<String> projectRepositoriesMarkdownCache = new ObjectCache<String>();
 
 	private ServletContext servletContext;
+	
+	private File baseFolder;
 
 	private File repositoriesFolder;
 
@@ -168,6 +185,8 @@
 	private TimeZone timezone;
 	
 	private FileBasedConfig projectConfigs;
+	
+	private FanoutService fanoutService;
 
 	public GitBlit() {
 		if (gitblit == null) {
@@ -377,12 +396,8 @@
 	 * @return the file
 	 */
 	public static File getFileOrFolder(String fileOrFolder) {
-		String openShift = System.getenv("OPENSHIFT_DATA_DIR");
-		if (!StringUtils.isEmpty(openShift)) {
-			// running on RedHat OpenShift
-			return new File(openShift, fileOrFolder);
-		}
-		return new File(fileOrFolder);
+		return com.gitblit.utils.FileUtils.resolveParameter(Constants.baseFolder$,
+				self().baseFolder, fileOrFolder);
 	}
 
 	/**
@@ -392,7 +407,7 @@
 	 * @return the repositories folder path
 	 */
 	public static File getRepositoriesFolder() {
-		return getFileOrFolder(Keys.git.repositoriesFolder, "git");
+		return getFileOrFolder(Keys.git.repositoriesFolder, "${baseFolder}/git");
 	}
 
 	/**
@@ -402,7 +417,7 @@
 	 * @return the proposals folder path
 	 */
 	public static File getProposalsFolder() {
-		return getFileOrFolder(Keys.federation.proposalsFolder, "proposals");
+		return getFileOrFolder(Keys.federation.proposalsFolder, "${baseFolder}/proposals");
 	}
 
 	/**
@@ -412,9 +427,9 @@
 	 * @return the Groovy scripts folder path
 	 */
 	public static File getGroovyScriptsFolder() {
-		return getFileOrFolder(Keys.groovy.scriptsFolder, "groovy");
+		return getFileOrFolder(Keys.groovy.scriptsFolder, "${baseFolder}/groovy");
 	}
-
+	
 	/**
 	 * Updates the list of server settings.
 	 * 
@@ -459,36 +474,48 @@
 		this.userService.setup(settings);
 	}
 	
+	public boolean supportsAddUser() {
+		return supportsCredentialChanges(new UserModel(""));
+	}
+	
 	/**
+	 * Returns true if the user's credentials can be changed.
 	 * 
+	 * @param user
 	 * @return true if the user service supports credential changes
 	 */
-	public boolean supportsCredentialChanges() {
-		return userService.supportsCredentialChanges();
+	public boolean supportsCredentialChanges(UserModel user) {
+		return (user != null && user.isLocalAccount()) || userService.supportsCredentialChanges();
 	}
 
 	/**
+	 * Returns true if the user's display name can be changed.
 	 * 
+	 * @param user
 	 * @return true if the user service supports display name changes
 	 */
-	public boolean supportsDisplayNameChanges() {
-		return userService.supportsDisplayNameChanges();
+	public boolean supportsDisplayNameChanges(UserModel user) {
+		return (user != null && user.isLocalAccount()) || userService.supportsDisplayNameChanges();
 	}
 
 	/**
+	 * Returns true if the user's email address can be changed.
 	 * 
+	 * @param user
 	 * @return true if the user service supports email address changes
 	 */
-	public boolean supportsEmailAddressChanges() {
-		return userService.supportsEmailAddressChanges();
+	public boolean supportsEmailAddressChanges(UserModel user) {
+		return (user != null && user.isLocalAccount()) || userService.supportsEmailAddressChanges();
 	}
 
 	/**
+	 * Returns true if the user's team memberships can be changed.
 	 * 
+	 * @param user
 	 * @return true if the user service supports team membership changes
 	 */
-	public boolean supportsTeamMembershipChanges() {
-		return userService.supportsTeamMembershipChanges();
+	public boolean supportsTeamMembershipChanges(UserModel user) {
+		return (user != null && user.isLocalAccount()) || userService.supportsTeamMembershipChanges();
 	}
 
 	/**
@@ -536,7 +563,7 @@
 	 * @param cookies
 	 * @return a user object or null
 	 */
-	public UserModel authenticate(Cookie[] cookies) {
+	protected UserModel authenticate(Cookie[] cookies) {
 		if (userService == null) {
 			return null;
 		}
@@ -554,14 +581,113 @@
 	}
 
 	/**
-	 * Authenticate a user based on HTTP request paramters.
-	 * This method is inteded to be used as fallback when other
-	 * means of authentication are failing (username / password or cookies).
+	 * Authenticate a user based on HTTP request parameters.
+	 * 
+	 * Authentication by X509Certificate is tried first and then by cookie.
+	 * 
 	 * @param httpRequest
 	 * @return a user object or null
 	 */
 	public UserModel authenticate(HttpServletRequest httpRequest) {
+		return authenticate(httpRequest, false);
+	}
+	
+	/**
+	 * Authenticate a user based on HTTP request parameters.
+	 * 
+	 * Authentication by X509Certificate, servlet container principal, cookie,
+	 * and BASIC header.
+	 * 
+	 * @param httpRequest
+	 * @param requiresCertificate
+	 * @return a user object or null
+	 */
+	public UserModel authenticate(HttpServletRequest httpRequest, boolean requiresCertificate) {
+		// try to authenticate by certificate
+		boolean checkValidity = settings.getBoolean(Keys.git.enforceCertificateValidity, true);
+		String [] oids = getStrings(Keys.git.certificateUsernameOIDs).toArray(new String[0]);
+		UserModel model = HttpUtils.getUserModelFromCertificate(httpRequest, checkValidity, oids);
+		if (model != null) {
+			// grab real user model and preserve certificate serial number
+			UserModel user = getUserModel(model.username);
+			X509Metadata metadata = HttpUtils.getCertificateMetadata(httpRequest);
+			if (user != null) {
+				flagWicketSession(AuthenticationType.CERTIFICATE);
+				logger.info(MessageFormat.format("{0} authenticated by client certificate {1} from {2}",
+						user.username, metadata.serialNumber, httpRequest.getRemoteAddr()));
+				return user;
+			} else {
+				logger.warn(MessageFormat.format("Failed to find UserModel for {0}, attempted client certificate ({1}) authentication from {2}",
+						model.username, metadata.serialNumber, httpRequest.getRemoteAddr()));
+			}
+		}
+		
+		if (requiresCertificate) {
+			// caller requires client certificate authentication (e.g. git servlet)
+			return null;
+		}
+		
+		// try to authenticate by servlet container principal
+		Principal principal = httpRequest.getUserPrincipal();
+		if (principal != null) {
+			UserModel user = getUserModel(principal.getName());
+			if (user != null) {
+				flagWicketSession(AuthenticationType.CONTAINER);
+				logger.info(MessageFormat.format("{0} authenticated by servlet container principal from {1}",
+						user.username, httpRequest.getRemoteAddr()));
+				return user;
+			} else {
+				logger.warn(MessageFormat.format("Failed to find UserModel for {0}, attempted servlet container authentication from {1}",
+						principal.getName(), httpRequest.getRemoteAddr()));
+			}
+		}
+		
+		// try to authenticate by cookie
+		if (allowCookieAuthentication()) {
+			UserModel user = authenticate(httpRequest.getCookies());
+			if (user != null) {
+				flagWicketSession(AuthenticationType.COOKIE);
+				logger.info(MessageFormat.format("{0} authenticated by cookie from {1}",
+						user.username, httpRequest.getRemoteAddr()));
+				return user;
+			}
+		}
+		
+		// try to authenticate by BASIC
+		final String authorization = httpRequest.getHeader("Authorization");
+		if (authorization != null && authorization.startsWith("Basic")) {
+			// Authorization: Basic base64credentials
+			String base64Credentials = authorization.substring("Basic".length()).trim();
+			String credentials = new String(Base64.decode(base64Credentials),
+					Charset.forName("UTF-8"));
+			// credentials = username:password
+			final String[] values = credentials.split(":",2);
+
+			if (values.length == 2) {
+				String username = values[0];
+				char[] password = values[1].toCharArray();
+				UserModel user = authenticate(username, password);
+				if (user != null) {
+					flagWicketSession(AuthenticationType.CREDENTIALS);
+					logger.info(MessageFormat.format("{0} authenticated by BASIC request header from {1}",
+							user.username, httpRequest.getRemoteAddr()));
+					return user;
+				} else {
+					logger.warn(MessageFormat.format("Failed login attempt for {0}, invalid credentials ({1}) from {2}", 
+							username, credentials, httpRequest.getRemoteAddr()));
+				}
+			}
+		}
 		return null;
+	}
+	
+	protected void flagWicketSession(AuthenticationType authenticationType) {
+		RequestCycle requestCycle = RequestCycle.get();
+		if (requestCycle != null) {
+			// flag the Wicket session, if this is a Wicket request
+			GitBlitWebSession session = GitBlitWebSession.get();
+			session.authenticationType = authenticationType;
+		}
 	}
 
 	/**
@@ -649,6 +775,9 @@
 	 * @return true if successful
 	 */
 	public boolean deleteUser(String username) {
+		if (StringUtils.isEmpty(username)) {
+			return false;
+		}
 		return userService.deleteUser(username);
 	}
 
@@ -660,46 +789,83 @@
 	 * @return a user object or null
 	 */
 	public UserModel getUserModel(String username) {
-		UserModel user = userService.getUserModel(username);
+		if (StringUtils.isEmpty(username)) {
+			return null;
+		}
+		UserModel user = userService.getUserModel(username);		
 		return user;
+	}
+	
+	/**
+	 * Returns the effective list of permissions for this user, taking into account
+	 * team memberships, ownerships.
+	 * 
+	 * @param user
+	 * @return the effective list of permissions for the user
+	 */
+	public List<RegistrantAccessPermission> getUserAccessPermissions(UserModel user) {
+		if (StringUtils.isEmpty(user.username)) {
+			// new user
+			return new ArrayList<RegistrantAccessPermission>();
+		}
+		Set<RegistrantAccessPermission> set = new LinkedHashSet<RegistrantAccessPermission>();
+		set.addAll(user.getRepositoryPermissions());
+		// Flag missing repositories
+		for (RegistrantAccessPermission permission : set) {
+			if (permission.mutable && PermissionType.EXPLICIT.equals(permission.permissionType)) {
+				RepositoryModel rm = GitBlit.self().getRepositoryModel(permission.registrant);
+				if (rm == null) {
+					permission.permissionType = PermissionType.MISSING;
+					permission.mutable = false;
+					continue;
+				}
+			}
+		}
+
+		// TODO reconsider ownership as a user property
+		// manually specify personal repository ownerships
+		for (RepositoryModel rm : repositoryListCache.values()) {
+			if (rm.isUsersPersonalRepository(user.username) || rm.isOwner(user.username)) {
+				RegistrantAccessPermission rp = new RegistrantAccessPermission(rm.name, AccessPermission.REWIND,
+						PermissionType.OWNER, RegistrantType.REPOSITORY, null, false);
+				// user may be owner of a repository to which they've inherited
+				// a team permission, replace any existing perm with owner perm
+				set.remove(rp);
+				set.add(rp);
+			}
+		}
+		
+		List<RegistrantAccessPermission> list = new ArrayList<RegistrantAccessPermission>(set);
+		Collections.sort(list);
+		return list;
 	}
 
 	/**
-	 * Returns the list of users and their access permissions for the specified repository.
+	 * Returns the list of users and their access permissions for the specified
+	 * repository including permission source information such as the team or
+	 * regular expression which sets the permission.
 	 * 
 	 * @param repository
-	 * @return a list of User-AccessPermission tuples
+	 * @return a list of RegistrantAccessPermissions
 	 */
 	public List<RegistrantAccessPermission> getUserAccessPermissions(RepositoryModel repository) {
-		Set<RegistrantAccessPermission> permissions = new LinkedHashSet<RegistrantAccessPermission>();
-		if (!StringUtils.isEmpty(repository.owner)) {
-			UserModel owner = userService.getUserModel(repository.owner);
-			if (owner != null) {
-				permissions.add(new RegistrantAccessPermission(owner.username, AccessPermission.REWIND, PermissionType.OWNER, RegistrantType.USER, false));
+		List<RegistrantAccessPermission> list = new ArrayList<RegistrantAccessPermission>();
+		if (AccessRestrictionType.NONE.equals(repository.accessRestriction)) {
+			// no permissions needed, REWIND for everyone!
+			return list;
+		}
+		if (AuthorizationControl.AUTHENTICATED.equals(repository.authorizationControl)) {
+			// no permissions needed, REWIND for authenticated!
+			return list;
+		}
+		// NAMED users and teams
+		for (UserModel user : userService.getAllUsers()) {
+			RegistrantAccessPermission ap = user.getRepositoryPermission(repository);
+			if (ap.permission.exceeds(AccessPermission.NONE)) {
+				list.add(ap);
 			}
 		}
-		if (repository.isPersonalRepository()) {
-			UserModel owner = userService.getUserModel(repository.projectPath.substring(1));
-			if (owner != null) {
-				permissions.add(new RegistrantAccessPermission(owner.username, AccessPermission.REWIND, PermissionType.OWNER, RegistrantType.USER, false));
-			}
-		}
-		for (String user : userService.getUsernamesForRepositoryRole(repository.name)) {
-			UserModel model = userService.getUserModel(user);
-			AccessPermission ap = model.getRepositoryPermission(repository);
-			PermissionType pType = PermissionType.REGEX;
-			boolean editable = false;
-			if (repository.isOwner(model.username)) {
-				pType = PermissionType.OWNER;
-			} else if (repository.isUsersPersonalRepository(model.username)) {
-				pType = PermissionType.OWNER;
-			} else if (model.hasExplicitRepositoryPermission(repository.name)) {
-				pType = PermissionType.EXPLICIT;
-				editable = true;
-			}			
-			permissions.add(new RegistrantAccessPermission(user, ap, pType, RegistrantType.USER, editable));
-		}
-		return new ArrayList<RegistrantAccessPermission>(permissions);
+		return list;
 	}
 	
 	/**
@@ -712,7 +878,7 @@
 	public boolean setUserAccessPermissions(RepositoryModel repository, Collection<RegistrantAccessPermission> permissions) {
 		List<UserModel> users = new ArrayList<UserModel>();
 		for (RegistrantAccessPermission up : permissions) {
-			if (up.isEditable) {
+			if (up.mutable) {
 				// only set editable defined permissions
 				UserModel user = userService.getUserModel(up.registrant);
 				user.setRepositoryPermission(repository.name, up.permission);
@@ -773,14 +939,14 @@
 			for (RepositoryModel model : getRepositoryModels(user)) {
 				if (model.isUsersPersonalRepository(username)) {
 					// personal repository
-					model.owner = user.username;
+					model.addOwner(user.username);
 					String oldRepositoryName = model.name;
 					model.name = "~" + user.username + model.name.substring(model.projectPath.length());
 					model.projectPath = "~" + user.username;
 					updateRepositoryModel(oldRepositoryName, model, false);
 				} else if (model.isOwner(username)) {
 					// common/shared repo
-					model.owner = user.username;
+					model.addOwner(user.username);
 					updateRepositoryModel(model.name, model, false);
 				}
 			}
@@ -823,25 +989,23 @@
 	}
 	
 	/**
-	 * Returns the list of teams and their access permissions for the specified repository.
+	 * Returns the list of teams and their access permissions for the specified
+	 * repository including the source of the permission such as the admin flag
+	 * or a regular expression.
 	 * 
 	 * @param repository
-	 * @return a list of Team-AccessPermission tuples
+	 * @return a list of RegistrantAccessPermissions
 	 */
 	public List<RegistrantAccessPermission> getTeamAccessPermissions(RepositoryModel repository) {
-		List<RegistrantAccessPermission> permissions = new ArrayList<RegistrantAccessPermission>();
-		for (String team : userService.getTeamnamesForRepositoryRole(repository.name)) {
-			TeamModel model = userService.getTeamModel(team);
-			AccessPermission ap = model.getRepositoryPermission(repository);
-			PermissionType pType = PermissionType.REGEX;
-			boolean editable = false;
-			if (model.hasExplicitRepositoryPermission(repository.name)) {
-				pType = PermissionType.EXPLICIT;
-				editable = true;
+		List<RegistrantAccessPermission> list = new ArrayList<RegistrantAccessPermission>();
+		for (TeamModel team : userService.getAllTeams()) {
+			RegistrantAccessPermission ap = team.getRepositoryPermission(repository);
+			if (ap.permission.exceeds(AccessPermission.NONE)) {
+				list.add(ap);
 			}
-			permissions.add(new RegistrantAccessPermission(team, ap, pType, RegistrantType.TEAM, editable));
 		}
-		return permissions;
+		Collections.sort(list);
+		return list;
 	}
 	
 	/**
@@ -854,7 +1018,7 @@
 	public boolean setTeamAccessPermissions(RepositoryModel repository, Collection<RegistrantAccessPermission> permissions) {
 		List<TeamModel> teams = new ArrayList<TeamModel>();
 		for (RegistrantAccessPermission tp : permissions) {
-			if (tp.isEditable) {
+			if (tp.mutable) {
 				// only set explicitly defined access permissions
 				TeamModel team = userService.getTeamModel(tp.registrant);
 				team.setRepositoryPermission(repository.name, tp.permission);
@@ -932,7 +1096,7 @@
 	 */
 	private void addToCachedRepositoryList(RepositoryModel model) {
 		if (settings.getBoolean(Keys.git.cacheRepositoryList, true)) {
-			repositoryListCache.put(model.name, model);
+			repositoryListCache.put(model.name.toLowerCase(), model);
 			
 			// update the fork origin repository with this repository clone
 			if (!StringUtils.isEmpty(model.originRepository)) {
@@ -954,7 +1118,7 @@
 		if (StringUtils.isEmpty(name)) {
 			return null;
 		}
-		return repositoryListCache.remove(name);
+		return repositoryListCache.remove(name.toLowerCase());
 	}
 
 	/**
@@ -1188,7 +1352,7 @@
 		}
 		
 		// cached model
-		RepositoryModel model = repositoryListCache.get(repositoryName);
+		RepositoryModel model = repositoryListCache.get(repositoryName.toLowerCase());
 
 		if (gcExecutor.isCollectingGarbage(model.name)) {
 			// Gitblit is busy collecting garbage, use our cached model
@@ -1198,7 +1362,7 @@
 		}
 
 		// check for updates
-		Repository r = getRepository(repositoryName);
+		Repository r = getRepository(model.name);
 		if (r == null) {
 			// repository is missing
 			removeFromCachedRepositoryList(repositoryName);
@@ -1210,8 +1374,8 @@
 		if (config.isOutdated()) {
 			// reload model
 			logger.info(MessageFormat.format("Config for \"{0}\" has changed. Reloading model and updating cache.", repositoryName));
-			model = loadRepositoryModel(repositoryName);
-			removeFromCachedRepositoryList(repositoryName);
+			model = loadRepositoryModel(model.name);
+			removeFromCachedRepositoryList(model.name);
 			addToCachedRepositoryList(model);
 		} else {
 			// update a few repository parameters 
@@ -1261,7 +1425,30 @@
 				}
 				project.title = projectConfigs.getString("project", name, "title");
 				project.description = projectConfigs.getString("project", name, "description");
-				configs.put(name.toLowerCase(), project);				
+				
+				// project markdown
+				File pmkd = new File(getRepositoriesFolder(), (project.isRoot ? "" : name) + "/project.mkd");
+				if (pmkd.exists()) {
+					Date lm = new Date(pmkd.lastModified());
+					if (!projectMarkdownCache.hasCurrent(name, lm)) {
+						String mkd = com.gitblit.utils.FileUtils.readContent(pmkd,  "\n");
+						projectMarkdownCache.updateObject(name, lm, mkd);
+					}
+					project.projectMarkdown = projectMarkdownCache.getObject(name);
+				}
+				
+				// project repositories markdown
+				File rmkd = new File(getRepositoriesFolder(), (project.isRoot ? "" : name) + "/repositories.mkd");
+				if (rmkd.exists()) {
+					Date lm = new Date(rmkd.lastModified());
+					if (!projectRepositoriesMarkdownCache.hasCurrent(name, lm)) {
+						String mkd = com.gitblit.utils.FileUtils.readContent(rmkd,  "\n");
+						projectRepositoriesMarkdownCache.updateObject(name, lm, mkd);
+					}
+					project.repositoriesMarkdown = projectRepositoriesMarkdownCache.getObject(name);
+				}
+				
+				configs.put(name.toLowerCase(), project);
 			}
 			projectCache.clear();
 			projectCache.putAll(configs);
@@ -1385,6 +1572,49 @@
 	}
 	
 	/**
+	 * Returns the list of project models that are referenced by the supplied
+	 * repository model	list.  This is an alternative method exists to ensure
+	 * Gitblit does not call getRepositoryModels(UserModel) twice in a request.
+	 * 
+	 * @param repositoryModels
+	 * @param includeUsers
+	 * @return a list of project models
+	 */
+	public List<ProjectModel> getProjectModels(List<RepositoryModel> repositoryModels, boolean includeUsers) {
+		Map<String, ProjectModel> projects = new LinkedHashMap<String, ProjectModel>();
+		for (RepositoryModel repository : repositoryModels) {
+			if (!includeUsers && repository.isPersonalRepository()) {
+				// exclude personal repositories
+				continue;
+			}
+			if (!projects.containsKey(repository.projectPath)) {
+				ProjectModel project = getProjectModel(repository.projectPath);
+				if (project == null) {
+					logger.warn(MessageFormat.format("excluding project \"{0}\" from project list because it is empty!",
+							repository.projectPath));
+					continue;
+				}
+				projects.put(repository.projectPath, project);
+				// clear the repo list in the project because that is the system
+				// list, not the user-accessible list and start building the
+				// user-accessible list
+				project.repositories.clear();
+				project.repositories.add(repository.name);
+				project.lastChange = repository.lastChange;
+			} else {
+				// update the user-accessible list
+				// this is used for repository count
+				ProjectModel project = projects.get(repository.projectPath);
+				project.repositories.add(repository.name);
+				if (project.lastChange.before(repository.lastChange)) {
+					project.lastChange = repository.lastChange;
+				}
+			}
+		}
+		return new ArrayList<ProjectModel>(projects.values());
+	}
+	
+	/**
 	 * Workaround JGit.  I need to access the raw config object directly in order
 	 * to see if the config is dirty so that I can reload a repository model.
 	 * If I use the stock JGit method to get the config it already reloads the
@@ -1420,7 +1650,7 @@
 		}
 		RepositoryModel model = new RepositoryModel();
 		model.isBare = r.isBare();
-		File basePath = getFileOrFolder(Keys.git.repositoriesFolder, "git");
+		File basePath = getFileOrFolder(Keys.git.repositoriesFolder, "${baseFolder}/git");
 		if (model.isBare) {
 			model.name = com.gitblit.utils.FileUtils.getRelativePath(basePath, r.getDirectory());
 		} else {
@@ -1435,7 +1665,7 @@
 		
 		if (config != null) {
 			model.description = getConfig(config, "description", "");
-			model.owner = getConfig(config, "owner", "");
+			model.addOwners(ArrayUtils.fromString(getConfig(config, "owner", "")));
 			model.useTickets = getConfig(config, "useTickets", false);
 			model.useDocs = getConfig(config, "useDocs", false);
 			model.allowForks = getConfig(config, "allowForks", true);
@@ -1461,6 +1691,7 @@
 			} catch (Exception e) {
 				model.lastGC = new Date(0);
 			}
+			model.maxActivityCommits = getConfig(config, "maxActivityCommits", settings.getInteger(Keys.web.maxActivityCommits, 0));
 			model.origin = config.getString("remote", "origin", "url");
 			if (model.origin != null) {
 				model.origin = model.origin.replace('\\', '/');
@@ -1482,6 +1713,7 @@
 		}
 		model.HEAD = JGitUtils.getHEADRef(r);
 		model.availableRefs = JGitUtils.getAvailableHeadTargets(r);
+		model.sparkleshareId = JGitUtils.getSparkleshareId(r);
 		r.close();
 		
 		if (model.origin != null && model.origin.startsWith("file://")) {
@@ -1493,7 +1725,7 @@
 					// ensure origin still exists
 					File repoFolder = new File(getRepositoriesFolder(), originRepo);
 					if (repoFolder.exists()) {
-						model.originRepository = originRepo;
+						model.originRepository = originRepo.toLowerCase();
 					}
 				}
 			} catch (URISyntaxException e) {
@@ -1510,10 +1742,21 @@
 	 * @return true if the repository exists
 	 */
 	public boolean hasRepository(String repositoryName) {
-		if (settings.getBoolean(Keys.git.cacheRepositoryList, true)) {
+		return hasRepository(repositoryName, false);
+	}
+	
+	/**
+	 * Determines if this server has the requested repository.
+	 * 
+	 * @param name
+	 * @param caseInsensitive
+	 * @return true if the repository exists
+	 */
+	public boolean hasRepository(String repositoryName, boolean caseSensitiveCheck) {
+		if (!caseSensitiveCheck && settings.getBoolean(Keys.git.cacheRepositoryList, true)) {
 			// if we are caching use the cache to determine availability
 			// otherwise we end up adding a phantom repository to the cache
-			return repositoryListCache.containsKey(repositoryName);
+			return repositoryListCache.containsKey(repositoryName.toLowerCase());
 		}		
 		Repository r = getRepository(repositoryName, false);
 		if (r == null) {
@@ -1571,7 +1814,7 @@
 			}
 			
 			for (String repository : repositoryListCache.keySet()) {
-				if (repository.toLowerCase().startsWith(userPath)) {
+				if (repository.startsWith(userPath)) {
 					RepositoryModel model = repositoryListCache.get(repository);
 					if (!StringUtils.isEmpty(model.originRepository)) {
 						if (roots.contains(model.originRepository)) {
@@ -1585,8 +1828,8 @@
 			// not caching
 			ProjectModel project = getProjectModel(userProject);
 			for (String repository : project.repositories) {
-				if (repository.toLowerCase().startsWith(userProject)) {
-					RepositoryModel model = repositoryListCache.get(repository);
+				if (repository.startsWith(userProject)) {
+					RepositoryModel model = getRepositoryModel(repository);
 					if (model.originRepository.equalsIgnoreCase(origin)) {
 						// user has a fork
 						return model.name;
@@ -1607,24 +1850,53 @@
 	 */
 	public ForkModel getForkNetwork(String repository) {
 		if (settings.getBoolean(Keys.git.cacheRepositoryList, true)) {
-			// find the root
-			RepositoryModel model = repositoryListCache.get(repository);
+			// find the root, cached
+			RepositoryModel model = repositoryListCache.get(repository.toLowerCase());
 			while (model.originRepository != null) {
 				model = repositoryListCache.get(model.originRepository);
+			}
+			ForkModel root = getForkModelFromCache(model.name);
+			return root;
+		} else {
+			// find the root, non-cached
+			RepositoryModel model = getRepositoryModel(repository.toLowerCase());
+			while (model.originRepository != null) {
+				model = getRepositoryModel(model.originRepository);
 			}
 			ForkModel root = getForkModel(model.name);
 			return root;
 		}
-		return null;
+	}
+	
+	private ForkModel getForkModelFromCache(String repository) {
+		RepositoryModel model = repositoryListCache.get(repository.toLowerCase());
+		if (model == null) {
+			return null;
+		}
+		ForkModel fork = new ForkModel(model);
+		if (!ArrayUtils.isEmpty(model.forks)) {
+			for (String aFork : model.forks) {
+				ForkModel fm = getForkModelFromCache(aFork);
+				if (fm != null) {
+					fork.forks.add(fm);
+				}
+			}
+		}
+		return fork;
 	}
 	
 	private ForkModel getForkModel(String repository) {
-		RepositoryModel model = repositoryListCache.get(repository);
+		RepositoryModel model = getRepositoryModel(repository.toLowerCase());
+		if (model == null) {
+			return null;
+		}
 		ForkModel fork = new ForkModel(model);
 		if (!ArrayUtils.isEmpty(model.forks)) {
 			for (String aFork : model.forks) {
 				ForkModel fm = getForkModel(aFork);
-				fork.forks.add(fm);
+				if (fm != null) {
+					fork.forks.add(fm);
+				}
 			}
 		}
 		return fork;
@@ -1795,7 +2067,7 @@
 			if (!repository.name.toLowerCase().endsWith(org.eclipse.jgit.lib.Constants.DOT_GIT_EXT)) {
 				repository.name += org.eclipse.jgit.lib.Constants.DOT_GIT_EXT;
 			}
-			if (new File(repositoriesFolder, repository.name).exists()) {
+			if (hasRepository(repository.name)) {
 				throw new GitBlitException(MessageFormat.format(
 						"Can not create repository ''{0}'' because it already exists.",
 						repository.name));
@@ -1911,7 +2183,7 @@
 	public void updateConfiguration(Repository r, RepositoryModel repository) {
 		StoredConfig config = r.getConfig();
 		config.setString(Constants.CONFIG_GITBLIT, null, "description", repository.description);
-		config.setString(Constants.CONFIG_GITBLIT, null, "owner", repository.owner);
+		config.setString(Constants.CONFIG_GITBLIT, null, "owner", ArrayUtils.toString(repository.owners));
 		config.setBoolean(Constants.CONFIG_GITBLIT, null, "useTickets", repository.useTickets);
 		config.setBoolean(Constants.CONFIG_GITBLIT, null, "useDocs", repository.useDocs);
 		config.setBoolean(Constants.CONFIG_GITBLIT, null, "allowForks", repository.allowForks);
@@ -1927,9 +2199,20 @@
 				repository.federationStrategy.name());
 		config.setBoolean(Constants.CONFIG_GITBLIT, null, "isFederated", repository.isFederated);
 		config.setString(Constants.CONFIG_GITBLIT, null, "gcThreshold", repository.gcThreshold);
-		config.setInt(Constants.CONFIG_GITBLIT, null, "gcPeriod", repository.gcPeriod);
+		if (repository.gcPeriod == settings.getInteger(Keys.git.defaultGarbageCollectionPeriod, 7)) {
+			// use default from config
+			config.unset(Constants.CONFIG_GITBLIT, null, "gcPeriod");
+		} else {
+			config.setInt(Constants.CONFIG_GITBLIT, null, "gcPeriod", repository.gcPeriod);
+		}
 		if (repository.lastGC != null) {
 			config.setString(Constants.CONFIG_GITBLIT, null, "lastGC", new SimpleDateFormat(Constants.ISO8601).format(repository.lastGC));
+		}
+		if (repository.maxActivityCommits == settings.getInteger(Keys.web.maxActivityCommits, 0)) {
+			// use default from config
+			config.unset(Constants.CONFIG_GITBLIT, null, "maxActivityCommits");
+		} else {
+			config.setInt(Constants.CONFIG_GITBLIT, null, "maxActivityCommits", repository.maxActivityCommits);
 		}
 
 		updateList(config, "federationSets", repository.federationSets);
@@ -2226,6 +2509,8 @@
 		case PULL_SETTINGS:
 		case PULL_SCRIPTS:
 			return token.equals(all);
+		default:
+			break;
 		}
 		return false;
 	}
@@ -2368,6 +2653,8 @@
 				if (!StringUtils.isEmpty(model.origin)) {
 					url = model.origin;
 				}
+				break;
+			default:
 				break;
 			}
 
@@ -2626,6 +2913,37 @@
 	}
 
 	/**
+	 * Notify users by email of something.
+	 * 
+	 * @param subject
+	 * @param message
+	 * @param toAddresses
+	 */
+	public void sendHtmlMail(String subject, String message, Collection<String> toAddresses) {
+		this.sendHtmlMail(subject, message, toAddresses.toArray(new String[0]));
+	}
+
+	/**
+	 * Notify users by email of something.
+	 * 
+	 * @param subject
+	 * @param message
+	 * @param toAddresses
+	 */
+	public void sendHtmlMail(String subject, String message, String... toAddresses) {
+		try {
+			Message mail = mailExecutor.createMessage(toAddresses);
+			if (mail != null) {
+				mail.setSubject(subject);
+				mail.setContent(message, "text/html");
+				mailExecutor.queue(mail);
+			}
+		} catch (MessagingException e) {
+			logger.error("Messaging error", e);
+		}
+	}
+
+	/**
 	 * Returns the descriptions/comments of the Gitblit config settings.
 	 * 
 	 * @return SettingsModel
@@ -2723,18 +3041,21 @@
 	 * 
 	 * @param settings
 	 */
-	public void configureContext(IStoredSettings settings, boolean startFederation) {
-		logger.info("Reading configuration from " + settings.toString());
+	public void configureContext(IStoredSettings settings, File folder, boolean startFederation) {
 		this.settings = settings;
-		
+		this.baseFolder = folder;
+
+		repositoriesFolder = getRepositoriesFolder();
+
+		logger.info("Gitblit base folder     = " + folder.getAbsolutePath());
+		logger.info("Git repositories folder = " + repositoriesFolder.getAbsolutePath());
+		logger.info("Gitblit settings        = " + settings.toString());
+
 		// prepare service executors
 		mailExecutor = new MailExecutor(settings);
 		luceneExecutor = new LuceneExecutor(settings, repositoriesFolder);
 		gcExecutor = new GCExecutor(settings);
 		
-		repositoriesFolder = getRepositoriesFolder();
-		logger.info("Git repositories folder " + repositoriesFolder.getAbsolutePath());
-
 		// calculate repository list settings checksum for future config changes
 		repositoryListSettingsChecksum.set(getRepositoryListSettingsChecksum());
 
@@ -2750,7 +3071,7 @@
 		serverStatus = new ServerStatus(isGO());
 
 		if (this.userService == null) {
-			String realm = settings.getString(Keys.realm.userService, "users.properties");
+			String realm = settings.getString(Keys.realm.userService, "${baseFolder}/users.properties");
 			IUserService loginService = null;
 			try {
 				// check to see if this "file" is a login service class
@@ -2763,7 +3084,7 @@
 		}
 		
 		// load and cache the project metadata
-		projectConfigs = new FileBasedConfig(getFileOrFolder(Keys.web.projectsFile, "projects.conf"), FS.detect());
+		projectConfigs = new FileBasedConfig(getFileOrFolder(Keys.web.projectsFile, "${baseFolder}/projects.conf"), FS.detect());
 		getProjectConfigs();
 		
 		// schedule mail engine
@@ -2829,6 +3150,32 @@
 		}
 
 		ContainerUtils.CVE_2007_0450.test();
+		
+		// startup Fanout PubSub service
+		if (settings.getInteger(Keys.fanout.port, 0) > 0) {
+			String bindInterface = settings.getString(Keys.fanout.bindInterface, null);
+			int port = settings.getInteger(Keys.fanout.port, FanoutService.DEFAULT_PORT);
+			boolean useNio = settings.getBoolean(Keys.fanout.useNio, true);
+			int limit = settings.getInteger(Keys.fanout.connectionLimit, 0);
+			
+			if (useNio) {
+				if (StringUtils.isEmpty(bindInterface)) {
+					fanoutService = new FanoutNioService(port);
+				} else {
+					fanoutService = new FanoutNioService(bindInterface, port);
+				}
+			} else {
+				if (StringUtils.isEmpty(bindInterface)) {
+					fanoutService = new FanoutSocketService(port);
+				} else {
+					fanoutService = new FanoutSocketService(bindInterface, port);
+				}
+			}
+			
+			fanoutService.setConcurrentConnectionLimit(limit);
+			fanoutService.setAllowAllChannelAnnouncements(false);
+			fanoutService.start();
+		}
 	}
 	
 	private void logTimezone(String type, TimeZone zone) {
@@ -2852,41 +3199,63 @@
 	public void contextInitialized(ServletContextEvent contextEvent, InputStream referencePropertiesInputStream) {
 		servletContext = contextEvent.getServletContext();
 		if (settings == null) {
-			// Gitblit WAR is running in a servlet container
+			// Gitblit is running in a servlet container
 			ServletContext context = contextEvent.getServletContext();
 			WebXmlSettings webxmlSettings = new WebXmlSettings(context);
-
-			// 0.7.0 web.properties in the deployed war folder
-			String webProps = context.getRealPath("/WEB-INF/web.properties");
-			if (!StringUtils.isEmpty(webProps)) {
-				File overrideFile = new File(webProps);
-				if (overrideFile.exists()) {
-					webxmlSettings.applyOverrides(overrideFile);
-				}
-			}
+			File contextFolder = new File(context.getRealPath("/"));
+			String openShift = System.getenv("OPENSHIFT_DATA_DIR");
 			
+			if (!StringUtils.isEmpty(openShift)) {
+				// Gitblit is running in OpenShift/JBoss
+				File base = new File(openShift);
 
-			// 0.8.0 gitblit.properties file located outside the deployed war
-			// folder lie, for example, on RedHat OpenShift.
-			File overrideFile = getFileOrFolder("gitblit.properties");
-			if (!overrideFile.getPath().equals("gitblit.properties")) {
+				// gitblit.properties setting overrides
+				File overrideFile = new File(base, "gitblit.properties");
 				webxmlSettings.applyOverrides(overrideFile);
-			}
-			configureContext(webxmlSettings, true);
-
-			// Copy the included scripts to the configured groovy folder
-			File localScripts = getFileOrFolder(Keys.groovy.scriptsFolder, "groovy");
-			if (!localScripts.exists()) {
-				File includedScripts = new File(context.getRealPath("/WEB-INF/groovy"));
-				if (!includedScripts.equals(localScripts)) {
-					try {
-						com.gitblit.utils.FileUtils.copy(localScripts, includedScripts.listFiles());
-					} catch (IOException e) {
-						logger.error(MessageFormat.format(
-								"Failed to copy included Groovy scripts from {0} to {1}",
-								includedScripts, localScripts));
+				
+				// Copy the included scripts to the configured groovy folder
+				File localScripts = new File(base, webxmlSettings.getString(Keys.groovy.scriptsFolder, "groovy"));
+				if (!localScripts.exists()) {
+					File warScripts = new File(contextFolder, "/WEB-INF/data/groovy");
+					if (!warScripts.equals(localScripts)) {
+						try {
+							com.gitblit.utils.FileUtils.copy(localScripts, warScripts.listFiles());
+						} catch (IOException e) {
+							logger.error(MessageFormat.format(
+									"Failed to copy included Groovy scripts from {0} to {1}",
+									warScripts, localScripts));
+						}
 					}
 				}
+				
+				// configure context using the web.xml
+				configureContext(webxmlSettings, base, true);
+			} else {
+				// Gitblit is running in a standard servlet container
+				logger.info("WAR contextFolder is " + contextFolder.getAbsolutePath());
+				
+				String path = webxmlSettings.getString(Constants.baseFolder, Constants.contextFolder$ + "/WEB-INF/data");
+				File base = com.gitblit.utils.FileUtils.resolveParameter(Constants.contextFolder$, contextFolder, path);
+				base.mkdirs();
+				
+				// try to copy the data folder contents to the baseFolder
+				File localSettings = new File(base, "gitblit.properties");
+				if (!localSettings.exists()) {
+					File contextData = new File(contextFolder, "/WEB-INF/data");
+					if (!base.equals(contextData)) {
+						try {
+							com.gitblit.utils.FileUtils.copy(base, contextData.listFiles());
+						} catch (IOException e) {
+							logger.error(MessageFormat.format(
+									"Failed to copy included data from {0} to {1}",
+								contextData, base));
+						}
+					}
+				}
+				
+				// delegate all config to baseFolder/gitblit.properties file
+				FileSettings settings = new FileSettings(localSettings.getAbsolutePath());				
+				configureContext(settings, base, true);
 			}
 		}
 		
@@ -2904,6 +3273,9 @@
 		scheduledExecutor.shutdownNow();
 		luceneExecutor.close();
 		gcExecutor.close();
+		if (fanoutService != null) {
+			fanoutService.stop();
+		}
 	}
 	
 	/**
@@ -2948,15 +3320,17 @@
 		// create a Gitblit repository model for the clone
 		RepositoryModel cloneModel = repository.cloneAs(cloneName);
 		// owner has REWIND/RW+ permissions
-		cloneModel.owner = user.username;
+		cloneModel.addOwner(user.username);
 		updateRepositoryModel(cloneName, cloneModel, false);
 
 		// add the owner of the source repository to the clone's access list
-		if (!StringUtils.isEmpty(repository.owner)) {
-			UserModel originOwner = getUserModel(repository.owner);
-			if (originOwner != null) {
-				originOwner.setRepositoryPermission(cloneName, AccessPermission.CLONE);
-				updateUserModel(originOwner.username, originOwner, false);
+		if (!ArrayUtils.isEmpty(repository.owners)) {
+			for (String owner : repository.owners) {
+				UserModel originOwner = getUserModel(owner);
+				if (originOwner != null) {
+					originOwner.setRepositoryPermission(cloneName, AccessPermission.CLONE);
+					updateUserModel(originOwner.username, originOwner, false);
+				}
 			}
 		}
 

--
Gitblit v1.9.1