diff --git a/README.md b/README.md index 41c57d1..f47318c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ - 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 @@ 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 @@ 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 db255c2..37b489c 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 bcf125e..9d2e16d 100644 --- a/project/build.scala +++ b/project/build.scala @@ -31,12 +31,13 @@ "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 15ac806..7d7ab3e 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 672c26f..0000000 --- a/sbt-launch-0.12.3.jar +++ /dev/null Binary files differ diff --git a/sbt-launch-0.13.1.jar b/sbt-launch-0.13.1.jar new file mode 100644 index 0000000..5c7d052 --- /dev/null +++ b/sbt-launch-0.13.1.jar Binary files differ diff --git a/sbt.bat b/sbt.bat index d86d1e0..cd356dd 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 23c721f..86cf93e 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 0000000..f8658a2 --- /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 de3c40d..21c213d 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -20,7 +20,6 @@ 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 df24aad..d19af7e 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 @@ 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 @@ "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 @@ 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 @@ 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 @@ 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 cf91fea..d7d7171 100644 --- a/src/main/scala/app/ControllerBase.scala +++ b/src/main/scala/app/ControllerBase.scala @@ -28,7 +28,7 @@ // 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 @@ 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 @@ // 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 @@ 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 16cd05c..0000000 --- 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 0e46b3c..426ec63 100644 --- a/src/main/scala/app/IndexController.scala +++ b/src/main/scala/app/IndexController.scala @@ -22,7 +22,6 @@ html.index(getRecentActivities(), getVisibleRepositories(loginAccount, baseUrl), - loadSystemSettings(), loginAccount.map{ account => getUserRepositories(account.userName, baseUrl) }.getOrElse(Nil) ) } @@ -32,11 +31,11 @@ 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 bf3bea2..47c0614 100644 --- a/src/main/scala/app/PullRequestsController.scala +++ b/src/main/scala/app/PullRequestsController.scala @@ -105,9 +105,9 @@ } 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 @@ 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 6893e63..dda0e61 100644 --- a/src/main/scala/app/RepositoryViewerController.scala +++ b/src/main/scala/app/RepositoryViewerController.scala @@ -82,44 +82,45 @@ 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 @@ /** * 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 @@ /** * 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 @@ 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 @@ 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 @@ 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 c6f56fe..478c7c5 100644 --- a/src/main/scala/app/SystemSettingsController.scala +++ b/src/main/scala/app/SystemSettingsController.scala @@ -4,18 +4,21 @@ 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 @@ "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 50ffb3c..dc3543c 100644 --- a/src/main/scala/app/UserManagementController.scala +++ b/src/main/scala/app/UserManagementController.scala @@ -5,6 +5,7 @@ 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 @@ 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 @@ )(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 @@ 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 @@ }) 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 @@ } } 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 @@ } }) - 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 0bcd0af..a2a38f3 100644 --- a/src/main/scala/model/GroupMembers.scala +++ b/src/main/scala/model/GroupMembers.scala @@ -5,10 +5,12 @@ 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 0000000..39d89d4 --- /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 ff1186f..c57d0ce 100644 --- a/src/main/scala/service/AccountService.scala +++ b/src/main/scala/service/AccountService.scala @@ -122,18 +122,17 @@ 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 b121599..590e300 100644 --- a/src/main/scala/service/IssuesService.scala +++ b/src/main/scala/service/IssuesService.scala @@ -8,7 +8,6 @@ import model._ import util.Implicits._ import util.StringUtil._ -import util.StringUtil trait IssuesService { import IssuesService._ @@ -120,16 +119,10 @@ // 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 @@ } } .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 @@ } 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 ac4f177..33ec942 100644 --- a/src/main/scala/service/RepositorySearchService.scala +++ b/src/main/scala/service/RepositorySearchService.scala @@ -63,8 +63,9 @@ 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 74bd139..38e7606 100644 --- a/src/main/scala/service/RepositoryService.scala +++ b/src/main/scala/service/RepositoryService.scala @@ -147,7 +147,8 @@ getForkedCount( repository.originUserName.getOrElse(repository.userName), repository.originRepositoryName.getOrElse(repository.repositoryName) - )) + ), + getRepositoryManagers(repository.userName)) } } @@ -162,7 +163,8 @@ getForkedCount( repository.originUserName.getOrElse(repository.userName), repository.originRepositoryName.getOrElse(repository.repositoryName) - )) + ), + getRepositoryManagers(repository.userName)) } } @@ -195,10 +197,18 @@ 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 @@ 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 0f0b0f0..3747e85 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 @@ * 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 0000000..c249a3a --- /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 f15bd37..ba425a3 100644 --- a/src/main/scala/service/SystemSettingsService.scala +++ b/src/main/scala/service/SystemSettingsService.scala @@ -15,10 +15,12 @@ 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 @@ 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 @@ allowAccountRegistration: Boolean, gravatar: Boolean, notification: Boolean, + ssh: Boolean, + sshPort: Option[Int], smtp: Option[Smtp], ldapAuthentication: Boolean, ldap: Option[Ldap]) @@ -127,6 +133,7 @@ fromAddress: Option[String], fromName: Option[String]) + val DefaultSshPort = 29418 val DefaultSmtpPort = 25 val DefaultLdapPort = 389 @@ -134,6 +141,8 @@ 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 496f784..5662c44 100644 --- a/src/main/scala/service/WebHookService.scala +++ b/src/main/scala/service/WebHookService.scala @@ -87,7 +87,7 @@ 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 @@ }.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 00fbabd..870fe20 100644 --- a/src/main/scala/service/WikiService.scala +++ b/src/main/scala/service/WikiService.scala @@ -15,6 +15,7 @@ import org.eclipse.jgit.api.errors.PatchFormatException import scala.collection.JavaConverters._ import scala.Some +import service.RepositoryService.RepositoryInfo object WikiService { @@ -40,6 +41,10 @@ */ 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 @@ 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 @@ */ 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 bfa6992..8cafb60 100644 --- a/src/main/scala/servlet/AutoUpdateListener.scala +++ b/src/main/scala/servlet/AutoUpdateListener.scala @@ -50,6 +50,7 @@ * 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 @@ */ 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 9efefd3..60a5224 100644 --- a/src/main/scala/servlet/GitRepositoryServlet.scala +++ b/src/main/scala/servlet/GitRepositoryServlet.scala @@ -8,7 +8,7 @@ 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 @@ * 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 @@ 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 @@ 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 0000000..9f014f0 --- /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 0000000..c107be6 --- /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 0000000..a9ea634 --- /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 0000000..f8e08fd --- /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 0000000..db578de --- /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 c524713..f40af7b 100644 --- a/src/main/scala/util/Authenticator.scala +++ b/src/main/scala/util/Authenticator.scala @@ -29,7 +29,7 @@ /** * 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 @@ 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 @@ } /** - * 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 @@ } } } + +/** + * 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 57fd421..5ab4bf7 100644 --- a/src/main/scala/util/JGitUtil.scala +++ b/src/main/scala/util/JGitUtil.scala @@ -11,17 +11,20 @@ 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 @@ * @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 @@ 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){ @@ -105,6 +105,15 @@ 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. * * @param git the Git object @@ -152,7 +161,7 @@ } } } - + /** * Returns the file list of the specified path. * @@ -162,7 +171,7 @@ * @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 @@ }) } 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 @@ } }.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. * @@ -326,27 +354,6 @@ } /** - * 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. */ def getDiffs(git: Git, id: String, fetchContent: Boolean = true): (List[DiffInfo], Option[String]) = { @@ -377,7 +384,7 @@ 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 @@ 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 @@ 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 bcc2d6b..1dff9ab 100644 --- a/src/main/scala/view/AvatarImageProvider.scala +++ b/src/main/scala/view/AvatarImageProvider.scala @@ -16,7 +16,7 @@ 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 @@ } 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 6b7ab2d..4d998bd 100644 --- a/src/main/scala/view/Markdown.scala +++ b/src/main/scala/view/Markdown.scala @@ -45,7 +45,7 @@ (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 @@ 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 5213ddc..c02bcbe 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){ -