diff --git a/README.md b/README.md index 41c57d1a8..f47318c32 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Following features are not implemented, but we will make them in the future rele - File editing in repository viewer - Comment for the changeset - Network graph -- Statics +- Statistics - Watch / Star If you want to try the development version of GitBucket, see the documentation for developers at [Wiki](https://github.com/takezoe/gitbucket/wiki). @@ -35,6 +35,8 @@ Installation 2. Deploy it to the Servlet 3.0 container such as Tomcat 7.x, Jetty 8.x, GlassFish 3.x or higher. 3. Access **http://[hostname]:[port]/gitbucket/** using your web browser. +If you are using Gitbucket behind a webserver please make sure you have increased the **client_max_body_size** (on nignx) + The default administrator account is **root** and password is **root**. or you can start GitBucket by `java -jar gitbucket.war` without servlet container. In this case, GitBucket URL is **http://[hostname]:8080/**. You can specify following options. @@ -58,6 +60,19 @@ Run the following commands in `Terminal` to Release Notes -------- +### 1.12 - 29 Mar 2014 +- SSH repository access is available +- Allow users can create and management their groups +- Git submodule support +- Close issues via commit messages +- Show repository description below the name on repository page +- Fix presentation of the source viewer +- Upgrade to sbt 0.13 +- Fix some bugs + +### 1.11.1 - 06 Mar 2014 +- Bug fix + ### 1.11 - 01 Mar 2014 - Base URL for redirection, notification and repository URL box is configurable - Remove ```--https``` option because it's possible to substitute in the base url diff --git a/project/build.properties b/project/build.properties index db255c253..37b489cb6 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.12.3 \ No newline at end of file +sbt.version=0.13.1 diff --git a/project/build.scala b/project/build.scala index bcf125e95..9d2e16d0e 100644 --- a/project/build.scala +++ b/project/build.scala @@ -31,12 +31,13 @@ object MyBuild extends Build { "org.scalatra" %% "scalatra-specs2" % ScalatraVersion % "test", "org.scalatra" %% "scalatra-json" % ScalatraVersion, "org.json4s" %% "json4s-jackson" % "3.2.5", - "jp.sf.amateras" %% "scalatra-forms" % "0.0.11", + "jp.sf.amateras" %% "scalatra-forms" % "0.0.14", "commons-io" % "commons-io" % "2.4", "org.pegdown" % "pegdown" % "1.4.1", "org.apache.commons" % "commons-compress" % "1.5", "org.apache.commons" % "commons-email" % "1.3.1", "org.apache.httpcomponents" % "httpclient" % "4.3", + "org.apache.sshd" % "apache-sshd" % "0.10.0", "com.typesafe.slick" %% "slick" % "1.0.1", "com.novell.ldap" % "jldap" % "2009-10-07", "com.h2database" % "h2" % "1.3.173", diff --git a/project/plugins.sbt b/project/plugins.sbt index 15ac80673..7d7ab3e3b 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,9 +1,11 @@ -addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.2.0") +addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.4.0") -addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.5.1") +addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.6.0") -addSbtPlugin("org.scalatra.sbt" % "scalatra-sbt" % "0.3.0") +addSbtPlugin("org.scalatra.sbt" % "scalatra-sbt" % "0.3.5") -addSbtPlugin("io.spray" % "sbt-twirl" % "0.6.1") +resolvers += "spray repo" at "http://repo.spray.io" -addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.2") +addSbtPlugin("io.spray" % "sbt-twirl" % "0.7.0") + +addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.1.4") diff --git a/sbt-launch-0.12.3.jar b/sbt-launch-0.12.3.jar deleted file mode 100644 index 672c26f67..000000000 Binary files a/sbt-launch-0.12.3.jar and /dev/null differ diff --git a/sbt-launch-0.13.1.jar b/sbt-launch-0.13.1.jar new file mode 100644 index 000000000..5c7d052e6 Binary files /dev/null and b/sbt-launch-0.13.1.jar differ diff --git a/sbt.bat b/sbt.bat index d86d1e05b..cd356dd34 100644 --- a/sbt.bat +++ b/sbt.bat @@ -1,2 +1,2 @@ set SCRIPT_DIR=%~dp0 -java -Dsbt.log.noformat=true -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -jar "%SCRIPT_DIR%\sbt-launch-0.12.3.jar" %* +java -Dsbt.log.noformat=true -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar "%SCRIPT_DIR%\sbt-launch-0.13.1.jar" %* diff --git a/sbt.sh b/sbt.sh index 23c721f7d..86cf93e25 100755 --- a/sbt.sh +++ b/sbt.sh @@ -1 +1 @@ -java -Dsbt.log.noformat=true -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -jar `dirname $0`/sbt-launch-0.12.3.jar "$@" +java -Dsbt.log.noformat=true -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=256m -Xmx512M -Xss2M -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -jar `dirname $0`/sbt-launch-0.13.1.jar "$@" diff --git a/src/main/resources/update/1_12.sql b/src/main/resources/update/1_12.sql new file mode 100644 index 000000000..f8658a24c --- /dev/null +++ b/src/main/resources/update/1_12.sql @@ -0,0 +1,11 @@ +ALTER TABLE GROUP_MEMBER ADD COLUMN MANAGER BOOLEAN DEFAULT FALSE; + +CREATE TABLE SSH_KEY ( + USER_NAME VARCHAR(100) NOT NULL, + SSH_KEY_ID INT AUTO_INCREMENT, + TITLE VARCHAR(100) NOT NULL, + PUBLIC_KEY TEXT NOT NULL +); + +ALTER TABLE SSH_KEY ADD CONSTRAINT IDX_SSH_KEY_PK PRIMARY KEY (USER_NAME, SSH_KEY_ID); +ALTER TABLE SSH_KEY ADD CONSTRAINT IDX_SSH_KEY_FK0 FOREIGN KEY (USER_NAME) REFERENCES ACCOUNT (USER_NAME); diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index de3c40d04..21c213d49 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -20,7 +20,6 @@ class ScalatraBootstrap extends LifeCycle { context.mount(new DashboardController, "/*") context.mount(new UserManagementController, "/*") context.mount(new SystemSettingsController, "/*") - context.mount(new CreateRepositoryController, "/*") context.mount(new AccountController, "/*") context.mount(new RepositoryViewerController, "/*") context.mount(new WikiController, "/*") diff --git a/src/main/scala/app/AccountController.scala b/src/main/scala/app/AccountController.scala index df24aad3f..d19af7eaa 100644 --- a/src/main/scala/app/AccountController.scala +++ b/src/main/scala/app/AccountController.scala @@ -1,17 +1,25 @@ package app import service._ -import util.{FileUtil, OneselfAuthenticator} +import util._ import util.StringUtil._ import util.Directory._ +import util.ControlUtil._ import jp.sf.amateras.scalatra.forms._ import org.apache.commons.io.FileUtils +import org.scalatra.i18n.Messages +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.{FileMode, Constants} +import org.eclipse.jgit.dircache.DirCache +import model.GroupMember class AccountController extends AccountControllerBase - with AccountService with RepositoryService with ActivityService with OneselfAuthenticator + with AccountService with RepositoryService with ActivityService with WikiService with LabelsService with SshKeyService + with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator trait AccountControllerBase extends AccountManagementControllerBase { - self: AccountService with RepositoryService with ActivityService with OneselfAuthenticator => + self: AccountService with RepositoryService with ActivityService with WikiService with LabelsService with SshKeyService + with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator => case class AccountNewForm(userName: String, password: String, fullName: String, mailAddress: String, url: Option[String], fileId: Option[String]) @@ -19,6 +27,8 @@ trait AccountControllerBase extends AccountManagementControllerBase { case class AccountEditForm(password: Option[String], fullName: String, mailAddress: String, url: Option[String], fileId: Option[String], clearImage: Boolean) + case class SshKeyForm(title: String, publicKey: String) + val newForm = mapping( "userName" -> trim(label("User name" , text(required, maxlength(100), identifier, uniqueUserName))), "password" -> trim(label("Password" , text(required, maxlength(20)))), @@ -37,6 +47,45 @@ trait AccountControllerBase extends AccountManagementControllerBase { "clearImage" -> trim(label("Clear image" , boolean())) )(AccountEditForm.apply) + val sshKeyForm = mapping( + "title" -> trim(label("Title", text(required, maxlength(100)))), + "publicKey" -> trim(label("Key" , text(required))) + )(SshKeyForm.apply) + + case class NewGroupForm(groupName: String, url: Option[String], fileId: Option[String], members: String) + case class EditGroupForm(groupName: String, url: Option[String], fileId: Option[String], members: String, clearImage: Boolean) + + val newGroupForm = mapping( + "groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier, uniqueUserName))), + "url" -> trim(label("URL" ,optional(text(maxlength(200))))), + "fileId" -> trim(label("File ID" ,optional(text()))), + "members" -> trim(label("Members" ,text(required, members))) + )(NewGroupForm.apply) + + val editGroupForm = mapping( + "groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier))), + "url" -> trim(label("URL" ,optional(text(maxlength(200))))), + "fileId" -> trim(label("File ID" ,optional(text()))), + "members" -> trim(label("Members" ,text(required, members))), + "clearImage" -> trim(label("Clear image" ,boolean())) + )(EditGroupForm.apply) + + case class RepositoryCreationForm(owner: String, name: String, description: Option[String], isPrivate: Boolean, createReadme: Boolean) + case class ForkRepositoryForm(owner: String, name: String) + + val newRepositoryForm = mapping( + "owner" -> trim(label("Owner" , text(required, maxlength(40), identifier, existsAccount))), + "name" -> trim(label("Repository name", text(required, maxlength(40), identifier, uniqueRepository))), + "description" -> trim(label("Description" , optional(text()))), + "isPrivate" -> trim(label("Repository Type", boolean())), + "createReadme" -> trim(label("Create README" , boolean())) + )(RepositoryCreationForm.apply) + + val forkRepositoryForm = mapping( + "owner" -> trim(label("Repository owner", text(required))), + "name" -> trim(label("Repository name", text(required))) + )(ForkRepositoryForm.apply) + /** * Displays user information. */ @@ -51,14 +100,20 @@ trait AccountControllerBase extends AccountManagementControllerBase { getActivitiesByUser(userName, true)) // Members - case "members" if(account.isGroupAccount) => - _root_.account.html.members(account, getGroupMembers(account.userName)) + case "members" if(account.isGroupAccount) => { + val members = getGroupMembers(account.userName) + _root_.account.html.members(account, members.map(_.userName), + context.loginAccount.exists(x => members.exists { member => member.userName == x.userName && member.isManager })) + } // Repositories - case _ => + case _ => { + val members = getGroupMembers(account.userName) _root_.account.html.repositories(account, if(account.isGroupAccount) Nil else getGroupsByUserName(userName), - getVisibleRepositories(context.loginAccount, baseUrl, Some(userName))) + getVisibleRepositories(context.loginAccount, baseUrl, Some(userName)), + context.loginAccount.exists(x => members.exists { member => member.userName == x.userName && member.isManager })) + } } } getOrElse NotFound } @@ -76,7 +131,9 @@ trait AccountControllerBase extends AccountManagementControllerBase { get("/:userName/_edit")(oneselfOnly { val userName = params("userName") - getAccountByUserName(userName).map(x => account.html.edit(Some(x), flash.get("info"))) getOrElse NotFound + getAccountByUserName(userName).map { x => + account.html.edit(x, flash.get("info")) + } getOrElse NotFound }) post("/:userName/_edit", editForm)(oneselfOnly { form => @@ -116,22 +173,266 @@ trait AccountControllerBase extends AccountManagementControllerBase { redirect("/") }) + get("/:userName/_ssh")(oneselfOnly { + val userName = params("userName") + getAccountByUserName(userName).map { x => + account.html.ssh(x, getPublicKeys(x.userName)) + } getOrElse NotFound + }) + + post("/:userName/_ssh", sshKeyForm)(oneselfOnly { form => + val userName = params("userName") + addPublicKey(userName, form.title, form.publicKey) + redirect(s"/${userName}/_ssh") + }) + + get("/:userName/_ssh/delete/:id")(oneselfOnly { + val userName = params("userName") + val sshKeyId = params("id").toInt + deletePublicKey(userName, sshKeyId) + redirect(s"/${userName}/_ssh") + }) + get("/register"){ - if(loadSystemSettings().allowAccountRegistration){ + if(context.settings.allowAccountRegistration){ if(context.loginAccount.isDefined){ redirect("/") } else { - account.html.edit(None, None) + account.html.register() } } else NotFound } post("/register", newForm){ form => - if(loadSystemSettings().allowAccountRegistration){ + if(context.settings.allowAccountRegistration){ createAccount(form.userName, sha1(form.password), form.fullName, form.mailAddress, false, form.url) updateImage(form.userName, form.fileId, false) redirect("/signin") } else NotFound } + get("/groups/new")(usersOnly { + account.html.group(None, List(GroupMember("", context.loginAccount.get.userName, true))) + }) + + post("/groups/new", newGroupForm)(usersOnly { form => + createGroup(form.groupName, form.url) + updateGroupMembers(form.groupName, form.members.split(",").map { + _.split(":") match { + case Array(userName, isManager) => (userName, isManager.toBoolean) + } + }.toList) + updateImage(form.groupName, form.fileId, false) + redirect(s"/${form.groupName}") + }) + + get("/:groupName/_editgroup")(managersOnly { + defining(params("groupName")){ groupName => + account.html.group(getAccountByUserName(groupName, true), getGroupMembers(groupName)) + } + }) + + get("/:groupName/_deletegroup")(managersOnly { + defining(params("groupName")){ groupName => + // Remove from GROUP_MEMBER + updateGroupMembers(groupName, Nil) + // Remove repositories + getRepositoryNamesOfUser(groupName).foreach { repositoryName => + deleteRepository(groupName, repositoryName) + FileUtils.deleteDirectory(getRepositoryDir(groupName, repositoryName)) + FileUtils.deleteDirectory(getWikiRepositoryDir(groupName, repositoryName)) + FileUtils.deleteDirectory(getTemporaryDir(groupName, repositoryName)) + } + } + redirect("/") + }) + + post("/:groupName/_editgroup", editGroupForm)(managersOnly { form => + defining(params("groupName"), form.members.split(",").map { + _.split(":") match { + case Array(userName, isManager) => (userName, isManager.toBoolean) + } + }.toList){ case (groupName, members) => + getAccountByUserName(groupName, true).map { account => + updateGroup(groupName, form.url, false) + + // Update GROUP_MEMBER + updateGroupMembers(form.groupName, members) + // Update COLLABORATOR for group repositories + getRepositoryNamesOfUser(form.groupName).foreach { repositoryName => + removeCollaborators(form.groupName, repositoryName) + members.foreach { case (userName, isManager) => + addCollaborator(form.groupName, repositoryName, userName) + } + } + + updateImage(form.groupName, form.fileId, form.clearImage) + redirect(s"/${form.groupName}") + + } getOrElse NotFound + } + }) + + /** + * Show the new repository form. + */ + get("/new")(usersOnly { + account.html.newrepo(getGroupsByUserName(context.loginAccount.get.userName)) + }) + + /** + * Create new repository. + */ + post("/new", newRepositoryForm)(usersOnly { form => + LockUtil.lock(s"${form.owner}/${form.name}/create"){ + if(getRepository(form.owner, form.name, baseUrl).isEmpty){ + val ownerAccount = getAccountByUserName(form.owner).get + val loginAccount = context.loginAccount.get + val loginUserName = loginAccount.userName + + // Insert to the database at first + createRepository(form.name, form.owner, form.description, form.isPrivate) + + // Add collaborators for group repository + if(ownerAccount.isGroupAccount){ + getGroupMembers(form.owner).foreach { member => + addCollaborator(form.owner, form.name, member.userName) + } + } + + // Insert default labels + insertDefaultLabels(form.owner, form.name) + + // Create the actual repository + val gitdir = getRepositoryDir(form.owner, form.name) + JGitUtil.initRepository(gitdir) + + if(form.createReadme){ + using(Git.open(gitdir)){ git => + val builder = DirCache.newInCore.builder() + val inserter = git.getRepository.newObjectInserter() + val headId = git.getRepository.resolve(Constants.HEAD + "^{commit}") + val content = if(form.description.nonEmpty){ + form.name + "\n" + + "===============\n" + + "\n" + + form.description.get + } else { + form.name + "\n" + + "===============\n" + } + + builder.add(JGitUtil.createDirCacheEntry("README.md", FileMode.REGULAR_FILE, + inserter.insert(Constants.OBJ_BLOB, content.getBytes("UTF-8")))) + builder.finish() + + JGitUtil.createNewCommit(git, inserter, headId, builder.getDirCache.writeTree(inserter), + loginAccount.fullName, loginAccount.mailAddress, "Initial commit") + } + } + + // Create Wiki repository + createWikiRepository(loginAccount, form.owner, form.name) + + // Record activity + recordCreateRepositoryActivity(form.owner, form.name, loginUserName) + } + + // redirect to the repository + redirect(s"/${form.owner}/${form.name}") + } + }) + + get("/:owner/:repository/fork")(readableUsersOnly { repository => + val loginAccount = context.loginAccount.get + val loginUserName = loginAccount.userName + + LockUtil.lock(s"${loginUserName}/${repository.name}/create"){ + if(repository.owner == loginUserName){ + // redirect to the repository + redirect(s"/${repository.owner}/${repository.name}") + } else { + getForkedRepositories(repository.owner, repository.name).find(_._1 == loginUserName).map { case (owner, name) => + // redirect to the repository + redirect(s"/${owner}/${name}") + } getOrElse { + // Insert to the database at first + val originUserName = repository.repository.originUserName.getOrElse(repository.owner) + val originRepositoryName = repository.repository.originRepositoryName.getOrElse(repository.name) + + createRepository( + repositoryName = repository.name, + userName = loginUserName, + description = repository.repository.description, + isPrivate = repository.repository.isPrivate, + originRepositoryName = Some(originRepositoryName), + originUserName = Some(originUserName), + parentRepositoryName = Some(repository.name), + parentUserName = Some(repository.owner) + ) + + // Insert default labels + insertDefaultLabels(loginUserName, repository.name) + + // clone repository actually + JGitUtil.cloneRepository( + getRepositoryDir(repository.owner, repository.name), + getRepositoryDir(loginUserName, repository.name)) + + // Create Wiki repository + JGitUtil.cloneRepository( + getWikiRepositoryDir(repository.owner, repository.name), + getWikiRepositoryDir(loginUserName, repository.name)) + + // insert commit id + using(Git.open(getRepositoryDir(loginUserName, repository.name))){ git => + JGitUtil.getRepositoryInfo(loginUserName, repository.name, baseUrl).branchList.foreach { branch => + JGitUtil.getCommitLog(git, branch) match { + case Right((commits, _)) => commits.foreach { commit => + if(!existsCommitId(loginUserName, repository.name, commit.id)){ + insertCommitId(loginUserName, repository.name, commit.id) + } + } + case Left(_) => ??? + } + } + } + + // Record activity + recordForkActivity(repository.owner, repository.name, loginUserName) + // redirect to the repository + redirect(s"/${loginUserName}/${repository.name}") + } + } + } + }) + + private def insertDefaultLabels(userName: String, repositoryName: String): Unit = { + createLabel(userName, repositoryName, "bug", "fc2929") + createLabel(userName, repositoryName, "duplicate", "cccccc") + createLabel(userName, repositoryName, "enhancement", "84b6eb") + createLabel(userName, repositoryName, "invalid", "e6e6e6") + createLabel(userName, repositoryName, "question", "cc317c") + createLabel(userName, repositoryName, "wontfix", "ffffff") + } + + private def existsAccount: Constraint = new Constraint(){ + override def validate(name: String, value: String, messages: Messages): Option[String] = + if(getAccountByUserName(value).isEmpty) Some("User or group does not exist.") else None + } + + private def uniqueRepository: Constraint = new Constraint(){ + override def validate(name: String, value: String, params: Map[String, String], messages: Messages): Option[String] = + params.get("owner").flatMap { userName => + getRepositoryNamesOfUser(userName).find(_ == value).map(_ => "Repository already exists.") + } + } + + private def members: Constraint = new Constraint(){ + override def validate(name: String, value: String, messages: Messages): Option[String] = { + if(value.split(",").exists { + _.split(":") match { case Array(userName, isManager) => isManager.toBoolean } + }) None else Some("Must select one manager at least.") + } + } } diff --git a/src/main/scala/app/ControllerBase.scala b/src/main/scala/app/ControllerBase.scala index cf91fea25..d7d7171a2 100644 --- a/src/main/scala/app/ControllerBase.scala +++ b/src/main/scala/app/ControllerBase.scala @@ -28,7 +28,7 @@ abstract class ControllerBase extends ScalatraFilter // Don't set content type via Accept header. override def format(implicit request: HttpServletRequest) = "" - override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { + override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain): Unit = try { val httpRequest = request.asInstanceOf[HttpServletRequest] val httpResponse = response.asInstanceOf[HttpServletResponse] val context = request.getServletContext.getContextPath @@ -36,15 +36,16 @@ abstract class ControllerBase extends ScalatraFilter if(path.startsWith("/console/")){ val account = httpRequest.getSession.getAttribute(Keys.Session.LoginAccount).asInstanceOf[Account] + val baseUrl = this.baseUrl(httpRequest) if(account == null){ // Redirect to login form - httpResponse.sendRedirect(context + "/signin?" + StringUtil.urlEncode(path)) + httpResponse.sendRedirect(baseUrl + "/signin?redirect=" + StringUtil.urlEncode(path)) } else if(account.isAdmin){ // H2 Console (administrators only) chain.doFilter(request, response) } else { // Redirect to dashboard - httpResponse.sendRedirect(context + "/") + httpResponse.sendRedirect(baseUrl + "/") } } else if(path.startsWith("/git/")){ // Git repository @@ -53,12 +54,25 @@ abstract class ControllerBase extends ScalatraFilter // Scalatra actions super.doFilter(request, response, chain) } + } finally { + contextCache.remove(); } + private val contextCache = new java.lang.ThreadLocal[Context]() + /** * Returns the context object for the request. */ - implicit def context: Context = Context(servletContext.getContextPath, LoginAccount, request) + implicit def context: Context = { + contextCache.get match { + case null => { + val context = Context(loadSystemSettings(), LoginAccount, request) + contextCache.set(context) + context + } + case context => context + } + } private def LoginAccount: Option[Account] = session.getAs[Account](Keys.Session.LoginAccount) @@ -116,14 +130,18 @@ abstract class ControllerBase extends ScalatraFilter includeContextPath: Boolean = true, includeServletPath: Boolean = true) (implicit request: HttpServletRequest, response: HttpServletResponse) = if (path.startsWith("http")) path - else baseUrl + url(path, params, includeContextPath, includeServletPath) + else baseUrl + url(path, params, false, false, false) } /** * Context object for the current request. */ -case class Context(path: String, loginAccount: Option[Account], request: HttpServletRequest){ +case class Context(settings: SystemSettingsService.SystemSettings, loginAccount: Option[Account], request: HttpServletRequest){ + + lazy val path = settings.baseUrl.getOrElse(request.getServletContext.getContextPath) + + lazy val currentPath = request.getRequestURI.substring(request.getContextPath.length) /** * Get object from cache. diff --git a/src/main/scala/app/CreateRepositoryController.scala b/src/main/scala/app/CreateRepositoryController.scala deleted file mode 100644 index 16cd05c95..000000000 --- a/src/main/scala/app/CreateRepositoryController.scala +++ /dev/null @@ -1,199 +0,0 @@ -package app - -import util.Directory._ -import util.ControlUtil._ -import util._ -import service._ -import org.eclipse.jgit.api.Git -import jp.sf.amateras.scalatra.forms._ -import org.eclipse.jgit.lib.{FileMode, Constants} -import org.eclipse.jgit.dircache.DirCache -import org.scalatra.i18n.Messages - -class CreateRepositoryController extends CreateRepositoryControllerBase - with RepositoryService with AccountService with WikiService with LabelsService with ActivityService - with UsersAuthenticator with ReadableUsersAuthenticator - -/** - * Creates new repository. - */ -trait CreateRepositoryControllerBase extends ControllerBase { - self: RepositoryService with AccountService with WikiService with LabelsService with ActivityService - with UsersAuthenticator with ReadableUsersAuthenticator => - - case class RepositoryCreationForm(owner: String, name: String, description: Option[String], isPrivate: Boolean, createReadme: Boolean) - - case class ForkRepositoryForm(owner: String, name: String) - - val newForm = mapping( - "owner" -> trim(label("Owner" , text(required, maxlength(40), identifier, existsAccount))), - "name" -> trim(label("Repository name", text(required, maxlength(40), identifier, unique))), - "description" -> trim(label("Description" , optional(text()))), - "isPrivate" -> trim(label("Repository Type", boolean())), - "createReadme" -> trim(label("Create README" , boolean())) - )(RepositoryCreationForm.apply) - - val forkForm = mapping( - "owner" -> trim(label("Repository owner", text(required))), - "name" -> trim(label("Repository name", text(required))) - )(ForkRepositoryForm.apply) - - /** - * Show the new repository form. - */ - get("/new")(usersOnly { - html.newrepo(getGroupsByUserName(context.loginAccount.get.userName)) - }) - - /** - * Create new repository. - */ - post("/new", newForm)(usersOnly { form => - LockUtil.lock(s"${form.owner}/${form.name}/create"){ - if(getRepository(form.owner, form.name, baseUrl).isEmpty){ - val ownerAccount = getAccountByUserName(form.owner).get - val loginAccount = context.loginAccount.get - val loginUserName = loginAccount.userName - - // Insert to the database at first - createRepository(form.name, form.owner, form.description, form.isPrivate) - - // Add collaborators for group repository - if(ownerAccount.isGroupAccount){ - getGroupMembers(form.owner).foreach { userName => - addCollaborator(form.owner, form.name, userName) - } - } - - // Insert default labels - insertDefaultLabels(form.owner, form.name) - - // Create the actual repository - val gitdir = getRepositoryDir(form.owner, form.name) - JGitUtil.initRepository(gitdir) - - if(form.createReadme){ - using(Git.open(gitdir)){ git => - val builder = DirCache.newInCore.builder() - val inserter = git.getRepository.newObjectInserter() - val headId = git.getRepository.resolve(Constants.HEAD + "^{commit}") - val content = if(form.description.nonEmpty){ - form.name + "\n" + - "===============\n" + - "\n" + - form.description.get - } else { - form.name + "\n" + - "===============\n" - } - - builder.add(JGitUtil.createDirCacheEntry("README.md", FileMode.REGULAR_FILE, - inserter.insert(Constants.OBJ_BLOB, content.getBytes("UTF-8")))) - builder.finish() - - JGitUtil.createNewCommit(git, inserter, headId, builder.getDirCache.writeTree(inserter), - loginAccount.fullName, loginAccount.mailAddress, "Initial commit") - } - } - - // Create Wiki repository - createWikiRepository(loginAccount, form.owner, form.name) - - // Record activity - recordCreateRepositoryActivity(form.owner, form.name, loginUserName) - } - - // redirect to the repository - redirect(s"/${form.owner}/${form.name}") - } - }) - - get("/:owner/:repository/fork")(readableUsersOnly { repository => - val loginAccount = context.loginAccount.get - val loginUserName = loginAccount.userName - - LockUtil.lock(s"${loginUserName}/${repository.name}/create"){ - if(repository.owner == loginUserName){ - // redirect to the repository - redirect(s"/${repository.owner}/${repository.name}") - } else { - getForkedRepositories(repository.owner, repository.name).find(_._1 == loginUserName).map { case (owner, name) => - // redirect to the repository - redirect(s"/${owner}/${name}") - } getOrElse { - // Insert to the database at first - val originUserName = repository.repository.originUserName.getOrElse(repository.owner) - val originRepositoryName = repository.repository.originRepositoryName.getOrElse(repository.name) - - createRepository( - repositoryName = repository.name, - userName = loginUserName, - description = repository.repository.description, - isPrivate = repository.repository.isPrivate, - originRepositoryName = Some(originRepositoryName), - originUserName = Some(originUserName), - parentRepositoryName = Some(repository.name), - parentUserName = Some(repository.owner) - ) - - // Insert default labels - insertDefaultLabels(loginUserName, repository.name) - - // clone repository actually - JGitUtil.cloneRepository( - getRepositoryDir(repository.owner, repository.name), - getRepositoryDir(loginUserName, repository.name)) - - // Create Wiki repository - JGitUtil.cloneRepository( - getWikiRepositoryDir(repository.owner, repository.name), - getWikiRepositoryDir(loginUserName, repository.name)) - - // insert commit id - using(Git.open(getRepositoryDir(loginUserName, repository.name))){ git => - JGitUtil.getRepositoryInfo(loginUserName, repository.name, baseUrl).branchList.foreach { branch => - JGitUtil.getCommitLog(git, branch) match { - case Right((commits, _)) => commits.foreach { commit => - if(!existsCommitId(loginUserName, repository.name, commit.id)){ - insertCommitId(loginUserName, repository.name, commit.id) - } - } - case Left(_) => ??? - } - } - } - - // Record activity - recordForkActivity(repository.owner, repository.name, loginUserName) - // redirect to the repository - redirect(s"/${loginUserName}/${repository.name}") - } - } - } - }) - - private def insertDefaultLabels(userName: String, repositoryName: String): Unit = { - createLabel(userName, repositoryName, "bug", "fc2929") - createLabel(userName, repositoryName, "duplicate", "cccccc") - createLabel(userName, repositoryName, "enhancement", "84b6eb") - createLabel(userName, repositoryName, "invalid", "e6e6e6") - createLabel(userName, repositoryName, "question", "cc317c") - createLabel(userName, repositoryName, "wontfix", "ffffff") - } - - private def existsAccount: Constraint = new Constraint(){ - override def validate(name: String, value: String, messages: Messages): Option[String] = - if(getAccountByUserName(value).isEmpty) Some("User or group does not exist.") else None - } - - /** - * Duplicate check for the repository name. - */ - private def unique: Constraint = new Constraint(){ - override def validate(name: String, value: String, params: Map[String, String], messages: Messages): Option[String] = - params.get("owner").flatMap { userName => - getRepositoryNamesOfUser(userName).find(_ == value).map(_ => "Repository already exists.") - } - } - -} diff --git a/src/main/scala/app/IndexController.scala b/src/main/scala/app/IndexController.scala index 0e46b3c46..426ec631a 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -22,7 +22,6 @@ trait IndexControllerBase extends ControllerBase { html.index(getRecentActivities(), getVisibleRepositories(loginAccount, baseUrl), - loadSystemSettings(), loginAccount.map{ account => getUserRepositories(account.userName, baseUrl) }.getOrElse(Nil) ) } @@ -32,11 +31,11 @@ trait IndexControllerBase extends ControllerBase { if(redirect.isDefined && redirect.get.startsWith("/")){ flash += Keys.Flash.Redirect -> redirect.get } - html.signin(loadSystemSettings()) + html.signin() } post("/signin", form){ form => - authenticate(loadSystemSettings(), form.userName, form.password) match { + authenticate(context.settings, form.userName, form.password) match { case Some(account) => signin(account) case None => redirect("/signin") } diff --git a/src/main/scala/app/PullRequestsController.scala b/src/main/scala/app/PullRequestsController.scala index bf3bea268..47c061430 100644 --- a/src/main/scala/app/PullRequestsController.scala +++ b/src/main/scala/app/PullRequestsController.scala @@ -105,9 +105,9 @@ trait PullRequestsControllerBase extends ControllerBase { } getOrElse NotFound }) - get("/:owner/:repository/pull/:id/delete/:branchName")(collaboratorsOnly { repository => + get("/:owner/:repository/pull/:id/delete/*")(collaboratorsOnly { repository => params("id").toIntOpt.map { issueId => - val branchName = params("branchName") + val branchName = multiParams("splat").head val userName = context.loginAccount.get.userName if(repository.repository.defaultBranch != branchName){ using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => @@ -228,16 +228,16 @@ trait PullRequestsControllerBase extends ControllerBase { val oldBranch = JGitUtil.getDefaultBranch(oldGit, originRepository).get._2 val newBranch = JGitUtil.getDefaultBranch(newGit, forkedRepository).get._2 - redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}/compare/${originUserName}:${oldBranch}...${newBranch}") + redirect(s"/${forkedRepository.owner}/${forkedRepository.name}/compare/${originUserName}:${oldBranch}...${newBranch}") } } getOrElse NotFound } case _ => { using(Git.open(getRepositoryDir(forkedRepository.owner, forkedRepository.name))){ git => JGitUtil.getDefaultBranch(git, forkedRepository).map { case (_, defaultBranch) => - redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}/compare/${defaultBranch}...${defaultBranch}") + redirect(s"/${forkedRepository.owner}/${forkedRepository.name}/compare/${defaultBranch}...${defaultBranch}") } getOrElse { - redirect(s"${context.path}/${forkedRepository.owner}/${forkedRepository.name}") + redirect(s"/${forkedRepository.owner}/${forkedRepository.name}") } } } diff --git a/src/main/scala/app/RepositoryViewerController.scala b/src/main/scala/app/RepositoryViewerController.scala index 6893e63b1..dda0e6147 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -82,44 +82,45 @@ trait RepositoryViewerControllerBase extends ControllerBase { val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(id)) @scala.annotation.tailrec - def getPathObjectId(path: String, walk: TreeWalk): ObjectId = walk.next match { - case true if(walk.getPathString == path) => walk.getObjectId(0) - case true => getPathObjectId(path, walk) + def getPathObjectId(path: String, walk: TreeWalk): Option[ObjectId] = walk.next match { + case true if(walk.getPathString == path) => Some(walk.getObjectId(0)) + case true => getPathObjectId(path, walk) + case false => None } - val objectId = using(new TreeWalk(git.getRepository)){ treeWalk => + using(new TreeWalk(git.getRepository)){ treeWalk => treeWalk.addTree(revCommit.getTree) treeWalk.setRecursive(true) getPathObjectId(path, treeWalk) - } - - if(raw){ - // Download - defining(JGitUtil.getContent(git, objectId, false).get){ bytes => - contentType = FileUtil.getContentType(path, bytes) - bytes - } - } else { - // Viewer - val large = FileUtil.isLarge(git.getRepository.getObjectDatabase.open(objectId).getSize) - val viewer = if(FileUtil.isImage(path)) "image" else if(large) "large" else "other" - val bytes = if(viewer == "other") JGitUtil.getContent(git, objectId, false) else None - - val content = if(viewer == "other"){ - if(bytes.isDefined && FileUtil.isText(bytes.get)){ - // text - JGitUtil.ContentInfo("text", bytes.map(StringUtil.convertFromByteArray)) - } else { - // binary - JGitUtil.ContentInfo("binary", None) + } map { objectId => + if(raw){ + // Download + defining(JGitUtil.getContentFromId(git, objectId, false).get){ bytes => + contentType = FileUtil.getContentType(path, bytes) + bytes } } else { - // image or large - JGitUtil.ContentInfo(viewer, None) - } + // Viewer + val large = FileUtil.isLarge(git.getRepository.getObjectDatabase.open(objectId).getSize) + val viewer = if(FileUtil.isImage(path)) "image" else if(large) "large" else "other" + val bytes = if(viewer == "other") JGitUtil.getContentFromId(git, objectId, false) else None - repo.html.blob(id, repository, path.split("/").toList, content, new JGitUtil.CommitInfo(revCommit)) - } + val content = if(viewer == "other"){ + if(bytes.isDefined && FileUtil.isText(bytes.get)){ + // text + JGitUtil.ContentInfo("text", bytes.map(StringUtil.convertFromByteArray)) + } else { + // binary + JGitUtil.ContentInfo("binary", None) + } + } else { + // image or large + JGitUtil.ContentInfo(viewer, None) + } + + repo.html.blob(id, repository, path.split("/").toList, content, new JGitUtil.CommitInfo(revCommit)) + } + } getOrElse NotFound } }) @@ -158,8 +159,8 @@ trait RepositoryViewerControllerBase extends ControllerBase { /** * Deletes branch. */ - get("/:owner/:repository/delete/:branchName")(collaboratorsOnly { repository => - val branchName = params("branchName") + get("/:owner/:repository/delete/*")(collaboratorsOnly { repository => + val branchName = multiParams("splat").head val userName = context.loginAccount.get.userName if(repository.repository.defaultBranch != branchName){ using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => @@ -180,8 +181,8 @@ trait RepositoryViewerControllerBase extends ControllerBase { /** * Download repository contents as an archive. */ - get("/:owner/:repository/archive/:name")(referrersOnly { repository => - val name = params("name") + get("/:owner/:repository/archive/*")(referrersOnly { repository => + val name = multiParams("splat").head if(name.endsWith(".zip")){ val revision = name.replaceFirst("\\.zip$", "") @@ -192,7 +193,7 @@ trait RepositoryViewerControllerBase extends ControllerBase { workDir.mkdirs val zipFile = new File(workDir, repository.name + "-" + - (if(revision.length == 40) revision.substring(0, 10) else revision) + ".zip") + (if(revision.length == 40) revision.substring(0, 10) else revision).replace('/', '_') + ".zip") using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(revision)) @@ -207,7 +208,7 @@ trait RepositoryViewerControllerBase extends ControllerBase { while(walk.next){ val name = walk.getPathString val mode = walk.getFileMode(0) - if(mode != FileMode.TREE){ + if(mode == FileMode.REGULAR_FILE || mode == FileMode.EXECUTABLE_FILE){ walk.getObjectId(objectId, 0) val entry = new ZipEntry(name) val loader = reader.open(objectId) @@ -266,21 +267,21 @@ trait RepositoryViewerControllerBase extends ControllerBase { repo.html.guide(repository) } else { using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git => - val revisions = Seq(if(revstr.isEmpty) repository.repository.defaultBranch else revstr, repository.branchList.head) + //val revisions = Seq(if(revstr.isEmpty) repository.repository.defaultBranch else revstr, repository.branchList.head) // get specified commit - JGitUtil.getDefaultBranch(git, repository, revstr).map { - case (objectId, revision) => - defining(JGitUtil.getRevCommitFromId(git, objectId)) { revCommit => - // get files - val files = JGitUtil.getFileList(git, revision, path) - val parentPath = if (path == ".") Nil else path.split("/").toList - // process README.md or README.markdown - val readme = files.find { file => - readmeFiles.contains(file.name.toLowerCase) - }.map { file => - val path = (file.name :: parentPath.reverse).reverse - path -> StringUtil.convertFromByteArray(JGitUtil.getContent(Git.open(getRepositoryDir(repository.owner, repository.name)), file.id, true).get) - } + JGitUtil.getDefaultBranch(git, repository, revstr).map { case (objectId, revision) => + defining(JGitUtil.getRevCommitFromId(git, objectId)) { revCommit => + // get files + val files = JGitUtil.getFileList(git, revision, path) + val parentPath = if (path == ".") Nil else path.split("/").toList + // process README.md or README.markdown + val readme = files.find { file => + readmeFiles.contains(file.name.toLowerCase) + }.map { file => + val path = (file.name :: parentPath.reverse).reverse + path -> StringUtil.convertFromByteArray(JGitUtil.getContentFromId( + Git.open(getRepositoryDir(repository.owner, repository.name)), file.id, true).get) + } repo.html.files(revision, repository, if(path == ".") Nil else path.split("/").toList, // current path diff --git a/src/main/scala/app/SystemSettingsController.scala b/src/main/scala/app/SystemSettingsController.scala index c6f56fe34..478c7c59e 100644 --- a/src/main/scala/app/SystemSettingsController.scala +++ b/src/main/scala/app/SystemSettingsController.scala @@ -4,18 +4,21 @@ import service.{AccountService, SystemSettingsService} import SystemSettingsService._ import util.AdminAuthenticator import jp.sf.amateras.scalatra.forms._ +import ssh.SshServer class SystemSettingsController extends SystemSettingsControllerBase - with SystemSettingsService with AccountService with AdminAuthenticator + with AccountService with AdminAuthenticator trait SystemSettingsControllerBase extends ControllerBase { - self: SystemSettingsService with AccountService with AdminAuthenticator => + self: AccountService with AdminAuthenticator => private val form = mapping( "baseUrl" -> trim(label("Base URL", optional(text()))), "allowAccountRegistration" -> trim(label("Account registration", boolean())), "gravatar" -> trim(label("Gravatar", boolean())), "notification" -> trim(label("Notification", boolean())), + "ssh" -> trim(label("SSH access", boolean())), + "sshPort" -> trim(label("SSH port", optional(number()))), "smtp" -> optionalIfNotChecked("notification", mapping( "host" -> trim(label("SMTP Host", text(required))), "port" -> trim(label("SMTP Port", optional(number()))), @@ -38,15 +41,28 @@ trait SystemSettingsControllerBase extends ControllerBase { "tls" -> trim(label("Enable TLS", optional(boolean()))), "keystore" -> trim(label("Keystore", optional(text()))) )(Ldap.apply)) - )(SystemSettings.apply) + )(SystemSettings.apply).verifying { settings => + if(settings.ssh && settings.baseUrl.isEmpty){ + Seq("baseUrl" -> "Base URL is required if SSH access is enabled.") + } else Nil + } get("/admin/system")(adminOnly { - admin.html.system(loadSystemSettings(), flash.get("info")) + admin.html.system(flash.get("info")) }) post("/admin/system", form)(adminOnly { form => saveSystemSettings(form) + + if(form.ssh && !SshServer.isActive && form.baseUrl.isDefined){ + SshServer.start(request.getServletContext, + form.sshPort.getOrElse(SystemSettingsService.DefaultSshPort), + form.baseUrl.get) + } else if(!form.ssh && SshServer.isActive){ + SshServer.stop() + } + flash += "info" -> "System settings has been updated." redirect("/admin/system") }) diff --git a/src/main/scala/app/UserManagementController.scala b/src/main/scala/app/UserManagementController.scala index 50ffb3c34..dc3543c00 100644 --- a/src/main/scala/app/UserManagementController.scala +++ b/src/main/scala/app/UserManagementController.scala @@ -5,6 +5,7 @@ import util.AdminAuthenticator import util.StringUtil._ import util.ControlUtil._ import jp.sf.amateras.scalatra.forms._ +import org.scalatra.i18n.Messages import org.apache.commons.io.FileUtils import util.Directory._ @@ -23,10 +24,10 @@ trait UserManagementControllerBase extends AccountManagementControllerBase { fileId: Option[String], clearImage: Boolean, isRemoved: Boolean) case class NewGroupForm(groupName: String, url: Option[String], fileId: Option[String], - memberNames: Option[String]) + members: String) case class EditGroupForm(groupName: String, url: Option[String], fileId: Option[String], - memberNames: Option[String], clearImage: Boolean, isRemoved: Boolean) + members: String, clearImage: Boolean, isRemoved: Boolean) val newUserForm = mapping( "userName" -> trim(label("Username" ,text(required, maxlength(100), identifier, uniqueUserName))), @@ -51,28 +52,28 @@ trait UserManagementControllerBase extends AccountManagementControllerBase { )(EditUserForm.apply) val newGroupForm = mapping( - "groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier, uniqueUserName))), - "url" -> trim(label("URL" ,optional(text(maxlength(200))))), - "fileId" -> trim(label("File ID" ,optional(text()))), - "memberNames" -> trim(label("Member Names" ,optional(text()))) + "groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier, uniqueUserName))), + "url" -> trim(label("URL" ,optional(text(maxlength(200))))), + "fileId" -> trim(label("File ID" ,optional(text()))), + "members" -> trim(label("Members" ,text(required, members))) )(NewGroupForm.apply) val editGroupForm = mapping( - "groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier))), - "url" -> trim(label("URL" ,optional(text(maxlength(200))))), - "fileId" -> trim(label("File ID" ,optional(text()))), - "memberNames" -> trim(label("Member Names" ,optional(text()))), - "clearImage" -> trim(label("Clear image" ,boolean())), - "removed" -> trim(label("Disable" ,boolean())) + "groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier))), + "url" -> trim(label("URL" ,optional(text(maxlength(200))))), + "fileId" -> trim(label("File ID" ,optional(text()))), + "members" -> trim(label("Members" ,text(required, members))), + "clearImage" -> trim(label("Clear image" ,boolean())), + "removed" -> trim(label("Disable" ,boolean())) )(EditGroupForm.apply) get("/admin/users")(adminOnly { val includeRemoved = params.get("includeRemoved").map(_.toBoolean).getOrElse(false) - val users = getAllUsers(includeRemoved) - - val members = users.collect { case account if(account.isGroupAccount) => - account.userName -> getGroupMembers(account.userName) + val users = getAllUsers(includeRemoved) + val members = users.collect { case account if(account.isGroupAccount) => + account.userName -> getGroupMembers(account.userName).map(_.userName) }.toMap + admin.users.html.list(users, members, includeRemoved) }) @@ -127,7 +128,11 @@ trait UserManagementControllerBase extends AccountManagementControllerBase { post("/admin/users/_newgroup", newGroupForm)(adminOnly { form => createGroup(form.groupName, form.url) - updateGroupMembers(form.groupName, form.memberNames.map(_.split(",").toList).getOrElse(Nil)) + updateGroupMembers(form.groupName, form.members.split(",").map { + _.split(":") match { + case Array(userName, isManager) => (userName, isManager.toBoolean) + } + }.toList) updateImage(form.groupName, form.fileId, false) redirect("/admin/users") }) @@ -139,7 +144,11 @@ trait UserManagementControllerBase extends AccountManagementControllerBase { }) post("/admin/users/:groupName/_editgroup", editGroupForm)(adminOnly { form => - defining(params("groupName"), form.memberNames.map(_.split(",").toList).getOrElse(Nil)){ case (groupName, memberNames) => + defining(params("groupName"), form.members.split(",").map { + _.split(":") match { + case Array(userName, isManager) => (userName, isManager.toBoolean) + } + }.toList){ case (groupName, members) => getAccountByUserName(groupName, true).map { account => updateGroup(groupName, form.url, form.isRemoved) @@ -155,11 +164,11 @@ trait UserManagementControllerBase extends AccountManagementControllerBase { } } else { // Update GROUP_MEMBER - updateGroupMembers(form.groupName, memberNames) + updateGroupMembers(form.groupName, members) // Update COLLABORATOR for group repositories getRepositoryNamesOfUser(form.groupName).foreach { repositoryName => removeCollaborators(form.groupName, repositoryName) - memberNames.foreach { userName => + members.foreach { case (userName, isManager) => addCollaborator(form.groupName, repositoryName, userName) } } @@ -172,8 +181,17 @@ trait UserManagementControllerBase extends AccountManagementControllerBase { } }) - post("/admin/users/_usercheck")(adminOnly { + // TODO Move to other generic controller? + post("/admin/users/_usercheck"){ getAccountByUserName(params("userName")).isDefined - }) + } + + private def members: Constraint = new Constraint(){ + override def validate(name: String, value: String, messages: Messages): Option[String] = { + if(value.split(",").exists { + _.split(":") match { case Array(userName, isManager) => isManager.toBoolean } + }) None else Some("Must select one manager at least.") + } + } } diff --git a/src/main/scala/model/GroupMembers.scala b/src/main/scala/model/GroupMembers.scala index 0bcd0af73..a2a38f350 100644 --- a/src/main/scala/model/GroupMembers.scala +++ b/src/main/scala/model/GroupMembers.scala @@ -5,10 +5,12 @@ import scala.slick.driver.H2Driver.simple._ object GroupMembers extends Table[GroupMember]("GROUP_MEMBER") { def groupName = column[String]("GROUP_NAME", O PrimaryKey) def userName = column[String]("USER_NAME", O PrimaryKey) - def * = groupName ~ userName <> (GroupMember, GroupMember.unapply _) + def isManager = column[Boolean]("MANAGER") + def * = groupName ~ userName ~ isManager <> (GroupMember, GroupMember.unapply _) } case class GroupMember( groupName: String, - userName: String + userName: String, + isManager: Boolean ) \ No newline at end of file diff --git a/src/main/scala/model/SshKey.scala b/src/main/scala/model/SshKey.scala new file mode 100644 index 000000000..39d89d481 --- /dev/null +++ b/src/main/scala/model/SshKey.scala @@ -0,0 +1,22 @@ +package model + +import scala.slick.driver.H2Driver.simple._ + +object SshKeys extends Table[SshKey]("SSH_KEY") { + def userName = column[String]("USER_NAME") + def sshKeyId = column[Int]("SSH_KEY_ID", O AutoInc) + def title = column[String]("TITLE") + def publicKey = column[String]("PUBLIC_KEY") + + def ins = userName ~ title ~ publicKey returning sshKeyId + def * = userName ~ sshKeyId ~ title ~ publicKey <> (SshKey, SshKey.unapply _) + + def byPrimaryKey(userName: String, sshKeyId: Int) = (this.userName is userName.bind) && (this.sshKeyId is sshKeyId.bind) +} + +case class SshKey( + userName: String, + sshKeyId: Int, + title: String, + publicKey: String +) diff --git a/src/main/scala/service/AccountService.scala b/src/main/scala/service/AccountService.scala index ff1186f65..c57d0ce06 100644 --- a/src/main/scala/service/AccountService.scala +++ b/src/main/scala/service/AccountService.scala @@ -122,18 +122,17 @@ trait AccountService { def updateGroup(groupName: String, url: Option[String], removed: Boolean): Unit = Accounts.filter(_.userName is groupName.bind).map(t => t.url.? ~ t.removed).update(url, removed) - def updateGroupMembers(groupName: String, members: List[String]): Unit = { + def updateGroupMembers(groupName: String, members: List[(String, Boolean)]): Unit = { Query(GroupMembers).filter(_.groupName is groupName.bind).delete - members.foreach { userName => - GroupMembers insert GroupMember (groupName, userName) + members.foreach { case (userName, isManager) => + GroupMembers insert GroupMember (groupName, userName, isManager) } } - def getGroupMembers(groupName: String): List[String] = + def getGroupMembers(groupName: String): List[GroupMember] = Query(GroupMembers) .filter(_.groupName is groupName.bind) .sortBy(_.userName) - .map(_.userName) .list def getGroupsByUserName(userName: String): List[String] = diff --git a/src/main/scala/service/IssuesService.scala b/src/main/scala/service/IssuesService.scala index b1215995f..590e300bf 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -8,7 +8,6 @@ import Q.interpolation import model._ import util.Implicits._ import util.StringUtil._ -import util.StringUtil trait IssuesService { import IssuesService._ @@ -120,16 +119,10 @@ trait IssuesService { // get issues and comment count and labels searchIssueQuery(repos, condition, filterUser, onlyPullRequest) .innerJoin(IssueOutline).on { (t1, t2) => t1.byIssue(t2.userName, t2.repositoryName, t2.issueId) } - .leftJoin (IssueLabels) .on { case ((t1, t2), t3) => t1.byIssue(t3.userName, t3.repositoryName, t3.issueId) } - .leftJoin (Labels) .on { case (((t1, t2), t3), t4) => t3.byLabel(t4.userName, t4.repositoryName, t4.labelId) } - .map { case (((t1, t2), t3), t4) => - (t1, t2.commentCount, t4.labelId.?, t4.labelName.?, t4.color.?) - } - .sortBy(_._4) // labelName - .sortBy { case (t1, commentCount, _,_,_) => + .sortBy { case (t1, t2) => (condition.sort match { case "created" => t1.registeredDate - case "comments" => commentCount + case "comments" => t2.commentCount case "updated" => t1.updatedDate }) match { case sort => condition.direction match { @@ -139,6 +132,11 @@ trait IssuesService { } } .drop(offset).take(limit) + .leftJoin (IssueLabels) .on { case ((t1, t2), t3) => t1.byIssue(t3.userName, t3.repositoryName, t3.issueId) } + .leftJoin (Labels) .on { case (((t1, t2), t3), t4) => t3.byLabel(t4.userName, t4.repositoryName, t4.labelId) } + .map { case (((t1, t2), t3), t4) => + (t1, t2.commentCount, t4.labelId.?, t4.labelName.?, t4.color.?) + } .list .splitWith { (c1, c2) => c1._1.userName == c2._1.userName && @@ -316,7 +314,7 @@ trait IssuesService { } def closeIssuesFromMessage(message: String, userName: String, owner: String, repository: String) = { - StringUtil.extractCloseId(message).foreach { issueId => + extractCloseId(message).foreach { issueId => for(issue <- getIssue(owner, repository, issueId) if !issue.closed){ createComment(owner, repository, userName, issue.issueId, "Close", "close") updateClosed(owner, repository, issue.issueId, true) diff --git a/src/main/scala/service/RepositorySearchService.scala b/src/main/scala/service/RepositorySearchService.scala index ac4f17743..33ec94275 100644 --- a/src/main/scala/service/RepositorySearchService.scala +++ b/src/main/scala/service/RepositorySearchService.scala @@ -63,8 +63,9 @@ RepositorySearchService { self: IssuesService => val list = new ListBuffer[(String, String)] while (treeWalk.next()) { - if(treeWalk.getFileMode(0) != FileMode.TREE){ - JGitUtil.getContent(git, treeWalk.getObjectId(0), false).foreach { bytes => + val mode = treeWalk.getFileMode(0) + if(mode == FileMode.REGULAR_FILE || mode == FileMode.EXECUTABLE_FILE){ + JGitUtil.getContentFromId(git, treeWalk.getObjectId(0), false).foreach { bytes => if(FileUtil.isText(bytes)){ val text = StringUtil.convertFromByteArray(bytes) val lowerText = text.toLowerCase diff --git a/src/main/scala/service/RepositoryService.scala b/src/main/scala/service/RepositoryService.scala index 74bd13923..38e7606b1 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -147,7 +147,8 @@ trait RepositoryService { self: AccountService => getForkedCount( repository.originUserName.getOrElse(repository.userName), repository.originRepositoryName.getOrElse(repository.repositoryName) - )) + ), + getRepositoryManagers(repository.userName)) } } @@ -162,7 +163,8 @@ trait RepositoryService { self: AccountService => getForkedCount( repository.originUserName.getOrElse(repository.userName), repository.originRepositoryName.getOrElse(repository.repositoryName) - )) + ), + getRepositoryManagers(repository.userName)) } } @@ -195,10 +197,18 @@ trait RepositoryService { self: AccountService => getForkedCount( repository.originUserName.getOrElse(repository.userName), repository.originRepositoryName.getOrElse(repository.repositoryName) - )) + ), + getRepositoryManagers(repository.userName)) } } + private def getRepositoryManagers(userName: String): Seq[String] = + if(getAccountByUserName(userName).exists(_.isGroupAccount)){ + getGroupMembers(userName).collect { case x if(x.isManager) => x.userName } + } else { + Seq(userName) + } + /** * Updates the last activity date of the repository. */ @@ -278,21 +288,25 @@ trait RepositoryService { self: AccountService => object RepositoryService { - case class RepositoryInfo(owner: String, name: String, url: String, repository: Repository, + case class RepositoryInfo(owner: String, name: String, httpUrl: String, repository: Repository, issueCount: Int, pullCount: Int, commitCount: Int, forkedCount: Int, - branchList: List[String], tags: List[util.JGitUtil.TagInfo]){ + branchList: Seq[String], tags: Seq[util.JGitUtil.TagInfo], managers: Seq[String]){ + + lazy val host = """^https?://(.+?)(:\d+)?/""".r.findFirstMatchIn(httpUrl).get.group(1) + + def sshUrl(port: Int, userName: String) = s"ssh://${userName}@${host}:${port}/${owner}/${name}.git" /** * Creates instance with issue count and pull request count. */ - def this(repo: JGitUtil.RepositoryInfo, model: Repository, issueCount: Int, pullCount: Int, forkedCount: Int) = - this(repo.owner, repo.name, repo.url, model, issueCount, pullCount, repo.commitCount, forkedCount, repo.branchList, repo.tags) + def this(repo: JGitUtil.RepositoryInfo, model: Repository, issueCount: Int, pullCount: Int, forkedCount: Int, managers: Seq[String]) = + this(repo.owner, repo.name, repo.url, model, issueCount, pullCount, repo.commitCount, forkedCount, repo.branchList, repo.tags, managers) /** * Creates instance without issue count and pull request count. */ - def this(repo: JGitUtil.RepositoryInfo, model: Repository, forkedCount: Int) = - this(repo.owner, repo.name, repo.url, model, 0, 0, repo.commitCount, forkedCount, repo.branchList, repo.tags) + def this(repo: JGitUtil.RepositoryInfo, model: Repository, forkedCount: Int, managers: Seq[String]) = + this(repo.owner, repo.name, repo.url, model, 0, 0, repo.commitCount, forkedCount, repo.branchList, repo.tags, managers) } case class RepositoryTreeNode(owner: String, name: String, children: List[RepositoryTreeNode]) diff --git a/src/main/scala/service/RequestCache.scala b/src/main/scala/service/RequestCache.scala index 0f0b0f0a4..3747e8575 100644 --- a/src/main/scala/service/RequestCache.scala +++ b/src/main/scala/service/RequestCache.scala @@ -1,7 +1,6 @@ package service import model._ -import service.SystemSettingsService.SystemSettings /** * This service is used for a view helper mainly. @@ -9,28 +8,23 @@ import service.SystemSettingsService.SystemSettings * It may be called many times in one request, so each method stores * its result into the cache which available during a request. */ -trait RequestCache { - - def getSystemSettings()(implicit context: app.Context): SystemSettings = - context.cache("system_settings"){ - new SystemSettingsService {}.loadSystemSettings() - } +trait RequestCache extends SystemSettingsService with AccountService with IssuesService { def getIssue(userName: String, repositoryName: String, issueId: String)(implicit context: app.Context): Option[Issue] = { context.cache(s"issue.${userName}/${repositoryName}#${issueId}"){ - new IssuesService {}.getIssue(userName, repositoryName, issueId) + super.getIssue(userName, repositoryName, issueId) } } def getAccountByUserName(userName: String)(implicit context: app.Context): Option[Account] = { context.cache(s"account.${userName}"){ - new AccountService {}.getAccountByUserName(userName) + super.getAccountByUserName(userName) } } def getAccountByMailAddress(mailAddress: String)(implicit context: app.Context): Option[Account] = { context.cache(s"account.${mailAddress}"){ - new AccountService {}.getAccountByMailAddress(mailAddress) + super.getAccountByMailAddress(mailAddress) } } } diff --git a/src/main/scala/service/SshKeyService.scala b/src/main/scala/service/SshKeyService.scala new file mode 100644 index 000000000..c249a3a6e --- /dev/null +++ b/src/main/scala/service/SshKeyService.scala @@ -0,0 +1,19 @@ +package service + +import model._ +import scala.slick.driver.H2Driver.simple._ +import Database.threadLocalSession + +trait SshKeyService { + + def addPublicKey(userName: String, title: String, publicKey: String): Unit = + SshKeys.ins insert (userName, title, publicKey) + + def getPublicKeys(userName: String): List[SshKey] = + Query(SshKeys).filter(_.userName is userName.bind).sortBy(_.sshKeyId).list + + def deletePublicKey(userName: String, sshKeyId: Int): Unit = + SshKeys filter (_.byPrimaryKey(userName, sshKeyId)) delete + + +} diff --git a/src/main/scala/service/SystemSettingsService.scala b/src/main/scala/service/SystemSettingsService.scala index f15bd3782..ba425a3ce 100644 --- a/src/main/scala/service/SystemSettingsService.scala +++ b/src/main/scala/service/SystemSettingsService.scala @@ -15,10 +15,12 @@ trait SystemSettingsService { def saveSystemSettings(settings: SystemSettings): Unit = { defining(new java.util.Properties()){ props => - settings.baseUrl.foreach(props.setProperty(BaseURL, _)) + settings.baseUrl.foreach(x => props.setProperty(BaseURL, x.replaceFirst("/\\Z", ""))) props.setProperty(AllowAccountRegistration, settings.allowAccountRegistration.toString) props.setProperty(Gravatar, settings.gravatar.toString) props.setProperty(Notification, settings.notification.toString) + props.setProperty(Ssh, settings.ssh.toString) + settings.sshPort.foreach(x => props.setProperty(SshPort, x.toString)) if(settings.notification) { settings.smtp.foreach { smtp => props.setProperty(SmtpHost, smtp.host) @@ -56,10 +58,12 @@ trait SystemSettingsService { props.load(new java.io.FileInputStream(GitBucketConf)) } SystemSettings( - getOptionValue(props, BaseURL, None), + getOptionValue[String](props, BaseURL, None).map(x => x.replaceFirst("/\\Z", "")), getValue(props, AllowAccountRegistration, false), getValue(props, Gravatar, true), getValue(props, Notification, false), + getValue(props, Ssh, false), + getOptionValue(props, SshPort, Some(DefaultSshPort)), if(getValue(props, Notification, false)){ Some(Smtp( getValue(props, SmtpHost, ""), @@ -102,6 +106,8 @@ object SystemSettingsService { allowAccountRegistration: Boolean, gravatar: Boolean, notification: Boolean, + ssh: Boolean, + sshPort: Option[Int], smtp: Option[Smtp], ldapAuthentication: Boolean, ldap: Option[Ldap]) @@ -127,6 +133,7 @@ object SystemSettingsService { fromAddress: Option[String], fromName: Option[String]) + val DefaultSshPort = 29418 val DefaultSmtpPort = 25 val DefaultLdapPort = 389 @@ -134,6 +141,8 @@ object SystemSettingsService { private val AllowAccountRegistration = "allow_account_registration" private val Gravatar = "gravatar" private val Notification = "notification" + private val Ssh = "ssh" + private val SshPort = "ssh.port" private val SmtpHost = "smtp.host" private val SmtpPort = "smtp.port" private val SmtpUser = "smtp.user" diff --git a/src/main/scala/service/WebHookService.scala b/src/main/scala/service/WebHookService.scala index 496f78486..5662c4483 100644 --- a/src/main/scala/service/WebHookService.scala +++ b/src/main/scala/service/WebHookService.scala @@ -87,7 +87,7 @@ object WebHookService { refName, commits.map { commit => val diffs = JGitUtil.getDiffs(git, commit.id, false) - val commitUrl = repositoryInfo.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/commit/" + commit.id + val commitUrl = repositoryInfo.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/commit/" + commit.id WebHookCommit( id = commit.id, @@ -106,7 +106,7 @@ object WebHookService { }.toList, WebHookRepository( name = repositoryInfo.name, - url = repositoryInfo.url, + url = repositoryInfo.httpUrl, description = repositoryInfo.repository.description.getOrElse(""), watchers = 0, forks = repositoryInfo.forkedCount, diff --git a/src/main/scala/service/WikiService.scala b/src/main/scala/service/WikiService.scala index 00fbabd99..870fe2084 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -15,6 +15,7 @@ import org.eclipse.jgit.patch._ import org.eclipse.jgit.api.errors.PatchFormatException import scala.collection.JavaConverters._ import scala.Some +import service.RepositoryService.RepositoryInfo object WikiService { @@ -40,6 +41,10 @@ object WikiService { */ case class WikiPageHistoryInfo(name: String, committer: String, message: String, date: Date) + def httpUrl(repository: RepositoryInfo) = repository.httpUrl.replaceFirst("\\.git\\Z", ".wiki.git") + + def sshUrl(repository: RepositoryInfo, settings: SystemSettingsService.SystemSettings, userName: String) = + repository.sshUrl(settings.sshPort.getOrElse(SystemSettingsService.DefaultSshPort), userName).replaceFirst("\\.git\\Z", ".wiki.git") } trait WikiService { @@ -234,7 +239,7 @@ trait WikiService { builder.add(JGitUtil.createDirCacheEntry(path, tree.getEntryFileMode, tree.getEntryObjectId)) } else { created = false - updated = JGitUtil.getContent(git, tree.getEntryObjectId, true).map(new String(_, "UTF-8") != content).getOrElse(false) + updated = JGitUtil.getContentFromId(git, tree.getEntryObjectId, true).map(new String(_, "UTF-8") != content).getOrElse(false) } } } @@ -268,35 +273,35 @@ trait WikiService { */ def deleteWikiPage(owner: String, repository: String, pageName: String, committer: String, mailAddress: String, message: String): Unit = { - LockUtil.lock(s"${owner}/${repository}/wiki"){ - using(Git.open(Directory.getWikiRepositoryDir(owner, repository))){ git => - val builder = DirCache.newInCore.builder() - val inserter = git.getRepository.newObjectInserter() - val headId = git.getRepository.resolve(Constants.HEAD + "^{commit}") - var removed = false + LockUtil.lock(s"${owner}/${repository}/wiki"){ + using(Git.open(Directory.getWikiRepositoryDir(owner, repository))){ git => + val builder = DirCache.newInCore.builder() + val inserter = git.getRepository.newObjectInserter() + val headId = git.getRepository.resolve(Constants.HEAD + "^{commit}") + var removed = false - using(new RevWalk(git.getRepository)){ revWalk => - using(new TreeWalk(git.getRepository)){ treeWalk => - val index = treeWalk.addTree(revWalk.parseTree(headId)) - treeWalk.setRecursive(true) - while(treeWalk.next){ - val path = treeWalk.getPathString - val tree = treeWalk.getTree(index, classOf[CanonicalTreeParser]) - if(path != pageName + ".md"){ - builder.add(JGitUtil.createDirCacheEntry(path, tree.getEntryFileMode, tree.getEntryObjectId)) - } else { - removed = true - } + using(new RevWalk(git.getRepository)){ revWalk => + using(new TreeWalk(git.getRepository)){ treeWalk => + val index = treeWalk.addTree(revWalk.parseTree(headId)) + treeWalk.setRecursive(true) + while(treeWalk.next){ + val path = treeWalk.getPathString + val tree = treeWalk.getTree(index, classOf[CanonicalTreeParser]) + if(path != pageName + ".md"){ + builder.add(JGitUtil.createDirCacheEntry(path, tree.getEntryFileMode, tree.getEntryObjectId)) + } else { + removed = true } } + } - if(removed){ - builder.finish() - JGitUtil.createNewCommit(git, inserter, headId, builder.getDirCache.writeTree(inserter), committer, mailAddress, message) - } + if(removed){ + builder.finish() + JGitUtil.createNewCommit(git, inserter, headId, builder.getDirCache.writeTree(inserter), committer, mailAddress, message) } } } + } } } diff --git a/src/main/scala/servlet/AutoUpdateListener.scala b/src/main/scala/servlet/AutoUpdateListener.scala index bfa699208..8cafb6059 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -50,6 +50,7 @@ object AutoUpdate { * The history of versions. A head of this sequence is the current BitBucket version. */ val versions = Seq( + Version(1, 12), Version(1, 11), Version(1, 10), Version(1, 9), @@ -97,7 +98,7 @@ object AutoUpdate { */ def getCurrentVersion(): Version = { if(versionFile.exists){ - FileUtils.readFileToString(versionFile, "UTF-8").split("\\.") match { + FileUtils.readFileToString(versionFile, "UTF-8").trim.split("\\.") match { case Array(majorVersion, minorVersion) => { versions.find { v => v.majorVersion == majorVersion.toInt && v.minorVersion == minorVersion.toInt diff --git a/src/main/scala/servlet/GitRepositoryServlet.scala b/src/main/scala/servlet/GitRepositoryServlet.scala index 9efefd35f..60a5224dc 100644 --- a/src/main/scala/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/servlet/GitRepositoryServlet.scala @@ -8,7 +8,7 @@ import org.slf4j.LoggerFactory import javax.servlet.ServletConfig import javax.servlet.ServletContext -import javax.servlet.http.HttpServletRequest +import javax.servlet.http.{HttpServletResponse, HttpServletRequest} import util.{StringUtil, Keys, JGitUtil, Directory} import util.ControlUtil._ import util.Implicits._ @@ -23,7 +23,7 @@ import util.JGitUtil.CommitInfo * This servlet provides only Git repository functionality. * Authentication is provided by [[servlet.BasicAuthenticationFilter]]. */ -class GitRepositoryServlet extends GitServlet { +class GitRepositoryServlet extends GitServlet with SystemSettingsService { private val logger = LoggerFactory.getLogger(classOf[GitRepositoryServlet]) @@ -47,7 +47,19 @@ class GitRepositoryServlet extends GitServlet { super.init(config) } - + + override def service(req: HttpServletRequest, res: HttpServletResponse): Unit = { + val agent = req.getHeader("USER-AGENT") + val index = req.getRequestURI.indexOf(".git") + if(index >= 0 && (agent == null || agent.toLowerCase.indexOf("git/") < 0)){ + // redirect for browsers + val paths = req.getRequestURI.substring(0, index).split("/") + res.sendRedirect(baseUrl(req) + "/" + paths.dropRight(1).last + "/" + paths.last) + } else { + // response for git client + super.service(req, res) + } + } } class GitBucketReceivePackFactory extends ReceivePackFactory[HttpServletRequest] with SystemSettingsService { @@ -87,12 +99,16 @@ class CommitLogHook(owner: String, repository: String, pusher: String, baseUrl: using(Git.open(Directory.getRepositoryDir(owner, repository))) { git => commands.asScala.foreach { command => logger.debug(s"commandType: ${command.getType}, refName: ${command.getRefName}") - val commits = command.getType match { - case ReceiveCommand.Type.DELETE => Nil - case _ => JGitUtil.getCommitLog(git, command.getOldId.name, command.getNewId.name) - } val refName = command.getRefName.split("/") val branchName = refName.drop(2).mkString("/") + val commits = if (refName(1) == "tags") { + Nil + } else { + command.getType match { + case ReceiveCommand.Type.DELETE => Nil + case _ => JGitUtil.getCommitLog(git, command.getOldId.name, command.getNewId.name) + } + } // Extract new commit and apply issue comment val newCommits = if(commits.size > 1000){ diff --git a/src/main/scala/ssh/GitCommand.scala b/src/main/scala/ssh/GitCommand.scala new file mode 100644 index 000000000..9f014f0e7 --- /dev/null +++ b/src/main/scala/ssh/GitCommand.scala @@ -0,0 +1,130 @@ +package ssh + +import org.apache.sshd.server.{CommandFactory, Environment, ExitCallback, Command} +import org.slf4j.LoggerFactory +import java.io.{InputStream, OutputStream} +import util.ControlUtil._ +import org.eclipse.jgit.api.Git +import util.Directory._ +import org.eclipse.jgit.transport.{ReceivePack, UploadPack} +import org.apache.sshd.server.command.UnknownCommand +import servlet.{Database, CommitLogHook} +import service.{AccountService, RepositoryService, SystemSettingsService} +import org.eclipse.jgit.errors.RepositoryNotFoundException +import javax.servlet.ServletContext + + +object GitCommand { + val CommandRegex = """\Agit-(upload|receive)-pack '/([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+).git'\Z""".r +} + +abstract class GitCommand(val context: ServletContext, val owner: String, val repoName: String) extends Command { + self: RepositoryService with AccountService => + + private val logger = LoggerFactory.getLogger(classOf[GitCommand]) + protected var err: OutputStream = null + protected var in: InputStream = null + protected var out: OutputStream = null + protected var callback: ExitCallback = null + + protected def runTask(user: String): Unit + + private def newTask(user: String): Runnable = new Runnable { + override def run(): Unit = { + Database(context) withTransaction { + try { + runTask(user) + callback.onExit(0) + } catch { + case e: RepositoryNotFoundException => + logger.info(e.getMessage) + callback.onExit(1, "Repository Not Found") + case e: Throwable => + logger.error(e.getMessage, e) + callback.onExit(1) + } + } + } + } + + override def start(env: Environment): Unit = { + val user = env.getEnv.get("USER") + val thread = new Thread(newTask(user)) + thread.start() + } + + override def destroy(): Unit = {} + + override def setExitCallback(callback: ExitCallback): Unit = { + this.callback = callback + } + + override def setErrorStream(err: OutputStream): Unit = { + this.err = err + } + + override def setOutputStream(out: OutputStream): Unit = { + this.out = out + } + + override def setInputStream(in: InputStream): Unit = { + this.in = in + } + + protected def isWritableUser(username: String, repositoryInfo: RepositoryService.RepositoryInfo): Boolean = + getAccountByUserName(username) match { + case Some(account) => hasWritePermission(repositoryInfo.owner, repositoryInfo.name, Some(account)) + case None => false + } + +} + +class GitUploadPack(context: ServletContext, owner: String, repoName: String, baseUrl: String) extends GitCommand(context, owner, repoName) + with RepositoryService with AccountService { + + override protected def runTask(user: String): Unit = { + getRepository(owner, repoName.replaceFirst("\\.wiki\\Z", ""), baseUrl).foreach { repositoryInfo => + if(!repositoryInfo.repository.isPrivate || isWritableUser(user, repositoryInfo)){ + using(Git.open(getRepositoryDir(owner, repoName))) { git => + val repository = git.getRepository + val upload = new UploadPack(repository) + upload.upload(in, out, err) + } + } + } + } + +} + +class GitReceivePack(context: ServletContext, owner: String, repoName: String, baseUrl: String) extends GitCommand(context, owner, repoName) + with SystemSettingsService with RepositoryService with AccountService { + + override protected def runTask(user: String): Unit = { + getRepository(owner, repoName.replaceFirst("\\.wiki\\Z", ""), baseUrl).foreach { repositoryInfo => + if(isWritableUser(user, repositoryInfo)){ + using(Git.open(getRepositoryDir(owner, repoName))) { git => + val repository = git.getRepository + val receive = new ReceivePack(repository) + if(!repoName.endsWith(".wiki")){ + receive.setPostReceiveHook(new CommitLogHook(owner, repoName, user, baseUrl)) + } + receive.receive(in, out, err) + } + } + } + } + +} + +class GitCommandFactory(context: ServletContext, baseUrl: String) extends CommandFactory { + private val logger = LoggerFactory.getLogger(classOf[GitCommandFactory]) + + override def createCommand(command: String): Command = { + logger.debug(s"command: $command") + command match { + case GitCommand.CommandRegex("upload", owner, repoName) => new GitUploadPack(context, owner, repoName, baseUrl) + case GitCommand.CommandRegex("receive", owner, repoName) => new GitReceivePack(context, owner, repoName, baseUrl) + case _ => new UnknownCommand(command) + } + } +} \ No newline at end of file diff --git a/src/main/scala/ssh/NoShell.scala b/src/main/scala/ssh/NoShell.scala new file mode 100644 index 000000000..c107be695 --- /dev/null +++ b/src/main/scala/ssh/NoShell.scala @@ -0,0 +1,62 @@ +package ssh + +import org.apache.sshd.common.Factory +import org.apache.sshd.server.{Environment, ExitCallback, Command} +import java.io.{OutputStream, InputStream} +import org.eclipse.jgit.lib.Constants +import service.SystemSettingsService + +class NoShell extends Factory[Command] with SystemSettingsService { + override def create(): Command = new Command() { + private var in: InputStream = null + private var out: OutputStream = null + private var err: OutputStream = null + private var callback: ExitCallback = null + + override def start(env: Environment): Unit = { + val user = env.getEnv.get("USER") + val port = loadSystemSettings().sshPort.getOrElse(SystemSettingsService.DefaultSshPort) + val message = + """ + | Welcome to + | _____ _ _ ____ _ _ + | / ____| (_) | | | _ \ | | | | + | | | __ _ | |_ | |_) | _ _ ___ | | __ ___ | |_ + | | | |_ | | | | __| | _ < | | | | / __| | |/ / / _ \ | __| + | | |__| | | | | |_ | |_) | | |_| | | (__ | < | __/ | |_ + | \_____| |_| \__| |____/ \__,_| \___| |_|\_\ \___| \__| + | + | Successfully SSH Access. + | But interactive shell is disabled. + | + | Please use: + | + | git clone ssh://%s@GITBUCKET_HOST:%d/OWNER/REPOSITORY_NAME.git + """.stripMargin.format(user, port).replace("\n", "\r\n") + "\r\n" + err.write(Constants.encode(message)) + err.flush() + in.close() + out.close() + err.close() + callback.onExit(127) + } + + override def destroy(): Unit = {} + + override def setInputStream(in: InputStream): Unit = { + this.in = in + } + + override def setOutputStream(out: OutputStream): Unit = { + this.out = out + } + + override def setErrorStream(err: OutputStream): Unit = { + this.err = err + } + + override def setExitCallback(callback: ExitCallback): Unit = { + this.callback = callback + } + } +} diff --git a/src/main/scala/ssh/PublicKeyAuthenticator.scala b/src/main/scala/ssh/PublicKeyAuthenticator.scala new file mode 100644 index 000000000..a9ea634fa --- /dev/null +++ b/src/main/scala/ssh/PublicKeyAuthenticator.scala @@ -0,0 +1,23 @@ +package ssh + +import org.apache.sshd.server.PublickeyAuthenticator +import org.apache.sshd.server.session.ServerSession +import java.security.PublicKey +import service.SshKeyService +import servlet.Database +import javax.servlet.ServletContext + +class PublicKeyAuthenticator(context: ServletContext) extends PublickeyAuthenticator with SshKeyService { + + override def authenticate(username: String, key: PublicKey, session: ServerSession): Boolean = { + Database(context) withTransaction { + getPublicKeys(username).exists { sshKey => + SshUtil.str2PublicKey(sshKey.publicKey) match { + case Some(publicKey) => key.equals(publicKey) + case _ => false + } + } + } + } + +} diff --git a/src/main/scala/ssh/SshServerListener.scala b/src/main/scala/ssh/SshServerListener.scala new file mode 100644 index 000000000..f8e08fdb0 --- /dev/null +++ b/src/main/scala/ssh/SshServerListener.scala @@ -0,0 +1,68 @@ +package ssh + +import javax.servlet.{ServletContext, ServletContextEvent, ServletContextListener} +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.slf4j.LoggerFactory +import util.Directory +import service.SystemSettingsService +import java.util.concurrent.atomic.AtomicBoolean + +object SshServer { + private val logger = LoggerFactory.getLogger(SshServer.getClass) + private val server = org.apache.sshd.SshServer.setUpDefaultServer() + private val active = new AtomicBoolean(false) + + private def configure(context: ServletContext, port: Int, baseUrl: String) = { + server.setPort(port) + server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(s"${Directory.GitBucketHome}/gitbucket.ser")) + server.setPublickeyAuthenticator(new PublicKeyAuthenticator(context)) + server.setCommandFactory(new GitCommandFactory(context, baseUrl)) + server.setShellFactory(new NoShell) + } + + def start(context: ServletContext, port: Int, baseUrl: String) = { + if(active.compareAndSet(false, true)){ + configure(context, port, baseUrl) + server.start() + logger.info(s"Start SSH Server Listen on ${server.getPort}") + } + } + + def stop() = { + if(active.compareAndSet(true, false)){ + server.stop(true) + } + } + + def isActive = active.get +} + +/* + * Start a SSH Server Daemon + * + * How to use: + * git clone ssh://username@host_or_ip:29418/owner/repository_name.git + */ +class SshServerListener extends ServletContextListener with SystemSettingsService { + + override def contextInitialized(sce: ServletContextEvent): Unit = { + val settings = loadSystemSettings() + if(settings.ssh){ + if(settings.baseUrl.isEmpty){ + // TODO use logger? + println("Could not start SshServer because the baseUrl is not configured.") + } else { + SshServer.start(sce.getServletContext, + settings.sshPort.getOrElse(SystemSettingsService.DefaultSshPort), + settings.baseUrl.get) + } + } + } + + override def contextDestroyed(sce: ServletContextEvent): Unit = { + if(loadSystemSettings().ssh){ + SshServer.stop() + } + } + +} diff --git a/src/main/scala/ssh/SshUtil.scala b/src/main/scala/ssh/SshUtil.scala new file mode 100644 index 000000000..db578ded2 --- /dev/null +++ b/src/main/scala/ssh/SshUtil.scala @@ -0,0 +1,33 @@ +package ssh + +import java.security.PublicKey +import org.slf4j.LoggerFactory +import org.apache.commons.codec.binary.Base64 +import org.eclipse.jgit.lib.Constants +import org.apache.sshd.common.util.{KeyUtils, Buffer} + +object SshUtil { + + private val logger = LoggerFactory.getLogger(SshUtil.getClass) + + def str2PublicKey(key: String): Option[PublicKey] = { + // TODO RFC 4716 Public Key is not supported... + val parts = key.split(" ") + if (parts.size < 2) { + logger.debug(s"Invalid PublicKey Format: key") + return None + } + try { + val encodedKey = parts(1) + val decode = Base64.decodeBase64(Constants.encodeASCII(encodedKey)) + Some(new Buffer(decode).getRawPublicKey) + } catch { + case e: Throwable => + logger.debug(e.getMessage, e) + None + } + } + + def fingerPrint(key: String): String = KeyUtils.getFingerPrint(str2PublicKey(key).get) + +} diff --git a/src/main/scala/util/Authenticator.scala b/src/main/scala/util/Authenticator.scala index c5247137c..f40af7bcc 100644 --- a/src/main/scala/util/Authenticator.scala +++ b/src/main/scala/util/Authenticator.scala @@ -29,7 +29,7 @@ trait OneselfAuthenticator { self: ControllerBase => /** * Allows only the repository owner and administrators. */ -trait OwnerAuthenticator { self: ControllerBase with RepositoryService => +trait OwnerAuthenticator { self: ControllerBase with RepositoryService with AccountService => protected def ownerOnly(action: (RepositoryInfo) => Any) = { authenticate(action) } protected def ownerOnly[T](action: (T, RepositoryInfo) => Any) = (form: T) => { authenticate(action(form, _)) } @@ -40,6 +40,9 @@ trait OwnerAuthenticator { self: ControllerBase with RepositoryService => context.loginAccount match { case Some(x) if(x.isAdmin) => action(repository) case Some(x) if(repository.owner == x.userName) => action(repository) + case Some(x) if(getGroupMembers(repository.owner).exists { member => + member.userName == x.userName && member.isManager == true + }) => action(repository) case _ => Unauthorized() } } getOrElse NotFound() @@ -106,7 +109,7 @@ trait CollaboratorsAuthenticator { self: ControllerBase with RepositoryService = } /** - * Allows only the repository owner and administrators. + * Allows only the repository owner (or manager for group repository) and administrators. */ trait ReferrerAuthenticator { self: ControllerBase with RepositoryService => protected def referrersOnly(action: (RepositoryInfo) => Any) = { authenticate(action) } @@ -155,3 +158,24 @@ trait ReadableUsersAuthenticator { self: ControllerBase with RepositoryService = } } } + +/** + * Allows only the group managers. + */ +trait GroupManagerAuthenticator { self: ControllerBase with AccountService => + protected def managersOnly(action: => Any) = { authenticate(action) } + protected def managersOnly[T](action: T => Any) = (form: T) => { authenticate(action(form)) } + + private def authenticate(action: => Any) = { + { + defining(request.paths){ paths => + context.loginAccount match { + case Some(x) if(getGroupMembers(paths(0)).exists { member => + member.userName == x.userName && member.isManager + }) => action + case _ => Unauthorized() + } + } + } + } +} diff --git a/src/main/scala/util/JGitUtil.scala b/src/main/scala/util/JGitUtil.scala index 57fd42192..5ab4bf76f 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -11,17 +11,20 @@ import org.eclipse.jgit.revwalk.filter._ import org.eclipse.jgit.treewalk._ import org.eclipse.jgit.treewalk.filter._ import org.eclipse.jgit.diff.DiffEntry.ChangeType -import org.eclipse.jgit.errors.MissingObjectException +import org.eclipse.jgit.errors.{ConfigInvalidException, MissingObjectException} import java.util.Date import org.eclipse.jgit.api.errors.NoHeadException import service.RepositoryService import org.eclipse.jgit.dircache.DirCacheEntry +import org.slf4j.LoggerFactory /** * Provides complex JGit operations. */ object JGitUtil { + private val logger = LoggerFactory.getLogger(JGitUtil.getClass) + /** * The repository data. * @@ -45,9 +48,10 @@ object JGitUtil { * @param commitId the last commit id * @param committer the last committer name * @param mailAddress the committer's mail address + * @param linkUrl the url of submodule */ case class FileInfo(id: ObjectId, isDirectory: Boolean, name: String, time: Date, message: String, commitId: String, - committer: String, mailAddress: String) + committer: String, mailAddress: String, linkUrl: Option[String]) /** * The commit data. @@ -72,11 +76,7 @@ object JGitUtil { rev.getFullMessage, rev.getParents().map(_.name).toList) - val summary = defining(fullMessage.trim.indexOf("\n")){ i => - defining(if(i >= 0) fullMessage.trim.substring(0, i).trim else fullMessage){ firstLine => - if(firstLine.length > shortMessage.length) shortMessage else firstLine - } - } + val summary = getSummaryMessage(fullMessage, shortMessage) val description = defining(fullMessage.trim.indexOf("\n")){ i => if(i >= 0){ @@ -104,6 +104,15 @@ object JGitUtil { */ case class TagInfo(name: String, time: Date, id: String) + /** + * The submodule data + * + * @param name the module name + * @param path the path in the repository + * @param url the repository url of this module + */ + case class SubmoduleInfo(name: String, path: String, url: String) + /** * Returns RevCommit from the commit or tag id. * @@ -152,7 +161,7 @@ object JGitUtil { } } } - + /** * Returns the file list of the specified path. * @@ -162,7 +171,7 @@ object JGitUtil { * @return HTML of the file list */ def getFileList(git: Git, revision: String, path: String = "."): List[FileInfo] = { - val list = new scala.collection.mutable.ListBuffer[(ObjectId, FileMode, String, String)] + val list = new scala.collection.mutable.ListBuffer[(ObjectId, FileMode, String, String, Option[String])] using(new RevWalk(git.getRepository)){ revWalk => val objectId = git.getRepository.resolve(revision) @@ -195,22 +204,30 @@ object JGitUtil { }) } while (treeWalk.next()) { - list.append((treeWalk.getObjectId(0), treeWalk.getFileMode(0), treeWalk.getPathString, treeWalk.getNameString)) + // submodule + val linkUrl = if(treeWalk.getFileMode(0) == FileMode.GITLINK){ + getSubmodules(git, revCommit.getTree).find(_.path == treeWalk.getPathString).map(_.url) + } else None + + list.append((treeWalk.getObjectId(0), treeWalk.getFileMode(0), treeWalk.getPathString, treeWalk.getNameString, linkUrl)) } } } val commits = getLatestCommitFromPaths(git, list.toList.map(_._3), revision) - list.map { case (objectId, fileMode, path, name) => - FileInfo( - objectId, - fileMode == FileMode.TREE, - name, - commits(path).getCommitterIdent.getWhen, - commits(path).getShortMessage, - commits(path).getName, - commits(path).getCommitterIdent.getName, - commits(path).getCommitterIdent.getEmailAddress) + list.map { case (objectId, fileMode, path, name, linkUrl) => + defining(commits(path)){ commit => + FileInfo( + objectId, + fileMode == FileMode.TREE || fileMode == FileMode.GITLINK, + name, + commit.getCommitterIdent.getWhen, + getSummaryMessage(commit.getFullMessage, commit.getShortMessage), + commit.getName, + commit.getCommitterIdent.getName, + commit.getCommitterIdent.getEmailAddress, + linkUrl) + } }.sortWith { (file1, file2) => (file1.isDirectory, file2.isDirectory) match { case (true , false) => true @@ -219,7 +236,18 @@ object JGitUtil { } }.toList } - + + /** + * Returns the first line of the commit message. + */ + private def getSummaryMessage(fullMessage: String, shortMessage: String): String = { + defining(fullMessage.trim.indexOf("\n")){ i => + defining(if(i >= 0) fullMessage.trim.substring(0, i).trim else fullMessage){ firstLine => + if(firstLine.length > shortMessage.length) shortMessage else firstLine + } + } + } + /** * Returns the commit list of the specified branch. * @@ -325,27 +353,6 @@ object JGitUtil { }.toMap } - /** - * Get object content of the given id as String from the Git repository. - * - * @param git the Git object - * @param id the object id - * @param large if false then returns None for the large file - * @return the object or None if object does not exist - */ - def getContent(git: Git, id: ObjectId, large: Boolean): Option[Array[Byte]] = try { - val loader = git.getRepository.getObjectDatabase.open(id) - if(large == false && FileUtil.isLarge(loader.getSize)){ - None - } else { - using(git.getRepository.getObjectDatabase){ db => - Some(db.open(id).getBytes) - } - } - } catch { - case e: MissingObjectException => None - } - /** * Returns the tuple of diff of the given commit and the previous commit id. */ @@ -377,7 +384,7 @@ object JGitUtil { DiffInfo(ChangeType.ADD, null, treeWalk.getPathString, None, None) } else { DiffInfo(ChangeType.ADD, null, treeWalk.getPathString, None, - JGitUtil.getContent(git, treeWalk.getObjectId(0), false).filter(FileUtil.isText).map(convertFromByteArray)) + JGitUtil.getContentFromId(git, treeWalk.getObjectId(0), false).filter(FileUtil.isText).map(convertFromByteArray)) })) } (buffer.toList, None) @@ -400,8 +407,8 @@ object JGitUtil { DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, None, None) } else { DiffInfo(diff.getChangeType, diff.getOldPath, diff.getNewPath, - JGitUtil.getContent(git, diff.getOldId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray), - JGitUtil.getContent(git, diff.getNewId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray)) + JGitUtil.getContentFromId(git, diff.getOldId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray), + JGitUtil.getContentFromId(git, diff.getNewId.toObjectId, false).filter(FileUtil.isText).map(convertFromByteArray)) } }.toList } @@ -494,4 +501,73 @@ object JGitUtil { newHeadId.getName } + /** + * Read submodule information from .gitmodules + */ + def getSubmodules(git: Git, tree: RevTree): List[SubmoduleInfo] = { + val repository = git.getRepository + getContentFromPath(git, tree, ".gitmodules", true).map { bytes => + (try { + val config = new BlobBasedConfig(repository.getConfig(), bytes) + config.getSubsections("submodule").asScala.map { module => + val path = config.getString("submodule", module, "path") + val url = config.getString("submodule", module, "url") + SubmoduleInfo(module, path, url) + } + } catch { + case e: ConfigInvalidException => { + logger.error("Failed to load .gitmodules file for " + repository.getDirectory(), e) + Nil + } + }).toList + } getOrElse Nil + } + + /** + * Get object content of the given path as byte array from the Git repository. + * + * @param git the Git object + * @param revTree the rev tree + * @param path the path + * @param fetchLargeFile if false then returns None for the large file + * @return the byte array of content or None if object does not exist + */ + def getContentFromPath(git: Git, revTree: RevTree, path: String, fetchLargeFile: Boolean): Option[Array[Byte]] = { + @scala.annotation.tailrec + def getPathObjectId(path: String, walk: TreeWalk): Option[ObjectId] = walk.next match { + case true if(walk.getPathString == path) => Some(walk.getObjectId(0)) + case true => getPathObjectId(path, walk) + case false => None + } + + using(new TreeWalk(git.getRepository)){ treeWalk => + treeWalk.addTree(revTree) + treeWalk.setRecursive(true) + getPathObjectId(path, treeWalk) + } flatMap { objectId => + getContentFromId(git, objectId, fetchLargeFile) + } + } + + /** + * Get object content of the given object id as byte array from the Git repository. + * + * @param git the Git object + * @param id the object id + * @param fetchLargeFile if false then returns None for the large file + * @return the byte array of content or None if object does not exist + */ + def getContentFromId(git: Git, id: ObjectId, fetchLargeFile: Boolean): Option[Array[Byte]] = try { + val loader = git.getRepository.getObjectDatabase.open(id) + if(fetchLargeFile == false && FileUtil.isLarge(loader.getSize)){ + None + } else { + using(git.getRepository.getObjectDatabase){ db => + Some(db.open(id).getBytes) + } + } + } catch { + case e: MissingObjectException => None + } + } diff --git a/src/main/scala/view/AvatarImageProvider.scala b/src/main/scala/view/AvatarImageProvider.scala index bcc2d6bed..1dff9ab4a 100644 --- a/src/main/scala/view/AvatarImageProvider.scala +++ b/src/main/scala/view/AvatarImageProvider.scala @@ -16,7 +16,7 @@ trait AvatarImageProvider { self: RequestCache => val src = if(mailAddress.isEmpty){ // by user name getAccountByUserName(userName).map { account => - if(account.image.isEmpty && getSystemSettings().gravatar){ + if(account.image.isEmpty && context.settings.gravatar){ s"""https://www.gravatar.com/avatar/${StringUtil.md5(account.mailAddress.toLowerCase)}?s=${size}""" } else { s"""${context.path}/${account.userName}/_avatar""" @@ -27,13 +27,13 @@ trait AvatarImageProvider { self: RequestCache => } else { // by mail address getAccountByMailAddress(mailAddress).map { account => - if(account.image.isEmpty && getSystemSettings().gravatar){ + if(account.image.isEmpty && context.settings.gravatar){ s"""https://www.gravatar.com/avatar/${StringUtil.md5(account.mailAddress.toLowerCase)}?s=${size}""" } else { s"""${context.path}/${account.userName}/_avatar""" } } getOrElse { - if(getSystemSettings().gravatar){ + if(context.settings.gravatar){ s"""https://www.gravatar.com/avatar/${StringUtil.md5(mailAddress.toLowerCase)}?s=${size}""" } else { s"""${context.path}/_unknown/_avatar""" diff --git a/src/main/scala/view/Markdown.scala b/src/main/scala/view/Markdown.scala index 6b7ab2dc7..4d998bd27 100644 --- a/src/main/scala/view/Markdown.scala +++ b/src/main/scala/view/Markdown.scala @@ -45,7 +45,7 @@ class GitBucketLinkRender(context: app.Context, repository: service.RepositorySe (text, text) } - val url = repository.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/" + StringUtil.urlEncode(page) + val url = repository.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/" + StringUtil.urlEncode(page) if(getWikiPage(repository.owner, repository.name, page).isDefined){ new Rendering(url, label) @@ -104,7 +104,7 @@ class GitBucketHtmlSerializer( if(!enableWikiLink || url.startsWith("http://") || url.startsWith("https://")){ url } else { - repository.url.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/_blob/" + url + repository.httpUrl.replaceFirst("/git/", "/").replaceFirst("\\.git$", "") + "/wiki/_blob/" + url } } diff --git a/src/main/twirl/account/edit.scala.html b/src/main/twirl/account/edit.scala.html index 5213ddc54..c02bcbea1 100644 --- a/src/main/twirl/account/edit.scala.html +++ b/src/main/twirl/account/edit.scala.html @@ -1,71 +1,62 @@ -@(account: Option[model.Account], info: Option[Any])(implicit context: app.Context) +@(account: model.Account, info: Option[Any])(implicit context: app.Context) @import context._ @import view.helpers._ -@html.main((if(account.isDefined) "Edit your profile" else "Create your account")){ - @if(account.isDefined){ -

Edit your profile

- } else { -

Create your account

- } - @helper.html.information(info) -
-
-
- @if(account.isEmpty){ -
- - - -
- } - @if(account.map(_.password.nonEmpty).getOrElse(true)){ -
- - - -
- } -
- - - -
-
- - - -
-
- - - -
-
-
-
- - @helper.html.uploadavatar(account) -
+@html.main("Edit your profile"){ +
+
+ @menu("profile", settings.ssh) +
+
+ @helper.html.information(info) + +
+
Profile
+
+
+
+ @if(account.password.nonEmpty){ +
+ + + +
+ } +
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + @helper.html.uploadavatar(Some(account)) +
+
+
+
+ + + Cancel +
-
- @if(account.isDefined){ - - - Cancel - } else { - - } -
+
} \ No newline at end of file diff --git a/src/main/twirl/account/main.scala.html b/src/main/twirl/account/main.scala.html index 0b6cc6e6d..17e05c0f1 100644 --- a/src/main/twirl/account/main.scala.html +++ b/src/main/twirl/account/main.scala.html @@ -1,4 +1,5 @@ -@(account: model.Account, groupNames: List[String], active: String)(body: Html)(implicit context: app.Context) +@(account: model.Account, groupNames: List[String], active: String, + isGroupManager: Boolean = false)(body: Html)(implicit context: app.Context) @import context._ @import view.helpers._ @html.main(account.userName){ @@ -41,6 +42,13 @@
} + @if(loginAccount.isDefined && account.isGroupAccount && isGroupManager){ +
  • + +
  • + } @body
    diff --git a/src/main/twirl/account/members.scala.html b/src/main/twirl/account/members.scala.html index 14d7c773f..0e21d0102 100644 --- a/src/main/twirl/account/members.scala.html +++ b/src/main/twirl/account/members.scala.html @@ -1,7 +1,7 @@ -@(account: model.Account, members: List[String])(implicit context: app.Context) +@(account: model.Account, members: List[String], isGroupManager: Boolean)(implicit context: app.Context) @import context._ @import view.helpers._ -@main(account, Nil, "members"){ +@main(account, Nil, "members", isGroupManager){ @if(members.isEmpty){ No members } else { diff --git a/src/main/twirl/account/menu.scala.html b/src/main/twirl/account/menu.scala.html new file mode 100644 index 000000000..a5d913948 --- /dev/null +++ b/src/main/twirl/account/menu.scala.html @@ -0,0 +1,14 @@ +@(active: String, ssh: Boolean)(implicit context: app.Context) +@import context._ +
    + +
    diff --git a/src/main/twirl/newrepo.scala.html b/src/main/twirl/account/newrepo.scala.html similarity index 98% rename from src/main/twirl/newrepo.scala.html rename to src/main/twirl/account/newrepo.scala.html index 05fefbc4d..b5f6c6aa0 100644 --- a/src/main/twirl/newrepo.scala.html +++ b/src/main/twirl/account/newrepo.scala.html @@ -1,7 +1,7 @@ @(groupNames: List[String])(implicit context: app.Context) @import context._ @import view.helpers._ -@main("Create a New Repository"){ +@html.main("Create a New Repository"){
    diff --git a/src/main/twirl/account/register.scala.html b/src/main/twirl/account/register.scala.html new file mode 100644 index 000000000..7bce2dba2 --- /dev/null +++ b/src/main/twirl/account/register.scala.html @@ -0,0 +1,48 @@ +@()(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("Create your account"){ +

    Create your account

    + +
    +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    + + + +
    +
    +
    +
    + + @helper.html.uploadavatar(None) +
    +
    +
    +
    + +
    + +} diff --git a/src/main/twirl/account/repositories.scala.html b/src/main/twirl/account/repositories.scala.html index f9037f568..100c2b4f6 100644 --- a/src/main/twirl/account/repositories.scala.html +++ b/src/main/twirl/account/repositories.scala.html @@ -1,7 +1,9 @@ -@(account: model.Account, groupNames: List[String], repositories: List[service.RepositoryService.RepositoryInfo])(implicit context: app.Context) +@(account: model.Account, groupNames: List[String], + repositories: List[service.RepositoryService.RepositoryInfo], + isGroupManager: Boolean)(implicit context: app.Context) @import context._ @import view.helpers._ -@main(account, groupNames, "repositories"){ +@main(account, groupNames, "repositories", isGroupManager){ @if(repositories.isEmpty){ No repositories } else { diff --git a/src/main/twirl/account/ssh.scala.html b/src/main/twirl/account/ssh.scala.html new file mode 100644 index 000000000..e6ee34c55 --- /dev/null +++ b/src/main/twirl/account/ssh.scala.html @@ -0,0 +1,45 @@ +@(account: model.Account, sshKeys: List[model.SshKey])(implicit context: app.Context) +@import context._ +@import view.helpers._ +@html.main("SSH Keys"){ +
    +
    + @menu("ssh", settings.ssh) +
    +
    +
    +
    SSH Keys
    +
    + @if(sshKeys.isEmpty){ + No keys + } + @sshKeys.zipWithIndex.map { case (key, i) => + @if(i != 0){ +
    + } + @key.title (@_root_.ssh.SshUtil.fingerPrint(key.publicKey)) + Delete + } +
    +
    +
    +
    +
    Add an SSH Key
    +
    +
    + +
    + +
    +
    + +
    + +
    + +
    +
    +
    +
    +
    +} \ No newline at end of file diff --git a/src/main/twirl/admin/system.scala.html b/src/main/twirl/admin/system.scala.html index 6594e293c..13a247d7d 100644 --- a/src/main/twirl/admin/system.scala.html +++ b/src/main/twirl/admin/system.scala.html @@ -1,4 +1,4 @@ -@(settings: service.SystemSettingsService.SystemSettings, info: Option[Any])(implicit context: app.Context) +@(info: Option[Any])(implicit context: app.Context) @import context._ @import util.Directory._ @import view.helpers._ @@ -22,9 +22,10 @@
    +
    -

    +

    The base URL is used for redirect, notification email, git repository URL box and more. If the base URL is empty, GitBucket generates URL from request information. You can use this property to adjust URL difference between the reverse proxy and GitBucket. @@ -56,6 +57,29 @@

    + + +
    + +
    + +
    +
    +
    + +
    + + +
    +
    +
    +

    + Base URL is required if SSH access is enabled. +

    +
    @@ -206,6 +230,10 @@ } \ No newline at end of file diff --git a/src/main/twirl/header.scala.html b/src/main/twirl/header.scala.html index 55f19eec9..39b156621 100644 --- a/src/main/twirl/header.scala.html +++ b/src/main/twirl/header.scala.html @@ -6,7 +6,7 @@ } @@ -56,7 +56,7 @@ Network - @if(loginAccount.isDefined && (loginAccount.get.isAdmin || loginAccount.get.userName == repository.owner)){ + @if(loginAccount.isDefined && (loginAccount.get.isAdmin || repository.managers.contains(loginAccount.get.userName))){ Settings diff --git a/src/main/twirl/helper/copy.scala.html b/src/main/twirl/helper/copy.scala.html index 1cba6e6d4..a4f8896a2 100644 --- a/src/main/twirl/helper/copy.scala.html +++ b/src/main/twirl/helper/copy.scala.html @@ -1,5 +1,5 @@ -@(id: String, value: String)(html: Html) -
    +@(id: String, value: String, prepend: Boolean = false)(html: Html) +
    @html
    diff --git a/src/main/twirl/index.scala.html b/src/main/twirl/index.scala.html index c6e3de6a9..21db2e0e2 100644 --- a/src/main/twirl/index.scala.html +++ b/src/main/twirl/index.scala.html @@ -1,6 +1,5 @@ @(activities: List[model.Activity], recentRepositories: List[service.RepositoryService.RepositoryInfo], - systemSettings: service.SystemSettingsService.SystemSettings, userRepositories: List[service.RepositoryService.RepositoryInfo])(implicit context: app.Context) @import context._ @import view.helpers._ @@ -12,7 +11,7 @@
    @if(loginAccount.isEmpty){ - @signinform(systemSettings) + @signinform(settings) } else { diff --git a/src/main/twirl/issues/create.scala.html b/src/main/twirl/issues/create.scala.html index 7b92e2c88..b82884c5b 100644 --- a/src/main/twirl/issues/create.scala.html +++ b/src/main/twirl/issues/create.scala.html @@ -33,7 +33,7 @@ @helper.html.dropdown() {
  • No milestone
  • - @milestones.map { milestone => + @milestones.filter(_.closedDate.isEmpty).map { milestone =>
  • @milestone.title diff --git a/src/main/twirl/issues/issuedetail.scala.html b/src/main/twirl/issues/issuedetail.scala.html index 7a618191b..df678d1ef 100644 --- a/src/main/twirl/issues/issuedetail.scala.html +++ b/src/main/twirl/issues/issuedetail.scala.html @@ -54,7 +54,7 @@ @if(hasWritePermission){ @helper.html.dropdown() {
  • No milestone
  • - @milestones.map { case (milestone, _, _) => + @milestones.filter(_._1.closedDate.isEmpty).map { case (milestone, _, _) =>
  • @helper.html.checkicon(Some(milestone.milestoneId) == issue.milestoneId) @milestone.title diff --git a/src/main/twirl/main.scala.html b/src/main/twirl/main.scala.html index 7ec984289..51ba07b3b 100644 --- a/src/main/twirl/main.scala.html +++ b/src/main/twirl/main.scala.html @@ -54,14 +54,18 @@ } @if(loginAccount.isDefined){ @avatar(loginAccount.get.userName, 20) @loginAccount.get.userName - + + @if(loginAccount.get.isAdmin){ } } else { - Sign in + Sign in } @@ -76,7 +80,6 @@ $('#search').submit(function(){ return $.trim($(this).find('input[name=query]').val()) != ''; }); - $('#signin').attr('href', '@path/signin?redirect=' + encodeURIComponent(location.pathname + location.search + location.hash)); }); diff --git a/src/main/twirl/repo/branches.scala.html b/src/main/twirl/repo/branches.scala.html index 219c7c116..bc26459ad 100644 --- a/src/main/twirl/repo/branches.scala.html +++ b/src/main/twirl/repo/branches.scala.html @@ -1,4 +1,4 @@ -@(branchInfo: List[(String, java.util.Date)], +@(branchInfo: Seq[(String, java.util.Date)], hasWritePermission: Boolean, repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ diff --git a/src/main/twirl/repo/files.scala.html b/src/main/twirl/repo/files.scala.html index c7e97e51d..db21928a7 100644 --- a/src/main/twirl/repo/files.scala.html +++ b/src/main/twirl/repo/files.scala.html @@ -55,14 +55,22 @@
  • @if(file.isDirectory){ - + @if(file.linkUrl.isDefined){ + + } else { + + } } else { } @if(file.isDirectory){ - @file.name + @if(file.linkUrl.isDefined){ + @file.name + } else { + @file.name + } } else { @file.name } diff --git a/src/main/twirl/repo/guide.scala.html b/src/main/twirl/repo/guide.scala.html index ad0231382..a5c7738fe 100644 --- a/src/main/twirl/repo/guide.scala.html +++ b/src/main/twirl/repo/guide.scala.html @@ -9,13 +9,13 @@ touch README.md git init git add README.md git commit -m "first commit" -git remote add origin @repository.url +git remote add origin @repository.httpUrl git push -u origin master

    Push an existing repository from the command line

    -git remote add origin @repository.url
    +git remote add origin @repository.httpUrl
     git push -u origin master
     
    } diff --git a/src/main/twirl/repo/tab.scala.html b/src/main/twirl/repo/tab.scala.html index c245780ad..0d9c28ce5 100644 --- a/src/main/twirl/repo/tab.scala.html +++ b/src/main/twirl/repo/tab.scala.html @@ -22,8 +22,15 @@ Branches@if(repository.branchList.length > 0){ @repository.branchList.length} Tags@if(repository.tags.length > 0){ @repository.tags.length}
  • - @helper.html.copy("repository-url-copy", repository.url){ - + @helper.html.copy("repository-url-copy", repository.httpUrl, true){ + @if(settings.ssh && loginAccount.isDefined){ +
    + +
    + } else { + HTTP + } + }
  • @@ -32,3 +39,17 @@
  • +@if(settings.ssh && loginAccount.isDefined){ + +} \ No newline at end of file diff --git a/src/main/twirl/settings/collaborators.scala.html b/src/main/twirl/settings/collaborators.scala.html index 8264764e6..cf894a4ca 100644 --- a/src/main/twirl/settings/collaborators.scala.html +++ b/src/main/twirl/settings/collaborators.scala.html @@ -13,6 +13,10 @@ @collaboratorName @if(!isGroupRepository){ (remove) + } else { + @if(repository.managers.contains(collaboratorName)){ + (Manager) + } } } diff --git a/src/main/twirl/signin.scala.html b/src/main/twirl/signin.scala.html index abe033e3f..f70e8446b 100644 --- a/src/main/twirl/signin.scala.html +++ b/src/main/twirl/signin.scala.html @@ -1,7 +1,7 @@ -@(systemSettings: service.SystemSettingsService.SystemSettings)(implicit context: app.Context) +@()(implicit context: app.Context) @import context._ @main("Sign in"){ } diff --git a/src/main/twirl/wiki/pages.scala.html b/src/main/twirl/wiki/pages.scala.html index d1d35f2a3..429c43887 100644 --- a/src/main/twirl/wiki/pages.scala.html +++ b/src/main/twirl/wiki/pages.scala.html @@ -1,4 +1,6 @@ -@(pages: List[String], repository: service.RepositoryService.RepositoryInfo, hasWritePermission: Boolean)(implicit context: app.Context) +@(pages: List[String], + repository: service.RepositoryService.RepositoryInfo, + hasWritePermission: Boolean)(implicit context: app.Context) @import context._ @import view.helpers._ @html.main(s"Pages - ${repository.owner}/${repository.name}", Some(repository)){ diff --git a/src/main/twirl/wiki/tab.scala.html b/src/main/twirl/wiki/tab.scala.html index 959ea8c1a..e68c88521 100644 --- a/src/main/twirl/wiki/tab.scala.html +++ b/src/main/twirl/wiki/tab.scala.html @@ -1,15 +1,36 @@ -@(active: String, repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) +@(active: String, + repository: service.RepositoryService.RepositoryInfo)(implicit context: app.Context) @import context._ +@import service.WikiService._ @import view.helpers._ +@if(settings.ssh && loginAccount.isDefined){ + +} diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index d57d64e16..398ffd867 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -18,6 +18,7 @@ servlet.AutoUpdateListener + @@ -25,6 +26,13 @@ org.scalatra.servlet.ScalatraListener + + + + + ssh.SshServerListener + + diff --git a/src/main/webapp/assets/common/css/gitbucket.css b/src/main/webapp/assets/common/css/gitbucket.css index 01c96b414..c8cd50188 100644 --- a/src/main/webapp/assets/common/css/gitbucket.css +++ b/src/main/webapp/assets/common/css/gitbucket.css @@ -80,19 +80,13 @@ table.global-nav th a:link, table.global-nav th a:hover, table.global-nav th a:v text-decoration: none; } -div.input-prepend span.add-on { +div.input-prepend span.count { background-color: white; -webkit-border-radius: 0 4px 4px 0; -moz-border-radius: 0 4px 4px 0; border-radius: 0 4px 4px 0; } -/* -div.input-prepend span.add-on a { - color: #333; -} -*/ - /* ======================================================================== */ /* General Styles */ /* ======================================================================== */ @@ -464,10 +458,9 @@ ul#commit-file-list li.border { li.L0, li.L1, li.L2, li.L3, li.L4, li.L5, li.L6, li.L7, li.L8, li.L9 { list-style-type: decimal; background: white; -} - -li.L1, li.L3, li.L5, li.L7, li.L9 { - background: #f5f5f5; + border-left: 1px solid #E5E5E5; + padding-left: 10px; + color: rgba(0, 0, 0, 0.3); } pre.blob { diff --git a/src/main/webapp/assets/common/images/folder_link.png b/src/main/webapp/assets/common/images/folder_link.png new file mode 100644 index 000000000..b9b75f6c3 Binary files /dev/null and b/src/main/webapp/assets/common/images/folder_link.png differ diff --git a/src/main/webapp/assets/common/js/gitbucket.js b/src/main/webapp/assets/common/js/gitbucket.js index 12e86fa2a..26df1492e 100644 --- a/src/main/webapp/assets/common/js/gitbucket.js +++ b/src/main/webapp/assets/common/js/gitbucket.js @@ -34,3 +34,14 @@ $(function(){ // syntax highlighting by google-code-prettify prettyPrint(); }); + +function displayErrors(data){ + var i = 0; + $.each(data, function(key, value){ + $('#error-' + key.split(".").join("_")).text(value); + if(i == 0){ + $('#' + key).focus(); + } + i++; + }); +} \ No newline at end of file diff --git a/src/main/webapp/assets/jsdifflib/diffview.css b/src/main/webapp/assets/jsdifflib/diffview.css index 4c4a7c7f2..399a6c7af 100644 --- a/src/main/webapp/assets/jsdifflib/diffview.css +++ b/src/main/webapp/assets/jsdifflib/diffview.css @@ -35,12 +35,22 @@ table.diff { table.diff tbody { font-family:Courier, monospace } +table.diff tbody tr:hover { + background-color:#F8EEC7; +} + +table.diff tbody tr:hover th { + background-color:#F6E8B5; +} +table.diff tbody tr:hover td { + background-color:#F6E8B5; +} table.diff tbody th { font-family:verdana,arial,'Bitstream Vera Sans',helvetica,sans-serif; - background:#EED; + background-color:#FBFBFB; font-size:11px; font-weight:normal; - border:1px solid #BBC; + border-top:none; /* for overriding bootstrap */ color:#886; padding:.3em .5em .1em 2em; text-align:right; @@ -58,6 +68,7 @@ table.diff tbody td { padding:0px .4em; padding-top:.4em; vertical-align:top; + border-top: none; } table.diff .empty { background-color:#DDD; @@ -69,9 +80,10 @@ table.diff .delete { background-color:#FFDDDD; } table.diff .skip { - background-color:#EFEFEF; - border:1px solid #AAA; - border-right:1px solid #BBC; + background-color: #F8F8FF; +} +table.diff .skip:before { + content: " ..."; } table.diff .insert { background-color:#DDFFDD diff --git a/src/test/scala/service/AccountServiceServiceSpec.scala b/src/test/scala/service/AccountServiceSpec.scala similarity index 89% rename from src/test/scala/service/AccountServiceServiceSpec.scala rename to src/test/scala/service/AccountServiceSpec.scala index ba5332740..344f8e3c8 100644 --- a/src/test/scala/service/AccountServiceServiceSpec.scala +++ b/src/test/scala/service/AccountServiceSpec.scala @@ -2,8 +2,9 @@ package service import org.specs2.mutable.Specification import java.util.Date +import model.GroupMember -class AccountServiceServiceSpec extends Specification with ServiceSpecBase { +class AccountServiceSpec extends Specification with ServiceSpecBase { "AccountService" should { val RootMailAddress = "root@localhost" @@ -63,9 +64,9 @@ class AccountServiceServiceSpec extends Specification with ServiceSpecBase { AccountService.getGroupMembers(group1) must_== Nil AccountService.getGroupsByUserName(user1) must_== Nil - AccountService.updateGroupMembers(group1, List(user1)) + AccountService.updateGroupMembers(group1, List((user1, true))) - AccountService.getGroupMembers(group1) must_== List(user1) + AccountService.getGroupMembers(group1) must_== List(GroupMember(group1, user1, true)) AccountService.getGroupsByUserName(user1) must_== List(group1) AccountService.updateGroupMembers(group1, Nil) diff --git a/src/test/scala/ssh/GitCommandSpec.scala b/src/test/scala/ssh/GitCommandSpec.scala new file mode 100644 index 000000000..c5c463000 --- /dev/null +++ b/src/test/scala/ssh/GitCommandSpec.scala @@ -0,0 +1,40 @@ +package ssh + +import org.specs2.mutable._ +import org.specs2.mock.Mockito +import org.apache.sshd.server.command.UnknownCommand +import javax.servlet.ServletContext + +class GitCommandFactorySpec extends Specification with Mockito { + + val factory = new GitCommandFactory(mock[ServletContext], "http://localhost:8080") + + "createCommand" should { + "returns GitReceivePack when command is git-receive-pack" in { + factory.createCommand("git-receive-pack '/owner/repo.git'").isInstanceOf[GitReceivePack] must beTrue + factory.createCommand("git-receive-pack '/owner/repo.wiki.git'").isInstanceOf[GitReceivePack] must beTrue + + } + "returns GitUploadPack when command is git-upload-pack" in { + factory.createCommand("git-upload-pack '/owner/repo.git'").isInstanceOf[GitUploadPack] must beTrue + factory.createCommand("git-upload-pack '/owner/repo.wiki.git'").isInstanceOf[GitUploadPack] must beTrue + + } + "returns UnknownCommand when command is not git-(upload|receive)-pack" in { + factory.createCommand("git- '/owner/repo.git'").isInstanceOf[UnknownCommand] must beTrue + factory.createCommand("git-pack '/owner/repo.git'").isInstanceOf[UnknownCommand] must beTrue + factory.createCommand("git-a-pack '/owner/repo.git'").isInstanceOf[UnknownCommand] must beTrue + factory.createCommand("git-up-pack '/owner/repo.git'").isInstanceOf[UnknownCommand] must beTrue + factory.createCommand("\ngit-upload-pack '/owner/repo.git'").isInstanceOf[UnknownCommand] must beTrue + } + "returns UnknownCommand when git command has no valid arguments" in { + // must be: git-upload-pack '/owner/repository_name.git' + factory.createCommand("git-upload-pack").isInstanceOf[UnknownCommand] must beTrue + factory.createCommand("git-upload-pack /owner/repo.git").isInstanceOf[UnknownCommand] must beTrue + factory.createCommand("git-upload-pack 'owner/repo.git'").isInstanceOf[UnknownCommand] must beTrue + factory.createCommand("git-upload-pack '/ownerrepo.git'").isInstanceOf[UnknownCommand] must beTrue + factory.createCommand("git-upload-pack '/owner/repo.wiki'").isInstanceOf[UnknownCommand] must beTrue + } + } + +} diff --git a/src/test/scala/view/AvatarImageProviderSpec.scala b/src/test/scala/view/AvatarImageProviderSpec.scala index f33731649..4a00c4fe4 100644 --- a/src/test/scala/view/AvatarImageProviderSpec.scala +++ b/src/test/scala/view/AvatarImageProviderSpec.scala @@ -10,53 +10,58 @@ import twirl.api.Html class AvatarImageProviderSpec extends Specification { - implicit val context = app.Context("", None, null) - "getAvatarImageHtml" should { "show Gravatar image for no image account if gravatar integration is enabled" in { - val provider = new AvatarImageProviderImpl(Some(createAccount(None)), createSystemSettings(true)) + implicit val context = app.Context(createSystemSettings(true), None, null) + val provider = new AvatarImageProviderImpl(Some(createAccount(None))) provider.toHtml("user", 20).toString mustEqual "" } "show uploaded image even if gravatar integration is enabled" in { - val provider = new AvatarImageProviderImpl(Some(createAccount(Some("icon.png"))), createSystemSettings(true)) + implicit val context = app.Context(createSystemSettings(true), None, null) + val provider = new AvatarImageProviderImpl(Some(createAccount(Some("icon.png")))) provider.toHtml("user", 20).toString mustEqual "" } "show local image for no image account if gravatar integration is disabled" in { - val provider = new AvatarImageProviderImpl(Some(createAccount(None)), createSystemSettings(false)) + implicit val context = app.Context(createSystemSettings(false), None, null) + val provider = new AvatarImageProviderImpl(Some(createAccount(None))) provider.toHtml("user", 20).toString mustEqual "" } "show Gravatar image for specified mail address if gravatar integration is enabled" in { - val provider = new AvatarImageProviderImpl(None, createSystemSettings(true)) + implicit val context = app.Context(createSystemSettings(true), None, null) + val provider = new AvatarImageProviderImpl(None) provider.toHtml("user", 20, "hoge@hoge.com").toString mustEqual "" } "show unknown image for unknown user if gravatar integration is enabled" in { - val provider = new AvatarImageProviderImpl(None, createSystemSettings(true)) + implicit val context = app.Context(createSystemSettings(true), None, null) + val provider = new AvatarImageProviderImpl(None) provider.toHtml("user", 20).toString mustEqual "" } "show unknown image for specified mail address if gravatar integration is disabled" in { - val provider = new AvatarImageProviderImpl(None, createSystemSettings(false)) + implicit val context = app.Context(createSystemSettings(false), None, null) + val provider = new AvatarImageProviderImpl(None) provider.toHtml("user", 20, "hoge@hoge.com").toString mustEqual "" } "add tooltip if it's enabled" in { - val provider = new AvatarImageProviderImpl(None, createSystemSettings(false)) + implicit val context = app.Context(createSystemSettings(false), None, null) + val provider = new AvatarImageProviderImpl(None) provider.toHtml("user", 20, "hoge@hoge.com", true).toString mustEqual "" @@ -80,10 +85,12 @@ class AvatarImageProviderSpec extends Specification { private def createSystemSettings(useGravatar: Boolean) = SystemSettings( - baseUrl = None, + baseUrl = Some(""), allowAccountRegistration = false, gravatar = useGravatar, notification = false, + ssh = false, + sshPort = None, smtp = None, ldapAuthentication = false, ldap = None) @@ -91,15 +98,13 @@ class AvatarImageProviderSpec extends Specification { /** * Adapter to test AvatarImageProviderImpl. */ - class AvatarImageProviderImpl(account: Option[Account], settings: SystemSettings) - extends AvatarImageProvider with RequestCache { + class AvatarImageProviderImpl(account: Option[Account]) extends AvatarImageProvider with RequestCache { def toHtml(userName: String, size: Int, mailAddress: String = "", tooltip: Boolean = false) (implicit context: app.Context): Html = getAvatarImageHtml(userName, size, mailAddress, tooltip) override def getAccountByMailAddress(mailAddress: String)(implicit context: app.Context): Option[Account] = account override def getAccountByUserName(userName: String)(implicit context: app.Context): Option[Account] = account - override def getSystemSettings()(implicit context: app.Context): SystemSettings = settings } }